typer icon indicating copy to clipboard operation
typer copied to clipboard

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

Open Jimmy2027 opened this issue 5 years ago • 11 comments

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!

Jimmy2027 avatar Nov 24 '20 20:11 Jimmy2027

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

codethief avatar Mar 23 '21 18:03 codethief

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)

arquolo avatar Jun 23 '21 15:06 arquolo

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?

Jimmy2027 avatar Nov 17 '21 12:11 Jimmy2027

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 avatar Nov 18 '21 18:11 codethief

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.

codethief avatar Nov 18 '21 19:11 codethief

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

tbenthompson avatar May 11 '23 23:05 tbenthompson

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

rec avatar May 12 '23 07:05 rec

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?

tbenthompson avatar May 12 '23 21:05 tbenthompson

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

rec avatar May 13 '23 08:05 rec

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.

tbenthompson avatar May 13 '23 12:05 tbenthompson

This looks like a duplicate of #154 described in a different way

YuriiMotov avatar Sep 16 '25 18:09 YuriiMotov