-
-
Notifications
You must be signed in to change notification settings - Fork 637
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
[QUESTION] How do I share options or arguments between commands? #153
Comments
What about defining a variable to share Options? import typer
app = typer.Typer()
def name_callback(value: str):
if value != "Camila":
raise typer.BadParameter("Only Camila is allowed")
return value
ValidatedName = typer.Option(None, callback=name_callback, help=" HELP MSG")
@app.command()
def main(name: str = ValidatedName):
typer.echo(f"Hello {name}")
@app.command()
def sub(name: str = ValidatedName):
typer.echo(f"Hello {name}")
if __name__ == "__main__":
app() (.venv) ➜ airsflow git:(feature/apdoting_typer) ✗ airsflow-ci --help
Usage: airsflow-ci [OPTIONS] COMMAND [ARGS]...
Options:
--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:
main
sub
(.venv) ➜ airsflow git:(feature/apdoting_typer) ✗ airsflow-ci main --help
Usage: airsflow-ci main [OPTIONS]
Options:
--name TEXT HELP MSG
--help Show this message and exit.
(.venv) ➜ airsflow git:(feature/apdoting_typer) ✗ airsflow-ci sub --help
Usage: airsflow-ci sub [OPTIONS]
Options:
--name TEXT HELP MSG
--help Show this message and exit.
(.venv) ➜ airsflow git:(feature/apdoting_typer) ✗ airsflow-ci main --name foo
Usage: airsflow-ci main [OPTIONS]
Error: Invalid value for '--name': Only Camila is allowed
(.venv) ➜ airsflow git:(feature/apdoting_typer) ✗ airsflow-ci sub --name foo
Usage: airsflow-ci sub [OPTIONS]
Error: Invalid value for '--name': Only Camila is allowed
(.venv) ➜ airsflow git:(feature/apdoting_typer) ✗
|
This is not a bad pattern. However still requires lots of repetition. Suppose I have have a server manager CLI with many subcommands. They all require server IP parameter which I will have to define as a function argument for each of my sub-commands over and over again. Something like Click Root Command? https://click.palletsprojects.com/en/7.x/complex/#the-root-command |
I also have a question about callbacks for sharing options between (sub)commands, so I thought I would be appropriate to ask it in a comment instead of opening a new issue. I have three commands of which two share the same arguments. I followed the
My third command does not share the same options. Is there a way to exclude a command from the callback? Or do I have to split the app into sub apps (logically they belong together, so that would be a shame)? |
This is one way to implement common options: import typer
from dotenv import load_dotenv
from dataclasses import dataclass
load_dotenv()
app = typer.Typer(add_completion=False)
@dataclass
class Common:
username: str
password: str
host: str
@app.command()
def device_info(ctx: typer.Context,
device: str = typer.Option(..., help='Device name')):
"""Get device info"""
typer.echo(f'Login user {ctx.obj.username} to host {ctx.obj.host}')
typer.echo(f'Retrieving {device} info')
@app.callback()
def common(ctx: typer.Context,
username: str = typer.Option(..., envvar='APP_USERNAME', help='Username'),
password: str = typer.Option(..., envvar='APP_PASSWORD', help='Password'),
host: str = typer.Option(..., envvar='APP_HOST', help='Host')):
"""Common Entry Point"""
ctx.obj = Common(username, password, host)
if __name__ == "__main__":
app()
|
The trouble with @jtm0 's idea is that the help output for the device-info command will not include the common options (as mentioned in the OP):
I think what @adnion is looking for (as am I) is some way to define common options only once, and have them show up in the |
Another possibility is to use the |
@lovetoburnswhen Thanks - I shall investigate that. The example in #296 seems to work, although flake8 throws an error with the source:
I've raised that with pyflakes: PyCQA/pyflakes#681 Thanks again. |
@jtm0 is it possible to change your solution so that it also works with I currently have an app where all commands share one argument and 3 options. Your solution works great for the options, but typer puts the name of the command as the argument. I have treated the argument exactly the same as the options. Expected behaviour
Current behaviour
Do you have any idea what could work here? |
Hi, is there any updates? |
Hello, @jtm0 Thanks for your snippet above. Would you have any recommendations on how to return the {profile_dir} value from the code below:
When I try and access this using:
I get the following error:
|
I look at it this way: if each command needs the server IP, I'm either going to have to write server_ip: str = opts.ServerIP # assuming I've defined ServerIP = typer.Option(...) in opts.py in each command's function signature, or with some other library I'm going to have to write server_ip: str = cmd.root.server_ip somewhere in each command's body. |
Do we have any solution for this? Please help here. |
I think I'd like to state a little more strongly my support for @rino0601 's solution. And see my previous comment. The duplication we're talking about looks like this: ValidatedName = typer.Option(None, callback=name_callback, help=" HELP MSG")
@app.command()
def main(name: str = ValidatedName):
...
@app.command()
def sub(name: str = ValidatedName):
... People don't usually think of two functions having common parameters as being egregious duplication. It also helps your interface code be self-documenting. And it's the solution I'd call, uh, Typer-onic. I'd argue that if this solution isn't feasible for you, your project probably should use a cli library that exposes more of its internals to allow more customization. |
As mentioned in #405 (comment) I would also be interested in an elegant solution to this. :) |
A (near) similar problem: I have some "global" flags which are handled in application initialization. I want to give some flexibility to my users to use it anywhere: @app.callback(no_args_is_help=True)
def callback(ctx: typer.Context,
verbose: bool = typer.Option(False, help='Verbose mode.')):
# Handle verbose here (e.g. change log level for entire app)
...
@app.command()
def push(...):
...
@app.command()
def pull(...):
... So we could use both:
As it is today, only the first of above works. The second fails with error In this case I don't need (nor want) to re-declare the I tried to use I can contribute with PRs if this direction is acceptable. |
I'm still trying to figure out a workable solution to this. I created this gist to demonstrate a minimal example that doesn't quite work: https://gist.github.com/robinbowes/fa815f2c0576fc6c76409ff3ba31b407 The common_opts approach sort of works (I am using it with - but I don't understand why the Any thoughts? |
Any updates about this? |
I define all the common typer Options as objects that I then import # args_utils.py
import typer
common_option1 = typer.Option(..., help="arg1 help")
common_option2 = typer.Option(..., help="arg2 help")
common_option3 = typer.Option(..., help="arg3 help") # tool.py
from tool_package.arg_utils import *
app = typer.Typer()
# Create a nested subcommand group
group1 = typer.Typer(name="group1")
@group1.command()
def cmd1(
arg1: str = common_option1,
arg2: str = common_option2,
):
...
# Create a nested subcommand group
group2 = typer.Typer(name="group2")
@group2.command()
def cmd1(
arg1: str = common_option1,
arg3: str = common_option3,
):
...
app.add_typer(group1, name="group1")
app.add_typer(group2, name="group2")
if __name__ == "__main__":
app() |
I see a lot of answers regarding sharing options/arguments from the callback to the command but what about the other way around? How do I access the sub-command's options from the callback? import typer
from typing_extensions import Annotated
cli = typer.Typer()
@cli.command()
def command(verbosity: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0):
print('command', verbosity)
@cli.callback()
def init(
verbosity: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0
):
print('init', verbosity)
if __name__ == "__main__":
cli() Testing: $ python3 test.py -v command -v
init 1
command 1
$ python3 test.py -v command
init 1
command 0
$ python3 test.py command -v
init 0
command 1 Is there anyway to make it so that the callback and the command see's the -v if the $ python3 test.py command -v
init 1
command 1 |
By the way, the counter works for me only if I set
Else, I get the following error :
Using version 0.9.0. |
I had around 10 subcommands that shared 7 or so options each. After several approaches, I came up with the following snippet: # sample.py
import dataclasses
import functools
import inspect
from typing import ClassVar
import typer
from typing_extensions import Annotated
app = typer.Typer(name="app")
@dataclasses.dataclass
class CommonOpts:
ATTRNAME: ClassVar[str] = "_common_opts_"
dummy: Annotated[
str,
typer.Option("-d"),
] = "dummy"
@functools.wraps(app.command)
def command(*args, **kwargs):
def decorator(__f):
@functools.wraps(__f)
def wrapper(*__args, **__kwargs):
assert len(__args) == 0
__kwargs = _patch_wrapper_kwargs(**__kwargs)
return __f(*__args, **__kwargs)
_patch_command_sig(wrapper)
return app.command(*args, **kwargs)(wrapper)
return decorator
def _patch_wrapper_kwargs(**kwargs):
if (ctx := kwargs.get("ctx")) is None:
raise RuntimeError("Context should be provided")
common_opts_params = {}
for field in dataclasses.fields(CommonOpts):
common_opts_params[field.name] = kwargs.pop(field.name)
common_opts = CommonOpts(**common_opts_params)
setattr(ctx, CommonOpts.ATTRNAME, common_opts)
return {"ctx": ctx, **kwargs}
def _patch_command_sig(__w) -> None:
sig = inspect.signature(__w)
new_parameters = sig.parameters.copy()
for field in dataclasses.fields(CommonOpts):
new_parameters[field.name] = inspect.Parameter(
name=field.name,
kind=inspect.Parameter.KEYWORD_ONLY,
default=field.default,
annotation=field.type,
)
new_sig = sig.replace(parameters=tuple(new_parameters.values()))
setattr(__w, "__signature__", new_sig)
@command()
def poo(
*,
ctx: typer.Context,
):
typer.echo(f"Hello from {ctx.command.name}")
typer.echo(getattr(ctx, CommonOpts.ATTRNAME))
@command()
def foo(
*,
ctx: typer.Context,
xtc: Annotated[int, typer.Option()],
):
typer.echo(f"Hello from {ctx.command.name}")
typer.echo(f"{xtc = }")
typer.echo(getattr(ctx, CommonOpts.ATTRNAME))
app() Not as easy as topic started asked for, but this helped me a lot in reducing boilerplate. Help messages are printed properly, as well as common options are passed to the function as expected. python sample.py poo && python sample.py foo --xtc 0 -d 123 Tested with typer 0.9.0 and Python 3.10. |
I used the snippet provided by @Vezzp and modified it.
from dataclasses import fields
from functools import wraps
from inspect import Parameter, signature
from typing import TypeVar
from typer import Typer
from europa_1400_tools.cli.common_options import CommonOptions
OptionsType = TypeVar("OptionsType", bound="CommonOptions")
def callback(typer_app: Typer, options_type: OptionsType, *args, **kwargs):
def decorator(__f):
@wraps(__f)
def wrapper(*__args, **__kwargs):
if len(__args) > 0:
raise RuntimeError("Positional arguments are not supported")
__kwargs = _patch_wrapper_kwargs(options_type, **__kwargs)
return __f(*__args, **__kwargs)
_patch_command_sig(wrapper, options_type)
return typer_app.callback(*args, **kwargs)(wrapper)
return decorator
def command(typer_app, options_type, *args, **kwargs):
def decorator(__f):
@wraps(__f)
def wrapper(*__args, **__kwargs):
if len(__args) > 0:
raise RuntimeError("Positional arguments are not supported")
__kwargs = _patch_wrapper_kwargs(options_type, **__kwargs)
return __f(*__args, **__kwargs)
_patch_command_sig(wrapper, options_type)
return typer_app.command(*args, **kwargs)(wrapper)
return decorator
def _patch_wrapper_kwargs(options_type, **kwargs):
if (ctx := kwargs.get("ctx")) is None:
raise RuntimeError("Context should be provided")
common_opts_params: dict = {}
if options_type.instance is not None:
common_opts_params.update(options_type.instance.__dict__)
for field in fields(options_type):
if field.metadata.get("ignore", False):
continue
value = kwargs.pop(field.name)
if value == field.default:
continue
common_opts_params[field.name] = value
options_type(**common_opts_params)
setattr(ctx, options_type.ATTRNAME, common_opts_params)
return {"ctx": ctx, **kwargs}
def _patch_command_sig(__w, options_type) -> None:
sig = signature(__w)
new_parameters = sig.parameters.copy()
options_type_fields = fields(options_type)
for field in options_type_fields:
if field.metadata.get("ignore", False):
continue
new_parameters[field.name] = Parameter(
name=field.name,
kind=Parameter.KEYWORD_ONLY,
default=field.default,
annotation=field.type,
)
for kwarg in sig.parameters.values():
if kwarg.kind == Parameter.KEYWORD_ONLY and kwarg.name != "ctx":
if kwarg.name not in new_parameters:
new_parameters[kwarg.name] = kwarg.replace(default=kwarg.default)
new_sig = sig.replace(parameters=tuple(new_parameters.values()))
setattr(__w, "__signature__", new_sig)
@dataclass
class CommonOptions:
"""Dataclass defining CLI options used by all commands."""
instance = None
def __post_init__(self):
CommonOptions.instance = self
_game_path: Annotated[
Optional[str],
typer.Option(
"--game-path",
"-g",
help="Path to the game directory.",
),
] = None
_output_path: Annotated[
str,
typer.Option(
"--output-path",
"-o",
help="Path to the output directory.",
),
] = str(DEFAULT_OUTPUT_PATH)
use_cache: Annotated[
bool, typer.Option("--use-cache", "-c", help="Use cached files.")
] = False
verbose: Annotated[
bool, typer.Option("--verbose", "-v", help="Verbose output.")
] = False
ATTRNAME: str = field(default="common_params", metadata={"ignore": True})
@property
def game_path(self) -> Path:
"""Return the path to the game directory."""
if self._game_path is None:
self._game_path = ask_for_game_path()
return Path(self._game_path).resolve()
@property
def output_path(self) -> Path:
"""Return the path to the output directory."""
return Path(self._output_path).resolve()
@classmethod
def from_context(cls, ctx: typer.Context) -> "CommonOptions":
if (common_params_dict := getattr(ctx, "common_params", None)) is None:
raise ValueError("Context missing common_params")
return cls(**common_params_dict)
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Optional
import typer
from europa_1400_tools.cli.common_options import CommonOptions
@dataclass
class ConvertOptions(CommonOptions):
_target_format: Annotated[
Optional[str],
typer.Option("--target-format", "-t", help="Target format."),
] = None
_file_paths: Annotated[
Optional[list[str]], typer.Argument(help="File paths to convert.")
] = None
@property
def target_format(self) -> str:
"""Return the target format."""
return self._target_format
@property
def file_paths(self) -> list[Path] | None:
"""Return the file paths to convert."""
if self._file_paths is None or len(self._file_paths) == 0:
return None
return [Path(file_path).resolve() for file_path in self._file_paths]
app = typer.Typer()
app.add_typer(
convert_app,
name="convert",
)
app.add_typer(preprocess_app, name="preprocess")
@callback(app, CommonOptions)
def main(
ctx: typer.Context,
) -> None:
"""Main entry point."""
if "--help" in sys.argv:
return
app = typer.Typer()
@command(app, ConvertOptions, "groups")
def cmd_convert_groups(
ctx: typer.Context,
):
"""Command to convert OGR files"""
ogr_converter = OgrConverter()
ogr_converter.convert_files(ConvertOptions.instance.file_paths) |
@EuleMitKeule would it be possible to convert your example into something native to typer and submit it as a PR so we can all benefit from global arguments? In the meantime, I'm going to attempt to steal from your example code. Thanks! 🥇 I'm running into several issues getting this working, which makes me think there are some pieces missing from your example. edit: I found my issue. The examples are missing the imports for the newly created decorators: from my.command import command, callback |
@lhriley I'm not sure something like this would ever make it into the main repo, since the way I am accessing the global options classes is kind of an antipattern. I'll try to create a gist or maybe an extension package with a minimal implementation in the next days. |
@EuleMitKeule Global / common options are pretty common in big CLIs and clearly have their uses, so I assume that you would mean it is an anti-pattern for I was thinking that since you would have access to the actual code it might be more elegant to implement. For example, instead of passing the common options in the app = typer.Typer(common_options=my_common_options) where the my_common_options = {
use_cache: Annotated[
bool, typer.Option("--use-cache", "-c", help="Use cached files.")
] = False
verbose: Annotated[
bool, typer.Option("--verbose", "-v", help="Verbose output.")
] = False
} or class my_common_options(CommonOptions):
use_cache: Annotated[
bool, typer.Option("--use-cache", "-c", help="Use cached files.")
] = False
verbose: Annotated[
bool, typer.Option("--verbose", "-v", help="Verbose output.")
] = False
} |
@lhriley The anti-pattern is having to access the objects using a singleton pattern like |
Thanks for the clarification @EuleMitKeule. Also, I just took a look at your example repo. Much appreciated :chefskiss: |
|
Typer (v0.9) is flexible enough to let us use shared parameters without any extra code. import typer
class Launcher:
def __init__(self, verbose:bool=False, debug:bool=False):
self.verbose = verbose
self.debug = debug
def real(self, path:str):
print(f"Real {path=} {self.verbose=} {self.debug=}")
def synthetic(self, size):
print(f"Synthetic {size=} {self.verbose=} {self.debug=}")
app = typer.Typer()
inst = Launcher.__new__(Launcher)
app.callback()(inst.__init__)
app.command()(inst.real)
app.command()(inst.synthetic)
if __name__ == "__main__":
app() This script can be executed like this:
The |
Another option is to use inheritance of data classes. It might be easier to understand, but it is more verbose and it puts heavy computation in to post_init method which is far from ideal. import dataclasses
import typer
app = typer.Typer()
@dataclasses.dataclass
class common:
verbose: bool = False
debug: bool = False
@app.command()
@dataclasses.dataclass
class real(common):
path: str = ""
def __post_init__(self):
print(f"Real {self.path=} {self.verbose=} {self.debug=}")
@app.command()
@dataclasses.dataclass
class synthetic(common):
size: int = 10
def __post_init__(self):
print(f"Synthetic {self.size=} {self.verbose=} {self.debug=}")
if __name__ == "__main__":
app() |
Wow, I did not know about this syntax for f-strings, I could've saved so much work xD The dataclasses approach looks really interesting, I will definetely try that out. |
i really like the dataclass approach acutally from a readability standpoint, but if you execute help for the command you get instance information
which gets even more ugly if you would have more shared options or do like Annotated[bool, typer.Option()] things in Common. Idea to fix this? Also you have to put init=False to Common in order to fix pylance errors in @app.command() |
What you see is a synthesised help string for a data class. If you provide your own it will be picked up by typer. One think I would change is to use lower case names for the dataclasses to express the idea that they are not objects but rather function calls that can have side effects and can be expensive. |
Hi, I was looking for a way to share common parameters accross commands. I had a look at the The idea is is still to use a decorator that merges signatures, but done differently. Below is a proof of concept. First we need a function to merge two signatures. from functools import wraps
from itertools import groupby, chain, compress
from operator import attrgetter
import inspect
def merge_signatures(signature, other, drop = [], strict: bool = True):
"""
Merge two signatures.
Returns a new signature where the parameters of the second signature have been
injected in the first if they weren't already there (i.e. same name not found).
Also returns two maps that can be used to find out if a parameter in the new
signature was present in the originals (or can be used to recover the original
signatures).
If `strict` is true, parameters with same name in both original signatures must
be of same kind (positional only, keyword only or maybe keyword). Otherwise a
ValueError is raised.
"""
# Split parameters by kind
groups = {
k: list(g) for k, g in groupby(signature.parameters.values(), attrgetter('kind'))
}
# Append parameters from other signature
for name, param in other.parameters.items():
if name in drop:
continue
if name in signature.parameters:
if strict and param.kind != signature.parameters[name].kind:
raise ValueError(f"Both signature have same parameter {name!r} but with "
f"different kind")
continue
# Variadic args (*args or **kwargs)
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
if param.kind not in groups:
groups[param.kind] = [param]
elif param.name != groups[param.kind][0].name:
raise ValueError(f"Variadic args must have same name when present "
f"on both signatures: got {groups[param.kind][0].name!r} "
f"and {param.name!r}")
# Non variadic args
else:
groups.setdefault(param.kind, []).append(param)
# Depending on input signatures, the resulting one can be invalid as the
# insertion order could yield to a parameter with a default value being in
# front of a parameter without default value.
#
# Make sure params with default values are put after params without default.
# This is done on a per kind basis (?) and can lead to unintuitive parameter
# reordering...
for params in groups.values():
if params:
params.sort(key = lambda p: bool(p.default != inspect.Parameter.empty))
# Merged parameters list
parameters = list(sorted(chain(*(groups.values())), key = attrgetter('kind')))
# Memoize if parameters were present in original signatures
sel_0 = [1 if p.name in signature.parameters else 0 for p in parameters]
sel_1 = [1 if p.name in other.parameters else 0 for p in parameters]
return signature.replace(parameters = parameters), sel_0, sel_1 Then a decorator that inject extra parameters in the decorated function.
def with_parameters(shared):
def wrapper(command):
signature, sel_0, sel_1 = merge_signatures(
inspect.signature(command), inspect.signature(shared)
)
@wraps(command)
def wrapped(*args, **kwargs):
# Bind values
bound = signature.bind(*args, **kwargs)
bound.apply_defaults()
# Call outer function with a set of selected parameters
shared(*compress(bound.args, sel_1))
# Call inner function with another set of selected parameters
return command(*compress(bound.args, sel_0))
wrapped.__signature__ = signature
return wrapped
return wrapper And know we can use it to inject common parameters into commands. from typing import Optional
import typer
app = typer.Typer()
def debug_params(
ctx: typer.Context,
debug: bool = typer.Option(False, '--debug', help = "Enable debug mode"),
):
"""
Configure debug option
"""
ctx.ensure_object(dict)
ctx.obj['debug'] = debug
@app.command()
@with_parameters(debug_params)
def hello(ctx: typer.Context, name: Optional[str] = None):
"""
Say Hello
"""
if ctx.obj.get('debug'):
typer.echo("About to say hello")
if name:
typer.echo(f"Hello {name}")
else:
typer.echo("Hello World!")
@app.command()
@with_parameters(debug_params)
def bye(ctx: typer.Context, name: Optional[str] = None):
"""
Say goodbye
"""
if ctx.obj.get('debug'):
typer.echo("About to say goodbye")
if name:
typer.echo(f"Bye {name}")
else:
typer.echo("Goodbye!")
if __name__ == "__main__":
app() The > python -m test hello --help
Usage: python -m test hello [OPTIONS]
Say Hello
Options:
--name TEXT
--debug Enable debug mode
--help Show this message and exit.
> python -m test hello --name John
Hello John
> python -m test hello --name John --debug
About to say hello
Hello John
> python -m test bye --debug
About to say goodbye
Goodbye! Best regards, Antoine |
Do you use your approach already ? Any side-effects ? |
Yes I do use this approach. No side effect so far. Note however this was only a first attempt and the decorator I posted is not robust at all for more general signature merging. I use a different version. I can post it if you are interested. Best regards, Antoine |
Indeed, I am interested. I have large signatures, and many, that will certainly benefit from such a solution. There shouldn't be any penalty, in terms of speed, in sharing, right ? In fact, is there a gain ? ps- Balancing between readability and efficiency. |
Main issue I see with this is that it won't be possible to have defaults in the shared options if there's a non-default value in one of the subcommands that use it. Python would complain about non-default args following default args. |
Hi, here is the code I use now. Regarding efficiency, I did not made any benchmark. So, I can't really tell. The cost is that you have 3 function calls instead of one, and arguments must be dispatched. Also there is a side effect: i.e. you can not really control parameter order. Extra (shared) parameters are always appended after parameters in the decorated command... unless they are reordered because parameters without default value are put before those that have one. Another limitation in the code below is that it does not handle variadic args. the decorator would be more complicated and I have no use case using typer (I'm not a heavy user of typer, so I can't tell if this is really a limitation). Finally, I added an option for the decorated command to get a shared parameter directly without the Best regards, Antoine from typing import Sequence
from inspect import Signature, Parameter, signature
from operator import itemgetter
from functools import wraps
def merge_signatures(first: Signature,
second: Signature, *,
drop: Sequence[str] = []) -> Signature:
"""
Merge two signatures.
Returns a new signature where the parameters of the second signature have been
injected in the first if they weren't already there (i.e. same name not found).
The following rules are used:
- parameter order is preserved, with parameter from first signature coming
first, and parameters from second one coming after
- when a parameter (same name) is found in both signatures, parameter from
first signature is kept, but if its annotation or default value is Ellipsis
they are replaced with annotation and default value coming from second
parameter.
- parameters in second signature whose name appear in drop list are not
taken into account
Once this is done, we do not have a valid signature. The following extra step
are performed:
- move all positional only parameter first. Positional only parameters will
still be ordered together, but some parameters from second signature will
now appear before parameters from first signature (the non positional only
ones).
- make sure we have at most one variadic parameter of each kind (keyword and
non keyword). They can appear in both original signature but under same
name. Otherwise a ValueError is raised.
- move keyword only parameters last (just before variadic keyword perameter)
- keyword only parameters are left as is. It does not seem to be a problem is
some of have default values and appear before other keyword only parameters
without default value.
Result is still not a valid signature as we could have some positional only
parameter with a default value, followed by non keyword or positional without
default value. In this case, a ValueError will be raised.
"""
params = dict(first.parameters)
for n, p1 in second.parameters.items():
if n in drop:
continue
if (p0 := params.get(n)):
if p0.default is Ellipsis or p0.default is Parameter.empty:
p0 = p0.replace(default = p1.default)
if p0.annotation is Parameter.empty:
p0 = p0.replace(annotation = p1.annotation)
params[n] = p0
else:
params[n] = p1
# Sort params by kind, moving params with default value after params without
# default value within each kind group.
params = sorted(
params.values(), key = lambda p: 2 * p.kind + bool(p.default != Parameter.empty)
)
# Will raise if signature not valid
return first.replace(parameters = params)
def with_extra_parameters(extra, *, drop: Sequence[str] = []):
"""
Append extra parameters to Typer command.
"""
def wrapper(command):
s0 = signature(extra)
s1 = signature(command)
s2 = merge_signatures(s1, s0, drop = drop)
# Correct dispatch with variadic args and PO parameters would be more tricky.
# Don't konw if we have such use cases with typer.
assert not any(
p.kind in (Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD)
for p in s2.parameters.values()
)
n0 = list(p.name for p in s0.parameters.values() if p.name not in drop)
n1 = list(p.name for p in s1.parameters.values())
a0 = itemgetter(*n0)
a1 = itemgetter(*n1)
@wraps(command)
def wrapped(*args, **kwargs):
b2 = s2.bind(*args, **kwargs)
b2.apply_defaults()
# Process extra parameters
extra(**dict(zip(n0, a0(b2.arguments))))
# Invoke typer command
return command(**dict(zip(n1, a1(b2.arguments))))
setattr(wrapped, '__signature__', s2)
return wrapped
return wrapper
# ---
from typing import Annotated
from typer import Typer, Argument, Option, Context, echo
app = Typer()
def debug_params(
ctx: Context,
debug: Annotated[bool, Option(help = "Set log level to DEBUG")] = False,
drop: Annotated[bool, Option(help = "Drop option")] = False,
):
"""
"""
echo(f"debug_params({ctx=}, {debug=}, {drop=})")
ctx.ensure_object(dict)
ctx.obj['debug'] = debug
ctx.obj['drop'] = drop
@app.command()
@with_extra_parameters(debug_params, drop = ('drop',))
def hello(ctx: Context,
name: Annotated[str, Option()],
debug = ...):
"""
"""
echo(f"hello({ctx=}, {name=}, {debug=})")
@app.command()
@with_extra_parameters(debug_params)
def bye(ctx: Context,
name: Annotated[str, Argument()],
greeting: Annotated[str, Option()] = '',
):
"""
"""
echo(f"bye({ctx=}, {name=}, {greeting=}, debug={ctx.obj.get('debug')}, drop={ctx.obj.get('drop')})")
@app.command()
@with_extra_parameters(debug_params)
def debug(ctx: Context, debug = True):
"""
"""
echo(f"debug({ctx=}, debug={ctx.obj.get('debug')})")
if __name__ == "__main__":
app() |
I use the Annotated format and just define a default there, also if iam thinking about u could set kwargs_only or? i just use it simplified like this though and i really like it so far. I like to also do like log customization for example in the BaseRunner post_init and then just call super().post_init() in the other classes Have to say its a little slow to get running though, but for my use case that doesnt matter.
|
Support for PEP 692 annotations as in #665 would likely solve some of these issues. |
First check
Description
Is there a clean way in typer?
https://stackoverflow.com/questions/40182157/shared-options-and-flags-between-commands
I'd like to share options in subcommands.
A workaround is using a callback on a common parent command.
However this leads to unintuitive behavior as help shows on parent command only.
The text was updated successfully, but these errors were encountered: