typantic 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {typantic-0.2.1 → typantic-0.3.0}/PKG-INFO +83 -4
- {typantic-0.2.1 → typantic-0.3.0}/README.md +82 -3
- {typantic-0.2.1 → typantic-0.3.0}/pyproject.toml +1 -1
- {typantic-0.2.1 → typantic-0.3.0}/src/typantic/__init__.py +2 -2
- typantic-0.3.0/src/typantic/_decorator.py +457 -0
- typantic-0.2.1/src/typantic/_decorator.py +0 -201
- {typantic-0.2.1 → typantic-0.3.0}/src/typantic/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typantic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Auto-generate Typer CLI interfaces from Pydantic models.
|
|
5
5
|
Keywords: typer,pydantic,cli,validation
|
|
6
6
|
Author: Kilian Schnelle
|
|
@@ -111,8 +111,9 @@ The `@pydantic_to_typer(Model)` decorator:
|
|
|
111
111
|
1. Reads `Model.model_fields` to discover field names, types, descriptions, and defaults
|
|
112
112
|
2. Strips `Annotated` validator metadata to extract the base types Typer understands
|
|
113
113
|
3. Maps `kw_only=False` → `typer.Argument`, `kw_only=True` → `typer.Option`
|
|
114
|
-
4.
|
|
115
|
-
5.
|
|
114
|
+
4. Flattens nested `BaseModel` fields into prefixed parameters
|
|
115
|
+
5. Rewrites the function's `__signature__` so Typer sees the expanded parameters
|
|
116
|
+
6. At call time, re-nests the raw CLI values and passes them into `Model(...)` so all Pydantic validators run
|
|
116
117
|
|
|
117
118
|
Your function receives the **validated model instance** — validators, `default_factory`, union types, and everything else works exactly as in Pydantic.
|
|
118
119
|
|
|
@@ -124,12 +125,90 @@ Your function receives the **validated model instance** — validators, `default
|
|
|
124
125
|
| `kw_only=True` or unset | `typer.Option` (`--flag`) |
|
|
125
126
|
| `Field(description=...)` | `help=...` in the CLI |
|
|
126
127
|
| `Field(default=...)` | Default value shown in `--help` |
|
|
127
|
-
| `Field(default_factory=...)` |
|
|
128
|
+
| `Field(default_factory=...)` | Re-evaluated on every invocation |
|
|
129
|
+
| `Field(ge=..., le=...)` | Typer `min` / `max` (validated + shown) |
|
|
130
|
+
| `Literal["a", "b"]` | CLI choices |
|
|
131
|
+
| `Enum`, `tuple[...]` | Choices / multi-value option |
|
|
132
|
+
| nested `BaseModel` | Flattened into `--prefix-field` options |
|
|
133
|
+
| `SecretStr`, `SecretBytes` | Hidden input (secure prompt if required)|
|
|
128
134
|
| `int \| None` | Optional CLI option |
|
|
129
135
|
| `default=None` | Rendered as `[default: (None)]` |
|
|
130
136
|
| `list[Path]` | Variadic positional argument |
|
|
131
137
|
| `AfterValidator`, `BeforeValidator` | Run at call time via Pydantic |
|
|
132
138
|
|
|
139
|
+
Validators that raise `ValueError` / `AssertionError` surface as Typer
|
|
140
|
+
parameter errors; other exception types propagate unchanged.
|
|
141
|
+
|
|
142
|
+
## Per-field CLI hints
|
|
143
|
+
|
|
144
|
+
Customise individual flags with `Field(json_schema_extra=...)`:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
class Config(BaseModel):
|
|
148
|
+
verbose: Annotated[
|
|
149
|
+
bool,
|
|
150
|
+
Field(default=False, json_schema_extra={"cli_short": "-v"}),
|
|
151
|
+
]
|
|
152
|
+
output: Annotated[
|
|
153
|
+
Path,
|
|
154
|
+
Field(description="Output path.", json_schema_extra={"cli_name": "--dest"}),
|
|
155
|
+
]
|
|
156
|
+
api_key: Annotated[
|
|
157
|
+
str,
|
|
158
|
+
Field(default="", json_schema_extra={"cli_envvar": "MYAPP_API_KEY"}),
|
|
159
|
+
]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| Key | Effect |
|
|
163
|
+
|--------------|-----------------------------------------------------|
|
|
164
|
+
| `cli_short` | Adds a short flag (e.g. `-v`) alongside the long one |
|
|
165
|
+
| `cli_name` | Replaces the derived long flag (e.g. `--dest`) |
|
|
166
|
+
| `cli_envvar` | Reads the value from an environment variable |
|
|
167
|
+
|
|
168
|
+
## Nested models
|
|
169
|
+
|
|
170
|
+
Fields whose type is itself a `BaseModel` are flattened into prefixed options,
|
|
171
|
+
so layered configs map onto the CLI without manual wiring:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
class Database(BaseModel):
|
|
175
|
+
host: Annotated[str, Field(default="localhost", description="DB host.")]
|
|
176
|
+
port: Annotated[int, Field(default=5432, ge=1, le=65535, description="DB port.")]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Config(BaseModel):
|
|
180
|
+
name: Annotated[str, Field(description="App name.", kw_only=False)]
|
|
181
|
+
db: Database # -> --db-host, --db-port
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
$ python example.py myapp --db-host db.internal --db-port 9000
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The values are re-nested before the model is constructed, so `Database`'s own
|
|
189
|
+
validators and defaults apply as usual.
|
|
190
|
+
|
|
191
|
+
## Registering commands without a stub
|
|
192
|
+
|
|
193
|
+
`add_command` wires a model and a handler onto a Typer app directly, skipping
|
|
194
|
+
the decorate-a-stub-function boilerplate:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
import typer
|
|
198
|
+
|
|
199
|
+
from typantic import add_command
|
|
200
|
+
|
|
201
|
+
app = typer.Typer()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run(config: Config) -> None:
|
|
205
|
+
print(config)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
add_command(app, Config, run) # command name defaults to "run"
|
|
209
|
+
add_command(app, Config, run, name="go") # or set it explicitly
|
|
210
|
+
```
|
|
211
|
+
|
|
133
212
|
## Help panels for mixin-composed models
|
|
134
213
|
|
|
135
214
|
Large configs composed from mixins can group their options into titled Rich
|
|
@@ -87,8 +87,9 @@ The `@pydantic_to_typer(Model)` decorator:
|
|
|
87
87
|
1. Reads `Model.model_fields` to discover field names, types, descriptions, and defaults
|
|
88
88
|
2. Strips `Annotated` validator metadata to extract the base types Typer understands
|
|
89
89
|
3. Maps `kw_only=False` → `typer.Argument`, `kw_only=True` → `typer.Option`
|
|
90
|
-
4.
|
|
91
|
-
5.
|
|
90
|
+
4. Flattens nested `BaseModel` fields into prefixed parameters
|
|
91
|
+
5. Rewrites the function's `__signature__` so Typer sees the expanded parameters
|
|
92
|
+
6. At call time, re-nests the raw CLI values and passes them into `Model(...)` so all Pydantic validators run
|
|
92
93
|
|
|
93
94
|
Your function receives the **validated model instance** — validators, `default_factory`, union types, and everything else works exactly as in Pydantic.
|
|
94
95
|
|
|
@@ -100,12 +101,90 @@ Your function receives the **validated model instance** — validators, `default
|
|
|
100
101
|
| `kw_only=True` or unset | `typer.Option` (`--flag`) |
|
|
101
102
|
| `Field(description=...)` | `help=...` in the CLI |
|
|
102
103
|
| `Field(default=...)` | Default value shown in `--help` |
|
|
103
|
-
| `Field(default_factory=...)` |
|
|
104
|
+
| `Field(default_factory=...)` | Re-evaluated on every invocation |
|
|
105
|
+
| `Field(ge=..., le=...)` | Typer `min` / `max` (validated + shown) |
|
|
106
|
+
| `Literal["a", "b"]` | CLI choices |
|
|
107
|
+
| `Enum`, `tuple[...]` | Choices / multi-value option |
|
|
108
|
+
| nested `BaseModel` | Flattened into `--prefix-field` options |
|
|
109
|
+
| `SecretStr`, `SecretBytes` | Hidden input (secure prompt if required)|
|
|
104
110
|
| `int \| None` | Optional CLI option |
|
|
105
111
|
| `default=None` | Rendered as `[default: (None)]` |
|
|
106
112
|
| `list[Path]` | Variadic positional argument |
|
|
107
113
|
| `AfterValidator`, `BeforeValidator` | Run at call time via Pydantic |
|
|
108
114
|
|
|
115
|
+
Validators that raise `ValueError` / `AssertionError` surface as Typer
|
|
116
|
+
parameter errors; other exception types propagate unchanged.
|
|
117
|
+
|
|
118
|
+
## Per-field CLI hints
|
|
119
|
+
|
|
120
|
+
Customise individual flags with `Field(json_schema_extra=...)`:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
class Config(BaseModel):
|
|
124
|
+
verbose: Annotated[
|
|
125
|
+
bool,
|
|
126
|
+
Field(default=False, json_schema_extra={"cli_short": "-v"}),
|
|
127
|
+
]
|
|
128
|
+
output: Annotated[
|
|
129
|
+
Path,
|
|
130
|
+
Field(description="Output path.", json_schema_extra={"cli_name": "--dest"}),
|
|
131
|
+
]
|
|
132
|
+
api_key: Annotated[
|
|
133
|
+
str,
|
|
134
|
+
Field(default="", json_schema_extra={"cli_envvar": "MYAPP_API_KEY"}),
|
|
135
|
+
]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Key | Effect |
|
|
139
|
+
|--------------|-----------------------------------------------------|
|
|
140
|
+
| `cli_short` | Adds a short flag (e.g. `-v`) alongside the long one |
|
|
141
|
+
| `cli_name` | Replaces the derived long flag (e.g. `--dest`) |
|
|
142
|
+
| `cli_envvar` | Reads the value from an environment variable |
|
|
143
|
+
|
|
144
|
+
## Nested models
|
|
145
|
+
|
|
146
|
+
Fields whose type is itself a `BaseModel` are flattened into prefixed options,
|
|
147
|
+
so layered configs map onto the CLI without manual wiring:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
class Database(BaseModel):
|
|
151
|
+
host: Annotated[str, Field(default="localhost", description="DB host.")]
|
|
152
|
+
port: Annotated[int, Field(default=5432, ge=1, le=65535, description="DB port.")]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Config(BaseModel):
|
|
156
|
+
name: Annotated[str, Field(description="App name.", kw_only=False)]
|
|
157
|
+
db: Database # -> --db-host, --db-port
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
$ python example.py myapp --db-host db.internal --db-port 9000
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The values are re-nested before the model is constructed, so `Database`'s own
|
|
165
|
+
validators and defaults apply as usual.
|
|
166
|
+
|
|
167
|
+
## Registering commands without a stub
|
|
168
|
+
|
|
169
|
+
`add_command` wires a model and a handler onto a Typer app directly, skipping
|
|
170
|
+
the decorate-a-stub-function boilerplate:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import typer
|
|
174
|
+
|
|
175
|
+
from typantic import add_command
|
|
176
|
+
|
|
177
|
+
app = typer.Typer()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run(config: Config) -> None:
|
|
181
|
+
print(config)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
add_command(app, Config, run) # command name defaults to "run"
|
|
185
|
+
add_command(app, Config, run, name="go") # or set it explicitly
|
|
186
|
+
```
|
|
187
|
+
|
|
109
188
|
## Help panels for mixin-composed models
|
|
110
189
|
|
|
111
190
|
Large configs composed from mixins can group their options into titled Rich
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from importlib.metadata import version
|
|
4
4
|
|
|
5
|
-
from typantic._decorator import pydantic_to_typer
|
|
5
|
+
from typantic._decorator import add_command, pydantic_to_typer
|
|
6
6
|
|
|
7
7
|
__version__ = version("typantic")
|
|
8
|
-
__all__ = ["pydantic_to_typer"]
|
|
8
|
+
__all__ = ["add_command", "pydantic_to_typer"]
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Core decorator for converting Pydantic models to Typer CLI interfaces."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import types
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import (
|
|
8
|
+
Annotated,
|
|
9
|
+
Any,
|
|
10
|
+
TypeGuard,
|
|
11
|
+
Union,
|
|
12
|
+
cast,
|
|
13
|
+
get_args,
|
|
14
|
+
get_origin,
|
|
15
|
+
get_type_hints,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import annotated_types
|
|
19
|
+
import typer
|
|
20
|
+
from pydantic import BaseModel, SecretBytes, SecretStr, ValidationError
|
|
21
|
+
from pydantic.fields import FieldInfo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _extract_base_type(annotation: object) -> object:
|
|
25
|
+
"""Strip ``Annotated`` validator metadata, keeping the structural type.
|
|
26
|
+
|
|
27
|
+
Recursively walks through ``Annotated``, ``Union``, ``list``, and
|
|
28
|
+
``tuple`` wrappers, discarding everything except the base types that
|
|
29
|
+
Typer can interpret. ``Literal`` annotations are passed through
|
|
30
|
+
untouched -- Typer renders them as CLI choices.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
annotation: A (possibly nested) type annotation to unwrap.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The base type with all Pydantic validator metadata removed.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
>>> from typing import Annotated
|
|
40
|
+
>>> from pydantic import AfterValidator, Field
|
|
41
|
+
>>> _extract_base_type(Annotated[float, Field(description="x")])
|
|
42
|
+
<class 'float'>
|
|
43
|
+
"""
|
|
44
|
+
if get_origin(annotation) is Annotated:
|
|
45
|
+
inner = get_args(annotation)[0]
|
|
46
|
+
return _extract_base_type(inner)
|
|
47
|
+
|
|
48
|
+
if get_origin(annotation) in (Union, types.UnionType):
|
|
49
|
+
cleaned = tuple(_extract_base_type(a) for a in get_args(annotation))
|
|
50
|
+
return Union[cleaned] # noqa: UP007
|
|
51
|
+
|
|
52
|
+
if get_origin(annotation) is list:
|
|
53
|
+
args = get_args(annotation)
|
|
54
|
+
if args:
|
|
55
|
+
return list[_extract_base_type(args[0])] # type: ignore[misc]
|
|
56
|
+
|
|
57
|
+
if get_origin(annotation) is tuple:
|
|
58
|
+
args = get_args(annotation)
|
|
59
|
+
if args:
|
|
60
|
+
cleaned = tuple(_extract_base_type(a) for a in args)
|
|
61
|
+
return tuple[cleaned] # type: ignore[valid-type]
|
|
62
|
+
|
|
63
|
+
return annotation
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_model_type(tp: object) -> TypeGuard[type[BaseModel]]:
|
|
67
|
+
"""Return ``True`` if ``tp`` is a concrete ``BaseModel`` subclass."""
|
|
68
|
+
return isinstance(tp, type) and issubclass(tp, BaseModel) and tp is not BaseModel
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _panel_for_field(model_cls: type[BaseModel], field_name: str) -> str | None:
|
|
72
|
+
"""Return the help panel title for a field, or ``None`` for the default group.
|
|
73
|
+
|
|
74
|
+
The panel is taken from the ``cli_panel`` class attribute of the class that
|
|
75
|
+
*defines* the field -- the most-base class in the MRO whose ``model_fields``
|
|
76
|
+
contains it. Classes that declare no ``cli_panel`` of their own contribute
|
|
77
|
+
no panel, so grouping is fully explicit and opt-in per (mixin) class.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
model_cls: The decorated model class.
|
|
81
|
+
field_name: The field to resolve.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The panel title, or ``None`` if the defining class declares none.
|
|
85
|
+
"""
|
|
86
|
+
for klass in reversed(model_cls.__mro__):
|
|
87
|
+
if (
|
|
88
|
+
issubclass(klass, BaseModel)
|
|
89
|
+
and klass is not BaseModel
|
|
90
|
+
and field_name in klass.model_fields
|
|
91
|
+
):
|
|
92
|
+
panel = klass.__dict__.get("cli_panel")
|
|
93
|
+
return panel if isinstance(panel, str) else None
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _numeric_bounds(field_info: FieldInfo) -> tuple[float | None, float | None]:
|
|
98
|
+
"""Extract inclusive ``(min, max)`` bounds from a field's constraints.
|
|
99
|
+
|
|
100
|
+
Only ``ge`` / ``le`` (and the ``ge`` / ``le`` of an ``Interval``) map onto
|
|
101
|
+
Typer's inclusive ``min`` / ``max``. Exclusive ``gt`` / ``lt`` bounds are
|
|
102
|
+
left for Pydantic to enforce, since Typer has no exclusive equivalent.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
field_info: The Pydantic field metadata.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A ``(min, max)`` tuple; either element is ``None`` when unset.
|
|
109
|
+
"""
|
|
110
|
+
low: float | None = None
|
|
111
|
+
high: float | None = None
|
|
112
|
+
for meta in field_info.metadata:
|
|
113
|
+
if isinstance(meta, annotated_types.Ge):
|
|
114
|
+
low = float(meta.ge) # type: ignore[arg-type]
|
|
115
|
+
elif isinstance(meta, annotated_types.Le):
|
|
116
|
+
high = float(meta.le) # type: ignore[arg-type]
|
|
117
|
+
elif isinstance(meta, annotated_types.Interval):
|
|
118
|
+
if meta.ge is not None:
|
|
119
|
+
low = float(meta.ge) # type: ignore[arg-type]
|
|
120
|
+
if meta.le is not None:
|
|
121
|
+
high = float(meta.le) # type: ignore[arg-type]
|
|
122
|
+
return low, high
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cli_extra(field_info: FieldInfo) -> dict[str, str]:
|
|
126
|
+
"""Read typantic's CLI hints from a field's ``json_schema_extra``.
|
|
127
|
+
|
|
128
|
+
Recognised keys: ``cli_name`` (full long flag, e.g. ``"--output"``),
|
|
129
|
+
``cli_short`` (short flag, e.g. ``"-o"``), and ``cli_envvar`` (environment
|
|
130
|
+
variable name). Non-string values and non-dict ``json_schema_extra`` are
|
|
131
|
+
ignored.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
field_info: The Pydantic field metadata.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
A mapping of the recognised hint keys that were present and string-valued.
|
|
138
|
+
"""
|
|
139
|
+
raw = field_info.json_schema_extra
|
|
140
|
+
if not isinstance(raw, dict):
|
|
141
|
+
return {}
|
|
142
|
+
extra = cast("dict[str, object]", raw)
|
|
143
|
+
out: dict[str, str] = {}
|
|
144
|
+
for key in ("cli_name", "cli_short", "cli_envvar"):
|
|
145
|
+
value = extra.get(key)
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
out[key] = value
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _option_decls(cli_name: str, extra: dict[str, str]) -> list[str]:
|
|
152
|
+
"""Build the ``param_decls`` for a Typer option from CLI hints.
|
|
153
|
+
|
|
154
|
+
Returns an empty list when no hints are given, letting Typer derive the
|
|
155
|
+
``--flag`` from the parameter name. When a short flag is requested the long
|
|
156
|
+
flag must be stated explicitly, so it is always included alongside it.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
cli_name: The (possibly nested) flattened parameter name.
|
|
160
|
+
extra: The parsed CLI hints from :func:`_cli_extra`.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The positional declarations to pass to ``typer.Option``.
|
|
164
|
+
"""
|
|
165
|
+
long = extra.get("cli_name") or "--" + cli_name.replace("_", "-")
|
|
166
|
+
short = extra.get("cli_short")
|
|
167
|
+
if short:
|
|
168
|
+
return [long, short]
|
|
169
|
+
if "cli_name" in extra:
|
|
170
|
+
return [long]
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _build_params(
|
|
175
|
+
model_cls: type[BaseModel],
|
|
176
|
+
*,
|
|
177
|
+
subpanels: bool,
|
|
178
|
+
prefix: tuple[str, ...] = (),
|
|
179
|
+
seen: frozenset[type[BaseModel]] = frozenset(),
|
|
180
|
+
) -> tuple[
|
|
181
|
+
list[inspect.Parameter],
|
|
182
|
+
dict[str, object],
|
|
183
|
+
list[tuple[str, tuple[str, ...]]],
|
|
184
|
+
]:
|
|
185
|
+
"""Expand a model's fields into Typer parameters.
|
|
186
|
+
|
|
187
|
+
Fields whose type is itself a ``BaseModel`` are flattened recursively: a
|
|
188
|
+
``db: Database`` field with a ``host`` field becomes a ``--db-host`` option,
|
|
189
|
+
and the values are re-nested before the model is constructed.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
model_cls: The model whose fields to expand.
|
|
193
|
+
subpanels: Whether to assign Rich help panels from ``cli_panel``.
|
|
194
|
+
prefix: The nested path of field names leading to this model.
|
|
195
|
+
seen: Models already being expanded, to break self-referential cycles.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
A ``(parameters, annotations, mapping)`` tuple, where ``mapping`` pairs
|
|
199
|
+
each flattened parameter name with its nested path into the model.
|
|
200
|
+
"""
|
|
201
|
+
params: list[inspect.Parameter] = []
|
|
202
|
+
annotations: dict[str, object] = {}
|
|
203
|
+
mapping: list[tuple[str, tuple[str, ...]]] = []
|
|
204
|
+
|
|
205
|
+
resolved_hints = get_type_hints(model_cls, include_extras=True)
|
|
206
|
+
nested_seen = seen | {model_cls}
|
|
207
|
+
|
|
208
|
+
for name, field_info in model_cls.model_fields.items():
|
|
209
|
+
base_type = _extract_base_type(resolved_hints[name])
|
|
210
|
+
path = (*prefix, name)
|
|
211
|
+
|
|
212
|
+
if _is_model_type(base_type) and base_type not in nested_seen:
|
|
213
|
+
sub_params, sub_annotations, sub_mapping = _build_params(
|
|
214
|
+
base_type,
|
|
215
|
+
subpanels=subpanels,
|
|
216
|
+
prefix=path,
|
|
217
|
+
seen=nested_seen,
|
|
218
|
+
)
|
|
219
|
+
params.extend(sub_params)
|
|
220
|
+
annotations.update(sub_annotations)
|
|
221
|
+
mapping.extend(sub_mapping)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
cli_name = "_".join(path)
|
|
225
|
+
panel = _panel_for_field(model_cls, name) if subpanels else None
|
|
226
|
+
param, annotated = _build_leaf(
|
|
227
|
+
cli_name=cli_name,
|
|
228
|
+
field_info=field_info,
|
|
229
|
+
base_type=base_type,
|
|
230
|
+
panel=panel,
|
|
231
|
+
)
|
|
232
|
+
params.append(param)
|
|
233
|
+
annotations[cli_name] = annotated
|
|
234
|
+
mapping.append((cli_name, path))
|
|
235
|
+
|
|
236
|
+
return params, annotations, mapping
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _build_leaf(
|
|
240
|
+
*,
|
|
241
|
+
cli_name: str,
|
|
242
|
+
field_info: FieldInfo,
|
|
243
|
+
base_type: object,
|
|
244
|
+
panel: str | None,
|
|
245
|
+
) -> tuple[inspect.Parameter, object]:
|
|
246
|
+
"""Build the ``inspect.Parameter`` and annotation for a single leaf field.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
cli_name: The flattened parameter name (nested path joined by ``_``).
|
|
250
|
+
field_info: The Pydantic field metadata.
|
|
251
|
+
base_type: The structural type extracted from the field annotation.
|
|
252
|
+
panel: The Rich help panel title for options, or ``None`` for none.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
A ``(parameter, annotation)`` pair for the rewritten signature.
|
|
256
|
+
"""
|
|
257
|
+
help_text = field_info.description or ""
|
|
258
|
+
extra = _cli_extra(field_info)
|
|
259
|
+
envvar = extra.get("cli_envvar")
|
|
260
|
+
|
|
261
|
+
is_secret = base_type is SecretStr or base_type is SecretBytes
|
|
262
|
+
typer_type: object = base_type
|
|
263
|
+
if is_secret:
|
|
264
|
+
typer_type = bytes if base_type is SecretBytes else str
|
|
265
|
+
|
|
266
|
+
if typer_type is int or typer_type is float:
|
|
267
|
+
min_value, max_value = _numeric_bounds(field_info)
|
|
268
|
+
else:
|
|
269
|
+
min_value = max_value = None
|
|
270
|
+
|
|
271
|
+
required = field_info.is_required()
|
|
272
|
+
default: object
|
|
273
|
+
show_default: bool | str
|
|
274
|
+
if required:
|
|
275
|
+
default = inspect.Parameter.empty
|
|
276
|
+
show_default = True
|
|
277
|
+
elif field_info.default_factory is not None:
|
|
278
|
+
factory = field_info.default_factory
|
|
279
|
+
# Pass the factory itself as the default so Click re-evaluates it on
|
|
280
|
+
# every invocation (correct for time/identity-sensitive factories such
|
|
281
|
+
# as ``datetime.now`` or ``uuid4``), while still showing a sample value.
|
|
282
|
+
default = factory
|
|
283
|
+
show_default = str(factory()) # type: ignore[call-arg]
|
|
284
|
+
else:
|
|
285
|
+
default = field_info.default
|
|
286
|
+
# Click omits `None` defaults entirely; surface them as
|
|
287
|
+
# "[default: (None)]" so optional options are visibly so.
|
|
288
|
+
show_default = "None" if default is None else True
|
|
289
|
+
|
|
290
|
+
is_argument = field_info.kw_only is False
|
|
291
|
+
typer_meta: typer.models.ArgumentInfo | typer.models.OptionInfo
|
|
292
|
+
if is_argument:
|
|
293
|
+
typer_meta = typer.Argument(
|
|
294
|
+
help=help_text,
|
|
295
|
+
show_default=False,
|
|
296
|
+
min=min_value,
|
|
297
|
+
max=max_value,
|
|
298
|
+
envvar=envvar,
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
typer_meta = typer.Option(
|
|
302
|
+
*_option_decls(cli_name, extra),
|
|
303
|
+
help=help_text,
|
|
304
|
+
rich_help_panel=panel,
|
|
305
|
+
show_default=False if is_secret else show_default,
|
|
306
|
+
hide_input=is_secret,
|
|
307
|
+
prompt=is_secret and required,
|
|
308
|
+
min=min_value,
|
|
309
|
+
max=max_value,
|
|
310
|
+
envvar=envvar,
|
|
311
|
+
)
|
|
312
|
+
kind = (
|
|
313
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
314
|
+
if is_argument
|
|
315
|
+
else inspect.Parameter.KEYWORD_ONLY
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
annotated = Annotated[typer_type, typer_meta] # type: ignore[valid-type]
|
|
319
|
+
parameter = inspect.Parameter(
|
|
320
|
+
cli_name,
|
|
321
|
+
kind,
|
|
322
|
+
default=default,
|
|
323
|
+
annotation=annotated,
|
|
324
|
+
)
|
|
325
|
+
return parameter, annotated
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def pydantic_to_typer(
|
|
329
|
+
model_cls: type[BaseModel],
|
|
330
|
+
*,
|
|
331
|
+
subpanels: bool = False,
|
|
332
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
333
|
+
"""Rewrite a function's signature so Typer sees individual CLI params.
|
|
334
|
+
|
|
335
|
+
The parameters are derived from the fields of ``model_cls``.
|
|
336
|
+
|
|
337
|
+
Mapping rules:
|
|
338
|
+
- ``kw_only=False`` -> ``typer.Argument``
|
|
339
|
+
- ``kw_only=True`` (or unset) -> ``typer.Option``
|
|
340
|
+
- ``Field(description=...)`` -> ``help=...``
|
|
341
|
+
- ``Field(default=...)`` -> Typer default value
|
|
342
|
+
- ``Field(default_factory=...)`` -> the factory is passed through to
|
|
343
|
+
Click as a callable default, so it runs once per invocation (and a
|
|
344
|
+
sample value is shown in ``--help``)
|
|
345
|
+
- ``Field(ge=..., le=...)`` -> Typer ``min`` / ``max`` (exclusive
|
|
346
|
+
``gt`` / ``lt`` are left to Pydantic)
|
|
347
|
+
- ``SecretStr`` / ``SecretBytes`` -> hidden input (and a secure
|
|
348
|
+
prompt when the field is required)
|
|
349
|
+
- a ``None`` default -> rendered as ``[default: (None)]`` in
|
|
350
|
+
``--help`` (Click would otherwise omit it entirely)
|
|
351
|
+
- a nested ``BaseModel`` field -> flattened into prefixed params
|
|
352
|
+
(``db: Database`` with a ``host`` field becomes ``--db-host``)
|
|
353
|
+
|
|
354
|
+
Per-field CLI hints can be supplied via ``Field(json_schema_extra=...)``:
|
|
355
|
+
- ``cli_name``: full long flag, e.g. ``"--output"``
|
|
356
|
+
- ``cli_short``: short flag, e.g. ``"-o"``
|
|
357
|
+
- ``cli_envvar``: environment variable to read the value from
|
|
358
|
+
|
|
359
|
+
The decorated function receives the **validated** Pydantic model
|
|
360
|
+
instance, so all ``AfterValidator`` / ``BeforeValidator`` logic runs
|
|
361
|
+
as usual. Validators that raise ``ValueError`` / ``AssertionError`` are
|
|
362
|
+
reported as Typer parameter errors; other exception types propagate
|
|
363
|
+
unchanged.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
model_cls: The Pydantic model class whose fields define the CLI
|
|
367
|
+
parameters.
|
|
368
|
+
subpanels: Group options into Rich help panels. Each option is placed
|
|
369
|
+
in the panel named by the ``cli_panel`` class attribute of the
|
|
370
|
+
class that defines its field (useful for models composed from
|
|
371
|
+
mixins). Fields whose defining class declares no ``cli_panel``
|
|
372
|
+
stay in the default options group. Arguments are never panelled.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
A decorator that transforms a ``func(model)`` signature into one
|
|
376
|
+
that Typer can introspect.
|
|
377
|
+
|
|
378
|
+
Example:
|
|
379
|
+
>>> import typer
|
|
380
|
+
>>> app = typer.Typer()
|
|
381
|
+
>>> @app.command()
|
|
382
|
+
... @pydantic_to_typer(MyConfig, subpanels=True)
|
|
383
|
+
... def run(config: MyConfig): ...
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def decorator(
|
|
387
|
+
func: Callable[..., Any],
|
|
388
|
+
) -> Callable[..., Any]:
|
|
389
|
+
new_params, new_annotations, mapping = _build_params(
|
|
390
|
+
model_cls,
|
|
391
|
+
subpanels=subpanels,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
new_params.sort(
|
|
395
|
+
key=lambda p: (
|
|
396
|
+
p.kind == inspect.Parameter.KEYWORD_ONLY,
|
|
397
|
+
p.default is not inspect.Parameter.empty,
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
@wraps(func)
|
|
402
|
+
def wrapper(**kwargs: object) -> object:
|
|
403
|
+
data: dict[str, Any] = {}
|
|
404
|
+
for cli_name, path in mapping:
|
|
405
|
+
if cli_name not in kwargs:
|
|
406
|
+
continue
|
|
407
|
+
target = data
|
|
408
|
+
for part in path[:-1]:
|
|
409
|
+
target = target.setdefault(part, {})
|
|
410
|
+
target[path[-1]] = kwargs[cli_name]
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
model = model_cls(**data)
|
|
414
|
+
except ValidationError as exc:
|
|
415
|
+
messages: list[str] = []
|
|
416
|
+
for err in exc.errors():
|
|
417
|
+
loc = ".".join(str(p) for p in err["loc"])
|
|
418
|
+
msg = str(err["msg"])
|
|
419
|
+
messages.append(f"{loc}: {msg}" if loc else msg)
|
|
420
|
+
raise typer.BadParameter("\n ".join(messages)) from exc
|
|
421
|
+
return func(model)
|
|
422
|
+
|
|
423
|
+
wrapper.__signature__ = inspect.Signature(new_params) # type: ignore[attr-defined]
|
|
424
|
+
wrapper.__annotations__ = new_annotations
|
|
425
|
+
return wrapper
|
|
426
|
+
|
|
427
|
+
return decorator
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def add_command(
|
|
431
|
+
app: typer.Typer,
|
|
432
|
+
model_cls: type[BaseModel],
|
|
433
|
+
handler: Callable[[BaseModel], Any],
|
|
434
|
+
*,
|
|
435
|
+
name: str | None = None,
|
|
436
|
+
subpanels: bool = False,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Register ``handler`` on ``app`` as a command driven by ``model_cls``.
|
|
439
|
+
|
|
440
|
+
A convenience wrapper around :func:`pydantic_to_typer` and
|
|
441
|
+
``app.command`` that removes the boilerplate of decorating a stub function.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
app: The Typer application to register the command on.
|
|
445
|
+
model_cls: The Pydantic model whose fields define the CLI parameters.
|
|
446
|
+
handler: A function accepting the validated model instance.
|
|
447
|
+
name: The command name. Defaults to ``handler.__name__``.
|
|
448
|
+
subpanels: Forwarded to :func:`pydantic_to_typer`.
|
|
449
|
+
|
|
450
|
+
Example:
|
|
451
|
+
>>> import typer
|
|
452
|
+
>>> app = typer.Typer()
|
|
453
|
+
>>> def run(config: MyConfig) -> None: ...
|
|
454
|
+
>>> add_command(app, MyConfig, run)
|
|
455
|
+
"""
|
|
456
|
+
decorated = pydantic_to_typer(model_cls, subpanels=subpanels)(handler)
|
|
457
|
+
app.command(name=name)(decorated)
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
"""Core decorator for converting Pydantic models to Typer CLI interfaces."""
|
|
2
|
-
|
|
3
|
-
import inspect
|
|
4
|
-
import types
|
|
5
|
-
from collections.abc import Callable
|
|
6
|
-
from functools import wraps
|
|
7
|
-
from typing import Annotated, Any, Union, get_args, get_origin, get_type_hints
|
|
8
|
-
|
|
9
|
-
import typer
|
|
10
|
-
from pydantic import BaseModel, ValidationError
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _extract_base_type(annotation: object) -> object:
|
|
14
|
-
"""Strip ``Annotated`` validator metadata, keeping the structural type.
|
|
15
|
-
|
|
16
|
-
Recursively walks through ``Annotated``, ``Union``, ``list``, and
|
|
17
|
-
``tuple`` wrappers, discarding everything except the base types that
|
|
18
|
-
Typer can interpret.
|
|
19
|
-
|
|
20
|
-
Args:
|
|
21
|
-
annotation: A (possibly nested) type annotation to unwrap.
|
|
22
|
-
|
|
23
|
-
Returns:
|
|
24
|
-
The base type with all Pydantic validator metadata removed.
|
|
25
|
-
|
|
26
|
-
Examples:
|
|
27
|
-
>>> from typing import Annotated
|
|
28
|
-
>>> from pydantic import AfterValidator, Field
|
|
29
|
-
>>> _extract_base_type(Annotated[float, Field(description="x")])
|
|
30
|
-
<class 'float'>
|
|
31
|
-
"""
|
|
32
|
-
if get_origin(annotation) is Annotated:
|
|
33
|
-
inner = get_args(annotation)[0]
|
|
34
|
-
return _extract_base_type(inner)
|
|
35
|
-
|
|
36
|
-
if get_origin(annotation) in (Union, types.UnionType):
|
|
37
|
-
cleaned = tuple(_extract_base_type(a) for a in get_args(annotation))
|
|
38
|
-
return Union[cleaned] # noqa: UP007
|
|
39
|
-
|
|
40
|
-
if get_origin(annotation) is list:
|
|
41
|
-
args = get_args(annotation)
|
|
42
|
-
if args:
|
|
43
|
-
return list[_extract_base_type(args[0])] # type: ignore[misc]
|
|
44
|
-
|
|
45
|
-
if get_origin(annotation) is tuple:
|
|
46
|
-
args = get_args(annotation)
|
|
47
|
-
if args:
|
|
48
|
-
cleaned = tuple(_extract_base_type(a) for a in args)
|
|
49
|
-
return tuple[cleaned] # type: ignore[valid-type]
|
|
50
|
-
|
|
51
|
-
return annotation
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _panel_for_field(model_cls: type[BaseModel], field_name: str) -> str | None:
|
|
55
|
-
"""Return the help panel title for a field, or ``None`` for the default group.
|
|
56
|
-
|
|
57
|
-
The panel is taken from the ``cli_panel`` class attribute of the class that
|
|
58
|
-
*defines* the field -- the most-base class in the MRO whose ``model_fields``
|
|
59
|
-
contains it. Classes that declare no ``cli_panel`` of their own contribute
|
|
60
|
-
no panel, so grouping is fully explicit and opt-in per (mixin) class.
|
|
61
|
-
|
|
62
|
-
Args:
|
|
63
|
-
model_cls: The decorated model class.
|
|
64
|
-
field_name: The field to resolve.
|
|
65
|
-
|
|
66
|
-
Returns:
|
|
67
|
-
The panel title, or ``None`` if the defining class declares none.
|
|
68
|
-
"""
|
|
69
|
-
for klass in reversed(model_cls.__mro__):
|
|
70
|
-
if (
|
|
71
|
-
issubclass(klass, BaseModel)
|
|
72
|
-
and klass is not BaseModel
|
|
73
|
-
and field_name in klass.model_fields
|
|
74
|
-
):
|
|
75
|
-
panel = klass.__dict__.get("cli_panel")
|
|
76
|
-
return panel if isinstance(panel, str) else None
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def pydantic_to_typer(
|
|
81
|
-
model_cls: type[BaseModel],
|
|
82
|
-
*,
|
|
83
|
-
subpanels: bool = False,
|
|
84
|
-
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
85
|
-
"""Rewrite a function's signature so Typer sees individual CLI params.
|
|
86
|
-
|
|
87
|
-
The parameters are derived from the fields of ``model_cls``.
|
|
88
|
-
|
|
89
|
-
Mapping rules:
|
|
90
|
-
- ``kw_only=False`` -> ``typer.Argument``
|
|
91
|
-
- ``kw_only=True`` (or unset) -> ``typer.Option``
|
|
92
|
-
- ``Field(description=...)`` -> ``help=...``
|
|
93
|
-
- ``Field(default=...)`` -> Typer default value
|
|
94
|
-
- ``Field(default_factory=...)`` -> factory is called once at
|
|
95
|
-
decoration time to supply the Typer default
|
|
96
|
-
- a ``None`` default -> rendered as ``[default: (None)]`` in
|
|
97
|
-
``--help`` (Click would otherwise omit it entirely)
|
|
98
|
-
|
|
99
|
-
The decorated function receives the **validated** Pydantic model
|
|
100
|
-
instance, so all ``AfterValidator`` / ``BeforeValidator`` logic runs
|
|
101
|
-
as usual.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
model_cls: The Pydantic model class whose fields define the CLI
|
|
105
|
-
parameters.
|
|
106
|
-
subpanels: Group options into Rich help panels. Each option is placed
|
|
107
|
-
in the panel named by the ``cli_panel`` class attribute of the
|
|
108
|
-
class that defines its field (useful for models composed from
|
|
109
|
-
mixins). Fields whose defining class declares no ``cli_panel``
|
|
110
|
-
stay in the default options group. Arguments are never panelled.
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
A decorator that transforms a ``func(model)`` signature into one
|
|
114
|
-
that Typer can introspect.
|
|
115
|
-
|
|
116
|
-
Example:
|
|
117
|
-
>>> import typer
|
|
118
|
-
>>> app = typer.Typer()
|
|
119
|
-
>>> @app.command()
|
|
120
|
-
... @pydantic_to_typer(MyConfig, subpanels=True)
|
|
121
|
-
... def run(config: MyConfig): ...
|
|
122
|
-
"""
|
|
123
|
-
|
|
124
|
-
def decorator(
|
|
125
|
-
func: Callable[..., Any],
|
|
126
|
-
) -> Callable[..., Any]:
|
|
127
|
-
new_params: list[inspect.Parameter] = []
|
|
128
|
-
new_annotations: dict[str, object] = {}
|
|
129
|
-
|
|
130
|
-
resolved_hints = get_type_hints(
|
|
131
|
-
model_cls,
|
|
132
|
-
include_extras=True,
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
for name, field_info in model_cls.model_fields.items():
|
|
136
|
-
base_type = _extract_base_type(resolved_hints[name])
|
|
137
|
-
help_text = field_info.description or ""
|
|
138
|
-
|
|
139
|
-
default: object
|
|
140
|
-
if field_info.is_required():
|
|
141
|
-
default = inspect.Parameter.empty
|
|
142
|
-
elif field_info.default_factory is not None:
|
|
143
|
-
default = field_info.default_factory() # type: ignore[call-arg]
|
|
144
|
-
else:
|
|
145
|
-
default = field_info.default
|
|
146
|
-
|
|
147
|
-
typer_meta: typer.models.ArgumentInfo | typer.models.OptionInfo
|
|
148
|
-
if field_info.kw_only is False:
|
|
149
|
-
typer_meta = typer.Argument(
|
|
150
|
-
help=help_text,
|
|
151
|
-
show_default=False,
|
|
152
|
-
)
|
|
153
|
-
else:
|
|
154
|
-
panel = _panel_for_field(model_cls, name) if subpanels else None
|
|
155
|
-
typer_meta = typer.Option(
|
|
156
|
-
help=help_text,
|
|
157
|
-
rich_help_panel=panel,
|
|
158
|
-
# Click omits `None` defaults entirely; surface them as
|
|
159
|
-
# "[default: (None)]" so optional options are visibly so.
|
|
160
|
-
show_default="None" if default is None else True,
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
annotated = Annotated[base_type, typer_meta] # type: ignore[valid-type]
|
|
164
|
-
new_annotations[name] = annotated
|
|
165
|
-
|
|
166
|
-
new_params.append(
|
|
167
|
-
inspect.Parameter(
|
|
168
|
-
name,
|
|
169
|
-
inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
170
|
-
if field_info.kw_only is False
|
|
171
|
-
else inspect.Parameter.KEYWORD_ONLY,
|
|
172
|
-
default=default,
|
|
173
|
-
annotation=annotated,
|
|
174
|
-
),
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
new_params.sort(
|
|
178
|
-
key=lambda p: (
|
|
179
|
-
p.kind == inspect.Parameter.KEYWORD_ONLY,
|
|
180
|
-
p.default is not inspect.Parameter.empty,
|
|
181
|
-
),
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
@wraps(func)
|
|
185
|
-
def wrapper(**kwargs: object) -> object:
|
|
186
|
-
try:
|
|
187
|
-
model = model_cls(**kwargs)
|
|
188
|
-
except ValidationError as exc:
|
|
189
|
-
messages: list[str] = []
|
|
190
|
-
for err in exc.errors():
|
|
191
|
-
loc = ".".join(str(p) for p in err["loc"])
|
|
192
|
-
msg = str(err["msg"])
|
|
193
|
-
messages.append(f"{loc}: {msg}" if loc else msg)
|
|
194
|
-
raise typer.BadParameter("\n ".join(messages)) from exc
|
|
195
|
-
return func(model)
|
|
196
|
-
|
|
197
|
-
wrapper.__signature__ = inspect.Signature(new_params) # type: ignore[attr-defined]
|
|
198
|
-
wrapper.__annotations__ = new_annotations
|
|
199
|
-
return wrapper
|
|
200
|
-
|
|
201
|
-
return decorator
|
|
File without changes
|