typer icon indicating copy to clipboard operation
typer copied to clipboard

[BUG] Context .invoke() fails quietly and .forward() fails

Open jmaroeder opened this issue 4 years ago • 5 comments

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

jmaroeder avatar May 13 '20 03:05 jmaroeder

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.

jmaroeder avatar May 13 '20 03:05 jmaroeder

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()

jmaroeder avatar May 13 '20 04:05 jmaroeder

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)

jmaroeder avatar May 13 '20 18:05 jmaroeder

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.

mhadam avatar Apr 30 '21 13:04 mhadam

@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.)

alexreg avatar May 13 '22 00:05 alexreg