-
-
Notifications
You must be signed in to change notification settings - Fork 672
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
Comments
@lbellomo I think you are looking for |
Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues. |
Nice! Thanks for the fantastic documentation! |
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 @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: 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 Is there something I've missed that would let me build something like this? Thanks in advance, Typer is a wonderful library! |
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. |
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 |
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. |
@ABitMoreDepth I am trying to do something similar to your example here, but where I have a 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
Running callback When
I am wondering if and how you got around this? |
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? If anyone can provide the same functionality as this with a callback and/or context, I'd appreciate the example. 🙏 |
I am trying to do something similar to what 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 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 |
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:
|
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() |
Hi! Yet another solution inspired on @brianm78 message, in this one you specify the default command on 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") |
I wanna to be able to set a default command when I have multiples commands. For example, with:
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
The text was updated successfully, but these errors were encountered: