Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Set a defaut command in multiple commands #18

Closed
lbellomo opened this issue Jan 2, 2020 · 13 comments
Closed

[FEATURE] Set a defaut command in multiple commands #18

lbellomo opened this issue Jan 2, 2020 · 13 comments
Labels
feature New feature, enhancement or request

Comments

@lbellomo
Copy link

lbellomo commented Jan 2, 2020

I wanna to be able to set a default command when I have multiples commands. For example, with:

import typer

app = typer.Typer()


@app.command()
def hello(name: str):
    typer.echo(f"Hello {name}")


@app.command()
def goodbye(name: str, formal: bool = False):
    if formal:
        typer.echo(f"Goodbye Ms. {name}. Have a good day.")
    else:
        typer.echo(f"Bye {name}!")


if __name__ == "__main__":
    app()

I would like to have some way of being able to call it without specifying the command, calling a default command (maybe with something like @app.command(default=True) like this:

$ python main.py Hiro
Hello Hiro

$ python main.py helo Hiro  # This also should work
Hello Hiro
@lbellomo lbellomo added the feature New feature, enhancement or request label Jan 2, 2020
@tiangolo
Copy link
Member

@lbellomo I think you are looking for callbacks, that was not documented properly in the first versions, but it is now 🎉 📝 : https://typer.tiangolo.com/tutorial/commands/callback/

@github-actions
Copy link

Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues.

@lbellomo
Copy link
Author

Nice! Thanks for the fantastic documentation!

@martsa1
Copy link

martsa1 commented Apr 21, 2020

UPDATE 2: I can accomplish what I need using the context from within the callback:

@APP.callback(invoke_without_command=True)
def default(
        ctx: typer.Context,
        sample_arg: int = typer.Option(
            None,
        ),
        secondary_arg: int = typer.Option(
            None,
        ),
) -> None:
    """Sub-command that I would like to be the default."""
    if ctx.invoked_subcommand is not None:
        print("Skipping default command to run sub-command.")
        return
    
    ...

This yields the CLI I wanted. :)


UPDATE: I found the @APP.callback(invoke_without_command=True) stuff, which lets me run the callback like a command, I think that is probably enough for me to build the CLI I would like.

@tiangolo - I love the docs for this project, but when searching for specific options, I do miss an API reference page, would something like that be possible to add at some point? I'd be up for helping to build it, if that would help?


Apologies for opening an old thread, I bumped into this scenario just recently...

Is there a way to use a callback with a set of arguments, without specifying a subcommand?

What I would like to do, is provide a CLI along the lines of: tool release --option a, alongside tool release all. I would like these two actions to be exclusive, if I provide options to the default, but name no other command, I'd like to run that default command. If I provide a command to typer, I would like to run that command without (or simply skipping), the default command...

For example, here's a minimal reproduction of what I currently see (in reality, this CLI is nested into a larger project, hence the APP):

import typer

APP = typer.Typer(
    no_args_is_help=True,
    help="Minimal repro example for default sub-command.",
)


@APP.callback()
def default(
        sample_arg: int = typer.Option(
            None,
        ),
        secondary_arg: int = typer.Option(
            None,
        ),
) -> None:
    """Sub-command that I would like to be the default."""
    print(f"Sample Arg: {sample_arg}")
    print(f"Secondary Arg: {secondary_arg}")


@APP.command(name="all")
def all_(
        optional: bool = typer.Option(
            False,
            "-a",
            "--all",
            help="Some option.",
        ),
) -> None:
    """Sub-command."""
    print(f"Optional: {optional}")


if __name__ == "__main__":
    APP()

Outputs:

$ python temp.py 
Usage: temp.py [OPTIONS] COMMAND [ARGS]...

  Minimal repro example for default sub-command.

Options:
  --sample-arg INTEGER
  --secondary-arg INTEGER
  --install-completion     Install completion for the current shell.
  --show-completion        Show completion for the current shell, to copy it
                           or customize the installation.

  --help                   Show this message and exit.

Commands:
  all  Sub-command.

$ python temp.py --sample-arg 1
Usage: temp.py [OPTIONS] COMMAND [ARGS]...
Try 'temp.py --help' for help.

Error: Missing command.
# ^^ This, I would like to run the content of the callback function without erroring.

$ python temp.py --sample-arg 1 all
Sample Arg: None
Secondary Arg: None
Optional: False
# ^^ This, I would like to call without the callback running, so that I wouldn't see `Sample Arg: None` in this case.

Given the above desired output, I feel that perhaps some kind of default subcommand, rather than a callback would be more appropriate? An example of a CLI like this would be git remote, where it has a set of functionality and options for just git remote, but also includes subcommands like add etc.

Is there something I've missed that would let me build something like this?

Thanks in advance, Typer is a wonderful library!

@tiangolo
Copy link
Member

tiangolo commented Jun 5, 2020

I'm glad you found what you needed @ABitMoreDepth !

Yes, I want to add docs for the internal API, but I have some extra things I want to do first. Also, I want to make sure the current API works well before working on adding automatic docs for it.

@alexforencich
Copy link

alexforencich commented Dec 29, 2020

Yes, a way to do a "default" sub-command would be super useful. Right now, I am trying to add a way to run some test modules from the command line in addition to running them via pytest, while adding as little extra code as possible as this will get replicated in every test script across several repos. I want to add a 'clean' command to delete output files, the idea being to either run the python file directly (with no arguments at all), or possibly with a few arguments to set parameter values to run the tests with those values, or to run it with 'clean' to delete the output files without running the tests, similar to 'make clean'. (Ab)using callback means that I have to look at the context object to see if there was a command, but the problem is that under pytest there is no typer context object at all. I already have a wrapper method that pytest uses to call the main implementation which is set up so that I can point typer at it directly, I don't want to have to add another wrapper for typer and duplicate all of the parameters again.

Edit: and this is also complicated by the fact that I only import typer inside of if __name__ == '__main__' so pytest can run the tests without typer being installed, so I cannot add the typer.Context type annotation to the method in question and as a result typer does not handle the context argument correctly.

@alexforencich
Copy link

It seems like the real problem is that click does not natively support doing this. There are some possible out-of-tree solutions for making this work with click (such as https://github.com/click-contrib/click-default-group), but getting that to work with typer is likely not going to be straightforward.

@JohnGiorgi
Copy link

JohnGiorgi commented May 5, 2021

UPDATE 2: I can accomplish what I need using the context from within the callback:

@APP.callback(invoke_without_command=True)
def default(
        ctx: typer.Context,
        sample_arg: int = typer.Option(
            None,
        ),
        secondary_arg: int = typer.Option(
            None,
        ),
) -> None:
    """Sub-command that I would like to be the default."""
    if ctx.invoked_subcommand is not None:
        print("Skipping default command to run sub-command.")
        return
    
    ...

This yields the CLI I wanted. :)

@ABitMoreDepth I am trying to do something similar to your example here, but where I have a str argument to my callback like this:

import typer


app = typer.Typer()


@app.command()
def subcommand() -> None:
    print("Running subcommand")


@app.callback(invoke_without_command=True)
def main(
    ctx: typer.Context,
    example_arg: str = typer.Argument(...),
) -> None:
    if ctx.invoked_subcommand is not None:
        return

    print("Running callback")


if __name__ == "__main__":
    app()

Unfortunately, if I try to call python test.py subcommand, typer interprets "subcommand" as the string argument for "example_arg" and therefore ctx.invoked_subcommand is None so it runs the callback main, not subcommand as I would expect.

python test.py subcommand 
Running callback

When "example_arg" is an int, like in your example, I simply get an error

python test.py subcommand
Usage: test.py [OPTIONS] EXAMPLE_ARG COMMAND [ARGS]...
Try 'test.py --help' for help.

Error: Invalid value for 'EXAMPLE_ARG': subcommand is not a valid integer

I am wondering if and how you got around this?

@Insighttful
Copy link

Insighttful commented Jun 6, 2023

I couldn't get callbacks to work correctly for a default command without consuming the first argument as the command, so I reverted to mutating the args with sys module:

# inject command 'a' if the first arg is not any of the commands
if __name__ == "__main__":
    import sys
    commands = {'a', 'b', 'c'}
    sys.argv.insert(1, 'a') if sys.argv[1] not in commands else None
    app()

I feel like this is simple enough that it could be refactored as a parameter of the app object directly. Maybe a future version? app(default_cmd='a')

If anyone can provide the same functionality as this with a callback and/or context, I'd appreciate the example. 🙏

@NikosAlexandris
Copy link

I am trying to do something similar to what git remote is doing:

program command

should show some help for the (main) command and then also be able to run like

program command Argument_1 Argument_2 --option something

and run like

program command subcommand

showing help and then also run like

program command subcommand some_argument_a some_argument_b --some-option something

This is relevant to the current issue, I think, if we consider the program command the default "(sub)command".

None of the manual pages 1 or suggestions 2 I've read helps doing this. I am trying to understand if and how this frictionlessdata/frictionless-py#1095 (comment) is relevant and useful.

Footnotes

  1. https://typer.tiangolo.com/tutorial/commands/context/, https://typer.tiangolo.com/tutorial/commands/callback/

  2. in https://github.com/tiangolo/typer/issues:
    - https://github.com/tiangolo/typer/issues/18#issuecomment-617089716
    - https://github.com/tiangolo/typer/issues/18#issuecomment-1577788949

@brianm78
Copy link

brianm78 commented Jun 3, 2024

All the approaches here have a few failure cases for things like running with no args, using --help, callback args, or specifying options/arguments to the default command, or other corner cases.

I did try another approach:

    class TyperDefaultGroup(typer.core.TyperGroup):
        """Use a default command if none is specified.

        Use as:
            Typer(cls=TyperDefault.use_default("my_default"))
        """

        _default: ClassVar[str] = ""

        @classmethod
        def use_default(cls, default: str) -> type["TyperDefaultGroup"]:
            """Create a class that holds a default value."""

            class DefaultCommandGroup(cls):
                _default = default

            return DefaultCommandGroup

        def make_context(
            self,
            info_name: Optional[str],
            args: list[str],
            parent: Optional[click.Context] = None,
            **extra: Any,
        ) -> click.Context:
            # Note: click will mutate args, so take copy so we can retry
            # with original values if neccessary.
            orig_args = list(args)

            # Attempt to make the context.  If this fails, or no command was found,
            # retry using the default command.
            ctx: click.Context | None = None
            try:
                ctx = super().make_context(info_name, args, parent, **extra)
            except Exception:
                # Can fail if we specified arguments to the default command
                # Eg. foo --someflag
                # as these will not be valid for the root object.
                # Fall through to retry with default command prepended.
                ctx = None

            # Also ensure the detected command corresponds to a valid command.
            # We won't get an exception if there are no args, or args that happen to be valid
            # (or get interpreted as a command)
            # If the detected command doesn't exist, retry prepending default command name.
            if ctx is None or not ctx.protected_args or ctx.protected_args[0] not in self.commands:
                return super().make_context(info_name, [self._default, *orig_args], parent, **extra)

            return ctx

    app = Typer(cls=TyperDefaultGroup.use_default("my_default_command"))

Which solves some of these, but has a few issues still:

  • Using --help prints the help for both the group AND the default command
  • Doesn't work correctly if you've args to a callback, and are using the default, as it'll pass those args to the default command rather than to the callback (eg. foo --callback_flag ends up as foo default --callback-flag).
  • It's a bit hacky, in that it dynamically creates a subclass just to hold the default value.

@tonybenoy
Copy link

tonybenoy commented Jun 11, 2024

I couldn't get callbacks to work correctly for a default command without consuming the first argument as the command, so I reverted to mutating the args with sys module:

# inject command 'a' if the first arg is not any of the commands
if __name__ == "__main__":
    import sys
    commands = {'a', 'b', 'c'}
    sys.argv.insert(1, 'a') if sys.argv[1] not in commands else None
    app()

I feel like this is simple enough that it could be refactored as a parameter of the app object directly. Maybe a future version? app(default_cmd='a')

If anyone can provide the same functionality as this with a callback and/or context, I'd appreciate the example. 🙏

I extended this example so that I don't have to keep track of any new commands. Not sure if it's the best approach to get this functionality working.

if __name__ == "__main__":
    import sys
    from typer.main import get_command,get_command_name
    if len(sys.argv) == 1 or sys.argv[1] not in [get_command_name(key) for key in get_command(app).commands.keys()]:
        sys.argv.insert(1, "main")
    app()

@macro128
Copy link

Hi! Yet another solution inspired on @brianm78 message, in this one you specify the default command on @app.command decorator and you can display group help using --help flag

from collections.abc import Callable, Sequence
from typing import Any

import click
import typer
from typer.core import DEFAULT_MARKUP_MODE, MarkupMode, TyperCommand
from typer.models import CommandFunctionType, Default


class TyperDefaultCommand(typer.core.TyperCommand):
    """Type that indicates if a command is the default command."""


class TyperGroupWithDefault(typer.core.TyperGroup):
    """Use a default command if specified."""

    def __init__(
        self,
        *,
        name: str | None = None,
        commands: dict[str, click.Command] | Sequence[click.Command] | None = None,
        rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE,
        rich_help_panel: str | None = None,
        **attrs: Any,
    ) -> None:
        super().__init__(name=name, commands=commands, rich_markup_mode=rich_markup_mode, rich_help_panel=rich_help_panel, **attrs)
        # find the default command if any
        self.default_command = None
        if len(self.commands) > 1:
            for name, command in reversed(self.commands.items()):
                if isinstance(command, TyperDefaultCommand):
                    self.default_command = name
                    break

    def make_context(
        self,
        info_name: str | None,
        args: list[str],
        parent: click.Context | None = None,
        **extra: Any,
    ) -> click.Context:
        # if --help is specified, show the group help
        # else if default command was specified in the group and no args or no subcommand is specified, use the default command
        if self.default_command and (not args or args[0] not in self.commands) and "--help" not in args:
            args = [self.default_command] + args
        return super().make_context(info_name, args, parent, **extra)


class Typer(typer.Typer):
    """Typer with default command support."""

    def __init__(
        self,
        *,
        name: str | None = Default(None),
        invoke_without_command: bool = Default(False),
        no_args_is_help: bool = Default(False),
        subcommand_metavar: str | None = Default(None),
        chain: bool = Default(False),
        result_callback: Callable[..., Any] | None = Default(None),
        context_settings: dict[Any, Any] | None = Default(None),
        callback: Callable[..., Any] | None = Default(None),
        help: str | None = Default(None),
        epilog: str | None = Default(None),
        short_help: str | None = Default(None),
        options_metavar: str = Default("[OPTIONS]"),
        add_help_option: bool = Default(True),
        hidden: bool = Default(False),
        deprecated: bool = Default(False),
        add_completion: bool = True,
        rich_markup_mode: MarkupMode = Default(DEFAULT_MARKUP_MODE),
        rich_help_panel: str | None = Default(None),
        pretty_exceptions_enable: bool = True,
        pretty_exceptions_show_locals: bool = True,
        pretty_exceptions_short: bool = True,
    ):
        super().__init__(
            name=name,
            cls=TyperGroupWithDefault,
            invoke_without_command=invoke_without_command,
            no_args_is_help=no_args_is_help,
            subcommand_metavar=subcommand_metavar,
            chain=chain,
            result_callback=result_callback,
            context_settings=context_settings,
            callback=callback,
            help=help,
            epilog=epilog,
            short_help=short_help,
            options_metavar=options_metavar,
            add_help_option=add_help_option,
            hidden=hidden,
            deprecated=deprecated,
            add_completion=add_completion,
            rich_markup_mode=rich_markup_mode,
            rich_help_panel=rich_help_panel,
            pretty_exceptions_enable=pretty_exceptions_enable,
            pretty_exceptions_show_locals=pretty_exceptions_show_locals,
            pretty_exceptions_short=pretty_exceptions_short,
        )

    def command(
        self,
        name: str | None = None,
        *,
        cls: type[TyperCommand] | None = None,
        context_settings: dict[Any, Any] | None = None,
        help: str | None = None,
        epilog: str | None = None,
        short_help: str | None = None,
        options_metavar: str = "[OPTIONS]",
        add_help_option: bool = True,
        no_args_is_help: bool = False,
        hidden: bool = False,
        deprecated: bool = False,
        rich_help_panel: str | None = Default(None),
        default: bool = False,
    ) -> Callable[[CommandFunctionType], CommandFunctionType]:
        return super().command(
            name,
            cls=TyperDefaultCommand if default else cls,
            context_settings=context_settings,
            help=help,
            epilog=epilog,
            short_help=short_help,
            options_metavar=options_metavar,
            add_help_option=add_help_option,
            no_args_is_help=no_args_is_help,
            hidden=hidden,
            deprecated=deprecated,
            rich_help_panel=rich_help_panel,
        )

# use it instantiating Typer and some commands
app = Typer()

@app.command()
def some_command():
    click.echo("This command is not the default command")
    
@app.command(default=True)
def default_command():
    click.echo("This command is the default command")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature New feature, enhancement or request
Projects
None yet
Development

No branches or pull requests

10 participants