Skip to content

brianm78/typer_ext

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Extended Typer

A wrapper around Typer that adds handling for some further features aimed at reducing the need to boilerplate beyond standard python function definition syntax.

Features:

  • Allows python positional-only and keyword-only arguments to be mapped to Argument / Option regardless of provided default value. This makes creating required options or optional arguments less verbose.
  • Supports using *args syntax to take a variable number of positional arguments, automatically annotating the argument as a list of the given type.
  • Automatically parse helptext from the function docstring, and assign additional long and short options to the parameter if the argument section of the docstring starts with a list of options enclosed by "[" and "]"
  • Allows taking dataclass arguments which will be expanded into an option / argument for each member.

A quick example:

@dataclass
class CommonOptions:
    """Common options used by multiple commands
    
    Arguments:
        verbose: [-v]
            Enable verbose logging
        loglevel: [-l]
            Set loglevel
    """
    verbose: bool = False
    loglevel: str = "WARN"

app = ExtendedTyper()  # Create equivalenmt to Typer() object

@app.command()
def grep(match: str, *files: Path, insensitive: bool = False, color: bool | None = None, opts: CommonOptions):
    """Search files for string.

    Arguments:
        match:
            String to search for
        files:
            Files to search.  If none provided, use stdin
        insensitive: [-i]
            Search case-insensitively.
        color: [-c/-n --color/--nocolor]
            Colorise output.  Default behaviour will autodetect from terminal settings.
    """
    if opts.verbose:
        print("Verbose logging enabled")
    ...

When used as a typer command(), this will have all the specified options decls and help text for each argument.
Ie

$ grep foo file1.txt file2.txt -i -n -l "INFO" -v

will work as expected.

Motivation

Typer is handy in that the command-line arguments mirror the python function definition, meaning sometimes nothing more than the standard type hints you'd write anyway are required to generate a fully functional commandline.

However, for anything more than the basics, you usually have to add an annotation to the type to indicate the extra information, which can clutter up the function definition, making them much less readable.

Specifically this is the case for:

  • Argument help text
  • Short options / additional long options
  • arguments with defaults
  • required options

In addition, often commands will re-use many of the same options, which have to either be specified for each command, or added to a callback(), which can complicate the commandline as to which options should be specified in which location.

Also, some things can be a bit clunky. Eg. indicating a variable number of arguments that can be absent entirely requires annotating as list[type] | None = None, even though in practice, None is not a value it will ever have, requiring useless checks to satisfy a type checker.

This library reduces the need for this in several ways:

  1. It modifies the function before reaching typer, adapting things like *args, positional/keyword only options (* and /) to the appropriate Argument or Option annotation.

  2. It parses the docstring and finds helptext for the arguments, and allows short options or alternative names to be optionally specified by putting [ -o ] at the start of the argument string.

  3. It allows options to be bundled together as a dataclass whose members represent options. These will be expanded into individual arguments to the command, then their values bundled into the dataclass and passed to the function.

Usage

The library may be uses in two ways.

  1. Using the adapt_function decorator.

This is a decorator that takes a function and wraps it with an adapter that checks for the handled argument types and creates a new function suitable to pass to typer that takes only keyword arguments, and has the appropriate annotations. Eg:

app = typer.Typer()

@app.command()
@adapt_function
def func(*args: str, *, required_option: bool): 
    """Docstring here"""
  1. Using the ExtendedTyper subclass in place of typer.Typer

This is a small subclass of the typer.Typer() object that can be used in its place, and will apply the adapt_function decorator to all command / callback invocations. So the above may be written as:

app = typer_ext.ExtendedTyper()

@app.command()
def func(*args: str, *, required_option: bool):
    """Docstring here"""

Using this approach has the advantage that the original function will not be replaced, only the version passed to typer, allowing it to be called using its defined arguments.

Details

Docstring Parsing

Help text and short options can be automatically added through the docstring. The document is parsed with docstring_parser, which will detect parameters defined using either ReSt, goodle numpy or epydoc style docstrings.

Detected parameters will have the associated text set as the help argument to the typer annotation.

Further, if the argument declaration starts with text within [ and ] brackets, this will be parsed as a sequence of short or long option names that will be used.

If no long options are specified, a default long option based on the parameter name will be added.

Eg. the equivalent typer declaration:

def greet(
    name: Annotated[str, typer.Option("--name", "-n", help="Name of person to greet")] = "World",
    greeting: Annotated[str, typer.Option("--greeting", "-g", help="Greeting to use")] = "Hello",
):
    """Greet someone"""
    print("{greeting} {name}")

May instead be defined as:

def greet(name: str = "World", greeting: str = "Hello"):
    """Greet someone

    Arguments:
        name: [-n] 
            Name of person to greet
        greeting: [-g]
            Greeting to use
    """
    print("{greeting} {name}")

Varargs handling

A function defined a vararg parameter ( eg. *args: str) will be converted to the equivalent of:

args: Annotated[ list[str] | None, typer.Argument() ]

Ie. it will take 0 or more commandline arguments which till be received by the function as a tuple of arguments.

For example:

    @app.command()
    def add(*vals : int):
        print(sum(vals))

May be invoked as the commandline like:

    $ add 1 2 3
    6

Keyword-only arguments (Required option parameters)

Parameters appearing after a *varargs parameter, or a bare * marker in python are considered keyword-only. Ie. they pay be passed by keyword, but not as positional argument. Eg:

def greet(name, *, greeting: str = "Hello"):
    print(f"{greeting} {name}")

greet("World", "Goodbye")           # Invalid
greet("World", greeting="Goodbye")  # Valid

When used with this library, such parameters are converted to Options. Typer will already implicitly treat a parameter with a default value as an option, so in the above case no value is added. However, when an argument is intended to be an option, but has no default (ie. is required), an explicit annotation is required. Ie:

def foo(required_option: Annotated[bool, typer.Option()): ...

When adapted by this library, such required options can just use python keyword-only parameters and be adapted equivalently. Eg

def foo(*, required_option: bool): ...

Positional-only arguments

Parameters that appear before a / marker in python are positional-only. The may be passed by position, but not by keyword argument. Eg:

def greet(name: str, /, greeting: str): ...

greet(name="World", greeting="Hello")  # Invalid
greet("World", greeting="Hello")       # Valid
greet("World", "Hello")                # Valid

When used with this library, such parameters are converted to Arguments. This covers the reverse case of required options: non-required arguments. Ie. typer will normally implicitly treat an argument with no default as an Argument. However, if you want an argument that can be omitted to use a default value, you again have to explicitly add an annotation. Eg:

def greet(name: Annotated[str, typer.Argument()] = "World", greeting: str = "World"): ...

Which can be invoked like:

$ greet
Hello World
$ greet Joe
Hello Joe
$ greet Joe --greeting "Goodybe"
Goodybe Joe

The equivalent may instead written as:

def greet(name: str = "World", /, greeting: str="Hello"): ...

Bundled Dataclass Parameters

By defining an arg as being of type dataclass, you can bundle commonly used parameters into a collection of values that will get expanded into seperate options on the commandline.

Arguments defined in the dataclass docstring are parsed the same way as those in a function definition, and handled the same way as if they'd appeared in the function docstring.

For example:

@dataclass
Class CommonOptions:
    """Common options

    Arguments:
        verbose: [-v] Enable verbose logging
        name: [-n] Set name
    """

    verbose: bool = False
    name : str | None = None

@app.command()
def test(cmd: str, opts: CommonOptions, flag: bool = False):
    print(f"Called with {cmd=} {flag=} {opts.name=} {opts.verbose=}")

Which can be invoked like:

$ test command --flag --verbose --n "optname"
Called with cmd="command" flag=True opts.name="optname" opts.verbose=True

Dataclasses may contain arguments rather than options (ie. no default specified), but these need to obey the same ordering rules as if their parameters were at that point in the function definition. Ie. arguments must occur before options, and if it contains both, it must occur after other arguments, but before other options.

If a dataclass arg itself is positional-only or keywords only, this is applied to all options within it. They may not be varargs parameters.

Dataclass parameters may be given a default value which can allow changing the defaults for a given function, and allow it to be mixed with keyword-only arguments without running afoul of python's ordering requirements. Eg:

@app.command()
def test(cmd: str, flag: bool = False, opts: CommonOptions = CommonOptions(name="Changed default for this function")):
    print(f"Called with {cmd=} {flag=} {opts.name=} {opts.verbose=}")

Note that since this requires providing a value for all options, it cannot be used to define required arguments.

Keyword Vararg Parameters (**kwargs)

Limited support for **kwargs when combined with a TypedDict is present. A dict of the form:

class MyDict(TypedDict):
    """Use same doc parsing rules as for dataclasses

    Arguments:
        flag: [-f]  Some flag
        name: [-n --somename] Optional name
    """
    flag : bool
    name: str

def func(**kwargs: Unpack[MyDict]):
    ...

Will receive items in kwargs with the same name if they are passed. If not provided on the commandline, they will be absent from the dictionary.

Implementation Details

The adapt_function decorator will essentially create a wrapper function which takes keyword-only arguments, and which is given a signature annotated with the appropriate typer annotations for the needed arguments.

When called, this function will repack the passed arguments into the signature expected by the original function (ie. unpacking lists to *args, passing arguments positionally, and creating dataclasses), and invoke it.

Note that when used as a decorator, this will replace the original function with this wrapped version, meaning it may no longer be callable with the same signature. It is thus better to use the ExtendedTyper class rather than manually applying the decorator, as this will pass the modified function to typer, while still returning the unchanged function from the @app.command() decorator.

About

Wrapper for Typer to reduce the need for annotations

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages