typantic 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """typantic — Auto-generate Typer CLI interfaces from Pydantic models."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from typantic._decorator import pydantic_to_typer
6
+
7
+ __version__ = version("typantic")
8
+ __all__ = ["pydantic_to_typer"]
typantic/_decorator.py ADDED
@@ -0,0 +1,159 @@
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 pydantic_to_typer(
55
+ model_cls: type[BaseModel],
56
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
57
+ """Rewrite a function's signature so Typer sees individual CLI params.
58
+
59
+ The parameters are derived from the fields of ``model_cls``.
60
+
61
+ Mapping rules:
62
+ - ``kw_only=False`` -> ``typer.Argument``
63
+ - ``kw_only=True`` (or unset) -> ``typer.Option``
64
+ - ``Field(description=...)`` -> ``help=...``
65
+ - ``Field(default=...)`` -> Typer default value
66
+ - ``Field(default_factory=...)`` -> factory is called once at
67
+ decoration time to supply the Typer default
68
+
69
+ The decorated function receives the **validated** Pydantic model
70
+ instance, so all ``AfterValidator`` / ``BeforeValidator`` logic runs
71
+ as usual.
72
+
73
+ Args:
74
+ model_cls: The Pydantic model class whose fields define the CLI
75
+ parameters.
76
+
77
+ Returns:
78
+ A decorator that transforms a ``func(model)`` signature into one
79
+ that Typer can introspect.
80
+
81
+ Example:
82
+ >>> import typer
83
+ >>> app = typer.Typer()
84
+ >>> @app.command()
85
+ ... @pydantic_to_typer(MyConfig)
86
+ ... def run(config: MyConfig): ...
87
+ """
88
+
89
+ def decorator(
90
+ func: Callable[..., Any],
91
+ ) -> Callable[..., Any]:
92
+ new_params: list[inspect.Parameter] = []
93
+ new_annotations: dict[str, object] = {}
94
+
95
+ resolved_hints = get_type_hints(
96
+ model_cls,
97
+ include_extras=True,
98
+ )
99
+
100
+ for name, field_info in model_cls.model_fields.items():
101
+ base_type = _extract_base_type(resolved_hints[name])
102
+ help_text = field_info.description or ""
103
+
104
+ typer_meta: typer.models.ArgumentInfo | typer.models.OptionInfo
105
+ if field_info.kw_only is False:
106
+ typer_meta = typer.Argument(
107
+ help=help_text,
108
+ show_default=False,
109
+ )
110
+ else:
111
+ typer_meta = typer.Option(help=help_text)
112
+
113
+ annotated = Annotated[base_type, typer_meta] # type: ignore[valid-type]
114
+ new_annotations[name] = annotated
115
+
116
+ default: object
117
+ if field_info.is_required():
118
+ default = inspect.Parameter.empty
119
+ elif field_info.default_factory is not None:
120
+ default = field_info.default_factory() # type: ignore[call-arg]
121
+ else:
122
+ default = field_info.default
123
+
124
+ new_params.append(
125
+ inspect.Parameter(
126
+ name,
127
+ inspect.Parameter.POSITIONAL_OR_KEYWORD
128
+ if field_info.kw_only is False
129
+ else inspect.Parameter.KEYWORD_ONLY,
130
+ default=default,
131
+ annotation=annotated,
132
+ ),
133
+ )
134
+
135
+ new_params.sort(
136
+ key=lambda p: (
137
+ p.kind == inspect.Parameter.KEYWORD_ONLY,
138
+ p.default is not inspect.Parameter.empty,
139
+ ),
140
+ )
141
+
142
+ @wraps(func)
143
+ def wrapper(**kwargs: object) -> object:
144
+ try:
145
+ model = model_cls(**kwargs)
146
+ except ValidationError as exc:
147
+ messages: list[str] = []
148
+ for err in exc.errors():
149
+ loc = ".".join(str(p) for p in err["loc"])
150
+ msg = str(err["msg"])
151
+ messages.append(f"{loc}: {msg}" if loc else msg)
152
+ raise typer.BadParameter("\n ".join(messages)) from exc
153
+ return func(model)
154
+
155
+ wrapper.__signature__ = inspect.Signature(new_params) # type: ignore[attr-defined]
156
+ wrapper.__annotations__ = new_annotations
157
+ return wrapper
158
+
159
+ return decorator
typantic/py.typed ADDED
File without changes
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: typantic
3
+ Version: 0.1.0
4
+ Summary: Auto-generate Typer CLI interfaces from Pydantic models.
5
+ Keywords: typer,pydantic,cli,validation
6
+ Author: Kilian Schnelle
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: pydantic>=2.0
16
+ Requires-Dist: typer>=0.9
17
+ Requires-Python: >=3.12
18
+ Project-URL: Homepage, https://github.com/KiSchnelle/typantic
19
+ Project-URL: Repository, https://github.com/KiSchnelle/typantic
20
+ Project-URL: Issues, https://github.com/KiSchnelle/typantic/issues
21
+ Project-URL: Changelog, https://github.com/KiSchnelle/typantic/blob/main/CHANGELOG.md
22
+ Description-Content-Type: text/markdown
23
+
24
+ # typantic
25
+
26
+ [![CI](https://github.com/KiSchnelle/typantic/actions/workflows/ci.yml/badge.svg)](https://github.com/KiSchnelle/typantic/actions/workflows/ci.yml)
27
+ [![PyPI](https://img.shields.io/pypi/v/typantic.svg)](https://pypi.org/project/typantic/)
28
+ [![Python](https://img.shields.io/pypi/pyversions/typantic.svg)](https://pypi.org/project/typantic/)
29
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
30
+
31
+ Auto-generate [Typer](https://typer.tiangolo.com/) CLI interfaces from [Pydantic](https://docs.pydantic.dev/) models.
32
+
33
+ Define your config **once** as a Pydantic model with validators, and get a
34
+ fully-typed CLI for free — no duplication, no drift.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install typantic
40
+ ```
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ from pathlib import Path
46
+ from typing import Annotated
47
+
48
+ import typer
49
+ from pydantic import AfterValidator, BaseModel, Field
50
+
51
+ from typantic import pydantic_to_typer
52
+
53
+
54
+ # 1. Define your config with validators
55
+ class Config(BaseModel):
56
+ images: Annotated[
57
+ list[Path],
58
+ Field(description="Image folders to process.", kw_only=False),
59
+ ]
60
+ output: Annotated[
61
+ Path,
62
+ AfterValidator(Path.resolve),
63
+ Field(description="Output directory.", kw_only=True),
64
+ ]
65
+ threshold: Annotated[
66
+ float,
67
+ Field(default=0.5, description="Detection threshold.", kw_only=True),
68
+ ]
69
+ seed: Annotated[
70
+ int | None,
71
+ Field(default=None, description="Random seed.", kw_only=True),
72
+ ]
73
+
74
+
75
+ # 2. Use the decorator — that's it
76
+ app = typer.Typer()
77
+
78
+ @app.command()
79
+ @pydantic_to_typer(Config)
80
+ def run(config: Config):
81
+ """Process images with validation."""
82
+ print(config)
83
+
84
+ if __name__ == "__main__":
85
+ app()
86
+ ```
87
+
88
+ ```
89
+ $ python example.py --help
90
+
91
+ Usage: example.py [OPTIONS] IMAGES...
92
+
93
+ Process images with validation.
94
+
95
+ ╭─ Arguments ──────────────────────────────────────────────────╮
96
+ │ * images IMAGES... Image folders to process. [required] │
97
+ ╰──────────────────────────────────────────────────────────────╯
98
+ ╭─ Options ────────────────────────────────────────────────────╮
99
+ │ * --output PATH Output directory. [required] │
100
+ │ --threshold FLOAT Detection threshold. [default: 0.5]│
101
+ │ --seed INTEGER Random seed. │
102
+ │ --help Show this message and exit. │
103
+ ╰──────────────────────────────────────────────────────────────╯
104
+ ```
105
+
106
+ ## How it works
107
+
108
+ The `@pydantic_to_typer(Model)` decorator:
109
+
110
+ 1. Reads `Model.model_fields` to discover field names, types, descriptions, and defaults
111
+ 2. Strips `Annotated` validator metadata to extract the base types Typer understands
112
+ 3. Maps `kw_only=False` → `typer.Argument`, `kw_only=True` → `typer.Option`
113
+ 4. Rewrites the function's `__signature__` so Typer sees the expanded parameters
114
+ 5. At call time, passes the raw CLI values into `Model(...)` so all Pydantic validators run
115
+
116
+ Your function receives the **validated model instance** — validators, `default_factory`, union types, and everything else works exactly as in Pydantic.
117
+
118
+ ## Features
119
+
120
+ | Pydantic | CLI result |
121
+ |-----------------------------------|-----------------------------------------|
122
+ | `kw_only=False` | `typer.Argument` (positional) |
123
+ | `kw_only=True` or unset | `typer.Option` (`--flag`) |
124
+ | `Field(description=...)` | `help=...` in the CLI |
125
+ | `Field(default=...)` | Default value shown in `--help` |
126
+ | `Field(default_factory=...)` | Factory called once at decoration time |
127
+ | `int \| None` | Optional CLI option |
128
+ | `list[Path]` | Variadic positional argument |
129
+ | `AfterValidator`, `BeforeValidator` | Run at call time via Pydantic |
130
+
131
+ ## Requirements
132
+
133
+ - Python ≥ 3.12
134
+ - Pydantic ≥ 2.0
135
+ - Typer ≥ 0.9
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,6 @@
1
+ typantic/__init__.py,sha256=nv08fEyBZu66_WApKnXjtPWYEsbQqXIK75arb7spGkE,234
2
+ typantic/_decorator.py,sha256=VGt9JmkZpwQJIHWY2lT-9kl_x8doiMRed73vrMLNJ7Y,5461
3
+ typantic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ typantic-0.1.0.dist-info/WHEEL,sha256=i9aSRDivn5iP9LaR1BLQX2GNAuriQWPsFwbbWygTX2k,81
5
+ typantic-0.1.0.dist-info/METADATA,sha256=1CAqXhU191gpg701-7P5sMfs1MNma8XGVSUinxjV6GM,5372
6
+ typantic-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.15
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any