typer icon indicating copy to clipboard operation
typer copied to clipboard

[FEATURE] Using a dataclass for types/command generation

Open j-martin opened this issue 4 years ago • 7 comments

Is your feature request related to a problem

No.

The solution you would like

In a lot of our codebase we pass the typer arguments to a dataclass that we pass around the command-line tool code.

@dataclass
class ToolConfig:
    name: str
    parent: Optional[str] = None

@app.command(help="Some help")
def change_status(
    name: str = typer.Argument(...),
    parent: Optional[str] = typer.Argument(..., default=None)
) -> None:
    some_function(ToolConfig(name, parent))

This results in duplicating a fair bit of function arguments and can lead to mistakes.

It would nice if it was possible to do something like:

@dataclass
class ToolConfig:
    name: str = typer.Argument(...) # implementing `field` in some way.
    parent: Optional[str] = typer.Argument(..., default=None) # something like `field(default_factory=lambda: None)` 

@app.command(help="Some help.")
def change_status(tool_config: ToolConfig) -> None:
    some_function(tool_config)

Describe alternatives you've considered

Using typer/click context might be a solution, but I am not familiar enough with it all to say.

I am not even sure if this feature would be possible at all really.

j-martin avatar Aug 17 '20 20:08 j-martin

Not the most elegant solution, but one way to do it is to override get_params_from_function

import inspect
from typing import Any, Callable, Dict, get_type_hints, Optional, Tuple

import dataclasses
import typer
from dataclasses import dataclass
from typer.models import ParamMeta


def get_params_from_function(func: Callable[..., Any]) -> Dict[str, ParamMeta]:
    signature = inspect.signature(func)

    type_hints = get_type_hints(func)
    params = {}

    for param in signature.parameters.values():
        annotation = param.annotation
        if param.name == 'kwargs':
            continue
        if param.name in type_hints:
            annotation = type_hints[param.name]
        if inspect.isclass(annotation) and dataclasses.is_dataclass(annotation):
            if inspect.isclass(param.default) and issubclass(param.default, inspect.Parameter.empty):
                dct = dataclasses.asdict(annotation())
                subtype_hints = get_type_hints(annotation)
            else:
                dct = dataclasses.asdict(param.default)
                subtype_hints = get_type_hints(param.default)
            for k, v in dct.items():
                params[k] = ParamMeta(name=k, default=v, annotation=subtype_hints.get(k, str))
        else:
            params[param.name] = ParamMeta(
                name=param.name, default=param.default, annotation=annotation
            )
    return params


typer.main.get_params_from_function = get_params_from_function


@dataclass
class TestConfig:
    name: str = typer.Option('foo')
    param1: Optional[str] = typer.Argument(...)
    param2: Tuple[str, str, str] = typer.Option((None, None, None))


app = typer.Typer()


@app.command('cmd')
def func(test: TestConfig = TestConfig(), other_param=None, **kwargs):
    test = TestConfig(**kwargs)
    print(test)
    print(locals())


if __name__ == '__main__':
    app()

fk128 avatar Mar 30 '21 16:03 fk128

This feature would be incredibly useful, and I've seen a few ad-hoc implementation in other libs trying to solve the same problem, e.g. HfArgumentParser in hugginface/transformers which is pretty nice. Here's an example: github.com/huggingface/transformers/examples/legacy/question-answering/run_squad_trainer.py#L71.

agrinh avatar Aug 17 '21 15:08 agrinh

I was going to switch entirely from my awful simpcli3 package over to this until I figured out this this doesn't support Dataclasses. One workaround might be to generate from the dataclass a flattened list of params using whatever logic you'd like. Then with this new list of params generate a function at runtime which accepts those params, to annotate with typer. I think that offloads the biggest tricky part of the problem to the user. You can see an example of this runtime function generation in magicinvoke or the wrapt author's series of articles on decorators.

haydenflinner avatar Oct 11 '22 19:10 haydenflinner

Is there any plan to work on this?

alexmolas avatar Nov 04 '22 15:11 alexmolas

Here's a different variant that uses the signature of the auto-generated dataclass __init__ to define the typer CLI:

def dataclass_cli(func):
    """
    Converts a function taking a dataclass as its first argument into a
    dataclass that can be called via `typer` as a CLI.

    Additionally, the --config option will load a yaml configuration before the
    other arguments.

    Modified from:
    - https://github.com/tiangolo/typer/issues/197

    A couple related issues:
    - https://github.com/tiangolo/typer/issues/153
    - https://github.com/tiangolo/typer/issues/154
    """

    # The dataclass type is the first argument of the function.
    sig = inspect.signature(func)
    param = list(sig.parameters.values())[0]
    cls = param.annotation
    assert dataclasses.is_dataclass(cls)

    def wrapped(**kwargs):
        # Load the config file if specified.
        if kwargs.get("config", "") != "":
            with open(kwargs["config"], "r") as f:
                conf = yaml.safe_load(f)
        else:
            conf = {}

        # CLI options override the config file.
        conf.update(kwargs)

        # Convert back to the original dataclass type.
        arg = cls(**conf)

        # Actually call the entry point function.
        return func(arg)

    # To construct the signature, we remove the first argument (self)
    # from the dataclass __init__ signature.
    signature = inspect.signature(cls.__init__)
    parameters = list(signature.parameters.values())
    if len(parameters) > 0 and parameters[0].name == "self":
        del parameters[0]

    # Add the --config option to the signature.
    # When called through the CLI, we need to set defaults via the YAML file.
    # Otherwise, every field will get overwritten when the YAML is loaded.
    parameters = [
        inspect.Parameter(
            "config",
            inspect.Parameter.POSITIONAL_OR_KEYWORD,
            default=typer.Option("", callback=conf_callback, is_eager=True),
        )
    ] + [p for p in parameters if p.name != "config"]

    # The new signature is compatible with the **kwargs argument.
    wrapped.__signature__ = signature.replace(parameters=parameters)

    # The docstring is used for the explainer text in the CLI.
    wrapped.__doc__ = func.__doc__ + "\n" + ""

    return wrapped

A full implementation here: https://gist.github.com/tbenthompson/9db0452445451767b59f5cb0611ab483

tbenthompson avatar May 11 '23 23:05 tbenthompson

@tiangolo any plans on add this feature to the typer's core? It would be a great feature to improve code reusability. It would make sense using dataclass or pydantic here.

itepifanio avatar May 02 '24 11:05 itepifanio