BrianPugh / cyclopts

Intuitive, easy CLIs based on python type hints.
Apache License 2.0
283 stars 7 forks source link

Shared configuration between commands #212

Open mezuzza opened 1 month ago

mezuzza commented 1 month ago

Is there a good way to share flags between commands? It's a bit of a pain to rewrite them over and over. For example, I'd like to share an API key across all commands but right now I just copy that parameter across commands which is a bit tedious.

Love the library btw. It's a really intuitive approach overall.

BrianPugh commented 1 month ago

It depends on what you're trying to achieve, but here are 2 approaches:

  1. If the duplicate help-string is the main issue, you can define the type once and re-use it in all your command signatures:
from typing import Annotated
from cyclopts import App, Parameter

app = App()
ApiKeyType = Annotated[str, Parameter(help="User API key.")]

@app.command
def foo(api_key: ApiKeyType):
    """Foo short description."""
    pass

@app.command
def bar(api_key: ApiKeyType):
    """Bar short description."""
    pass

if __name__ == "__main__":
    app()
  1. If you're OK with global variables, and you want to make your function signatures simpler, you can use a meta-app.
    
    from typing import Annotated
    from cyclopts import App, Parameter

app = App()

API_KEY: str

@app.command def foo(): """Foo short description.""" print(f"foo {API_KEY=}")

@app.command def bar(count: int): """Bar short description.""" print(f"bar {count} {API_KEY=}")

@app.meta.default def meta( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], api_key: str, ): """My application description.

Parameters
----------
api_key: str
    User API key.
"""
global API_KEY
API_KEY = api_key

app(tokens)

if name == "main": app.meta() # NOTICE!!! This is "app.meta", not just "app"


```bash
$ python issue-212.py foo --api-key=SECRET
foo API_KEY='SECRET'

$ python issue-212.py bar 7 --api-key=SECRET
bar 7 API_KEY='SECRET'

Love the library btw. It's a really intuitive approach overall.

Thank you!

mezuzza commented 3 weeks ago

The idea is that commands can have configuration that's shared across sub commands with sub commands adding additional args.

Let's say the parent command has 10 parameters, it becomes quickly unmanageable. You can think about common flags like api keys, verbosity flags, etc.

Globals would work, but at that point, I think you'd end up in a world where you're using an argparse-ish library like simple_parsing.

I almost think something like:

@app.command
def foo(*, api_key: str):
    """Foo short description.

    Args:
        api_key: some description
    """

    @app.command
    def bar(pos_arg: str, /):
        """Bar's description.

        Args:
            pos_arg: some arg
        """
        print(pos_arg)

This would be a nice way of describing something like:

./my_cmd foo --api_key="some-key" bar "hello"

Of course, this has a number of problems, but the main point is that foo's configuration is shared with bar in a way that lets other subcommands exist.

Anyway, not really a complaint, just something I was dealing with. For my particular use case for now, it's fine to duplicate the flags across the commands, but I don't think I can really keep that up.

Looks like Typer doesn't have a good answer to this either afaict: https://github.com/fastapi/typer/issues/153

BrianPugh commented 3 weeks ago

Maybe something like this is closer to what you want:

from dataclasses import dataclass
from typing import Annotated

from cyclopts import App, Parameter

@dataclass
class CommonConfig:
    api_key: str

# Setting parse=False means that Cyclopts skips it;
# the config will be supplied from the meta app.
ConfigType = Annotated[CommonConfig, Parameter(parse=False)]

app = App()

@app.command
def foo(
    *,
    config: ConfigType,
):
    """Foo short description."""
    print(f"foo {config.api_key=}")

@app.command
def bar(
    count: int,
    *,
    config: ConfigType,
):
    """Bar short description."""
    print(f"bar {count} {config.api_key=}")

@app.meta.default
def meta(
    *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
    api_key: str,
):
    config = CommonConfig(api_key=api_key)
    command, bound = app.parse_args(tokens)
    return command(*bound.args, **bound.kwargs, config=config)

if __name__ == "__main__":
    app.meta()  # NOTICE!!! This is "app.meta", not just "app"

In the upcoming Cyclopts v3, you will be able to directly instantiate the CommonConfig without having to explode out the arguments in the meta-function-signature.

BrianPugh commented 1 day ago

related: https://github.com/BrianPugh/cyclopts/issues/228#issuecomment-2343734567