typer icon indicating copy to clipboard operation
typer copied to clipboard

[QUESTION] When using commands as normal functions, how can we get default values from eg. typer.Option

Open mboratko opened this issue 4 years ago • 9 comments

First check

  • [x] I used the GitHub search to find a similar issue and didn't find it.
  • [x] I searched the Typer documentation, with the integrated search.
  • [x] I already searched in Google "How to X in Typer" and didn't find any information.
  • [x] I already searched in Google "How to X in Click" and didn't find any information.

Description

I appreciate the design decision made by Typer to not replace the existing function when decorating with @app.command() as this conceivably allows reusing the function instead of making a CLI "shim" function.

I ran into an issue, however, with a function like this:

import typer

app = typer.Typer()

@app.command()
def example_command(num: int = typer.Option(1, help="example")):
    assert isinstance(num, int)

This succeeds when called from the command line, but not when called as a function directly, as num will still be a typer.Option object.

Is this intended behavior? If not, I guess it's conceivable that the @app.command() decorator could unwrap default arguments on the example_command function, stripping off any typer.Option and typer.Argument wrappers. I would be happy to attempt to implement this, if such an approach makes sense.

mboratko avatar May 13 '21 13:05 mboratko

This behaviour only occurs if you use the default value. The default value you set is of typer.Option so it propagates as it.

typing.Annotated was added to Python 3.9 that enhances possibilities of type hints. You can see Pydantic taking advantage of it

I checked the behaviour of Annotated and it does not work properly with Typer (the last release was some time ago already). You will get the correct type of int but you are unable to run the command on its own.

When you call command from another command you can just pass an argument (as the third call in main). If you are feeling fancy or lazy you could create some wrapper that will extract the defaults for you (POC as the last call in main).

Here is the snippet. The script requires python 3.9, you can remove annotated and Annotated import to run it if you don't have it.

from typing import Annotated

import typer

app = typer.Typer()


@app.command()
def annotated(arg: Annotated[int, typer.Argument(..., help='Holla')] = 1):
    typer.echo("Annotated")
    print('Printed arg: ', arg)
    typer.echo(
        f'Is instance of int: {isinstance(arg, int)}'
    )

@app.command()
def classic(arg: int = typer.Argument(1, help='Holla')):
    typer.echo("Classic")
    print('Printed arg: ', arg)
    typer.echo(
        f'Is instance of int: {isinstance(arg, int)}'
    )


@app.command()
def main():
    typer.echo()
    annotated()

    typer.echo()
    classic()

    typer.echo()
    typer.echo('With not default arg')
    classic(16)

    typer.echo()
    typer.echo("Fancy arg")
    arg_defaults = classic.__defaults__
    first_arg = arg_defaults[0]
    defualt_from_typer_argument = first_arg.default
    classic(defualt_from_typer_argument)

if __name__ == '__main__':
    app()

The result would be:


Annotated
Printed arg:  1
Is instance of int: True

Classic
Printed arg:  <typer.models.ArgumentInfo object at 0x7efe2670a8b0>
Is instance of int: False

With not default arg
Classic
Printed arg:  16
Is instance of int: True

Fancy arg
Classic
Printed arg:  1
Is instance of int: True

As for making typer unwrap the defaults - I think the most sense would make to have behaviour like Pydantic.

def f(arg: Annotated[int, typer.Option()] = 3). This way default will always be 3 and typer could access the typer.Option anyway.

sathoune avatar May 16 '21 08:05 sathoune

Thanks for the reply!

I understand that this only occurs when using the default value, and agree that moving toward the Annotated pattern you describe will be much nicer in the long-term. I'm quite happy with that as a long term solution, I can think of many tools (eg. attrs) which would benefit from such a pattern as well.

Since this requires 3.9, however, and most utilities I use have not been ported to it yet, are there any downsides for the unwrapping approach?

mboratko avatar May 16 '21 16:05 mboratko

I am not seeing any downsides actually. You can see in typer code that command just registers the function and returns it unchanged.

I took a few minutes and was able to achieve (I think) what you wanted but outside of Typer:

import functools
from typing import Callable

import typer
from typer.models import ParameterInfo

app = typer.Typer()


def typer_unpacker(f: Callable):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        default_values = f.__defaults__
        patched_defaults = [
            value.default if isinstance(value, ParameterInfo) else value
            for value in default_values
        ]
        f.__defaults__ = tuple(patched_defaults)

        return f(*args, **kwargs)

    return wrapper


@app.command()
def classic(arg: int = typer.Argument(1, help='Holla')):
    typer.echo("Classic")
    print('Printed arg: ', arg)
    typer.echo(
        f'Is instance of int: {isinstance(arg, int)}'
    )


@app.command()
@typer_unpacker
def smart(
    arg: int = typer.Argument(1, help='Holla'),
    option0: int = 4,
    option: int = typer.Option(12),
):
    typer.echo("smart")
    print('Printed arg: ', arg)
    print('Printed option0:', option0)
    print('Printed option: ', option)
    typer.echo(
        f'Is instance of int: {isinstance(arg, int)}'
    )


@app.command()
def main():
    typer.echo("Fancy arg")
    arg_defaults = classic.__defaults__
    first_arg = arg_defaults[0]
    defualt_from_typer_argument = first_arg.default
    classic(defualt_from_typer_argument)

    typer.echo()
    smart()


if __name__ == '__main__':
    app()

Looks like it works both called from main and as a standalone command. It would require some more testing but looks promising.

I believe many people would like to see new features in Typer as I observe the repo, but @tiangolo is the only maintainer and doesn't seem to be active right now in this project.

sathoune avatar May 16 '21 20:05 sathoune

Very nice! Yes, this is exactly what I was thinking. Thanks for the code snippet!

I guess I'll leave this issue open in case @tiangolo wants to weigh in.

mboratko avatar May 16 '21 20:05 mboratko

Amazing! Please post an update if you come across some edge cases, I am curious if that will actually affect the package. :)

sathoune avatar May 16 '21 20:05 sathoune

I'd extend this to inject the default argument into kwargs at wrapped function invocation time if the default is callable

import functools
import inspect
from typing import Callable

import typer
from typer.models import ParameterInfo


def typer_unpacker(f: Callable):
    @wraps(f)
    def wrapper(*args, **kwargs):
        # Get the default function argument that aren't passed in kwargs via the
        # inspect module: https://stackoverflow.com/a/12627202
        missing_default_values = {
            k: v.default
            for k, v in inspect.signature(f).parameters.items()
            if v.default is not inspect.Parameter.empty and k not in kwargs
        }

        for name, func_default in missing_default_values.items():
            # If the default value is a typer.Option or typer.Argument, we have to
            # pull either the .default attribute and pass it in the function
            # invocation, or call it first.
            if isinstance(func_default, ParameterInfo):
                if callable(func_default.default):
                    kwargs[name] = func_default.default()
                else:
                    kwargs[name] = func_default.default

        # Call the wrapped function with the defaults injected if not specified.
        return f(*args, **kwargs)

    return wrapper

shaneatendpoint avatar Aug 05 '21 17:08 shaneatendpoint

Thanks again for this code! I ended up working off a variant of the first one you posted, as the second gave some errors with my use-case.

Here's a package, if you're interested: https://gitlab.com/boratko/typer-utils

Incidentally, what's the purpose of calling any callable default? Are callable default arguments common with Typer? (I have not used it this way.)

mboratko avatar Sep 06 '21 03:09 mboratko

is something like tying.Annotated already in place in recent typer?

tomaszZdunek avatar Feb 21 '23 17:02 tomaszZdunek

For now it's recommended to use Annotated approach to declare arguments. So, I think I think we can close this.

YuriiMotov avatar Sep 17 '25 11:09 YuriiMotov