typer
typer copied to clipboard
[FEATURE] Using a dataclass for types/command generation
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.
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()
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.
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.
Is there any plan to work on this?
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
@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.