[QUESTION] When using commands as normal functions, how can we get default values from eg. typer.Option
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.
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.
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?
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.
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.
Amazing! Please post an update if you come across some edge cases, I am curious if that will actually affect the package. :)
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
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.)
is something like tying.Annotated already in place in recent typer?
For now it's recommended to use Annotated approach to declare arguments. So, I think I think we can close this.