Targets Python 3.11+ on the FastAPI / Pydantic stack. Ships the
metadata loader (canonical JSON + sigil-free YAML), conformance runner over the
shared corpora, the FR-004 render engine, and the entity / payload / router
codegen. Schema migrations are owned by the Node meta CLI (ADR-0015) — the
Python port has no migrate command by design; it consumes the canonical
schema.postgres.sql artifact verbatim.
Requires Python 3.11+. Published to PyPI as metaobjects:
pip install metaobjects # or: uv add metaobjectsOn an older default interpreter? Many systems (e.g. Ubuntu 22.04) still ship
python3= 3.10, wherepip install metaobjectsfails with a confusingERROR: No matching distribution found for metaobjects(the wheel isRequires-Python >=3.11). Create the venv with an explicit 3.11+:python3.11 -m venv .venv && . .venv/bin/activate, thenpip install metaobjects.
# pyproject.toml
[project]
requires-python = ">=3.11"
dependencies = [
"metaobjects>=0.19",
]Contributing to the port itself? Work from source instead: clone the repo and
uv run --extra dev pytestfromserver/python/(the in-repo dev workflow isuv-based, withpytest/mypy/ruffas thedevextra).
Drop metadata under metadata/:
# metadata/meta.blog.yaml
metadata:
package: acme::blog
children:
- object.entity:
name: Author
children:
- source.rdb:
table: authors
- field.long:
name: id
- field.string:
name: name
required: true
maxLength: 200
- field.string:
name: bio
maxLength: 2000
- identity.primary:
fields: id
generation: incrementIf your app needs a metamodel subtype the core doesn't ship, declare a
Provider and pass it through from_directory:
from metaobjects.provider import Provider
from metaobjects import MetaDataLoader
from .providers import your_provider
result = MetaDataLoader.from_directory("./metadata", providers=[your_provider])The provider object has the same four-member contract (id, dependencies,
description, register_types(registry)) as TS / C#. Composition errors
surface ERR_PROVIDER_DUPLICATE_ID, ERR_PROVIDER_MISSING_DEPENDENCY,
ERR_PROVIDER_DEPENDENCY_CYCLE — codes match the cross-port contract. See
../features/extending-with-providers.md
for the full reference and
../recipes/extending-metaobjects-with-providers.md
for a worked example.
The metaobjects console-script (installed by pip install metaobjects) runs
codegen and the drift gate — there is no python -m metaobjects.codegen
module entry point:
metaobjects gen ./metadata --out ./generated # codegen → write
metaobjects verify ./metadata --out ./generated # drift gate; bare verify defaults to --codegenFor multi-target projects (several outDirs, per-target generator/entity
selection, config-relative provider resolution), metaobjects gen/verify also
read a declarative metaobjects.config.yaml (#267) — run
either with no positional <metadata_dir> to use it; the flag path above stays
byte-identical.
Both pydantic and fastapi are consumer-installed, not transitive deps of
metaobjects itself — the entity/router files this emits import pydantic
and import fastapi, so add them before importing the generated code:
pip install pydantic fastapiThe entity-model generator emits one Pydantic BaseModel per entity (not
a @dataclass):
# generated/Author.py
from pydantic import BaseModel, Field
class Author(BaseModel):
id: int | None = None
name: str = Field(max_length=200) # required, max_length=200
bio: str | None = Field(default=None, max_length=2000)The router generator emits one FastAPI APIRouter per writable entity
(source.rdb with @kind="table"):
# generated/author_router.py (excerpt)
router = APIRouter(prefix="/api/authors", tags=["authors"])
class AuthorRepository(Protocol):
def list(self, limit: int, offset: int, sort: _SortClause | None) -> list[Any]: ...
def count(self) -> int: ...
def find_by_id(self, id: int) -> Any | None: ...
def create(self, dto: Any) -> Any: ...
def update(self, id: int, dto: Any) -> Any | None: ...
def delete(self, id: int) -> bool: ...
def get_repository() -> AuthorRepository:
raise NotImplementedError("Override get_repository via FastAPI dependency_overrides")
@router.get("") # list with ?limit / ?offset / ?sort / ?withCount=1
@router.get("/{author_id}")
@router.post("", status_code=status.HTTP_201_CREATED)
@router.patch("/{author_id}")
@router.put("/{author_id}")
@router.delete("/{author_id}", status_code=status.HTTP_204_NO_CONTENT)The router conforms to the cross-port API contract
(docs/features/api-contract.md):
?withCount=1 returns {"rows", "total"}; ?sort=field:asc|desc uses
a static per-entity allowlist (HTTP 400 envelope on unknown field); 404
envelope is {"error": "not_found"}. Filter operators (eq / ne / …) do ship —
a per-entity filter allowlist is generated and the router parses filter[field][op]
into typed predicates. Remaining gaps are tracked in
server/python/src/metaobjects/codegen/KNOWN_GAPS.md.
Wire the router into your consumer FastAPI app:
from fastapi import FastAPI
from acme.blog.author_router import router as author_router, get_repository
from my_app.persistence import SqlAlchemyAuthorRepository
app = FastAPI()
app.include_router(author_router)
app.dependency_overrides[get_repository] = lambda: SqlAlchemyAuthorRepository(session)The names generator ships in the default generator suite — a new
project gets <entity>_names.py without configuring anything. The module
mirrors the metadata that declared it: every node it describes — the object,
each source.rdb child, each identity.* and index.* child — carries its
own _TYPE, _SUB_TYPE and _NAME, and a source's physical name sits under
the member named for what kind of database object it is:
# generated/subscriber_names.py (excerpt)
SUBSCRIBER_TYPE: Final[str] = "object"
SUBSCRIBER_SUB_TYPE: Final[str] = "entity"
SUBSCRIBER_NAME: Final[str] = "Subscriber" # the OBJECT's name, not the table's
SUBSCRIBER_SOURCE_PRIMARY_TYPE: Final[str] = "source"
SUBSCRIBER_SOURCE_PRIMARY_SUB_TYPE: Final[str] = "rdb"
SUBSCRIBER_SOURCE_PRIMARY_KIND: Final[str] = "table"
SUBSCRIBER_SOURCE_PRIMARY_TABLE: Final[str] = "subscribers"
SUBSCRIBER_CREATED_AT_FIELD: Final[str] = "createdAt"
SUBSCRIBER_CREATED_AT_COLUMN: Final[str] = "created_at"
SUBSCRIBER_IDENTITY_PRIMARY_TYPE: Final[str] = "identity"
SUBSCRIBER_IDENTITY_PRIMARY_SUB_TYPE: Final[str] = "primary"
SUBSCRIBER_IDENTITY_PRIMARY_NAME: Final[str] = "primary"
SUBSCRIBER_COLUMNS_BY_FIELD: Final[dict[str, str]] = {
"createdAt": SUBSCRIBER_CREATED_AT_COLUMN,
}| member | means |
|---|---|
<E>_TYPE / <E>_SUB_TYPE |
the metamodel type of the object — object.entity, object.projection, … |
<E>_NAME |
the object's metamodel name |
<E>_SOURCE_<ROLE>_* |
one source.rdb child, keyed by its @role (PRIMARY | REPLICA) |
<E>_SOURCE_<ROLE>_KIND |
the source's @kind — table, view, materializedView, storedProc, tableFunction |
<E>_SOURCE_<ROLE>_TABLE / _VIEW / _PROC / … |
the physical name, under the member for that kind |
<E>_SOURCE_<ROLE>_SCHEMA |
the DB schema, emitted only when one is declared |
<E>_<FIELD>_FIELD |
the logical name — the field as your code and the wire call it |
<E>_<FIELD>_COLUMN |
the physical column — @column if declared, else the name through column_naming |
<E>_IDENTITY_<NAME>_* / <E>_INDEX_<NAME>_* |
one identity.* / index.* child, keyed by its metamodel name |
…_INDEX |
the database index name — on identity.secondary and index.lookup only |
Why the physical name is not just <E>_NAME. One member cannot hold a
table, a view and a stored procedure and still mean something. In a single run
it used to:
LEDGER_NAME = "TBL_LDG_ENTRY" KIND: "table"
CUSTOMERSUMMARY_NAME = "V_CUST_ROLLUP" KIND: "view"
PROCOUT_NAME = "SP_CUST_ROLLUP" KIND: "storedProc"
A reader had to consult <E>_KIND to find out what they were holding, and in
none of the three did <E>_NAME hold the object's own name.
A write-through entity has two physical names, and both have a home. It writes to a table and reads through a replica view; keying sources by role is what gives the view a slot:
LEDGER_SOURCE_PRIMARY_KIND: Final[str] = "table"
LEDGER_SOURCE_PRIMARY_TABLE: Final[str] = "tbl_ldg_entry"
LEDGER_SOURCE_REPLICA_KIND: Final[str] = "view"
LEDGER_SOURCE_REPLICA_VIEW: Final[str] = "v_ldg_entry_ro"The type segment (SOURCE / IDENTITY / INDEX) is load-bearing, and
fields are the one exception. A field's subtype does not change what
_COLUMN denotes, while an object's decides table-vs-view and an identity's
decides unique-vs-not (ADR-0040 put uniqueness in the type). It is also what
keeps the members apart at all: identity.primary defaults its name to
primary, so an unnamed primary key and a @role: primary source would both
want <E>_PRIMARY_* — as the excerpt above shows, that is the common case,
not a corner one.
READ_ONLY is not carried. It was a derivation over @kind, not something
anyone declared, and nothing in any port read it. Ask
<E>_SOURCE_<ROLE>_KIND — that is what the author actually wrote.
It follows extends, so a constant you do not find in a module is imported
from its parent's. Python has no static inheritance, so an artifact whose
object extends another re-exports the inherited constants by REFERENCE:
# generated/copay_auth_names.py (excerpt)
from .auth_names import (
AUTH_ID_COLUMN, AUTH_ID_FIELD, AUTH_IDENTITY_PK_NAME,
AUTH_SOURCE_PRIMARY_KIND, AUTH_SOURCE_PRIMARY_TABLE,
)
COPAYAUTH_NAME: Final[str] = "CopayAuth" # its own name, always
COPAYAUTH_SOURCE_PRIMARY_TABLE: Final[str] = AUTH_SOURCE_PRIMARY_TABLE # SHARED, spelled once
COPAYAUTH_ID_COLUMN: Final[str] = AUTH_ID_COLUMN
COPAYAUTH_COPAY_AMOUNT_COLUMN: Final[str] = "copay_cents"Two forms, and which one you get is structural. An object with its OWN source
declares its own _SOURCE_<ROLE>_* constants; one that INHERITS its source — a
TPH subtype sharing its base's single table — takes them from the base module.
Either way each object keeps its own _NAME/_TYPE/_SUB_TYPE, which is what
a single-table hierarchy legitimately has two of. An abstract base a persisted
entity extends gets a module of its own carrying the columns and keys it
declares and no _SOURCE_* at all — it has no table, and must never
acquire one. _COLUMNS_BY_FIELD stays complete in every module, inherited
entries included.
Prefer a typed handle where one exists. If the ORM gives you a type-checked object for the same thing, use that. Replacing it with a string constant trades an error the compiler catches for one the database raises at runtime. These constants are for the places with no typed handle: raw SQL, a migration script, a log line, an external system's column mapping.
In Python, that limit is never live advice — there is no typed handle to
prefer, anywhere. Python's models, create/patch shapes, router and filter
allowlists all key by field.name; none of them names a physical column.
Unlike TypeScript (Drizzle column objects) or C#/Kotlin (EF properties /
Exposed Column objects), no generated Python object has an attribute bound
to a physical column. A hand-written ObjectManager query, a raw SQL string,
or a non-MetaObjects persistence layer (SQLAlchemy Core, psycopg) has
exactly one alternative to respelling "created_at" as a literal: importing
SUBSCRIBER_CREATED_AT_COLUMN. That is precisely why this generator ships on
by default here, unlike the JVM ports where it is opt-in — it fills a gap
every other Python generated artifact leaves completely open, rather than
adding a convenience alongside an existing typed one.
The constants resolve through the same column-naming strategy
ObjectManager uses (literal by default) — pass the identical value to
both (--column-naming at metaobjects gen time, column_naming= on
ObjectManager), or the constant a consumer imports names a different column
than the one a row actually lands in. metaobjects verify also takes
--column-naming, defaulting to the same literal, so a verify --codegen
regen resolves the same column strings gen did instead of reporting
spurious drift against a differently-configured run.
Beyond the built-in Pydantic/FastAPI suite, the metaobjects gen console-script
runs declarative Mustache template generators from a JSON template-spec — the
cross-port contract shared with the C# port (see
docs/features/codegen-concepts.md
and the neutral data dict in
docs/features/codegen-data-shapes.md):
metaobjects gen ./metadata --out ./generated \
--template-spec ./template-spec.json --templates ./templatesThe spec is auto-discovered. Omit --template-spec and the CLI reads
<projectRoot>/template-spec.json, where projectRoot is the metadata dir's parent —
the same anchor .metaobjects/ uses. The flag overrides it.
Prefer the conventional path over the flag, because verify --codegen accepts no
--template-spec: discovery is how the drift gate learns your template generators exist.
A spec reachable only by flag leaves verify regenerating a different generator list from
gen and reporting your committed template output as stale.
metaobjects gen ./metadata --out ./generated --templates ./templates
metaobjects verify --codegen ./metadata --out ./generated --templates-root ./templates
# ^ both resolve <projectRoot>/template-spec.json — the gate agrees with the generator(gen spells the templates dir --templates; on verify that name is the subverb, so
the directory flag there is --templates-root.)
--template-spec is refused in declarative-config mode (metaobjects.config.yaml,
no positional metadata dir) rather than silently ignored: a spec entry names no target
while config mode writes per target, so there is no outDir to render into. A discovered
spec is ignored there rather than refused.
Each spec entry derives the neutral template data dict for its scope and names
each file via the outputPattern placeholders ({name}, {Name}, {package}).
The named generators are appended to the default suite and gated byte-identical
against the shared fixtures/template-codegen-conformance/ corpus. Output is
format-agnostic (text/markdown/csv/json/xml/html), so the template-spec pass emits
no __init__.py into its tree. A target field is rejected (the Python port has
no output-target concept); for output to be regenerable, the template must emit
the @generated header itself (the write path refuses to overwrite files lacking
it).
The router conforms to the same URL grammar as every other backend port,
so the universal browser client — React/TanStack today, Angular 18 once
FR-008 §2.5 lands — works against a FastAPI backend with no FastAPI-
specific client code. The same generated TanStack hooks (or Angular
services) that talk to a TS Fastify, Java Spring, Kotlin Ktor, or C#
ASP.NET backend will talk to this FastAPI router; the only consumer
wiring is the EntityFetcher base URL + auth.
The loader API is symmetric with the other ports in shape — from_directory /
from_uris / from_string factories returning a LoadResult, navigation
methods on its .root and child nodes. MetaDataLoader is re-exported at the
top-level metaobjects package (not metaobjects.loader); navigate the
tree with .children() and the resolving get_meta_attr(name) (own +
inherited via extends — see ADR-0039):
from metaobjects import MetaDataLoader
result = MetaDataLoader.from_directory("./metadata")
author = result.root.children()[0]
name_field = [f for f in author.children() if f.name == "name"][0]
print(name_field.get_meta_attr("maxLength")) # -> 200render takes a RenderRequest (only payload + provider are required; ref
defaults to None, format to "text"):
from metaobjects.render import FilesystemProvider
from metaobjects.render.renderer import render, RenderRequest
out = render(RenderRequest(
payload={
"displayName": "Ada",
"postCount": 12,
"posts": [{"title": "Hello"}],
},
provider=FilesystemProvider("./prompts"),
ref="lobby/welcome",
format="xml",
))metaobjects.render.verify drift-checks every template.* against its
@payloadRef. The Python renderer is conformance-gated to render byte-identical
output against the shared fixtures/render-conformance/ corpus.
Two generators ship together for the full prompt+parse story:
payload_vo_generatoremits one<template_name>_payload.pyper declaredtemplate.*(prompt / output / toolcall) — a Pydantic v2<TemplateName>PayloadBaseModeltyped from the DECLARED fields only (#270 — anyorigin.*child a payload field carries is ignored for typing; a nested payload is a declaredfield.object @objectRefto anotherobject.value). Mirrors the Kotlin reference shape. A respondingtemplate.prompt(one declaring@responseRef, ADR-0052) gets a SECOND record in its own module:<template_name>_response.pyholding<TemplateName>Response. It is a separate module because strictness is per-module here — the REQUEST payload emitsextra="forbid"so a mistyped render slot fails at construction, while a reply record must tolerate unknown fields.output_parser_generatoremits one<template_name>_response_parser.pyper respondingtemplate.prompt, importing the response class from the sibling response module.template.outputgets no parser at all — it renders outbound.
Pythonic single-API throw-only convention — Pydantic raises ValidationError
on bad input; callers wrap in try/except per their own error policy (matches
the pydantic / Instructor / FastAPI / LangChain norm; a Result-style wrapper
would be un-Pythonic).
# generated/npc_response_payload.py
from typing import Literal
from pydantic import BaseModel
class NpcResponsePayload(BaseModel):
name: str
level: int
role: Literal["merchant", "guard", "elder"]# generated/npc_response_response_parser.py
from .npc_response_response import NpcResponseResponse
def parse_npc_response(text: str) -> NpcResponseResponse:
"""Parse an LLM response into a typed ``NpcResponseResponse``.
Raises:
pydantic.ValidationError: when the input does not match the schema.
"""
return NpcResponseResponse.model_validate_json(text)
__all__ = ["parse_npc_response"]The strict parse_* is JSON-only (ADR-0053): an @responseFormat: xml reply gets the
tolerant extract_lenient_* and nothing strict.
Consumer wiring:
from pydantic import ValidationError
from generated.npc_response_response_parser import parse_npc_response
llm_response: str = my_llm_client.complete(prompt_text)
try:
npc = parse_npc_response(llm_response)
except ValidationError as e:
log.warning("LLM returned malformed payload: %s", e)
return None<TemplateName>Payload types what a template RENDERS (the consumer constructs it and
passes it to render(...)); <TemplateName>Response types what its parser RETURNS. The
split is ADR-0052's: @payloadRef is the request, @responseRef the reply, and they are
usually different shapes. metaobjects.render.verify walks both subtypes. Cross-port
design is at ADR-0010;
the feature reference is at
features/templates-and-payloads.md.
Per-file dedupe note. When two templates' payloads reference the same nested
field.object @objectRef target, each template's payload file contains its own
copy of the nested class (per-file, not per-run dedupe). This differs from
Kotlin's cross-run dedupe (KotlinPoet → one class per .kt file). The Python
choice keeps each generated payload module self-contained — see the
docstring on payload_vo_generator.py for the full rationale.
Consumer dependency. Both generators emit code that imports pydantic (v2).
Add it via pip install pydantic>=2 or uv add pydantic if you don't
already have it.
Note on emitted output. Both generators run ruff_format(content) on the
file before writing, so the literal emitted layout may reflow whitespace
slightly vs the snippets above. Function signatures, class definitions, and
import lines are stable.
| Feature | Status |
|---|---|
| Entities + fields | Yes |
| Relationships + FK | Loader-level yes; relationship-navigation codegen is on the roadmap |
| Source kinds (table / view / storedProc) | Loader-level yes; codegen for non-table kinds is in progress |
field.currency / field.enum / field.object + @storage |
Loader-level yes; codegen for field.object flattened storage is in progress |
| Templates + render (FR-004) | Yes (metaobjects.render) |
| Payload-VO codegen | Yes (payload_vo_generator — Pydantic v2 BaseModel per template, declared-type-authoritative per #270) |
| Output parser codegen (FR-006) | Yes (output_parser_generator — Pydantic throw-only; imports the payload class from the sibling payload module) |
| Declarative template-codegen | Yes — metaobjects gen --template-spec (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with C#) |
| Migrations | TS-only by design (ADR-0015) — no Python migrate command; consume the canonical schema.postgres.sql |
| Drift verify | Yes — template / payload drift (metaobjects.render.verify) |
| Runtime metadata | Yes (metaobjects.runtime.ObjectManager — DB-API 2 driver, pg8000/psycopg) + loader API + render engine |
| Corpus | Result |
|---|---|
Metamodel (fixtures/conformance/) |
91 / 91 |
YAML authoring (fixtures/yaml-conformance/) |
13 / 13 |
Render (fixtures/render-conformance/) |
14 / 14 |
Verify (fixtures/verify-conformance/) |
31 / 31 |
Persistence (fixtures/persistence-conformance/) |
12 / 12 (runnable via scripts/integration-test.sh python) |
API contract (fixtures/api-contract-conformance/) |
20 / 20 |
server/python/README.md— module-level overviewdocs/features/— every feature shows the Python output inlinedocs/superpowers/specs/2026-05-23-python-codegen-engine-entity-generator-design.mddocs/superpowers/specs/2026-05-23-python-codegen-persistence-foundation-roadmap.md