Open allinhtml opened 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!
:)
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
I need this feature, too. I posted some code related to working around this, here. The workarounds are "OK" but not great.
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/
Will have to check that out, hopefully this weekend.
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()
Not a perfect solution, but what I've been doing is defining the
Option
andArgument
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 Option
s and Argument
s only once, is less error prone and economical. Thank you.
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?
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]
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()
.
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 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 theoptions
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?
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"
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.
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.
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()
First Check
Commit to Help
Example Code
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