fastapi / typer

Typer, build great CLIs. Easy to code. Based on Python type hints.
https://typer.tiangolo.com/
MIT License
15.76k stars 671 forks source link

Need feature to share options or arguments between commands #405

Open allinhtml opened 2 years ago

allinhtml commented 2 years ago

First Check

Commit to Help

Example Code

from typing import Optional, List

import typer
import os

app = typer.Typer()

@app.command()
def start(
  debug: bool = typer.Option(False),
  output_dir: str = typer.Option(os.getcwd()),
  flows: Optional[List[str]] = typer.Option(None, "--flow", "-f")):
  typer.echo(f"Debug mode: {debug}")
  typer.echo(f"Output Dir: {output_dir}")
  typer.echo(f"start flows: {flows}")

@app.command()
def stop(
  debug: bool = typer.Option(False),
  output_dir: str = typer.Option(os.getcwd())):
  typer.echo(f"Debug mode: {debug}")
  typer.echo(f"Output Dir: {output_dir}")
  typer.echo("STOP!")

@app.command()
def clean(
  debug: bool = typer.Option(False),
  output_dir: str = typer.Option(os.getcwd())):
  typer.echo(f"Debug mode: {debug}")
  typer.echo(f"Output Dir: {output_dir}")
  typer.echo("STOP!")

if __name__ == "__main__":
    app()

Description

How can we easily add common options into multiple commands like debug or output_directory?

Related question - https://github.com/tiangolo/typer/issues/153 - But this is not working as expected. Help section don't show message properly as commented here.

Operating System

Linux, Windows, macOS

Operating System Details

No response

Typer Version

ALL

Python Version

ALL

Additional Context

No response

Andrew-Sheridan commented 2 years ago

@allinhtml One way to accomplish this is to have a module level object, a dataclass instance or a dict etc, to hold the state, and a callback to set it.

from typing import Optional, List

import typer
import os

app = typer.Typer(add_completion=False)
state = {}

@app.callback()
def _main(
    debug: bool = typer.Option(False, "--debug", help="If set print debug messages"),
    output_dir: str = typer.Option(os.getcwd(), help="The output directory"),
):
    state["debug"] = debug
    state["output_dir"] = output_dir

@app.command()
def start(
    flows: Optional[List[str]] = typer.Option(None, "--flow", "-f"),
):
    typer.echo(f"Debug mode: {state['debug']}")
    typer.echo(f"Output Dir: {state['output_dir']}")
    typer.echo(f"start flows: {flows}")

@app.command()
def stop():
    typer.echo(f"Debug mode: {state['debug']}")
    typer.echo(f"Output Dir: {state['output_dir']}")
    typer.echo("STOP!")

@app.command()
def clean():
    typer.echo(f"Debug mode: {state['debug']}")
    typer.echo(f"Output Dir: {state['output_dir']}")
    typer.echo("STOP!")

if __name__ == "__main__":
    app()

with debug:

❯ python issue.py --debug --output-dir GitHub start
Debug mode: True
Output Dir: GitHub
start flows: ()

without:

❯ python issue.py --output-dir GitHub clean
Debug mode: False
Output Dir: GitHub
STOP!

:)

Zaubeerer commented 2 years ago

Is there a way to create common CLI options such, that they are configurable on the sub command level instead of on the command level?

e.g.

Inherited/Shared Options
- common option 1 ...
- common option 2 ...

Options
- subcommand specific option 1 ...
- subcommand specific option 2 ...

So that the command could be given as follows:

python issue.py clean --output-dir GitHub
jimkring commented 1 year ago

I need this feature, too. I posted some code related to working around this, here. The workarounds are "OK" but not great.

chrisjsewell commented 1 year ago

You can use the Context object no?

import typer
app = typer.Typer()

@app.callback()
def main_app(
    ctx: typer.Context,
    verbose: Optional[bool] = typer.Option(None, help="Enable verbose mode.")
):
    obj = ctx.ensure_object(dict)
    obj["verbose"] = verbose

@app.command()
def test(ctx: typer.Context):
    obj = ctx.ensure_object(dict)
    print(obj)

see https://typer.tiangolo.com/tutorial/commands/context/ and https://click.palletsprojects.com/en/8.1.x/complex/

Zaubeerer commented 1 year ago

Will have to check that out, hopefully this weekend.

rodonn commented 1 year ago

Not a perfect solution, but what I've been doing is defining the Option and Argument options outside of the function arguments and then reusing them for functions that share the same arguments.

from typing import Optional, List, Annotated

import typer
import os

app = typer.Typer()

debug_option = typer.Option(
    "--debug",
    "-d",
    help="Enable debug mode.",
    show_default=True,
    default_factory=lambda: True,
)

output_dir_option = typer.Option(
    "--output-dir",
    "-o",
    help="Output directory for the generated files.",
    show_default=True,
    default_factory=os.getcwd,
)

flows_option = typer.Option(
    "--flow",
    "-f",
    help="Start flows.",
    show_default=True,
    default_factory=lambda: None,
)

@app.command()
def start(
    flows: Annotated[Optional[List[str]], flows_option],
    output_dir: Annotated[str, output_dir_option],
    debug: Annotated[bool, debug_option],
):
    typer.echo(f"Debug mode: {debug}")
    typer.echo(f"Output Dir: {output_dir}")
    typer.echo(f"start flows: {flows}")

@app.command()
def stop(
    output_dir: Annotated[str, output_dir_option],
    debug: Annotated[bool, debug_option],
):
    typer.echo(f"Debug mode: {debug}")
    typer.echo(f"Output Dir: {output_dir}")
    typer.echo("STOP!")

@app.command()
def clean(
    output_dir: Annotated[str, output_dir_option],
    debug: Annotated[bool, debug_option],
):
    typer.echo(f"Debug mode: {debug}")
    typer.echo(f"Output Dir: {output_dir}")
    typer.echo("STOP!")

if __name__ == "__main__":
        app()
NikosAlexandris commented 1 year ago

Not a perfect solution, but what I've been doing is defining the Option and Argument options outside of the function arguments and then reusing them for functions that share the same arguments.

from typing import Optional, List, Annotated

import typer
import os

app = typer.Typer()

debug_option = typer.Option(
    "--debug",
    "-d",
    help="Enable debug mode.",
    show_default=True,
    default_factory=lambda: True,
)

output_dir_option = typer.Option(
    "--output-dir",
    "-o",
    help="Output directory for the generated files.",
    show_default=True,
    default_factory=os.getcwd,
)

flows_option = typer.Option(
    "--flow",
    "-f",
    help="Start flows.",
    show_default=True,
    default_factory=lambda: None,
)

@app.command()
def start(
    flows: Annotated[Optional[List[str]], flows_option],
    output_dir: Annotated[str, output_dir_option],
    debug: Annotated[bool, debug_option],
):
    typer.echo(f"Debug mode: {debug}")
    typer.echo(f"Output Dir: {output_dir}")
    typer.echo(f"start flows: {flows}")

@app.command()
def stop(
    output_dir: Annotated[str, output_dir_option],
    debug: Annotated[bool, debug_option],
):
    typer.echo(f"Debug mode: {debug}")
    typer.echo(f"Output Dir: {output_dir}")
    typer.echo("STOP!")

@app.command()
def clean(
    output_dir: Annotated[str, output_dir_option],
    debug: Annotated[bool, debug_option],
):
    typer.echo(f"Debug mode: {debug}")
    typer.echo(f"Output Dir: {output_dir}")
    typer.echo("STOP!")

if __name__ == "__main__":
        app()

Actually, this is a good solution. Listing the input arguments in a function's definition (signature) makes code that is easy to read and understand. Defining typer Options and Arguments only once, is less error prone and economical. Thank you.

robinbowes commented 1 year ago

I'm using something similar to the previous example to share options.

Here's a contrived example:

from datetime import datetime
from types import SimpleNamespace
from typing import Annotated, Optional

import typer

app = typer.Typer()

options = SimpleNamespace(
    start_date=Annotated[
        Optional[datetime],
        typer.Option(
            formats=["%Y-%m-%d"],
            help="Start date",
        ),
    ],
    end_date=Annotated[
        Optional[datetime],
        typer.Option(
            formats=["%Y-%m-%d"],
            help="End date",
        ),
    ],
)

@app.command()
def main(
    start_date: options.start_date = None,
    end_date: options.end_date = None,
):
    print(f"{start_date} - {end_date}")

if __name__ == "__main__":
    app()

Note that the only difference between the start_date and end_date options is the help text.

I'm trying to figure out some way I can use a single date option, and set the help text in the command, ie. something like this:

@app.command()
def main(
    start_date: options.date(help="Start date") = None,
    end_date: options.date(help="End date") = None,
):
    print(f"{start_date} - {end_date}")

Anyone got any ideas how I might implement this?

robinbowes commented 1 year ago

I tried this:

def make_date_option(help="Enter a date"):
    return Annotated[
        Optional[datetime],
        typer.Option(
            formats=["%Y-%m-%d"],
            help=help,
        ),
    ],

@app.command()
def main(
    start_date: make_date_option(help="Start date") = None,
    end_date: make_date_option(help="End date") = None,
):
    print(f"{start_date} - {end_date}")

Sadly, this throws a couple of flake8 syntax errors:

NikosAlexandris commented 1 year ago

def make_date_option(help="Enter a date"): return Annotated[ Optional[datetime], typer.Option( formats=["%Y-%m-%d"], help=help, ), ],

@app.command() def main( start_date: make_date_option(help="Start date") = None, end_date: make_date_option(help="End date") = None, ): print(f"{start_date} - {end_date}")

Following works for me :

❯ cat test.py
from datetime import datetime
from types import SimpleNamespace
from typing import Annotated, Optional
import typer

app = typer.Typer()

def make_date_option(help="Enter a date"):
    return Annotated[
        Optional[datetime],
        typer.Option(
            formats=["%Y-%m-%d"],
            help=help,
        ),
    ]

@app.command()
def main(
    start_date: make_date_option(help='Start') = None,
    end_date: make_date_option(help='End') = None,
):
    print(f"{start_date} - {end_date}")

and

❯ typer test.py run --start-date '2010-01-01' --end-date '2011-02-02'
2010-01-01 00:00:00 - 2011-02-02 00:00:00

I think you have a comma left-over in the end of the make_date_option().

robinbowes commented 1 year ago

I think you have a comma left-over in the end of the make_date_option().

Good spot, but I think that's a copy/paste error and not the source of the F722 error.

Re-visiting, I've found that this code runs just fine (with the extra comma removed), but the Syntastic flake8 check still throws the error.

Using the power of google, I found this solution: https://stackoverflow.com/a/73235243

Adding SimpleNamesapce to the mix, I ended up with this final code:

from datetime import datetime
from types import SimpleNamespace
from typing import Annotated, Optional

import typer

app = typer.Typer()

def make_date_option(help="Enter a date"):
    return Annotated[
        Optional[datetime],
        typer.Option(
            formats=["%Y-%m-%d"],
            help=help,
        ),
    ]

options = SimpleNamespace(
    start_date=make_date_option(help="Start date"),
    end_date=make_date_option(help="End date"),
)

@app.command()
def main(
    start_date: options.start_date = None,
    end_date: options.end_date = None,
):
    print(f"{start_date} - {end_date}")

if __name__ == "__main__":
    app()

In a real project, make_date_option and the options object would be defined in a separate module and imported wherever required.

NikosAlexandris commented 1 year ago

I think you have a comma left-over in the end of the make_date_option().

Good spot, but I think that's a copy/paste error and not the source of the F722 error.

Re-visiting, I've found that this code runs just fine (with the extra comma removed), but the Syntastic flake8 check still throws the error.

Using the power of google, I found this solution: https://stackoverflow.com/a/73235243

Adding SimpleNamesapce to the mix, I ended up with this final code:

from datetime import datetime
from types import SimpleNamespace
from typing import Annotated, Optional

import typer

app = typer.Typer()

def make_date_option(help="Enter a date"):
    return Annotated[
        Optional[datetime],
        typer.Option(
            formats=["%Y-%m-%d"],
            help=help,
        ),
    ]

options = SimpleNamespace(
    start_date=make_date_option(help="Start date"),
    end_date=make_date_option(help="End date"),
)

@app.command()
def main(
    start_date: options.start_date = None,
    end_date: options.end_date = None,
):
    print(f"{start_date} - {end_date}")

if __name__ == "__main__":
    app()

In a real project, make_date_option and the options object would be defined in a separate module and imported wherever required.

I have many shared options, as per your options.start_date and .end_date. Do you think it's worth the effort to organise them thematically using the SimpleNamespace() like you do? This means practically that I can avoid importing multiple options defined elsewhere and one import would suffice. Right?

robinbowes commented 1 year ago

I have many shared options, as per your options.start_date and .end_date. Do you think it's worth the effort to organise them thematically using the SimpleNamespace() like you do? This means practically that I can avoid importing multiple options defined elsewhere and one import would suffice. Right?

Only you can decide what's "worth the effort"

palto42 commented 10 months ago

debug_option = typer.Option( "--debug", "-d", help="Enable debug mode.", show_default=True, default_factory=lambda: True, )

I don't understand why this only forks with default_factory=lambda: True, which looks a bit odd.

NikosAlexandris commented 5 months ago

debug_option = typer.Option( "--debug", "-d", help="Enable debug mode.", show_default=True, default_factory=lambda: True, )

I don't understand why this only forks with default_factory=lambda: True, which looks a bit odd.

I guess because it needs to be a function, and cannot be a variable/constant.

alwaysmpe commented 1 month ago

Ended up here trying to answer a similar question. My approach might be of interest to others:

Code is a bit rough as it's hacked from other stuff.

from click.core import Context, Parameter
from typer.core import TyperCommand, TyperOption
from rich.logging import RichHandler
import logging
from typing import override
from typer import Typer, Argument, Option
from typer.models import ArgumentInfo, OptionInfo
from functools import partial
from typing import Annotated, Callable

logger = logging.getLogger(__name__)

def as_callback(func: Callable):
    def wrapper(
        ctx: Context,
        param: TyperOption,
        *args,
        **kwargs,
    ):
        func(*args, **kwargs)
    return wrapper

def verbose(
        value: bool,
        ) -> None:
    """
    Increase otuput level.
    """
    handler = RichHandler(
        show_path=False,
        show_level=False,
        markup=True,
    )
    log_format = "%(message)s"
    date_format = "[%X]"

    if value:
        logging.basicConfig(
            datefmt=date_format,
            format=log_format,
            level=logging.DEBUG,
            handlers=[handler],
        )
    else:
        logging.basicConfig(
            datefmt=date_format,
            format=log_format,
            level=logging.INFO,
            handlers=[handler],
        )

class Command(TyperCommand):
    """
    Command class to add a verbose option to subcommands.
    """
    @override
    def get_params(self, ctx: Context) -> list[Parameter]:
        params = super().get_params(ctx)
        verbose_opt = TyperOption(
            param_decls=["--verbose", "-v"],
            type=bool,
            is_flag=True,
            show_default=False,
            callback=as_callback(verbose),
            help=verbose.__doc__,
            is_eager=True,
        )
        params.append(verbose_opt)
        return params

    @override
    def invoke(self, ctx: Context):
        if "verbose" in ctx.params:
            del ctx.params["verbose"]
        return super().invoke(ctx)

app = Typer()
app.command = partial(app.command, cls=Command)

def name_arg() -> ArgumentInfo:
    return Argument(
        show_default=False,
        help="Name to say hi to.",
    )

def count_arg():
    return Option(
        default=1,
        show_default=True,
        help="How many times to say hi.",
    )

def title_arg():
    return Argument(
        show_default=True,
        help='title for name.'
    )

@app.command()
def foo(
        name: Annotated[str, name_arg()]
):
    logger.debug("in foo command")
    logger.info(f"got name {name}")

@app.command()
def bar(
        name: Annotated[str, name_arg()],
        title: Annotated[str, title_arg()] = "sir",
        count: int = count_arg(),
):
    logger.debug("in bar command")
    for _ in range(count):
        logger.info(f"got name {title} {name}")

# verbose is meaningless - remove the arg.
@app.command(cls=None)
def version() -> None:
    """
    Print version and exit.
    """

    from rich.console import Console
    console = Console(
        log_path=False,
    )

    package = __package__

    if package is None:
        console.print("dev package")
    else:
        from importlib.metadata import version as meta_version

        console.print(f'{package} version {meta_version(package)}')

    from typer import Exit
    raise Exit

if __name__ == "__main__":
    app()