Targets Python 3.11+ on the SQLAlchemy + 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.
Published to PyPI as metaobjects:
pip install metaobjects # or: uv add metaobjects# pyproject.toml
[project]
dependencies = [
"metaobjects>=0.11",
]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
MetaDataTypeProvider and pass it through from_directory:
from metaobjects.provider import MetaDataTypeProvider
from metaobjects.loader import MetaDataLoader
from .providers import your_provider
loader = 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.
python -m metaobjects.codegen --metadata ./metadata --out ./generated
python -m metaobjects.render.verify --metadata ./metadata --templates ./promptsThe entity-model generator emits one @dataclass per entity:
# generated/acme/blog/author.py
from dataclasses import dataclass
from typing import Optional
@dataclass
class Author:
id: int
name: str # required, max_length=200
bio: Optional[str] = NoneThe router generator emits one FastAPI APIRouter per writable entity
(source.rdb with @kind="table"):
# generated/acme/blog/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 / ...) are a known gap — see
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)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 ./templatesEach 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 — from_directory /
from_resources / from_string factories, navigation methods on the loader and
its child nodes.
from metaobjects.loader import MetaDataLoader
loader = MetaDataLoader.from_directory("app", "./metadata")
author = loader.meta_object("acme::blog::Author")
name_field = author.field("name")
print(name_field.attr_string_or_none("maxLength")) # → "200"render 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>PayloadBaseModelresolving all three origin subtypes (passthrough/aggregate/collection). Mirrors the Kotlin reference shape.output_parser_generatoremits one<template_name>_output_parser.pypertemplate.output, importing the payload class from the sibling payload module.
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_output_parser.py
from .npc_response_payload import NpcResponsePayload
def parse_npc_response(text: str) -> NpcResponsePayload:
"""Parse an LLM response into a typed ``NpcResponsePayload``.
Raises:
pydantic.ValidationError: when the input does not match the schema.
"""
return NpcResponsePayload.model_validate_json(text)
__all__ = ["parse_npc_response"]Consumer wiring:
from pydantic import ValidationError
from generated.npc_response_output_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 NoneThe same <TemplateName>Payload class is reused for both prompt rendering
(consumer constructs it, passes to render(...)) and output parsing (parser
returns it from parse_<template_name>(...)) — matches the Java payload-VO ↔
output-parser handoff. metaobjects.render.verify extends to walk
template.output nodes the same way it walks template.prompt. Cross-port
design is at ADR-0010;
the feature reference is at
features/templates-and-payloads.md.
Per-file dedupe note. When origin.collection references the same nested
target across two templates, 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; SQLAlchemy relationship() 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, origin-aware) |
| 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 | Loader API + render engine; SQLAlchemy ObjectManager-equivalent on the roadmap |
| 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