typer
typer copied to clipboard
[BUG] Context .invoke() fails quietly and .forward() fails
Describe the bug
When attempting to use the invoke()
or forward()
methods on a typer.Context
object, the results are unpredictable, resulting in either outright failures, or passing of strange values in place of defaults.
To Reproduce
- Create a file
main.py
with:
import typer
app = typer.Typer()
@app.command()
def test(count: int = typer.Option(1, help="How high to count")):
typer.echo(f"Count: {count}")
@app.command()
def forward(ctx: typer.Context, count: int = 1):
ctx.forward(test)
@app.command()
def invoke(ctx: typer.Context):
ctx.invoke(test)
if __name__ == "__main__":
app()
- Call it with:
python main.py forward
- It outputs:
...
TypeError: Callback is not a command.
- But I expected it to output:
Count: 42
- Call it with:
python main.py invoke
- It outputs:
Count: <typer.models.OptionInfo object at 0x10e082a10>
- But I expected it to output:
Count: 1
Expected behavior
It should be possible to use .invoke()
on typer.Context
objects in a manner similar to that described in the click documentation.
Environment
- OS: macOS
- Typer Version: 0.2.1
- Python version: 3.7.6
I figured out a workaround based on the using click docs:
import typer
app = typer.Typer()
@app.command()
def test(count: int = typer.Option(1, help="How high to count")):
typer.echo(f"Count: {count}")
@app.command()
def forward(ctx: typer.Context, count: int = 42):
ctx.forward(typer.main.get_command(app).get_command(ctx, "test"))
@app.command()
def invoke(ctx: typer.Context):
ctx.invoke(typer.main.get_command(app).get_command(ctx, "test"))
if __name__ == "__main__":
app()
This feels like something that could or should be handled by calling .invoke
or .forward
directly, though.
UPDATE: see below comment for a version that works with callback()
decorated functions as well as command()
decorated ones.
It appears that with the current structure, there is no way for the typer.Context
(really, just click.Context
) to be able to look up the Command
, since the decorated function is left unchanged and it is just registered on the Typer
instance.
I think the following functions would work similarly to click.Context.invoke
and forward
methods. These are just plain functions that take a Typer
object - they get the context using click.get_current_context()
:
def find_command_info(typer_instance: Typer, callback: Callable) -> Optional[CommandInfo]:
for command_info in typer_instance.registered_commands:
if command_info.callback == callback:
return command_info
for group in typer_instance.registered_groups:
command_info = find_command_info(group.typer_instance, callback)
if command_info:
return command_info
return None
def callback_to_click_command(
typer_instance: Typer,
callback: Union[Callable, click.Command],
) -> Union[Callable, click.Command]:
command_info = find_command_info(typer_instance, callback)
if command_info:
callback = get_command_from_info(command_info)
return callback
def invoke(typer_instance: Typer, callback: Union[Callable, click.Command], *args, **kwargs) -> Any:
ctx = click.get_current_context()
return ctx.invoke(callback_to_click_command(typer_instance, callback), *args, **kwargs)
def forward(typer_instance: Typer, callback: Union[Callable, click.Command], *args, **kwargs) -> Any:
ctx = click.get_current_context()
return ctx.forward(callback_to_click_command(typer_instance, callback), *args, **kwargs)
Usage Example:
import typer
app = typer.Typer()
@app.command()
def test(count: int = typer.Option(1, help="How high to count")):
typer.echo(f"Count: {count}")
@app.command()
def forward(ctx: typer.Context, count: int = 42):
typer.forward(app, test)
@app.command()
def invoke(ctx: typer.Context):
typer.invoke(app, test)
if __name__ == "__main__":
app()
Just discovered that the workaround I posted above only works for command()
decorated functions. To get callbacks to also work, use the following:
from typing import Optional, Callable, Union, TypeVar
import click
import typer
T = TypeVar("T")
def find_command_info(typer_instance: typer.Typer, callback: Callable) -> Optional[typer.models.CommandInfo]:
"""Return a CommandInfo that is contained within a Typer instance."""
for command_info in typer_instance.registered_commands:
if command_info.callback == callback:
return command_info
for group in typer_instance.registered_groups:
command_info = find_command_info(group.typer_instance, callback)
if command_info:
return command_info
return None
def find_typer_info(typer_instance: typer.Typer, callback: Callable) -> Optional[typer.models.TyperInfo]:
"""Return a TyperInfo that is contained within a Typer instance."""
if typer_instance.registered_callback.callback == callback:
return typer_instance.registered_callback
for group in typer_instance.registered_groups:
typer_info = find_typer_info(group.typer_instance, callback)
if typer_info:
return typer_info
return None
def callback_to_click_command(
typer_instance: typer.Typer, callback: Union[Callable[..., T], click.Command]
) -> Union[Callable[..., T], click.Command]:
"""
Return the click.Command object for a given callable, if it is registered under the given Typer instance.
If the callback is not a registered command, just returns the callback.
"""
command_info = find_command_info(typer_instance, callback)
if command_info:
callback = typer.main.get_command_from_info(command_info)
else:
typer_info = find_typer_info(typer_instance, callback)
if typer_info:
typer_info.typer_instance = typer_instance
callback = typer.main.get_group_from_info(typer_info)
return callback
def invoke(typer_instance: typer.Typer, callback: Callable[..., T], *args, **kwargs) -> T: ...
"""
Invoke a callable that is a registered command or subcommand of the typer_instance.
See `click.Context.invoke`.
"""
return click.get_current_context().invoke(callback_to_click_command(typer_instance, callback), *args, **kwargs)
def forward(typer_instance: typer.Typer, callback: Callable[..., T], *args, **kwargs) -> T:
"""
Forward a callable that is a registered command or subcommand of the typer_instance.
See `click.Context.forward`.
"""
return click.get_current_context().forward(callback_to_click_command(typer_instance, callback), *args, **kwargs)
I've run into a similar issue but this is just a (seemingly) plain usage trying to reproduce the -vvv
type logging verbose flag.
import typer
def main(
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
):
if verbose > 0:
pass
if __name__ == "__main__":
typer.run(main)
FWIW I used the docs example.
I get this, indicating the default isn't being set:
TypeError: '>' not supported between instances of 'OptionInfo' and 'int'
Update:
Nevermind! I realized that I was calling the main
function from Poetry's script entrypoint setting.
For anyone that happens to find this, just make sure you're handling that properly, e.g. creating another function that only calls typer.run(main)
much like the __name__ == "__main__"
thing if you're running it as a module.
@jmaroeder I agree, this looks like an oversight at the very least.
I mean, it does appear to be the case that the invoke
and forward
methods of click.Context
behave as they are documented (considering that they don't have inbuilt support for Typer, naturally)... That said, there's no immediate solution for Typer users, and I think yours is a good/natural one.
Hope you don't mind that I've integrated it into my fork of Typer. (If anyone wants to use it, see tests/issues/test_issue102.py
for an example.)