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

[QUESTION] How do I share options or arguments between commands? #153

Open
4 tasks done
bcm0 opened this issue Aug 10, 2020 · 41 comments
Open
4 tasks done

[QUESTION] How do I share options or arguments between commands? #153

bcm0 opened this issue Aug 10, 2020 · 41 comments
Labels
question Question or problem

Comments

@bcm0
Copy link

bcm0 commented Aug 10, 2020

First check

  • I used the GitHub search to find a similar issue and didn't find it.
  • I searched the Typer documentation, with the integrated search.
  • I already searched in Google "How to X in Typer" and didn't find any information.
  • I already searched in Google "How to X in Click" and didn't find any information.

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.

@bcm0 bcm0 added the question Question or problem label Aug 10, 2020
@rino0601
Copy link

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

@haizaar
Copy link

haizaar commented Oct 22, 2020

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

@larsclaussen
Copy link

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 @users_app.callback() patterned from the docs to apply validation and such.

import typer

app = typer.Typer()

users_app = typer.Typer()
app.add_typer(users_app, name="users")


@users_app.callback()
def users_callback():
    typer.echo("Running a users command")


@users_app.command()
def create(name: str):
    typer.echo(f"Creating user: {name}")


if __name__ == "__main__":
    app()

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

@jtm0
Copy link

jtm0 commented Dec 28, 2021

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()
$ python main.py --help
Usage: main.py [OPTIONS] COMMAND [ARGS]...

  Common Entry Point

Options:
  --username TEXT  Username  [env var: APP_USERNAME; required]
  --password TEXT  Password  [env var: APP_PASSWORD; required]
  --host TEXT      Host  [env var: APP_HOST; required]
  --help           Show this message and exit.

Commands:
  device-info  Get device info

@robinbowes
Copy link

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

$ python main.py device-info --help
Usage: main.py [OPTIONS] COMMAND [ARGS]...
Try 'main.py --help' for help.

Error: Missing option '--username'.

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 --help output.

@lovetoburnswhen
Copy link

Another possibility is to use the merge_args library as discussed here #296

@robinbowes
Copy link

Another possibility is to use the merge_args library as discussed here #296

@lovetoburnswhen Thanks - I shall investigate that. The example in #296 seems to work, although flake8 throws an error with the source:

$ flake8
./main.py:11:6: F821 undefined name 'wrapper'

I've raised that with pyflakes: PyCQA/pyflakes#681

Thanks again.

@ajoino
Copy link

ajoino commented Mar 16, 2022

@jtm0 is it possible to change your solution so that it also works with typer.Argument?

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

$ app create name
             ^^^^
...
>>> ctx.params
{'name_arg': 'name'}

Current behaviour

$ app create name
      ^^^^^^
...
>>> ctx.params
{'name_arg': 'create'}

Do you have any idea what could work here?

@fzyzcjy
Copy link

fzyzcjy commented Apr 10, 2022

Hi, is there any updates?

@LewisCharlesBaker
Copy link

LewisCharlesBaker commented Apr 22, 2022

Hello, @jtm0

Thanks for your snippet above.

Would you have any recommendations on how to return the {profile_dir} value from the code below:

app = typer.Typer()
@dataclass
class Common:
    def __init__(self, profile_dir):

        self.profile_dir = profile_dir

@app.callback()
def profile_callback(ctx: typer.Context,
                     profile_dir: str = typer.Option(..., )):

    typer.echo(f"Hello {profile_dir}")

    """Common Entry Point"""
    ctx.obj = Common(profile_dir)

    return (f"{profile_dir}")

When I try and access this using:

profile_path = profile_callback()

I get the following error:

profile_callback() missing 1 required positional argument: 'ctx'

@ajlive
Copy link

ajlive commented Jun 8, 2022

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.

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.

@allinhtml
Copy link

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

$ python main.py device-info --help
Usage: main.py [OPTIONS] COMMAND [ARGS]...
Try 'main.py --help' for help.

Error: Missing option '--username'.

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 --help output.

Do we have any solution for this? Please help here.

@ajlive
Copy link

ajlive commented Jun 15, 2022

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.

@Zaubeerer
Copy link

As mentioned in #405 (comment) I would also be interested in an elegant solution to this. :)

@fdcastel
Copy link

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:

myapp --verbose pull args_to_pull

myapp push args_to_push --verbose

As it is today, only the first of above works. The second fails with error No such option: --verbose.

In this case I don't need (nor want) to re-declare the verbose option in any of my commands. It is really a global feature which should be handled in one place only. It is not an option common to several commands.

I tried to use is_eager=True to no avail. Maybe this could be a possible solution? (Or even a new is_global=True?)

I can contribute with PRs if this direction is acceptable.

@robinbowes
Copy link

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 --version option requires an argument in Case 3?

Any thoughts?

@plusiv
Copy link

plusiv commented Apr 2, 2023

Any updates about this?

@Swebask
Copy link

Swebask commented May 9, 2023

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

@ylhan
Copy link

ylhan commented May 31, 2023

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 -v is provided anywhere? To be specific, I want this:

$ python3 test.py command -v 
init 1
command 1

@NikosAlexandris
Copy link

NikosAlexandris commented Sep 6, 2023

By the way, the counter works for me only if I set is_flag=False, i.e. if I use the following :

typer_option_verbose = typer.Option(
    '--verbose',
    '-v',
    count=True,
    is_flag=False,
    help='Show details while executing commands',
    rich_help_panel=rich_help_panel_output,
)

Else, I get the following error :

TypeError: 'count' is not valid with 'is_flag'.

Using version 0.9.0.

@Vezzp
Copy link

Vezzp commented Oct 2, 2023

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.

@EuleMitKeule
Copy link

I used the snippet provided by @Vezzp and modified it.
Now I am able to define dataclasses that contain the params and can inherit from each other.
Also the params are statically typed and can be accesed using a singleton pattern by ParamClass.instance.some_param.
The commands can specify the subclass of the base param class CommonOptions they would like to use.
Additionally I am able to use subcommands with arguments and options and am able to define the params at arbitrary positions.
python -m my_package --arg1 convert --arg2 works just as well as
python -m my_package convert --arg1 --arg2.

command.py

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)

common_options.py

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

convert_options.py

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]

main_commands.py

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

convert_commands.py

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)

@lhriley
Copy link

lhriley commented Nov 1, 2023

@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

@EuleMitKeule
Copy link

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

@lhriley
Copy link

lhriley commented Nov 2, 2023

@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 typer to have global options?

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 @command decorator, you could add them at:

app = typer.Typer(common_options=my_common_options)

where the my_common_options could either extend a new class or be a simple dict that mimics what is passed into methods decorated by @command.

⚠️ pseudo code below:

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
}

@EuleMitKeule
Copy link

EuleMitKeule commented Nov 2, 2023

@lhriley
I created a repository containing a minimal working example.

The anti-pattern is having to access the objects using a singleton pattern like CommonOptions.instance.
Also this singleton solution currently depends on having classmethod properties, which where deprecated with Python 3.11 even though they still seem to work.
If I got a solution on how to define the global options classes and accessing them without the singleton pattern and while still having full static typing and code completion, I might consider submitting this to typer.

@lhriley
Copy link

lhriley commented Nov 2, 2023

Thanks for the clarification @EuleMitKeule. Also, I just took a look at your example repo. Much appreciated :chefskiss:

@ankostis
Copy link

ankostis commented Nov 2, 2023

Also the params are statically typed and can be accesed using a singleton pattern by
...
ParamClass.instance.some_param.

  • Since when globals considered anti-pattern in Python?
  • Why implement Singletons with classes, when the language provides module globals, ie. module-attributes and module-properties (PEP-562)? These are guaranteed to be singletons on each module.

@PiotrCzapla
Copy link

Typer (v0.9) is flexible enough to let us use shared parameters without any extra code.
With a bit of python magic we were able to migrate aFire cli, to Typer with minimal modifications. In fire it is common to place shared options as parameters to init and then public methods become commands with their set of options.
Here is how to do this in typer:

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:

$ python launcher.py --verbose synthetic  12
Synthetic size='12' self.verbose=True self.debug=False

The __new__ / __init__ bit is needed to separate object creation from initialisation. It let us register bound methods as command before inst object is initialised. The initialisation is done later by typer when it runs init as callback.

@PiotrCzapla
Copy link

PiotrCzapla commented Jan 18, 2024

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

@EuleMitKeule
Copy link

{self.size=}

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.

@KiSchnelle
Copy link

i really like the dataclass approach acutally from a readability standpoint, but if you execute help for the command you get instance information

test.py real --help
 Usage: test.py real [OPTIONS]                                                                                                                                                                                                                                                                                                                                                                            
                                                                                                                                                                                                                                                                                                                                                                                                          
 Real(verbose: bool = False, debug: bool = False, path: str = '')                                                                                                                                                                                                                                                                                                                                        
                                                                                                                                                                                                                                                                                                                                                                                                          
╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --verbose    --no-verbose          [default: no-verbose]                                                                                                                                                                                                                                                                                                                                               │
│ --debug      --no-debug            [default: no-debug]                                                                                                                                                                                                                                                                                                                                                 │
│ --path                       TEXT                                                                                                                                                                                                                                                                                                                                                                      │
│ --help                             Show this message and exit.   

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

@PiotrCzapla
Copy link

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.

@adm271828
Copy link

Hi,

I was looking for a way to share common parameters accross commands. I had a look at the merge_args hint, but didn't find a suitable way to use it. However, another idea came to me.

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 debug parameters has been injected as we can see with --help, and is taken into account in hello command as well as bye command:

> 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

@NikosAlexandris
Copy link

Hi,

I was looking for a way to share common parameters accross commands. I had a look at the merge_args hint, but didn't find a suitable way to use it. However, another idea came to me.

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 debug parameters has been injected as we can see with --help, and is taken into account in hello command as well as bye command:

> 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 ?

@adm271828
Copy link

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

@NikosAlexandris
Copy link

NikosAlexandris commented Mar 17, 2024

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.
ps- sharing : the parameters or the action of sharing the solution :-)

@omniproc
Copy link

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

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.

@adm271828
Copy link

adm271828 commented Mar 24, 2024

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. ps- sharing : the parameters or the action of sharing the solution :-)

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 ctx.obj indirection. Simply repeat it and set default value to .... See the hello command.

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

@KiSchnelle
Copy link

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.

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.

# with default
@dataclass(init=False)
class BaseRunner:
    output_folder: Annotated[
        Path,
        typer.Option(
            default_factory=default_factories.get_default_output_folder,
            callback=parameter_callbacks.output_folder_callback,
            help="Path for the output folder. default: current_dir/bla_output_xxx",
            rich_help_panel="Optional options",
            show_default=False,
        ),

# no default
@dataclass(init=False)
class GeneralModeXRunner(BaseRunner):
    image_folder: Annotated[
        Path,
        typer.Argument(
            callback=parameter_callbacks.image_folder_callback,
            help="Path for the images folder.",
            rich_help_panel="Required Arguments",
            show_default=False,
        ),
    ]

# with default
@app.command("my_command")
@dataclass()
class MyCommandRunner(GeneralModeXRunner):
    model_path: Annotated[
        Path,
        typer.Option(
            default_factory=lambda: default_factories.get_newest_model_path(
                "detect_X"
            ),
            callback=parameter_callbacks.model_path_callback,
            help="Path for the model. default: newest model for the mode",
            rich_help_panel="Path Options",
            show_default=False,
        ),
    ]

@elupus
Copy link

elupus commented Jun 17, 2024

Support for PEP 692 annotations as in #665 would likely solve some of these issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Question or problem
Projects
None yet
Development

No branches or pull requests