Metadata-Version: 2.4
Name: typantic
Version: 0.8.0
Summary: Type-safe interfaces from Pydantic v2 models: Typer CLIs, plus an optional FastAPI web launcher + dashboard.
Keywords: typer,pydantic,pydantic-v2,cli,pydantic-cli,typer-cli,command-line,argument-parsing,validation,config,settings,fastapi,web,dashboard,job-launcher,json-schema,slurm,pbs,docker,podman,apptainer,ssh
Author: Kilian Schnelle
Author-email: Kilian Schnelle <kilian.schnelle@kischnelle.dev>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Environment :: Console
Classifier: Environment :: Web Environment
Classifier: Framework :: Pydantic
Classifier: Framework :: Pydantic :: 2
Classifier: Framework :: FastAPI
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Utilities
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Typing :: Typed
Requires-Dist: pydantic>=2.13.5
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: typer>=0.27.2
Requires-Dist: fastapi>=0.141.1 ; extra == 'web'
Requires-Dist: uvicorn>=0.53.0 ; extra == 'web'
Requires-Dist: websockets>=17.1 ; extra == 'web'
Requires-Dist: pillow>=12.3.0 ; extra == 'web'
Requires-Python: >=3.12
Project-URL: Homepage, https://github.com/KiSchnelle/typantic
Project-URL: Repository, https://github.com/KiSchnelle/typantic
Project-URL: Issues, https://github.com/KiSchnelle/typantic/issues
Project-URL: Changelog, https://github.com/KiSchnelle/typantic/blob/main/CHANGELOG.md
Provides-Extra: web
Description-Content-Type: text/markdown

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/KiSchnelle/typantic/main/logo/png/logo-dark-2x.png">
  <img alt="typantic" src="https://raw.githubusercontent.com/KiSchnelle/typantic/main/logo/png/logo-light-2x.png" height="72">
</picture>

[![CI](https://github.com/KiSchnelle/typantic/actions/workflows/ci.yml/badge.svg)](https://github.com/KiSchnelle/typantic/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/typantic.svg)](https://pypi.org/project/typantic/)
[![Python](https://img.shields.io/pypi/pyversions/typantic.svg)](https://pypi.org/project/typantic/)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21473880.svg)](https://doi.org/10.5281/zenodo.21473880)

Auto-generate [Typer](https://typer.tiangolo.com/) CLI interfaces from [Pydantic](https://docs.pydantic.dev/) 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

```bash
pip install typantic
```

## Quick start

```python
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      <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.          │
╰──────────────────────────────────────────────────────────────────────────────╯
```

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

## 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 |
| `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](#config-files)) makes the command
read every field from `--config`.

## Per-field CLI hints

Customise individual flags with `Field(json_schema_extra=...)`:

```python
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:

```python
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:

```python
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:

```python
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.

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

```python
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:

```console
$ 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):

```console
$ 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
```

Precedence, 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 helpers behind the flags

The same three steps are available directly, for code that reads or writes a
settings file outside a Typer command:

```python
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.

```python
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:

```python
# 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")
```

```toml
# 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. Only
  a lone `--version` / `-V` / `version` asks for it: `myapp --version 2.1` is a run
  of a single-command app with its own `version` field.
- **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`, in either `--flag value` or `--flag=value` form) 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. 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:

```python
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:

```bash
pip install 'typantic[web]'
```

The base `import typantic` never pulls in FastAPI; only `typantic.web` (and
`typantic web …`) does.

![typantic web — the command catalog](https://raw.githubusercontent.com/KiSchnelle/typantic/main/docs/img/dashboard-launch.png)

<p align="center">
  <img src="https://raw.githubusercontent.com/KiSchnelle/typantic/main/docs/img/dashboard-form.png" width="49%" alt="A form derived from a settings model, with a schema-driven backend options subform" />
  <img src="https://raw.githubusercontent.com/KiSchnelle/typantic/main/docs/img/dashboard-job.png" width="49%" alt="A job's live log tail and output-image gallery" />
</p>
<p align="center">
  <img src="https://raw.githubusercontent.com/KiSchnelle/typantic/main/docs/img/dashboard-jobs.png" width="49%" alt="The jobs list" />
  <img src="https://raw.githubusercontent.com/KiSchnelle/typantic/main/docs/img/dashboard-projects.png" width="49%" alt="Projects with grouped job history" />
</p>

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`](#add_endpoint--one-web-form-for-a-model) | 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`](#typantic-web-serve--a-dashboard-for-your-commands) | 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](https://fastapi.tiangolo.com/):
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.

```python
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`](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:

```python
# 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:

```python
# 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",
    },
]
```

```toml
# 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:

```bash
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` / `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.
- **Brand it as your own.** A package can show the dashboard under its own
  name, mark and accent colour by registering a brand — a `Brand` or a plain
  mapping of its fields — under the `typantic.web_brand` entry-point group:

  ```python
  # 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
  }
  ```

  ```toml
  [project.entry-points."typantic.web_brand"]
  myapp = "myapp.web_brand:BRAND"
  ```

  `typantic web serve` picks it up (the first by entry-point name, if several
  are installed), and `--title`, `--icon mark.svg` and `--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 through `currentColor`: 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 X` works too, but a command line is visible in `ps` to every user
  of the machine. `--no-token` is 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 --user` service takes its jobs down
  when the service stops (set `KillMode=process`), and one started inside a
  Slurm allocation takes them down when the allocation ends (use the `slurm`
  backend instead).
- **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_HOME` is honoured, and
  `$TYPANTIC_WEB_CACHE_DIR` overrides both); the server prunes thumbnails nobody
  has opened in 30 days when it starts.

## Requirements

- 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

## License

MIT
