Auto-generate Typer CLI interfaces from Pydantic models — a Pydantic → Typer bridge.
Define your config once as a Pydantic model with validators and get a typed,
validated command-line interface for free — no duplication, no drift — plus
optional YAML/JSON config files (--config / --generate-config).
pip install typanticfrom 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 <path> Image folders to process. [required] │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ * --output <path> Output directory. [required] │
│ --threshold <float> Detection threshold. [default: 0.5] │
│ --seed <int> Random seed. [default: (None)] │
│ --install-completion Install completion for the current │
│ shell. │
│ --show-completion Show completion for the current │
│ shell, to copy it or customize the │
│ installation. │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
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 values you actually passed (a flag, its environment variable, or a prompt — never a flag's default) and hands them to
Model(...), so Pydantic applies its own defaults and runs every validator
Your function receives the validated model instance, exactly as Model(**what_you_passed) would build it: model_fields_set holds only the fields you gave, a validator that derives a value "when unset" fires, a default_factory runs once per run (with the validated data, if it takes it), and a secret's default is its real value. A flag passed under a nested model with a default instance keeps that instance's other values, the ones --help advertises.
| 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 per invocation; --help shows [default: (computed at runtime)] |
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 |
BaseModel | None |
Flattened too; stays None unless one of its flags is passed |
SecretStr, SecretBytes |
Hidden input (secure prompt if required) |
int | None |
Optional CLI option |
default=None |
Rendered as [default: (None)] |
list[X], set[X], Sequence[X], deque[X], tuple[X, ...] |
Repeated option (--x a --x b); a variadic positional argument with kw_only=False |
Decimal, date, time, timedelta, datetime (timezone-aware too), URLs, IP addresses, ByteSize, bytes, Any, a union of values |
Taken as text and parsed by Pydantic; --help names what it takes (<decimal>) |
NewType, type X = ... |
Handled as the type they wrap |
AfterValidator, BeforeValidator |
Run at call time via Pydantic |
Validators that raise ValueError / AssertionError surface as Typer
parameter errors; other exception types propagate unchanged.
A type no flag can express — a dict, a list of lists, a list of models — is
refused when the command is decorated, naming the field. Such a model belongs in
a file: config_file="only" (see Config files) makes the command
read every field from --config.
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 |
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.
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 explicitlyLarge 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] │
│ --install-completion Install completion for the current │
│ shell. │
│ --show-completion Show completion for the current │
│ shell, to copy it or customize the │
│ installation. │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Compute ────────────────────────────────────────────────────────────────────╮
│ --cpus <int> 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.
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. Three
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;--schema— print the settings model's JSON Schema to stdout, then exit (a web front-end can subprocess this to build a form from the model without importing it, keeping heavy app dependencies out of the web process).
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,
nested models are expanded so their shape is visible, and any
default_factory field becomes a <DEFAULT: computed at runtime> sentinel
(rather than a frozen value) so it is recomputed fresh when the file is loaded —
handy for host/time-sensitive defaults like a timestamped output folder or a CPU
count that shouldn't be baked into a shared template. A secret's default gets the
same sentinel, so it is never written to the file. A file still holding a
<REQUIRED: ...> placeholder is refused on load, naming each one, rather than run
with the placeholder text as the value:
$ myapp run --generate-config run.yaml
$ cat run.yaml
name: '<REQUIRED: App name.>'
db:
host: '<REQUIRED: DB host.>'
port: 5432
workers: 4
tags:
- defaultFill 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 overridesPrecedence, highest first: a flag you pass, its environment variable
(cli_envvar), the file, then the model's own default. A relative path in a
config file resolves against the directory you run the command from, not the
file's own directory.
--help lists these options under a Config file panel:
╭─ Config file ──────────────────────────────────────────────────╮
│ --config <path> Load settings from a │
│ YAML/JSON file (flags passed │
│ still override). │
│ [default: (None)] │
│ --generate-config <path> Write an editable default │
│ config template to this file │
│ and exit. │
│ [default: (None)] │
│ --schema Print the settings model's │
│ JSON Schema to stdout 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 is a usage error (exit 2) naming
the file.
An unknown key in the file is rejected up front, so a typo like wrokers: 8
fails fast instead of being silently dropped and leaving the field at its
default. A field is accepted under exactly the keys Pydantic accepts for it —
its alias, and its own name only where the model allows that (a field's name on
a model that only takes its alias is reported with the key to use). The check
looks inside nested models, Model | None values and each item of a list of
models (mounts[1].destt), and skips models that allow extra keys. Computed-field
names are accepted and dropped, so a config written back out (which serialises
them) reloads even on a model with extra="forbid". Whichever spelling the file
uses, a flag you pass still overrides it.
The same three steps are available directly, for code that reads or writes a settings file outside a Typer command:
from pathlib import Path
from typantic import build_config_template, load_config_file, write_config_template
write_config_template(Config, Path("config.yaml")) # what --generate-config writes
template = build_config_template(Config) # the same mapping, unwritten
settings = Config(**load_config_file(Path("config.yaml")))write_config_template picks JSON for a .json suffix and YAML otherwise.
load_config_file accepts .yaml, .yml and .json, and raises ValueError
for an unsupported suffix, unparseable content, or a non-mapping top level.
Some models can't map onto flat flags at all — nested-model lists, or
scalar | (min, max) ranges. For those, pass config_file="only": the command
exposes just --config / --generate-config, with no per-field flags.
add_command(app, TuneConfig, run, config_file="only", help="Tune from a config file.")File-only commands still expose --schema, so a web front-end can build their
form the same way.
make_main builds the main() you point your console script at. It is optional —
main = app works — but it handles four things a hand-written entry point usually
gets wrong:
# myapp/cli.py
from typantic import make_main
def _load_app():
from myapp.commands import app # imported only when a command will actually run
return app
main = make_main(_load_app, package_name="myapp")# pyproject.toml
[project.scripts]
myapp = "myapp.cli:main"--versionis answered from package metadata beforeload_app()is called, so an app that imports a heavy stack (torch, say) still responds instantly. That is why the app is passed as a loader rather than as the app itself. Only a lone--version/-V/versionasks for it:myapp --version 2.1is a run of a single-command app with its ownversionfield.- Shell completion is handed straight to Typer, without setting up the run context below.
- A real run is timed, logging
Execution took N minutes.to a logger named afterpackage_name. Introspection flags (--help,--schema,--generate-config, in either--flag valueor--flag=valueform) exit without a run, so they are not timed and their stdout stays machine-readable. - A crash becomes exit 1 with the traceback logged, rather than a raw
traceback; a non-zero
Exitcode propagates unchanged. Ctrl-C while the command modules import exits 130.
Pass run_context to wrap the run in a context manager — typically logging setup
that has to be torn down even when the command raises. It is entered only for a
real run — not for --version, shell completion, or an introspection flag, so a
context that logs to stdout cannot corrupt --schema's JSON:
main = make_main(_load_app, package_name="myapp", run_context=MyLogger.running)The optional [web] extra turns the same settings models into web interfaces —
the FastAPI counterpart of the Typer bridge. Install it with:
pip install 'typantic[web]'The base import typantic never pulls in FastAPI; only typantic.web (and
typantic web …) does.
There are two ways to put a settings model on the web — pick the one that matches what you need:
| You want… | Use | What it gives you |
|---|---|---|
| one form + endpoint inside a FastAPI app you already run | add_endpoint |
a POST that validates the body into your model and calls your handler, in-process |
| a ready-made dashboard that runs your commands as tracked jobs | typantic web serve |
a form per command, live log tail, output-image gallery, and searchable history |
The mirror of add_command, but for FastAPI:
register a POST endpoint that validates the request body into your model and
calls a handler, plus a GET …/schema route serving the form-ready JSON Schema.
The handler runs in your own process — reach for this when you just want one
form on an app you already have.
from fastapi import FastAPI
from pydantic import BaseModel
from typantic.web import add_endpoint
class Config(BaseModel):
name: str
workers: int = 4
def run(config: Config) -> dict[str, str]:
return {"ran": config.name}
app = FastAPI()
add_endpoint(app, Config, run) # POST /run + GET /run/schematypantic web serve is a ready-made dashboard that finds your commands,
shows a form for each, and launches them as tracked background jobs — streaming
the log and showing any output images. It runs your CLI (it never imports
your code), so heavy dependencies stay out of the web process.
Getting a command to show up takes three small steps. (There's a complete,
runnable version in examples/typantic_demo.)
1. Make it a config-file CLI command. Any command registered with
add_command(..., config_file=True) gets the --schema and --config flags the
dashboard drives:
# myapp/cli.py
from pathlib import Path
from typing import Annotated
import typer
from pydantic import BaseModel, Field
from typantic import add_command
class DetectConfig(BaseModel):
images: Annotated[Path, Field(description="Folder of images to process.")]
threshold: Annotated[float, Field(default=0.5, description="Detection threshold.")]
def detect(config: DetectConfig) -> None:
... # do the work; write any output files into the current directory
app = typer.Typer()
add_command(app, DetectConfig, detect, name="detect", config_file=True)
def main() -> None:
app()2. Advertise it to the dashboard by listing your commands (as plain data —
nothing heavy is imported at discovery time) under the typantic.web_commands
entry-point group:
# myapp/web_meta.py
WEB_COMMANDS = [
{
"app": "myapp", # your console-script name
"command": "detect",
"argv": ["detect"], # the tokens after `myapp` that select it
"title": "Detect objects",
"description": "Run detection over a folder of images.",
"default_backend": "local",
},
]# pyproject.toml
[project.scripts]
myapp = "myapp.cli:main"
[project.entry-points."typantic.web_commands"]
myapp = "myapp.web_meta:WEB_COMMANDS"3. Install and serve — in the same environment:
pip install . 'typantic[web]'
typantic web serve
# typantic web is running. Open:
# http://127.0.0.1:54321/?token=…Open the printed URL: "Detect objects" is in the catalog. Fill the form and
click Launch — the dashboard writes your values to a config file, runs
myapp detect --config … as a background job, and streams its log. That's it.
A few things worth knowing:
-
It runs as you, on your machine, on a free port with a token in the URL — just open the URL it prints. On a remote server, forward the port over SSH (the command prints a ready-to-run
ssh -N -L …line with your user and host filled in). Name the page with--title, or brand it (below). -
Backends decide where a job runs, chosen per launch in the form.
local(a subprocess on this machine) is the default and needs no setup;slurm/pbssubmit to an HPC cluster,docker/podman/apptainerrun in a container, andsshruns on another host. You can register your own under thetypantic.web_backendsentry-point group. -
Brand it as your own. A package can show the dashboard under its own name, mark and accent colour by registering a brand — a
Brandor a plain mapping of its fields — under thetypantic.web_brandentry-point group:# myapp/web_brand.py from importlib.resources import files BRAND = { "title": "myapp", # the tab; the sidebar wordmark splits it at its first space "accent": "#5AA9FF", # buttons, links and highlights, as #rrggbb "icon": files("myapp").joinpath("mark.svg").read_text(), # SVG, ≤ 64 KiB }
[project.entry-points."typantic.web_brand"] myapp = "myapp.web_brand:BRAND"
typantic web servepicks it up (the first by entry-point name, if several are installed), and--title,--icon mark.svgand--accent '#ff0000'override it for a run. Without a brand the dashboard shows typantic's own mark and cyan. The icon is shown as an image (the sidebar mark and the tab's icon), so it cannot take the accent throughcurrentColor: give it colours of its own. -
Projects & history — file jobs under a project, then search, filter, sort, and page through the history (a stdlib SQLite index; nothing to set up).
-
Keep the token generated. The printed URL carries a fresh random token;
--token Xworks too, but a command line is visible inpsto every user of the machine.--no-tokenis for a loopback-only development run. -
One server per job store. Servers on several login nodes can share a store on a common filesystem — each job records the host its process runs on, and only that host's server probes or cancels it — but they do not coordinate otherwise.
-
Jobs outlive the server, so restarting it never kills a local job. Two exceptions: a server run as a
systemd --userservice takes its jobs down when the service stops (setKillMode=process), and one started inside a Slurm allocation takes them down when the allocation ends (use theslurmbackend instead). -
Upgrading an app needs no restart: its forms follow the version installed now. A newly installed app, and source edits to an editable install, show up after
POST /api/commands/refresh(with the token) or a restart; upgrading typantic itself needs a restart. Each job records the app version it ran with, and a job whose settings the installed version does not have — renamed or removed since, or added by a newer version before a downgrade — cannot be cloned or restarted: it names those settings, and you launch a new one. -
Where things live — each job's folder (config, log, outputs) under
~/.typantic/jobs, or$TYPANTIC_WEB_JOBS_DIR, readable by you alone: a submitted config can hold a secret typed into the form. Write outputs you share with colleagues to an output folder elsewhere. Gallery thumbnails are cached in~/.cache/typantic/thumbnails($XDG_CACHE_HOMEis honoured, and$TYPANTIC_WEB_CACHE_DIRoverrides both); the server prunes thumbnails nobody has opened in 30 days when it starts.
- Python ≥ 3.12 (tested on 3.12–3.15)
- Pydantic ≥ 2.13.5
- Typer ≥ 0.27.2
- PyYAML ≥ 6.0.3
- For
[web]: FastAPI ≥ 0.141.1, Uvicorn ≥ 0.53, WebSockets ≥ 17.1, Pillow ≥ 12.3
MIT




