fastapi / typer

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

Is there a way to construct a dataclass directly from the command line inputs? #197

Open Jimmy2027 opened 3 years ago

Jimmy2027 commented 3 years ago

Hi, is there a way to directly construct a dataclass from the command line inputs with typer? Like an argparser would do. For a large amount of arguments this would improve readability of the code, and would permit inheritances of dataclasses like in the example below:

import typer
from dataclasses import dataclass

@dataclass
class BaseFlags:
    arg1: int = typer.Argument(help='something', default=1)
    arg2: int = typer.Argument(help='something', default=2)

@dataclass
class Flags(BaseFlags):
    arg3: int = typer.Argument(help='something', default=3)
    arg4: int = typer.Argument(help='something', default=4)

def main(args:Flags=Flags()):
    print(args)

if __name__ == '__main__':
    typer.run(main)

Thanks a lot for the great package and your help!

codethief commented 3 years ago

This would be really helpful; in particular it would be easier to re-use a set of command line flags across different commands.

arquolo commented 3 years ago

Maybe it's better to add helper that runs callable (main from above), and returns its result. Currently typer.run is NoReturn by it's behavior, because it calls exit() deep inside of it.

I found typer can pass arguments directly to any class, using it's __init__ as source for types of arguments and options. But that requires to put code for main to __init__ of that class. With dataclasses it can be achieved in more elegant way - using theirs __post_init__ method.

With helper:

from dataclasses import dataclass, is_dataclass
from typing import Any, Callable, NoReturn, Type

import typer

T = TypeVar('T')

def run_dataclass(tp: Type[T], callback: Callable[[T], Any]) -> NoReturn:
    assert is_dataclass(tp)

    @dataclass
    class BindType(tp):  # type: ignore
        def __post_init__(self):
            super().__post_init__()
            callback(self)

    typer.run(BindType)

First snippet starts working:

from dataclasses import dataclass

@dataclass
class BaseFlags:
    arg1: int = typer.Argument(help='something', default=1)
    arg2: int = typer.Argument(help='something', default=2)

@dataclass
class Flags(BaseFlags):
    arg3: int = typer.Argument(help='something', default=3)
    arg4: int = typer.Argument(help='something', default=4)

def main(args: Flags):
    print(args)

if __name__ == '__main__':
    run_dataclass(Flags, main)
Jimmy2027 commented 2 years ago

Hi @arquolo, thanks for your answer! If I try your code snipped, I get the error 'super' object has no attribute '__post_init__'. Is it supposed to work as is?

codethief commented 2 years ago

Here's another helper which is based upon decorators. It might still be a bit rough around the edges (and I didn't test it with Typer because I need to get back to work) but at least the general idea should be clear:

from dataclasses import dataclass, is_dataclass
from typing import Callable, Type, TypeVar

@dataclass
class MyArguments:
    name: str
    formal: bool = False

T = TypeVar('T')
R = TypeVar('R')

# decorator generator
def inject_dataclass_args(cls: Type[T]):
    assert is_dataclass(cls)

    def decorator(func: Callable[[T], R]) -> R:
        def wrapped(*args, **kwargs):
            args_as_data_obj = cls(*args, **kwargs)
            return func(args_as_data_obj)

        wrapped.__annotations__ = cls.__init__.__annotations__
        return wrapped

    return decorator

# Usage:

@inject_dataclass_args(MyArguments)
def goodbye(data_obj: MyArguments):
    if data_obj.formal:
        print(f"Goodbye Ms. {data_obj.name}. Have a good day.")
    else:
        print(f"Bye {data_obj.name}!")

if __name__ == "__main__":
    typer.run(goodbye)

One issue that I'm already foreseeing is that Typer won't notice that formal is an optional parameter because it's not part of the annotations. I suppose Typer does some inspect-module-related magic here to check for optional parameters, so one would have to do some similar reverse magic to ensure that inspect yields the right results and not *args, **kwargs.

A quick test shows that @arquolo's solution works better here because @dataclass already sets up the right signature for MyArguments.__init__. So to fix my solution one would either have to look into how @dataclass achieves this or, inspired by @arquolo's suggestion, one could replace the above decorator with this monster:

def inject_dataclass_args(cls: Type[T]):
    assert is_dataclass(cls)

    def decorator(func: Callable[[T], R]) -> R:
        @dataclass
        class wrapped(cls):
            def __post_init__(self):
                super().__post_init__()
                func(self)

        return wrapped

    return decorator

Either way, the nice thing about a decorator here is that it keeps the signature rewriting close to the function whose signature it's changing.

codethief commented 2 years ago

Side note: On a meta level this issue is somewhat related to determining the full type of a function (and "copying" it to another function) which is being discussed here.

tbenthompson commented 1 year ago

I tweaked @codethief 's code and came up with this: https://gist.github.com/tbenthompson/9db0452445451767b59f5cb0611ab483

rec commented 1 year ago

I have an already productionized version of this idea, and it's here: https://github.com/rec/dtyper

tbenthompson commented 1 year ago

I have an already productionized version of this idea, and it's here: https://github.com/rec/dtyper

@rec From a skim of the docs, I think dtyper is doing the reverse of what's asked for here. It's making a dataclass from a CLI function. Instead, I already have a dataclass and I want to create a CLI function from that existing dataclass. Am I misunderstanding?

rec commented 1 year ago

Oops, no, the misreading is mine. You are exactly right that is the reverse thing.

However, I don't quite see how you can really get this to work. How can you set the help for each argument? Or the names of command line flags?

On Fri, May 12, 2023 at 11:18 PM Ben Thompson @.***> wrote:

I have an already productionized version of this idea, and it's here: https://github.com/rec/dtyper

@rec https://github.com/rec From a skim of the docs, I think dtyper is doing the reverse of what's asked for here. It's making a dataclass from a CLI function. I already have a dataclass and I want to create a CLI function from that existing dataclass. Am I misunderstanding?

— Reply to this email directly, view it on GitHub https://github.com/tiangolo/typer/issues/197#issuecomment-1546312127, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAB53MQ7FAFIDNAEJXB3DF3XF2SKTANCNFSM4UBMIWRA . You are receiving this because you were mentioned.Message ID: @.***>

-- /t

PGP Key: @.** https://tom.ritchford.com https://tom.ritchford.com https://tom.swirly.com https://tom.swirly.com*

tbenthompson commented 1 year ago

Oops, no, the misreading is mine. You are exactly right that is the reverse thing. However, I don't quite see how you can really get this to work. How can you set the help for each argument? Or the names of command line flags?

The solution above manages this by copying the type signature of the dataclass' __init__ function. This gets you flag names and defaults:

@dataclass
class Test:
    config: str = ""
    hi: int = 1
    bye: str = "bye"

@dataclass_cli
def main(c: Test):
    """docstring test"""
    pass

you get a CLI like:

> python config.py --help

 Usage: config.py [OPTIONS]

 docstring test

╭─ Options
│ --config        TEXT
│ --hi            INTEGER  [default: 1]
│ --bye           TEXT     [default: bye]
│ --help                   Show this message and exit.
╰─

This is enough for making a quick and easy CLI for personal use or a rough project.