typer icon indicating copy to clipboard operation
typer copied to clipboard

Need feature to share options or arguments between commands

Open allinhtml opened this issue 2 years ago • 15 comments

First Check

  • [X] I added a very descriptive title to this issue.
  • [X] I used the GitHub search to find a similar issue and didn't find it.
  • [X] I searched the Typer documentation, with the integrated search.
  • [X] I already searched in Google "How to X in Typer" and didn't find any information.
  • [X] I already read and followed all the tutorial in the docs and didn't find an answer.
  • [X] I already checked if it is not related to Typer but to Click.

Commit to Help

  • [X] I commit to help with one of those options 👆

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

allinhtml avatar Jun 15 '22 13:06 allinhtml

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

:)

Andrew-Sheridan avatar Jul 03 '22 15:07 Andrew-Sheridan

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

Zaubeerer avatar Jul 21 '22 16:07 Zaubeerer

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

jimkring avatar Jan 13 '23 03:01 jimkring

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/

chrisjsewell avatar Jan 30 '23 14:01 chrisjsewell

Will have to check that out, hopefully this weekend.

Zaubeerer avatar Jan 30 '23 21:01 Zaubeerer

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

rodonn avatar May 19 '23 20:05 rodonn

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.

NikosAlexandris avatar Aug 01 '23 06:08 NikosAlexandris

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 avatar Aug 09 '23 00:08 robinbowes

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:

  • error| syntax error in forward annotation 'Start date' [F722]
  • error| syntax error in forward annotation 'End date' [F722]

robinbowes avatar Aug 09 '23 00:08 robinbowes

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

NikosAlexandris avatar Oct 18 '23 12:10 NikosAlexandris

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.

robinbowes avatar Oct 19 '23 11:10 robinbowes

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?

NikosAlexandris avatar Nov 03 '23 12:11 NikosAlexandris

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"

robinbowes avatar Nov 03 '23 13:11 robinbowes

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.

palto42 avatar Jan 06 '24 17:01 palto42