Auto-generate Typer CLI interfaces from Pydantic models.
Project description
typantic
Auto-generate Typer CLI interfaces from Pydantic models.
Define your config once as a Pydantic model with validators, and get a fully-typed CLI for free — no duplication, no drift.
Installation
pip install typantic
Quick start
from pathlib import Path
from typing import Annotated
import typer
from pydantic import AfterValidator, BaseModel, Field
from typantic import pydantic_to_typer
# 1. Define your config with validators
class Config(BaseModel):
images: Annotated[
list[Path],
Field(description="Image folders to process.", kw_only=False),
]
output: Annotated[
Path,
AfterValidator(Path.resolve),
Field(description="Output directory.", kw_only=True),
]
threshold: Annotated[
float,
Field(default=0.5, description="Detection threshold.", kw_only=True),
]
seed: Annotated[
int | None,
Field(default=None, description="Random seed.", kw_only=True),
]
# 2. Use the decorator — that's it
app = typer.Typer()
@app.command()
@pydantic_to_typer(Config)
def run(config: Config):
"""Process images with validation."""
print(config)
if __name__ == "__main__":
app()
$ python example.py --help
Usage: example.py [OPTIONS] IMAGES...
Process images with validation.
╭─ Arguments ──────────────────────────────────────────────────╮
│ * images IMAGES... Image folders to process. [required] │
╰──────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────╮
│ * --output PATH Output directory. [required] │
│ --threshold FLOAT Detection threshold. [default: 0.5]│
│ --seed INTEGER Random seed. [default: (None)] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────╯
How it works
The @pydantic_to_typer(Model) decorator:
- Reads
Model.model_fieldsto discover field names, types, descriptions, and defaults - Strips
Annotatedvalidator metadata to extract the base types Typer understands - Maps
kw_only=False→typer.Argument,kw_only=True→typer.Option - Flattens nested
BaseModelfields into prefixed parameters - Rewrites the function's
__signature__so Typer sees the expanded parameters - At call time, re-nests the raw CLI values and passes them into
Model(...)so all Pydantic validators run
Your function receives the validated model instance — validators, default_factory, union types, and everything else works exactly as in Pydantic.
Features
| Pydantic | CLI result |
|---|---|
kw_only=False |
typer.Argument (positional) |
kw_only=True or unset |
typer.Option (--flag) |
Field(description=...) |
help=... in the CLI |
Field(default=...) |
Default value shown in --help |
Field(default_factory=...) |
Re-evaluated on every invocation |
Field(ge=..., le=...) |
Typer min / max (validated + shown) |
Literal["a", "b"] |
CLI choices |
Enum, tuple[...] |
Choices / multi-value option |
nested BaseModel |
Flattened into --prefix-field options |
SecretStr, SecretBytes |
Hidden input (secure prompt if required) |
int | None |
Optional CLI option |
default=None |
Rendered as [default: (None)] |
list[Path] |
Variadic positional argument |
AfterValidator, BeforeValidator |
Run at call time via Pydantic |
Validators that raise ValueError / AssertionError surface as Typer
parameter errors; other exception types propagate unchanged.
Per-field CLI hints
Customise individual flags with Field(json_schema_extra=...):
class Config(BaseModel):
verbose: Annotated[
bool,
Field(default=False, json_schema_extra={"cli_short": "-v"}),
]
output: Annotated[
Path,
Field(description="Output path.", json_schema_extra={"cli_name": "--dest"}),
]
api_key: Annotated[
str,
Field(default="", json_schema_extra={"cli_envvar": "MYAPP_API_KEY"}),
]
| Key | Effect |
|---|---|
cli_short |
Adds a short flag (e.g. -v) alongside the long one |
cli_name |
Replaces the derived long flag (e.g. --dest) |
cli_envvar |
Reads the value from an environment variable |
Nested models
Fields whose type is itself a BaseModel are flattened into prefixed options,
so layered configs map onto the CLI without manual wiring:
class Database(BaseModel):
host: Annotated[str, Field(default="localhost", description="DB host.")]
port: Annotated[int, Field(default=5432, ge=1, le=65535, description="DB port.")]
class Config(BaseModel):
name: Annotated[str, Field(description="App name.", kw_only=False)]
db: Database # -> --db-host, --db-port
$ python example.py myapp --db-host db.internal --db-port 9000
The values are re-nested before the model is constructed, so Database's own
validators and defaults apply as usual.
Registering commands without a stub
add_command wires a model and a handler onto a Typer app directly, skipping
the decorate-a-stub-function boilerplate:
import typer
from typantic import add_command
app = typer.Typer()
def run(config: Config) -> None:
print(config)
add_command(app, Config, run) # command name defaults to "run"
add_command(app, Config, run, name="go") # or set it explicitly
Help panels for mixin-composed models
Large configs composed from mixins can group their options into titled Rich
help panels. Opt in with subpanels=True and give each mixin a cli_panel
class attribute — every option lands in the panel of the class that defines
its field:
from typing import Annotated, ClassVar
from pydantic import BaseModel, Field
from typantic import pydantic_to_typer
class ComputeMixin(BaseModel):
cli_panel: ClassVar[str] = "Compute"
cpus: Annotated[int, Field(default=4, description="CPU count.")]
class Config(ComputeMixin):
dry_run: Annotated[bool, Field(default=False, description="Dry run.")]
@app.command()
@pydantic_to_typer(Config, subpanels=True)
def run(config: Config): ...
$ python example.py --help
Usage: example.py [OPTIONS]
╭─ Options ──────────────────────────────────────────────────────╮
│ --dry-run --no-dry-run Dry run. [default: no-dry-run] │
│ --help Show this message and exit. │
╰────────────────────────────────────────────────────────────────╯
╭─ Compute ──────────────────────────────────────────────────────╮
│ --cpus INTEGER CPU count. [default: 4] │
╰────────────────────────────────────────────────────────────────╯
--cpus renders under a "Compute" panel; --dry-run stays in the default
options group (its defining class declares no cli_panel). Arguments are
never panelled.
Config files
Some configs are too large or too nested to pass as flags every time. Opt in with
config_file=True and the command can be driven by a YAML/JSON file as well. Two
options are injected:
--generate-config PATH— write an editable default template, then exit without running;--config PATH— load settings from a file as the base; any flags you also pass override the file.
from typing import Annotated
import typer
from pydantic import BaseModel, Field
from typantic import add_command
class Database(BaseModel):
host: Annotated[str, Field(description="DB host.")] # required
port: Annotated[int, Field(default=5432, description="DB port.")]
class Config(BaseModel):
name: Annotated[str, Field(description="App name.")] # required
db: Database # required nested model
workers: Annotated[int, Field(default=4, description="Worker count.")]
tags: set[str] = {"default"}
app = typer.Typer()
def run(config: Config) -> None:
print(config)
add_command(app, Config, run, name="run", config_file=True)
Generate a template — required fields become <REQUIRED: ...> placeholders, and
nested models are expanded so their shape is visible:
$ myapp run --generate-config run.yaml
$ cat run.yaml
name: '<REQUIRED: App name.>'
db:
host: '<REQUIRED: DB host.>'
port: 5432
workers: 4
tags:
- default
Fill in the required values and run from the file (or override individual settings with flags, which take precedence over the file):
$ cat run.yaml
name: my-service
db:
host: db.internal
port: 9000
workers: 8
tags: [eu, prod]
$ myapp run --config run.yaml # run entirely from the file
$ myapp run --config run.yaml --workers 16 # file as base, --workers overrides
--help lists both options under a Config file panel:
╭─ Config file ──────────────────────────────────────────────────╮
│ --config PATH Load settings from a YAML/JSON file │
│ (flags passed still override). │
│ --generate-config PATH Write a default config template to │
│ PATH and exit. │
╰────────────────────────────────────────────────────────────────╯
Because --config may supply them, required fields are made optional at the Typer
layer; Pydantic re-checks requiredness after merging file and flags, so a value
missing from both is still reported as an error — it just no longer renders as
[required] in --help. A --config document must be a mapping; a bad suffix,
unparseable content, or a non-mapping top level raises a ValueError.
Requirements
- Python ≥ 3.12 (tested on 3.12–3.15)
- Pydantic ≥ 2.10
- Typer ≥ 0.26
- PyYAML ≥ 6.0
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file typantic-0.4.0.tar.gz.
File metadata
- Download URL: typantic-0.4.0.tar.gz
- Upload date:
- Size: 13.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5800638ce7458700f7c26741f4e0704c71d3d4efb06bb06e05b69f77af66b954
|
|
| MD5 |
b39906708c1b99c58a4c5d7e7e49429b
|
|
| BLAKE2b-256 |
2f9b604e0ee9287a598da63b1346d402ce028f451999029c333037e59c0e8f98
|
Provenance
The following attestation bundles were made for typantic-0.4.0.tar.gz:
Publisher:
publish.yml on KiSchnelle/typantic
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
typantic-0.4.0.tar.gz -
Subject digest:
5800638ce7458700f7c26741f4e0704c71d3d4efb06bb06e05b69f77af66b954 - Sigstore transparency entry: 1924162205
- Sigstore integration time:
-
Permalink:
KiSchnelle/typantic@2b4e9d56cbd5166ebd29fba543a47305321e7e3b -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/KiSchnelle
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2b4e9d56cbd5166ebd29fba543a47305321e7e3b -
Trigger Event:
release
-
Statement type:
File details
Details for the file typantic-0.4.0-py3-none-any.whl.
File metadata
- Download URL: typantic-0.4.0-py3-none-any.whl
- Upload date:
- Size: 15.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c4ed11eaad84020a707acfed3661babbb4bc76516787018a39f214d2fb7a51f1
|
|
| MD5 |
acd74b0abb5f8df046c6efd5dc8419de
|
|
| BLAKE2b-256 |
5b4460b31c3d79075b9d447e3ef4c50048b513fe8b03476880e70a6983a4ed80
|
Provenance
The following attestation bundles were made for typantic-0.4.0-py3-none-any.whl:
Publisher:
publish.yml on KiSchnelle/typantic
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
typantic-0.4.0-py3-none-any.whl -
Subject digest:
c4ed11eaad84020a707acfed3661babbb4bc76516787018a39f214d2fb7a51f1 - Sigstore transparency entry: 1924162366
- Sigstore integration time:
-
Permalink:
KiSchnelle/typantic@2b4e9d56cbd5166ebd29fba543a47305321e7e3b -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/KiSchnelle
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2b4e9d56cbd5166ebd29fba543a47305321e7e3b -
Trigger Event:
release
-
Statement type: