Skip to content

Quickstart

Point castiron at a schema, get typed Pydantic models. No database connection required — the OpenAPI/PostgREST source reads your project's public API description over HTTPS, so there is no connection string, no driver, and nothing to open in a firewall.

Pre-alpha

castiron is on PyPI and the install below works, but it is young and moving fast — APIs may change between releases. Everything on this page is the CLI as it ships.

Install

uv add cast-iron       # or: pip install cast-iron
castiron --version     # the command has no hyphen

You install cast-iron; you run castiron

Install the hyphenated cast-iron, then run the unhyphenated castironcast-iron --help will only ever tell you command not found, because no such command is installed. It is the ordinary Python split between what you install and what you get: pip install python-dateutil gives you import dateutil, and pip install scikit-learn gives you import sklearn. Here the import package is castiron too. PyPI does not allow castiron as a distribution name, so the hyphen is permanent; nothing else about the name changes.

castiron supports Python 3.10–3.13 and pulls in three runtime dependencies (click, inflection, pydantic) plus tomli on Python 3.10 only.

From a checkout

You do not need this to use castiron — it is how you run the code you are editing when you work on castiron itself:

git clone https://github.com/kmbhm1/castiron.git
cd castiron
uv sync
uv run castiron --version

Every castiron ... command below works as uv run castiron ... from a checkout.

One command to typed models

export CASTIRON_KEY='eyJhbGciOi...'
castiron gen --from https://abcdefgh.supabase.co --emit pydantic
castiron: read 6 tables, 1 enum and 4 functions from https://abcdefgh.supabase.co/rest/v1/
castiron: wrote schema.py (14.2 kB)

The counts and the size come from your schema; the two-line shape is fixed. That counts line is worth reading: PostgREST only exposes what your API key's role can see, so "read 2 tables" when you expected 20 means row-level security or a role grant is hiding things — not that castiron missed them.

A bare Supabase project URL is rewritten to its REST root (https://<ref>.supabase.co/rest/v1/); a plain PostgREST deployment works too — pass its API root. Put the key in CASTIRON_KEY rather than on the command line, where it lands in your shell history. See Environment variables.

Offline: generate from a saved OpenAPI document

--from also takes a path. This is the reproducible path — it touches no network at all, which makes it ideal for CI, for air-gapped builds, and for the rest of this page.

Save the document once — this is the same request castiron makes:

curl -s https://abcdefgh.supabase.co/rest/v1/ \
  -H "apikey: $CASTIRON_KEY" \
  -H "Authorization: Bearer $CASTIRON_KEY" \
  -H "Accept: application/openapi+json" \
  -H "Accept-Profile: public" \
  > openapi.json

Then generate from it as often as you like:

castiron gen --from ./openapi.json --emit pydantic --output out
castiron: 4 tables have an integer primary key with no visible default (orders, products, restricted_table and 1 more) -- PostgREST does not expose nextval()/identity defaults, so castiron marks those columns required on the Insert models. Pass --infer-generated-primary-keys (or set infer-generated-primary-keys = true) if they are serial/identity columns.
castiron: read 6 tables, 1 enum and 4 functions from openapi.json
castiron: wrote out/schema.py (8.5 kB)

The first line is a fidelity warning on stderr, and it is the one rough edge you will meet immediately. PostgREST does not publish nextval()/identity defaults, so castiron cannot tell a bigint generated by default as identity primary key from a natural integer key — and marks it required on the Insert model. If those columns really are serial/identity, add --infer-generated-primary-keys. The full trade-off is on What the OpenAPI source can and cannot see.

The summary lines go to stdout; logs, warnings and errors go to stderr. -q silences the summary and leaves errors intact.

What you get

One file, schema.py. It opens with two comment lines recording the castiron version that wrote it (the provenance header), and the rest is organised into bands of classes:

class UsersBaseSchema(CustomModel):
    """Users Base Schema.

    Application users.
    """

    # Primary Keys
    id: int

    # Columns
    bio: str | None = Field(default=None, description="A short profile blurb.")
    created_at: datetime.datetime | None = Field(default=None)
    email: str
    field_class: str | None = Field(default=None, alias="class")
    is_active: bool
    login_count: int
    metadata: dict | list[dict] | list[Any] | Json | None = Field(default=None)
    status: PublicOrderStatusEnum
    tags: list[str] | None = Field(default=None)
Band Classes What it is for
Enums PublicOrderStatusEnum One str, Enum per Postgres enum type the schema uses (member naming)
Base (Row) UsersBaseSchema Every column, exactly as the row comes back (class naming)
Insert UsersInsert Insert payloads: server-defaulted columns are optional
Update UsersUpdate Update payloads: every field optional
Operational Users The Base model plus nested foreign-key relationship fields — extend this one

Column comments become description=, a table comment (COMMENT ON TABLE) becomes the model docstring on every class generated for that table, a column name Python cannot use — a reserved word like class, but also 2fast or space name — becomes a safe attribute with the real name kept on alias= (column names), and a foreign key becomes a real nested model:

class Orders(OrdersBaseSchema):
    """Orders Schema for Pydantic.

    Customer orders.

    Inherits from OrdersBaseSchema. Add any customization here.
    """

    # Foreign Keys
    user: Users | None = Field(default=None)
    order_items: list[OrderItems] | None = Field(default=None)

Preview before you write

--dry-run runs the whole pipeline and reports what would land, creating no file and no directory:

castiron gen --from ./openapi.json --output out2 --dry-run
castiron: read 6 tables, 1 enum and 4 functions from openapi.json
castiron: would write out2/schema.py (8.5 kB) [dry run, nothing written]

Regeneration overwrites by default — that is the point of a compiler. --no-overwrite turns an existing target into a loud failure, checked for every file before any of them is written, so you never get a half-generated tree:

castiron gen --from ./openapi.json --output out --no-overwrite
Error: out/schema.py already exists and --no-overwrite was given; nothing was written.

Put the settings in your project

Stop retyping flags. Every option except the API key is a [tool.castiron] key in your pyproject.toml:

[tool.castiron]
from = "openapi.json"
emit = ["pydantic"]
output = "src/myapp/models"
castiron gen
castiron: read 6 tables, 1 enum and 4 functions from /home/you/proj/openapi.json
castiron: wrote src/myapp/models/schema.py (8.5 kB)

Relative paths in the config resolve against the config file's directory, not your shell's, so the same command produces the same file whether you run it from the project root or three directories down. The API key is deliberately rejected in a config file — pyproject.toml gets committed. Full details, including the precedence chain, on Configuration.

Determinism, and why it matters

The same schema, the same options and the same castiron version produce the same bytes, every time — no timestamps, no source URL in the file, and no post-hoc ruff/black pass whose version could change the output. The file's first two lines record which castiron wrote it (the provenance header), and that version is the only input that varies. That is what makes generated models safe to commit and diff, and it is the foundation castiron check stands on — the recorded version is what lets it tell a castiron upgrade apart from a schema change.

Running no formatter afterwards does not mean the output is untidy: it is written clean as emitted, and castiron promises that it passes ruff's F, UP and I rules at ruff's own default settings — so a generated module does not trip the linter of the project you just added it to. The promise and its limits (E501 is explicitly not covered) are on The generated code.

Where to go next