Skip to content

Latest commit

 

History

History
350 lines (278 loc) · 13.2 KB

File metadata and controls

350 lines (278 loc) · 13.2 KB

Python port

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.

Install

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 pytest from server/python/ (the in-repo dev workflow is uv-based, with pytest / mypy / ruff as the dev extra).

Configure

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: increment

Custom providers (optional)

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

Generate

python -m metaobjects.codegen --metadata ./metadata --out ./generated
python -m metaobjects.render.verify --metadata ./metadata --templates ./prompts

The 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] = None

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

Declarative template-codegen (--template-spec)

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 ./templates
// template-spec.json — the cross-port shape
{ "generators": [
    { "name": "entity-doc",
      "scope": "perEntity",            // perEntity | perPackage | perModel
      "outputPattern": "{package}/{Name}.md",
      "template": "entity-doc",          // resolved under --templates
      "format": "markdown" }            // optional; a registered escaper format
]}

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

Universal browser-client hookup (React / Angular 18)

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.

Use

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"

FR-004 — render

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.

FR-006 — output parsing

Two generators ship together for the full prompt+parse story:

  • payload_vo_generator emits one <template_name>_payload.py per declared template.* (prompt / output / toolcall) — a Pydantic v2 <TemplateName>Payload BaseModel resolving all three origin subtypes (passthrough / aggregate / collection). Mirrors the Kotlin reference shape.
  • output_parser_generator emits one <template_name>_output_parser.py per template.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 None

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

Capability snapshot

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

Conformance status (as of 2026-05-27)

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

See also