Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
Jimmy2027 opened this issue Nov 24, 2020 · 10 comments
Labels
question Question or problem

Comments

@Jimmy2027
Copy link

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 Jimmy2027 added the question Question or problem label Nov 24, 2020
@codethief
Copy link

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

@arquolo
Copy link

arquolo commented Jun 23, 2021

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
Copy link
Author

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
Copy link

codethief commented Nov 18, 2021

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
Copy link

codethief commented Nov 18, 2021

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
Copy link

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

@rec
Copy link

rec commented May 12, 2023

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

@tbenthompson
Copy link

tbenthompson commented May 12, 2023

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
Copy link

rec commented May 13, 2023 via email

@tbenthompson
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Question or problem
Projects
None yet
Development

No branches or pull requests

5 participants