AFI parity: close all 35 gaps — every adapter wires every AFI-common capability

The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View File

@@ -5,14 +5,20 @@ HTTP RPC dispatch and context bundling on top of mizan-core's function
registry, sharing the auth / invalidation / cache / upload core with the
Django adapter.
WebSocket, Forms, Shapes, and the SSR bridge are AFI-common capabilities
this adapter does not wire yet — open gaps on the capability-parity board
(`tests/afi/`), not out-of-scope. "Use FastAPI's native WebSocket / an ORM
of choice" is the non-goal: the AFI exists precisely to wire those to the
generated typed client through one decorator, so an adapter that defers to
the native primitive isn't yet a complete AFI adapter. The SSR bridge in
particular is framework-agnostic (`mizan.ssr.bridge.SSRBridge` has no Django
coupling) and is mountable here directly.
The full AFI-common surface is wired here over FastAPI-native primitives,
each riding the shared core:
- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)`
functions through the same `mizan_core.dispatch` as `POST /call/`.
- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared
`mizan_core.ssr.SSRBridge` Bun subprocess.
- Edge manifest / PSR — `edge_manifest` (and the `mizan-fastapi-edge-manifest`
console entry) emit the manifest derived in `mizan_core.manifest`, including
each context's `render_strategy`.
- Shapes — `mizan_fastapi.shapes.Shape` is the typed query projection bound to
SQLAlchemy (same declaration surface as the Django `django-readers` binding).
- Forms — `mizan_fastapi.forms.mizanForm` exposes schema / validate / submit
role functions over Pydantic.
Usage:
from fastapi import FastAPI
@@ -42,11 +48,30 @@ from .executor import (
compute_invalidation,
execute_function,
)
# Register the FastAPI/Starlette response base so view-path detection works in
# mizan_core.client.function (a @client function returning a Response is a
# view-path function — header-only invalidation, "view" in the edge manifest).
# Must run before any @client-decorated code is evaluated.
from starlette.responses import Response as _Response
from mizan_core.client.function import set_framework_response_base as _set_response_base
_set_response_base(_Response)
from . import shapes, forms
from .router import router, mizan_exception_handler, mizan_validation_handler
from .auth import MizanAuthMiddleware, mizan_auth
from .config import MizanConfig, from_env
from .manifest import edge_manifest, generate_edge_manifest, render_strategies
from .ssr import SSRRenderer
from mizan_core.upload import File, Upload, UploadedFile
# Shapes (SQLAlchemy query projection) and Forms (Pydantic schema/validate/submit)
# are submodule bindings; expose their public primitives at the package root.
Shape = shapes.Shape
Diff = shapes.Diff
NestedDiff = shapes.NestedDiff
mizanForm = forms.mizanForm
FormConfig = forms.FormConfig
__all__ = [
"Upload",
"File",
@@ -60,6 +85,17 @@ __all__ = [
"mizan_validation_handler",
"execute_function",
"compute_invalidation",
"edge_manifest",
"generate_edge_manifest",
"render_strategies",
"SSRRenderer",
"shapes",
"forms",
"Shape",
"Diff",
"NestedDiff",
"mizanForm",
"FormConfig",
"ErrorCode",
"MizanError",
"NotFound",

View File

@@ -0,0 +1,245 @@
"""
Forms — the Pydantic binding (schema / validate / submit roles).
A Mizan form is exposed as three server functions — `{name}.schema`,
`{name}.validate`, `{name}.submit` — carrying `_meta["form_role"]` of
`"schema"`, `"validate"`, `"submit"`. That role contract is AFI-common and
identical to the Django adapter's (`mizan.forms`); only the *binding* differs:
Django wraps a `forms.Form`, this wraps a Pydantic `BaseModel`.
from mizan_fastapi.forms import mizanForm, FormConfig
class ContactForm(mizanForm):
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
name: str
email: EmailStr
message: str
def on_submit_success(self, request) -> dict:
send_email(self.model_dump())
return {"sent": True}
Subclassing registers the three role functions automatically (parity with the
Django `mizanFormMixin.__init_subclass__` auto-registration):
contact.schema → field definitions (FormSchema)
contact.validate → structured field errors (FormValidation)
contact.submit → validate, then on_submit_success / on_submit_failure
"""
from __future__ import annotations
from typing import Any, ClassVar, get_args, get_origin
from pydantic import BaseModel, ValidationError, create_model
from mizan_core.client.function import ServerFunction
from mizan_core.registry import get_all_functions, register
from .schemas import (
FieldError,
FieldErrorList,
FieldSchema,
FormMeta,
FormSchema,
FormSubmitFail,
FormSubmitPass,
FormValidation,
)
__all__ = [
"FormConfig",
"mizanForm",
"get_forms",
"FormSchema",
"FormValidation",
"FormSubmitPass",
"FormSubmitFail",
]
# Pydantic annotation → the (type, widget) the frontend renders. Mirrors the
# Django binding's `_django_field_to_python_type` intent: hand the client a real
# field type instead of a generic string.
_TYPE_WIDGET = {
bool: ("checkbox", "CheckboxInput"),
int: ("number", "NumberInput"),
float: ("number", "NumberInput"),
str: ("text", "TextInput"),
}
class FormConfig(BaseModel):
"""Form metadata + frontend behavior (parity with `mizanFormMeta`)."""
name: str
title: str | None = None
subtitle: str | None = None
submit_label: str = "Submit"
live_validation: bool = True
live_form_errors: bool = False
refetch_schema_on_validate: bool = False
def _unwrap_optional(annotation: Any) -> Any:
"""`X | None` / `Optional[X]` → `X`; otherwise the annotation unchanged."""
if get_origin(annotation) in (None,):
return annotation
args = [a for a in get_args(annotation) if a is not type(None)]
if len(args) == 1 and type(None) in get_args(annotation):
return args[0]
return annotation
def _field_type_widget(annotation: Any) -> tuple[str, str]:
base = _unwrap_optional(annotation)
return _TYPE_WIDGET.get(base, ("text", "TextInput"))
def _humanize(name: str) -> str:
return name.replace("_", " ").title()
def build_form_schema(form_cls: type["mizanForm"]) -> FormSchema:
"""Derive a `FormSchema` from a Pydantic form's fields + config."""
cfg = form_cls.mizan
fields: list[FieldSchema] = []
for field_name, info in form_cls.model_fields.items():
type_str, widget = _field_type_widget(info.annotation)
required = info.is_required()
initial = None if required else info.get_default(call_default_factory=False)
if initial is None and info.default is not None and info.default is not ...:
initial = info.default
meta = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
fields.append(
FieldSchema(
name=field_name,
label=str(info.title or _humanize(field_name)),
type=type_str,
widget=widget,
required=required,
disabled=bool(meta.get("disabled", False)),
help_text=str(info.description or ""),
initial=initial if initial is not ... else None,
max_length=getattr(info, "max_length", None),
min_length=getattr(info, "min_length", None),
choices=None,
)
)
return FormSchema(
name=cfg.name,
title=cfg.title or _humanize(form_cls.__name__.removesuffix("Form")),
subtitle=cfg.subtitle,
submit_label=cfg.submit_label,
fields=fields,
meta=FormMeta(
refetch_schema_on_validate=cfg.refetch_schema_on_validate,
live_validation=cfg.live_validation,
live_form_errors=cfg.live_form_errors,
),
)
def _validation_from_error(exc: ValidationError) -> FormValidation:
"""Group a Pydantic `ValidationError` into the `FormValidation` wire shape."""
by_field: dict[str, list[FieldError]] = {}
for err in exc.errors():
loc = err.get("loc", ())
field = str(loc[0]) if loc else "__all__"
by_field.setdefault(field, []).append(
FieldError(message=err.get("msg", "Invalid value"), code=err.get("type"))
)
return FormValidation(
errors=[FieldErrorList(field=f, errors=errs) for f, errs in by_field.items()]
)
def _validate(form_cls: type["mizanForm"], data: dict[str, Any]) -> tuple["mizanForm | None", FormValidation]:
"""Validate `data`; return `(instance|None, validation)` — instance None on failure."""
try:
instance = form_cls(**(data or {}))
return instance, FormValidation(errors=[])
except ValidationError as exc:
return None, _validation_from_error(exc)
class mizanForm(BaseModel):
"""Base for a Pydantic-backed Mizan form.
Subclass with field annotations and a `mizan = FormConfig(...)`. Subclassing
auto-registers the schema/validate/submit role functions. Override
`on_submit_success` / `on_submit_failure` for submit-time behavior.
"""
mizan: ClassVar[FormConfig]
def on_submit_success(self, request: Any) -> dict | None:
"""Handle a validated submission. Override; returns optional result data."""
return None
def on_submit_failure(self, request: Any, errors: FormValidation) -> None:
"""Handle a failed submission (logging, etc.). Override."""
return None
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cfg = cls.__dict__.get("mizan")
if isinstance(cfg, FormConfig):
_register_form(cls)
def _register_form(form_cls: type[mizanForm]) -> None:
"""Register `{name}.schema/.validate/.submit` for a Pydantic form class."""
cfg = form_cls.mizan
name = cfg.name
pascal = "".join(w.capitalize() for w in name.replace(".", "_").replace("-", "_").split("_"))
schema_input = create_model(f"{pascal}SchemaInput", data=(dict[str, Any], {}))
validate_input = create_model(f"{pascal}ValidateInput", data=(dict[str, Any], ...))
submit_input = create_model(f"{pascal}SubmitInput", data=(dict[str, Any], ...))
class SchemaFunction(ServerFunction):
Input = schema_input
Output = FormSchema
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "schema"}
def call(self, input) -> FormSchema:
return build_form_schema(form_cls)
class ValidateFunction(ServerFunction):
Input = validate_input
Output = FormValidation
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "validate"}
def call(self, input) -> FormValidation:
_, validation = _validate(form_cls, input.data)
return validation
class SubmitFunction(ServerFunction):
Input = submit_input
Output = FormSubmitPass
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "submit"}
def call(self, input) -> FormSubmitPass | FormSubmitFail:
instance, validation = _validate(form_cls, input.data)
if instance is not None:
return FormSubmitPass(success=True, data=instance.on_submit_success(self.request))
instance_for_failure = form_cls.model_construct(**(input.data or {}))
instance_for_failure.on_submit_failure(self.request, validation)
return FormSubmitFail(success=False, errors=validation)
for fn, role in ((SchemaFunction, "schema"), (ValidateFunction, "validate"), (SubmitFunction, "submit")):
fn.__name__ = f"{name}_{role}"
fn.__qualname__ = fn.__name__
register(fn, f"{name}.{role}")
def get_forms() -> dict[str, list]:
"""Group registered form role functions by form name (parity helper)."""
forms: dict[str, list] = {}
for _, cls in get_all_functions().items():
meta = getattr(cls, "_meta", {})
if meta.get("form"):
forms.setdefault(meta.get("form_name"), []).append(cls)
return forms

View File

@@ -0,0 +1,77 @@
"""
Form role output schemas — the wire shapes the schema/validate/submit roles emit.
These mirror the Django adapter's `mizan.forms.schemas` field-for-field (FormMeta,
FieldSchema, FormSchema, FormValidation, FormSubmitPass/Fail) so the generated
client is identical regardless of which backend authored the form. The only
difference is the source: Django builds these from `forms.Field` introspection;
this builds them from Pydantic `FieldInfo`.
"""
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel
class FormMeta(BaseModel):
"""Frontend behavior flags (parity with the Django adapter)."""
refetch_schema_on_validate: bool = False
live_validation: bool = True
live_form_errors: bool = False
class FieldChoice(BaseModel):
value: str
label: str
class FieldError(BaseModel):
message: str
code: Optional[str] = None
class FieldErrorList(BaseModel):
field: str
errors: list[FieldError]
class FieldSchema(BaseModel):
name: str
label: str
type: str
widget: str
required: bool
disabled: bool
help_text: str
initial: Any = None
max_length: Optional[int] = None
min_length: Optional[int] = None
choices: Optional[list[FieldChoice]] = None
class FormSchema(BaseModel):
"""Schema returned by the `.schema` role: form metadata + field definitions."""
name: str
title: str
subtitle: Optional[str] = None
submit_label: str
fields: list[FieldSchema]
meta: FormMeta = FormMeta()
class FormValidation(BaseModel):
errors: list[FieldErrorList]
class FormSubmitPass(BaseModel):
success: bool
data: Optional[dict] = None
class FormSubmitFail(BaseModel):
success: bool
errors: FormValidation

View File

@@ -0,0 +1,98 @@
"""
Edge manifest — FastAPI adapter surface.
The manifest derivation is AFI-common (`mizan_core.manifest.generate_edge_manifest`);
this module exposes it over FastAPI's surface as a callable and a console entry
(`mizan-fastapi-edge-manifest`), mirroring Django's `export_edge_manifest`
management command.
The `render_strategy` field each context carries — `"psr"` when the context has
no user-scoped param, `"dynamic_cached"` when it does — is the PSR signal Edge
reads to decide between one shared pre-rendered artifact and a per-user cached
one. It is derived in the core from the same registry metadata, so FastAPI and
Django emit byte-identical manifests for an identical registry.
CLI:
mizan-fastapi-edge-manifest myproject.app
mizan-fastapi-edge-manifest myproject.app:app --base-url /api/mizan -o edge.json
The positional argument is an import target (``module`` or ``module:attr``); it
is imported for its registration side effects (importing the module runs the
`@client` decorators and `register(...)` calls that populate the registry)
before the manifest is derived.
"""
from __future__ import annotations
import argparse
import importlib
import sys
from pathlib import Path
from typing import Any
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
__all__ = ["edge_manifest", "generate_edge_manifest", "render_strategies", "main"]
def edge_manifest(base_url: str = "/api/mizan") -> dict[str, Any]:
"""The Edge manifest for the current registry.
Call after the app's `@client` functions are imported/registered. The
returned dict carries each context's ``render_strategy`` (PSR vs.
dynamic_cached) and the mutation→context invalidation routing.
"""
return generate_edge_manifest(base_url=base_url)
def render_strategies(base_url: str = "/api/mizan") -> dict[str, str]:
"""Map each context to its ``render_strategy`` — ``"psr"`` or ``"dynamic_cached"``.
PSR (Preemptive Static Rendering) is the per-context decision Edge needs: a
context with no user-scoped param renders one shared artifact (``psr``) that
is re-rendered on mutation; a user-scoped context renders per-user
(``dynamic_cached``). This surfaces that decision directly so a PSR driver can
enumerate which contexts to pre-render without re-deriving it.
"""
contexts = edge_manifest(base_url)["contexts"]
return {name: entry["render_strategy"] for name, entry in contexts.items()}
def _import_target(target: str) -> None:
"""Import a ``module`` or ``module:attr`` target for its registration effects."""
module_name = target.split(":", 1)[0]
importlib.import_module(module_name)
def main(argv: list[str] | None = None) -> int:
"""Console entry: import the app target, emit the Edge manifest as JSON."""
parser = argparse.ArgumentParser(
prog="mizan-fastapi-edge-manifest",
description="Export the Mizan Edge manifest for a FastAPI app.",
)
parser.add_argument(
"app",
help="Import target whose @client functions to register "
"(e.g. 'myproject.app' or 'myproject.app:app').",
)
parser.add_argument("--base-url", default="/api/mizan", help="Mizan API mount point.")
parser.add_argument("-o", "--output", default=None, help="Write to file instead of stdout.")
parser.add_argument("--indent", type=int, default=2, help="JSON indent (0 = compact).")
args = parser.parse_args(argv)
sys.path.insert(0, "")
_import_target(args.app)
indent = args.indent if args.indent > 0 else None
text = generate_edge_manifest_json(base_url=args.base_url, indent=indent)
if args.output:
Path(args.output).write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Request
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field, ValidationError
@@ -25,7 +25,7 @@ from starlette.datastructures import UploadFile
from mizan_core.auth import INVALID, authenticate
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
from mizan_core.errors import BadRequest, ErrorCode, MizanError, Unauthorized
from mizan_core.errors import BadRequest, ErrorCode, Forbidden, MizanError, NotFound, Unauthorized
from mizan_core.registry import get_function
from mizan_core.upload import UploadedFile, bind_uploads
@@ -150,6 +150,108 @@ async def context_fetch(context_name: str, request: Request) -> Response:
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
# ─── WebSocket RPC transport ──────────────────────────────────────────────────
def _ws_identity(websocket: WebSocket, cfg: MizanConfig):
"""Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a
token decode from the handshake headers. A present-but-invalid token rejects.
Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces
identically over either transport.
"""
existing = getattr(getattr(websocket, "state", None), "user", None)
if existing is not None:
return existing
ident = authenticate(websocket.headers, cfg.auth)
if ident is INVALID:
raise Unauthorized("Invalid or expired token")
return ident
def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]:
err: dict[str, Any] = {"code": exc.code.value, "message": exc.message}
if exc.details:
err["details"] = exc.details
return {"id": request_id, "ok": False, "error": err}
@router.websocket("/ws/")
async def websocket_rpc(websocket: WebSocket) -> None:
"""WebSocket RPC transport for `@client(websocket=True)` functions.
Frame protocol (parity with mizan-django's Channels consumer):
{"action": "rpc", "id": "<req>", "fn": "<name>", "args": {...}}
{"id": "<req>", "ok": true, "data": <result>, "invalidate": [...], "merge"?: [...]}
{"id": "<req>", "ok": false, "error": {"code", "message", "details"?}}
Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as
`POST /call/`, so input validation, `auth=` enforcement, invalidation, merge,
and origin-cache purge are identical across transports. Only functions that
declared `websocket=True` are callable here; an HTTP-only function returns a
`FORBIDDEN` frame rather than executing.
"""
cfg = get_config(websocket)
await websocket.accept()
try:
identity = _ws_identity(websocket, cfg)
except Unauthorized as exc:
await websocket.send_json(_error_frame(None, exc))
await websocket.close(code=1008)
return
try:
while True:
content = await websocket.receive_json()
await _handle_ws_rpc(websocket, content, identity, cfg)
except WebSocketDisconnect:
return
async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None:
"""Dispatch one WS RPC frame through the shared dispatch core."""
if content.get("action") != "rpc":
await websocket.send_json({"error": f"Unknown action: {content.get('action')}"})
return
request_id = content.get("id")
fn_name = content.get("fn")
args = content.get("args", {})
if not fn_name:
await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field")))
return
fn_class = get_function(fn_name)
if fn_class is None:
await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found")))
return
if not getattr(fn_class, "_meta", {}).get("websocket"):
await websocket.send_json(
_error_frame(
request_id,
Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."),
)
)
return
try:
res = await dispatch_call(
DispatchRequest(identity=identity, args=args, native_request=websocket),
fn_name, cfg.cache,
)
except MizanError as exc:
await websocket.send_json(_error_frame(request_id, exc))
return
frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data,
"invalidate": res.invalidate or []}
if res.merge:
frame["merge"] = res.merge
await websocket.send_json(frame)
# ─── Exception handler ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,307 @@
"""
Typed query projection (Shapes) — the SQLAlchemy binding.
A Shape is a Pydantic model that declares *which* fields and relationships of an
ORM model to project. The declaration surface is identical to the Django
adapter's `mizan.shapes` (`django-readers` binding):
class AuthorShape(Shape[Author]):
id: int
name: str
books: list[BookShape] = [] # nested relationship
AuthorShape.query(session, lambda s: s.where(Author.name == "Ann"))
Only the ORM binding differs: where the Django Shape lowers its spec to
`django-readers` pairs (queryset prepare + instance project), this lowers it to a
SQLAlchemy `select(Model)` with `selectinload(...)` eager-loading for each nested
relationship (the projection-load that keeps the query count flat), then projects
each loaded instance into the Pydantic shape. `.diff()` / `.diff_many()` compare a
constructed shape against current DB rows, mirroring the Django semantics.
The one surface difference SQLAlchemy forces is an explicit `session` argument to
`query` / `diff` / `diff_many` — Django models carry an implicit `objects`
manager; a SQLAlchemy mapped class does not. That is the ORM binding, not the
Shape declaration.
"""
from __future__ import annotations
import types
from typing import Any, ClassVar, Generic, TypeVar, Union, get_type_hints
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session, selectinload
_M = TypeVar("_M")
_S = TypeVar("_S", bound="Shape")
def _extract_shape_class(hint) -> type[Shape] | None:
"""The nested Shape a field annotation projects, if any.
Handles `SomeShape`, `list[SomeShape]`, and `SomeShape | None` / Optional —
the same forms the Django binding's `_extract_shape_class` accepts.
"""
origin = getattr(hint, "__origin__", None)
args = getattr(hint, "__args__", ())
if origin is list and args and isinstance(args[0], type) and issubclass(args[0], Shape):
return args[0]
if isinstance(hint, type) and issubclass(hint, Shape) and hint is not Shape:
return hint
if origin is Union or isinstance(hint, types.UnionType):
for arg in args:
if arg is type(None):
continue
if isinstance(arg, type) and issubclass(arg, Shape) and arg is not Shape:
return arg
return None
def _resolve_model(cls) -> Any | None:
"""The mapped model a Shape subclass is parameterized on (`Shape[Model]`)."""
for base in cls.__bases__:
meta = getattr(base, "__pydantic_generic_metadata__", None) or {}
if meta.get("origin") is Shape and (args := meta.get("args")):
return args[0]
return None
class Shape(BaseModel, Generic[_M]):
"""Typed projection over a SQLAlchemy mapped model.
Subclass as `Shape[Model]`; annotate the fields/relationships to project.
Scalar annotations become columns to read; annotations referencing another
Shape become relationships to eager-load and project recursively.
"""
_model: ClassVar[Any]
_nested: ClassVar[dict[str, type[Shape]]]
_field_names: ClassVar[list[str]]
_pk_field: ClassVar[str]
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not (model := _resolve_model(cls)):
return
mapper = sa_inspect(model)
cls._model = model
cls._nested = {}
pk_cols = mapper.primary_key
cls._pk_field = pk_cols[0].key if pk_cols else "id"
hints = get_type_hints(cls, include_extras=False, localns={cls.__name__: cls}) or cls.__annotations__
field_names: list[str] = []
for name, hint in hints.items():
if name.startswith("_"):
continue
if shape_cls := _extract_shape_class(hint):
cls._nested[name] = shape_cls
else:
field_names.append(name)
cls._field_names = field_names
# ─── Loading + projection ────────────────────────────────────────────────
@classmethod
def _loader_options(cls) -> list[Any]:
"""`selectinload(...)` chains for every nested relationship (recursive).
This is the SQLAlchemy analogue of django-readers' prefetch wiring: each
nested Shape contributes a `selectinload` on its relationship attribute,
with the child Shape's own loader options nested beneath it, so the whole
projection loads in O(depth) queries rather than N+1.
"""
options: list[Any] = []
for name, shape_cls in cls._nested.items():
attr = getattr(cls._model, name)
child = shape_cls._loader_options()
loader = selectinload(attr)
options.append(loader.options(*child) if child else loader)
return options
@classmethod
def _project(cls: type[_S], instance: Any) -> _S:
"""Project a loaded ORM instance into this Shape (recursively for nested)."""
data: dict[str, Any] = {name: getattr(instance, name) for name in cls._field_names}
for name, shape_cls in cls._nested.items():
related = getattr(instance, name)
if related is None:
data[name] = None
elif isinstance(related, (list, set, tuple)) or hasattr(related, "__iter__") and not isinstance(related, (str, bytes)):
data[name] = [shape_cls._project(child) for child in related]
else:
data[name] = shape_cls._project(related)
return cls.model_validate(data)
@classmethod
def query(cls: type[_S], session: Session, *stmt_fns, **relation_stmt) -> list[_S]:
"""Project the model into a list of shapes.
Args:
session: An open SQLAlchemy `Session`.
*stmt_fns: Callables `(select) -> select` applied in order to the base
`select(Model)` — filters/ordering/limits (the SQLAlchemy analogue
of the Django binding's queryset functions).
**relation_stmt: Per-relationship callables `(select) -> select` whose
criteria scope a nested relationship's load (e.g.
``books=lambda s: s.where(Book.is_published.is_(True))``).
Returns:
A list of projected shape instances.
"""
stmt = select(cls._model)
loaders = cls._loader_options_scoped(relation_stmt)
if loaders:
stmt = stmt.options(*loaders)
for fn in stmt_fns:
stmt = fn(stmt)
rows = session.execute(stmt).unique().scalars().all()
return [cls._project(obj) for obj in rows]
@classmethod
def _loader_options_scoped(cls, relation_stmt: dict[str, Any]) -> list[Any]:
"""`_loader_options`, but with caller-supplied criteria applied per relation."""
if not relation_stmt:
return cls._loader_options()
options: list[Any] = []
for name, shape_cls in cls._nested.items():
attr = getattr(cls._model, name)
loader = selectinload(attr)
child = shape_cls._loader_options()
if child:
loader = loader.options(*child)
scope = relation_stmt.get(name)
if scope is not None:
# `selectinload(...).and_(...)` filters the related rows loaded.
criteria = scope(select(shape_cls._model)).whereclause
if criteria is not None:
loader = selectinload(attr.and_(criteria))
if child:
loader = loader.options(*child)
options.append(loader)
return options
@classmethod
def _get_pk(cls, instance) -> Any | None:
return getattr(instance, cls._pk_field, None)
# ─── Diff ────────────────────────────────────────────────────────────────
@classmethod
def diff_many(cls: type[_S], session: Session, items: list[_S]) -> list[tuple[_S, "Diff"]]:
"""Diff a batch of shapes against current DB state in one fetch.
New items (no PK) diff against `None`; existing items batch-fetch by PK.
Raises if an item declares a PK that no row matches.
"""
pk_field = cls._pk_field
pk_map: dict[Any, _S] = {}
new_items: list[_S] = []
for item in items:
pk = cls._get_pk(item)
(pk_map.__setitem__(pk, item) if pk is not None else new_items.append(item))
current_map: dict[Any, _S] = {}
if pk_map:
pk_col = getattr(cls._model, pk_field)
current = cls.query(session, lambda s, _c=pk_col: s.where(_c.in_(list(pk_map.keys()))))
current_map = {cls._get_pk(c): c for c in current}
results: list[tuple[_S, Diff]] = []
for item in new_items:
results.append((item, cls._diff_one(item, None)))
for pk, item in pk_map.items():
current = current_map.get(pk)
if current is None:
raise LookupError(f"{cls._model.__name__} with {pk_field}={pk} does not exist")
results.append((item, cls._diff_one(item, current)))
return results
@classmethod
def _diff_one(cls, incoming: _S, current: _S | None) -> "Diff":
pk_field = cls._pk_field
changed = (
{k: getattr(incoming, k) for k in cls._field_names
if k != pk_field and getattr(incoming, k) != getattr(current, k)}
if current
else {k: getattr(incoming, k) for k in cls._field_names if k != pk_field}
)
nested: dict[str, NestedDiff] = {}
for name, shape_cls in cls._nested.items():
incoming_items = getattr(incoming, name, None) or []
current_items = (getattr(current, name, None) or []) if current else []
if not isinstance(incoming_items, list):
incoming_items = [incoming_items]
if not isinstance(current_items, list):
current_items = [current_items]
current_by_pk = {shape_cls._get_pk(c): c for c in current_items if shape_cls._get_pk(c) is not None}
incoming_by_pk = {shape_cls._get_pk(c): c for c in incoming_items if shape_cls._get_pk(c) is not None}
nested[name] = NestedDiff(
created=[c for c in incoming_items if shape_cls._get_pk(c) is None],
updated=[c for pk, c in incoming_by_pk.items() if pk in current_by_pk and c != current_by_pk[pk]],
deleted=[pk for pk in current_by_pk if pk not in incoming_by_pk],
)
return Diff(is_new=current is None, changed=changed, _nested=nested)
def diff(self, session: Session) -> "Diff":
"""Diff this shape against its current DB row (or `None` if new)."""
cls = type(self)
pk = cls._get_pk(self)
if pk is not None:
pk_col = getattr(cls._model, cls._pk_field)
results = cls.query(session, lambda s: s.where(pk_col == pk))
if not results:
raise LookupError(f"{cls._model.__name__} with {cls._pk_field}={pk} does not exist")
current = results[0]
else:
current = None
return cls._diff_one(self, current)
class NestedDiff:
__slots__ = ("created", "updated", "deleted")
def __init__(self, created=(), updated=(), deleted=()):
self.created = list(created)
self.updated = list(updated)
self.deleted = list(deleted)
class Diff:
__slots__ = ("is_new", "changed", "_nested")
def __init__(self, is_new: bool, changed: dict[str, Any], _nested: dict[str, NestedDiff]):
self.is_new = is_new
self.changed = changed
self._nested = _nested
def nested(self, name: str) -> NestedDiff:
"""Strict access to a nested diff. Raises `KeyError` for an unknown name."""
if name not in self._nested:
valid = ", ".join(sorted(self._nested)) or "(none)"
raise KeyError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
return self._nested[name]
def __getattr__(self, name: str) -> NestedDiff:
if name.startswith("_"):
raise AttributeError(name)
if name not in self._nested:
valid = ", ".join(sorted(self._nested)) or "(none)"
raise AttributeError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
return self._nested[name]

View File

@@ -0,0 +1,80 @@
"""
SSR render path — FastAPI adapter surface over the shared Bun bridge.
The SSR subprocess lifecycle and JSON-RPC wire protocol live in
`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine
backend, so instead of Django's `MizanTemplates` veneer this exposes an
`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs
in the persistent Bun worker — and returns an `HTMLResponse` with the rendered
markup plus the hydration payload the client reads on mount.
Usage:
from mizan_fastapi.ssr import SSRRenderer
ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"])
@app.get("/profile/{user_id}")
async def profile(user_id: int):
return ssr.render("components/Profile.tsx", {"user_id": user_id})
`render` resolves the template name to an absolute file path against `dirs`
(parity with Django's `DIRS`), then renders the component's default export. The
hydration wrapping matches the Django backend byte-for-byte so the same client
bundle hydrates either server.
"""
from __future__ import annotations
import json
import os
from typing import Any
from fastapi.responses import HTMLResponse
from mizan_core.ssr import SSRBridge
class SSRRenderer:
"""Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge.
One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge
serializes worker I/O); a single renderer can be shared across the app.
"""
def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None:
self._dirs = list(dirs or [])
self._bridge = SSRBridge(worker_path=worker, timeout=timeout)
def _resolve(self, template_name: str) -> str:
"""Resolve a template name to an absolute file path against `dirs`.
An already-absolute, existing path is used directly; otherwise each `dirs`
entry is tried in order (parity with Django's `DIRS` resolution).
"""
if os.path.isabs(template_name) and os.path.isfile(template_name):
return template_name
for dir_path in self._dirs:
candidate = os.path.join(dir_path, template_name)
if os.path.isfile(candidate):
return os.path.abspath(candidate)
raise FileNotFoundError(
f"SSR component '{template_name}' not found in dirs={self._dirs!r}"
)
def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str:
"""Render the component to an HTML string (markup + hydration script)."""
props = dict(props or {})
result = self._bridge.render(self._resolve(template_name), props)
hydration_json = json.dumps(props, sort_keys=True, default=str)
return (
f'<div id="mizan-root">{result.html}</div>'
f"<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>"
)
def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
"""Render the component and return a FastAPI `HTMLResponse`."""
return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code)
def shutdown(self) -> None:
"""Stop the underlying Bun subprocess."""
self._bridge.shutdown()