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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typantic
3
- Version: 0.2.1
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. Rewrites the function's `__signature__` so Typer sees the expanded parameters
115
- 5. At call time, passes the raw CLI values into `Model(...)` so all Pydantic validators run
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=...)` | Factory called once at decoration time |
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. Rewrites the function's `__signature__` so Typer sees the expanded parameters
91
- 5. At call time, passes the raw CLI values into `Model(...)` so all Pydantic validators run
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=...)` | Factory called once at decoration time |
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "typantic"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Auto-generate Typer CLI interfaces from Pydantic models."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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