Skip to main content

typantic

CI PyPI Python License DOI

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).

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:

  1. Reads Model.model_fields to discover field names, types, descriptions, and defaults
  2. Strips Annotated validator metadata to extract the base types Typer understands
  3. Maps kw_only=False → typer.Argument, kw_only=True → typer.Option
  4. Flattens nested BaseModel fields into prefixed parameters
  5. Rewrites the function's __signature__ so Typer sees the expanded parameters
  6. 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 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
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. 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:

$ 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 these 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.                         │
│ --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 raises a ValueError.

An unknown key in the file is rejected up front (recursing into nested models), so a typo like wrokers: 8 fails fast instead of being silently dropped and leaving the field at its default. Computed-field names are still accepted, so a config written back out (which serialises them) reloads cleanly.

The helpers behind the flags

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.

File-only commands

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.

Entry point (make_main)

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"
  • --version is answered from package metadata before load_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.
  • 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 after package_name. Introspection flags (--help, --schema, --generate-config) 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 Exit code propagates unchanged.

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 after the version and completion short-circuits, so neither pays for it:

main = make_main(_load_app, package_name="myapp", run_context=MyLogger.running)

Web (typantic[web])

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.

typantic web — the command catalog

A form derived from a settings model, with a schema-driven backend options subform A job's live log tail and output-image gallery

The jobs list Projects with grouped job history

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

add_endpoint — one web form for a model

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/schema

typantic web serve — a dashboard for your commands

typantic 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). Set the page's brand — sidebar and browser tab — with --title.
  • 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 / pbs submit to an HPC cluster, docker / podman / apptainer run in a container, and ssh runs on another host. You can register your own under the typantic.web_backends entry-point group.
  • Projects & history — file jobs under a project, then search, filter, sort, and page through the history (a stdlib SQLite index; nothing to set up).

Requirements

  • Python ≥ 3.12 (tested on 3.12–3.15)
  • Pydantic ≥ 2.10
  • Typer ≥ 0.27
  • PyYAML ≥ 6.0
  • For [web]: FastAPI, Uvicorn, WebSockets, Pillow

License

MIT

Metadata

Release files for typantic 0.7.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for typantic 0.7.1
File Size Uploaded
typantic-0.7.1.tar.gz 282.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for typantic 0.7.1
File Interpreter ABI Platform
typantic-0.7.1-py3-none-any.whl Python 3 none any Details

Total release size: 572.7 kB

Release files / typantic-0.7.1.tar.gz

Download URL typantic-0.7.1.tar.gz
Size 282.0 kB
Tags Source
SHA-256 checksum
How to use checksums
9429dc6eb72cce91b2e257f7c788f78aa4c283b7a0c9229fa3516866f7b754ec
BLAKE2b-256 checksum
How to use checksums
607ee25382002974d07c0c50e6758fb3b32d3f582d1659f85b140afc900439c6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.

Transparency log

Release files / typantic-0.7.1-py3-none-any.whl

Download URL typantic-0.7.1-py3-none-any.whl
Size 290.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
73a02e892f66caef8efc689e13d7a0a9e24f41e6667aa870bacd504ee9c10087
BLAKE2b-256 checksum
How to use checksums
f507759a18e8e6d35658dfc578e0818858a725469c17a76d1149c1804cab205b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.

Transparency log

Release history Release notifications | RSS feed

0.8.1

2 release files

0.8.0

2 release files

This release

0.7.1 This release

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page