A channel's message slots are named from the client, on every backend
The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,14 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PyJWT>=2.0",
|
||||
"jinja2>=3.1",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ckdl>=1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
"""
|
||||
Cache backends — MemoryCache (testing) and RedisCache (production).
|
||||
|
||||
Simple key-value stores. No reverse indexes. Cache keys are derived
|
||||
from HMAC, so scoped purge just recomputes the key and deletes it.
|
||||
Broad purge uses key-prefix scan (rare operation).
|
||||
"""
|
||||
"""Cache backends — a key/value store keyed by the derived HMAC cache key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
import abc
|
||||
|
||||
|
||||
class CacheBackend(Protocol):
|
||||
"""Interface that all Mizan cache backends implement."""
|
||||
class CacheBackend(abc.ABC):
|
||||
"""A key/value store holding serialized context payloads."""
|
||||
|
||||
def get(self, key: str) -> bytes | None: ...
|
||||
def set(self, key: str, value: bytes) -> None: ...
|
||||
def delete(self, key: str) -> bool: ...
|
||||
def delete_by_prefix(self, prefix: str) -> int: ...
|
||||
def clear(self) -> None: ...
|
||||
@abc.abstractmethod
|
||||
def get(self, key: str) -> bytes | None:
|
||||
"""The stored value for `key`, or None when absent."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set(self, key: str, value: bytes) -> None:
|
||||
"""Store `value` under `key`, replacing anything already there."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Drop `key`. True if it was present."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_by_prefix(self, prefix: str) -> int:
|
||||
"""Drop every key starting with `prefix`. Returns how many were dropped."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Drop every key this backend owns."""
|
||||
|
||||
|
||||
class MemoryCache:
|
||||
"""
|
||||
In-memory cache backend for testing.
|
||||
|
||||
Uses a Python dict. No persistence, no cross-process sharing.
|
||||
"""
|
||||
class MemoryCache(CacheBackend):
|
||||
"""A process-local dict. No persistence, no cross-process sharing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, bytes] = {}
|
||||
@@ -53,13 +57,8 @@ class MemoryCache:
|
||||
self._store.clear()
|
||||
|
||||
|
||||
class RedisCache:
|
||||
"""
|
||||
Redis-backed cache backend for production.
|
||||
|
||||
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
|
||||
the HMAC key and deletes directly. Broad purge uses SCAN.
|
||||
"""
|
||||
class RedisCache(CacheBackend):
|
||||
"""Redis GET/SET/UNLINK behind a key namespace, with SCAN for prefix drops."""
|
||||
|
||||
DEFAULT_TTL = 86400 # 24h safety-net
|
||||
|
||||
@@ -71,11 +70,11 @@ class RedisCache:
|
||||
) -> None:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
except ImportError:
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Redis is required for Mizan's cache backend. "
|
||||
"Install it with: pip install mizan[cache]"
|
||||
)
|
||||
) from exc
|
||||
self._client = redis_lib.from_url(
|
||||
redis_url,
|
||||
socket_connect_timeout=5,
|
||||
|
||||
16
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
16
cores/mizan-python/src/mizan_core/cache/keys.py
vendored
@@ -1,12 +1,4 @@
|
||||
"""
|
||||
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||
|
||||
Protocol-critical: every Mizan adapter must produce identical output
|
||||
for identical inputs. Cross-language conformance verified by pin tests.
|
||||
|
||||
Scoped purge recomputes the key directly — no reverse index needed.
|
||||
Broad purge uses a context prefix scan.
|
||||
"""
|
||||
"""Cache key derivation — HMAC-SHA256 over a canonical JSON form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,7 +7,6 @@ import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Context prefix for broad purge (SCAN pattern)
|
||||
CONTEXT_KEY_PREFIX = "ctx:"
|
||||
|
||||
|
||||
@@ -33,8 +24,9 @@ def derive_cache_key(
|
||||
broad purge can SCAN by prefix "ctx:{context}:*".
|
||||
"""
|
||||
def _normalize(v: Any) -> str:
|
||||
"""Normalize values for cross-language HMAC consistency.
|
||||
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
|
||||
"""Render a param value in its JSON-native spelling.
|
||||
Python str(True) is "True" but JS String(true) is "true", and the two
|
||||
must hash identically."""
|
||||
if v is True:
|
||||
return "true"
|
||||
if v is False:
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"""
|
||||
mizan Server Functions - Core Primitive
|
||||
Server functions: the `@client` decorator and the `ServerFunction` class it
|
||||
produces, `ReactContext` for grouping them, and `compose` for combining
|
||||
contexts into one provider.
|
||||
|
||||
Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
Two styles supported:
|
||||
|
||||
1. Function-based (recommended, Django Ninja style):
|
||||
@client("update-profile")
|
||||
def update_profile(request, input: UpdateProfileInput) -> UpdateProfileOutput:
|
||||
Function form:
|
||||
@client
|
||||
def update_profile(request, name: str) -> UpdateProfileOutput:
|
||||
return UpdateProfileOutput(success=True)
|
||||
|
||||
2. Class-based (for complex cases):
|
||||
Class form:
|
||||
class UpdateProfile(ServerFunction):
|
||||
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
|
||||
return UpdateProfileOutput(success=True)
|
||||
@@ -21,7 +19,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -30,24 +27,16 @@ from typing import (
|
||||
Generic,
|
||||
Literal,
|
||||
TypeVar,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ─── Framework-response-base hook ───────────────────────────────────────────
|
||||
#
|
||||
# View-path detection — distinguishing functions that return data (RPC path)
|
||||
# from functions that return a framework-native response object (view path) —
|
||||
# requires knowing the framework's response base class. Each backend adapter
|
||||
# registers its base class here at import time.
|
||||
#
|
||||
# Django sets this to django.http.HttpResponseBase. FastAPI would set it to
|
||||
# starlette.responses.Response. If unset, all functions are treated as RPC.
|
||||
# needs the framework's response base class, which only the backend adapter
|
||||
# knows. While it is unset, every function is treated as RPC.
|
||||
|
||||
_framework_response_base: type | None = None
|
||||
|
||||
@@ -101,7 +90,7 @@ class ReactContext:
|
||||
return f"ReactContext({self.name!r})"
|
||||
|
||||
|
||||
# Built-in global context (auto-mounted at root, SSR-hydrated)
|
||||
# The context named 'global', pre-made so callers share one instance of it.
|
||||
GlobalContext = ReactContext("global")
|
||||
|
||||
|
||||
@@ -209,7 +198,7 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
|
||||
class _FunctionWrapper(ServerFunction):
|
||||
"""Internal wrapper that makes a plain function behave like a ServerFunction."""
|
||||
|
||||
# Will be set per-wrapper instance
|
||||
# Set per-wrapper subclass by _create_server_function
|
||||
_wrapped_fn: ClassVar[Callable]
|
||||
_input_cls: ClassVar[type[BaseModel] | None]
|
||||
_output_cls: ClassVar[type[BaseModel]]
|
||||
@@ -284,12 +273,6 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
||||
if isinstance(context, str):
|
||||
if not context.strip():
|
||||
raise ValueError("context must be a non-empty string, ReactContext, or False.")
|
||||
if context == "local":
|
||||
warnings.warn(
|
||||
"context='local' is deprecated. Use ReactContext('name') instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return context
|
||||
raise ValueError(
|
||||
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
|
||||
@@ -327,7 +310,6 @@ def client(
|
||||
context: Named context for React state management.
|
||||
- False (default): Not a context, just a callable function.
|
||||
- ReactContext instance: groups functions into a named context.
|
||||
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
||||
|
||||
affects: Declare which contexts or functions this mutation invalidates.
|
||||
Mutually exclusive with context=.
|
||||
@@ -526,8 +508,8 @@ def _create_server_function(
|
||||
is_view_path = is_framework_response(output_type)
|
||||
|
||||
if is_view_path:
|
||||
# View path — no Pydantic output wrapping needed
|
||||
output_cls = BaseModel # placeholder, never used for serialization
|
||||
# A view path serializes nothing, so Output is never read off this class.
|
||||
output_cls = BaseModel
|
||||
is_primitive_output = False
|
||||
else:
|
||||
# RPC path — resolve output type
|
||||
@@ -555,7 +537,7 @@ def _create_server_function(
|
||||
FunctionWrapper._output_cls = output_cls
|
||||
FunctionWrapper._is_primitive_output = is_primitive_output
|
||||
|
||||
# Set Input/Output class attributes for compatibility
|
||||
# Input/Output are the names the ServerFunction contract exposes them under
|
||||
if input_cls is not None:
|
||||
FunctionWrapper.Input = input_cls
|
||||
FunctionWrapper.Output = output_cls
|
||||
@@ -615,8 +597,8 @@ def _create_server_function(
|
||||
# Always assign a fresh dict to prevent shared-dict mutation across classes
|
||||
FunctionWrapper._meta = {**meta}
|
||||
|
||||
# Note: Registration happens via discovery (mizan_clients), not here.
|
||||
# This allows the decorator to be used without import-time side effects.
|
||||
# Registration happens via discovery (mizan_clients), so the decorator has
|
||||
# no import-time side effects.
|
||||
|
||||
return FunctionWrapper
|
||||
|
||||
@@ -657,15 +639,14 @@ class ComposedContext:
|
||||
"leaves": [leaf.name for leaf in leaves],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_schema_export(cls) -> dict[str, Any]:
|
||||
def get_schema_export(self) -> dict[str, Any]:
|
||||
"""Export schema for TypeScript generation."""
|
||||
return {
|
||||
"name": cls.name,
|
||||
"name": self.name,
|
||||
"type": "compose",
|
||||
"meta": cls._meta,
|
||||
"children": cls._meta.get("children", []),
|
||||
"leaves": cls._meta.get("leaves", []),
|
||||
"meta": self._meta,
|
||||
"children": self._meta["children"],
|
||||
"leaves": self._meta["leaves"],
|
||||
}
|
||||
|
||||
|
||||
@@ -676,7 +657,6 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
||||
elif isinstance(item, ComposedContext):
|
||||
return item._leaves.copy()
|
||||
elif hasattr(item, "_leaves"):
|
||||
# Duck typing for composed contexts
|
||||
return item._leaves.copy()
|
||||
else:
|
||||
raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}")
|
||||
@@ -714,21 +694,18 @@ def compose(
|
||||
- True: Bundled call over WebSocket. All children must have websocket=True.
|
||||
|
||||
Usage:
|
||||
@client(context='local')
|
||||
UserContext = ReactContext('user')
|
||||
|
||||
@client(context=UserContext)
|
||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||
|
||||
@client(context='local')
|
||||
@client(context=UserContext)
|
||||
def user_posts(request, user_id: int) -> PostsOutput: ...
|
||||
|
||||
@compose(user_profile, user_posts)
|
||||
def user_page():
|
||||
pass
|
||||
|
||||
# Frontend generates:
|
||||
# <UserPageProvider user_id={123}>
|
||||
# <App />
|
||||
# </UserPageProvider>
|
||||
|
||||
Nesting:
|
||||
@compose(ctx_a, ctx_b)
|
||||
def ab(): pass
|
||||
@@ -767,7 +744,7 @@ def compose(
|
||||
if id(leaf) in seen:
|
||||
raise ValueError(
|
||||
f"Duplicate context '{leaf.name}' in @compose({name}). "
|
||||
f"Each context can only appear once. Use named kwargs for reuse (future feature)."
|
||||
f"Each context appears at most once across the flattened children."
|
||||
)
|
||||
seen.add(id(leaf))
|
||||
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
"""
|
||||
Mizan IR — KDL emission from the live `mizan_core.registry`.
|
||||
|
||||
`build_ir()` walks every registered function class, introspects its
|
||||
Pydantic Input/Output models directly (not via JSON-Schema), and emits
|
||||
KDL — the canonical Mizan protocol IR. Every backend adapter exposes
|
||||
this via a backend-specific entry point (Django management command,
|
||||
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
|
||||
`build_ir()` walks every registered function class, introspects its Pydantic
|
||||
Input/Output models directly (not via JSON-Schema), computes a plain-data
|
||||
document, and renders it through `templates/ir/document.kdl.j2`.
|
||||
|
||||
KDL grammar — locked contract:
|
||||
KDL grammar:
|
||||
|
||||
type "<Name>" {
|
||||
struct {
|
||||
field "<name>" required=#true|#false default=<lit> {
|
||||
primitive "integer|number|boolean|string"
|
||||
| ref "<TypeName>"
|
||||
| list { <type-child> }
|
||||
| optional { <type-child> }
|
||||
| enum "<v1>" "<v2>" ...
|
||||
field "<name>" required=#false default=<lit> {
|
||||
<type-child>
|
||||
}
|
||||
...
|
||||
}
|
||||
| list { <type-child> }
|
||||
| enum "<v1>" "<v2>" ...
|
||||
| alias { <type-child> }
|
||||
}
|
||||
|
||||
<type-child> =
|
||||
primitive "integer|number|boolean|string"
|
||||
| ref "<TypeName>"
|
||||
| enum "<v1>" "<v2>" ...
|
||||
| list { <type-child> }
|
||||
| optional { <type-child> }
|
||||
| union { <type-child> ... }
|
||||
|
||||
function "<wire_name>" {
|
||||
camel "<camelCase>"
|
||||
has-input #true|#false
|
||||
input "<TypeName>" // omitted if has-input=#false
|
||||
output "<TypeName>"
|
||||
output-nullable #true|#false // omitted when #false (default)
|
||||
transport "http"|"websocket"|"both"
|
||||
output-nullable #true // omitted when #false (default)
|
||||
transport "http"|"websocket"
|
||||
context "<ctx_name>" // omitted unless context-grouped
|
||||
affects "<ctx_name>" // 0..N occurrences
|
||||
merge "<ctx_name>" // 0..N occurrences
|
||||
@@ -51,303 +51,174 @@ KDL grammar — locked contract:
|
||||
}
|
||||
}
|
||||
|
||||
channel "<name>" {
|
||||
channel "<wire_name>" {
|
||||
pascal-name "<PascalCase>"
|
||||
params "<TypeName>" // omitted if no params
|
||||
react-message "<TypeName>" // omitted if no react message
|
||||
django-message "<TypeName>" // omitted if no django message
|
||||
params "<TypeName>" // omitted when the channel takes no params
|
||||
client-message "<TypeName>" // client -> server; omitted if none
|
||||
server-message "<TypeName>" // server -> client; omitted if none
|
||||
}
|
||||
|
||||
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance,
|
||||
the Pydantic→json-schema converter — all gone.
|
||||
Channel slots are named from the client's point of view — `client-message`
|
||||
travels up, `server-message` travels down — and their type names are
|
||||
`<Pascal>Params`, `<Pascal>ClientMessage` and `<Pascal>ServerMessage`, where
|
||||
`<Pascal>` comes from `wire_to_pascal`. Backends that publish channel types
|
||||
into their own schema documents call `wire_to_pascal` rather than deriving a
|
||||
second Pascal form.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import types
|
||||
from typing import Any, Literal, Union, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
from jinja2 import Environment, PackageLoader, StrictUndefined
|
||||
from pydantic import BaseModel, create_model
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||
from mizan_core.registry import (
|
||||
get_all_functions,
|
||||
get_context_groups,
|
||||
get_function,
|
||||
get_registry,
|
||||
)
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
|
||||
|
||||
__all__ = ["build_ir"]
|
||||
__all__ = ["build_ir", "wire_to_pascal"]
|
||||
|
||||
|
||||
# Common user-identity param names; mirrors the equivalent in mizan-django /
|
||||
# mizan-fastapi schema-export logic.
|
||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||
# ─── Wire-name derivations ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ─── KDL value formatting ───────────────────────────────────────────────────
|
||||
def wire_to_pascal(wire_name: str) -> str:
|
||||
"""The PascalCase stem every emitted type name for `wire_name` is built on."""
|
||||
return "".join(part.title() for part in re.split(r"[._-]", wire_name))
|
||||
|
||||
|
||||
def _kdl_string(s: str) -> str:
|
||||
"""KDL-escape a string and wrap in quotes."""
|
||||
# ─── KDL value encoding ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _kdl(value: str) -> str:
|
||||
"""KDL-escape a string and wrap it in quotes."""
|
||||
escaped = (
|
||||
s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
value.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _kdl_bool(b: bool) -> str:
|
||||
return "#true" if b else "#false"
|
||||
def _kdlbool(value: bool) -> str:
|
||||
return "#true" if value else "#false"
|
||||
|
||||
|
||||
def _kdl_value(v: Any) -> str:
|
||||
"""Render a JSON-shape Python value as a KDL literal."""
|
||||
if v is None:
|
||||
return "#null"
|
||||
if v is True or v is False:
|
||||
return _kdl_bool(v)
|
||||
if isinstance(v, (int, float)):
|
||||
return repr(v)
|
||||
if isinstance(v, str):
|
||||
return _kdl_string(v)
|
||||
# Fallback for compound values — defaults aren't typed in our IR.
|
||||
import json
|
||||
return _kdl_string(json.dumps(v))
|
||||
_ENV = Environment(
|
||||
loader=PackageLoader("mizan_core", "templates"),
|
||||
undefined=StrictUndefined,
|
||||
keep_trailing_newline=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
_ENV.filters["kdl"] = _kdl
|
||||
_ENV.filters["kdlbool"] = _kdlbool
|
||||
|
||||
|
||||
# ─── KDL Builder ────────────────────────────────────────────────────────────
|
||||
def _default_literal(value: Any) -> dict[str, Any] | None:
|
||||
"""Tag a field default so the template can pick its KDL literal form.
|
||||
|
||||
A KDL entry value is a scalar, so the literal forms are exactly bool,
|
||||
number and string. A default of any other shape — a nested model, a list,
|
||||
a dict, an enum member — has no scalar form and yields `None`: the field
|
||||
emits `required=#false` with no `default`, and the server-side Pydantic
|
||||
model stays the authority for the value it fills in.
|
||||
"""
|
||||
if value is True or value is False:
|
||||
return {"kind": "bool", "value": value}
|
||||
if isinstance(value, (int, float)):
|
||||
return {"kind": "number", "value": value}
|
||||
if isinstance(value, str):
|
||||
return {"kind": "string", "value": value}
|
||||
return None
|
||||
|
||||
|
||||
class _Block:
|
||||
"""Open-children context for a KDL node. Tracks indent level."""
|
||||
|
||||
__slots__ = ("lines", "indent")
|
||||
|
||||
def __init__(self, lines: list[str], indent: int):
|
||||
self.lines = lines
|
||||
self.indent = indent
|
||||
|
||||
def _prefix(self) -> str:
|
||||
return " " * self.indent
|
||||
|
||||
def node(self, name: str, *args: str, **props: str) -> "_OpenNode":
|
||||
"""Open a node. `args` are positional KDL args; `props` are key=value pairs."""
|
||||
return _OpenNode(self.lines, self.indent, name, list(args), dict(props))
|
||||
|
||||
def leaf(self, name: str, *args: str, **props: str) -> None:
|
||||
"""Emit a leaf node — no children block."""
|
||||
parts = [name]
|
||||
parts.extend(args)
|
||||
for k, v in props.items():
|
||||
parts.append(f"{k}={v}")
|
||||
self.lines.append(f"{self._prefix()}{' '.join(parts)}")
|
||||
# ─── Type shapes ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _OpenNode:
|
||||
"""A KDL node whose children are being built."""
|
||||
def _shape(annotation: Any, refs: list[type[BaseModel]]) -> dict[str, Any]:
|
||||
"""Reduce a Python annotation to a shape tree, appending every model it
|
||||
references to `refs`."""
|
||||
inner, is_optional = extract_optional(annotation)
|
||||
if is_optional:
|
||||
return {"kind": "optional", "of": _shape(inner, refs)}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lines: list[str],
|
||||
indent: int,
|
||||
name: str,
|
||||
args: list[str],
|
||||
props: dict[str, str],
|
||||
):
|
||||
self.lines = lines
|
||||
self.indent = indent
|
||||
self.name = name
|
||||
self.args = args
|
||||
self.props = props
|
||||
self._children_emitted = False
|
||||
|
||||
def __enter__(self) -> _Block:
|
||||
parts = [self.name]
|
||||
parts.extend(self.args)
|
||||
for k, v in self.props.items():
|
||||
parts.append(f"{k}={v}")
|
||||
self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{")
|
||||
self._children_emitted = True
|
||||
return _Block(self.lines, self.indent + 1)
|
||||
|
||||
def __exit__(self, *_exc: Any) -> None:
|
||||
if self._children_emitted:
|
||||
self.lines.append(f"{' ' * self.indent}}}")
|
||||
|
||||
|
||||
# ─── Type emission ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
|
||||
"""Emit the type-shape KDL for a Python annotation, recursing as needed."""
|
||||
# Strip Optional[T] → emit `optional` wrapper.
|
||||
inner, is_opt = extract_optional(annotation)
|
||||
if is_opt:
|
||||
with block.node("optional") as inner_block:
|
||||
_emit_type_child(inner_block, inner, named_types)
|
||||
return
|
||||
|
||||
# Multi-arm union (T | U) — emit `union { <each-branch> }`.
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union or isinstance(annotation, types.UnionType):
|
||||
branches = [a for a in get_args(annotation) if a is not type(None)]
|
||||
if len(branches) > 1:
|
||||
with block.node("union") as inner_block:
|
||||
for branch in branches:
|
||||
_emit_type_child(inner_block, branch, named_types)
|
||||
return
|
||||
return {
|
||||
"kind": "union",
|
||||
"branches": [_shape(branch, refs) for branch in branches],
|
||||
}
|
||||
|
||||
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }`
|
||||
elem = extract_list_element(annotation)
|
||||
if elem is not None:
|
||||
with block.node("list") as inner_block:
|
||||
_emit_type_child(inner_block, elem, named_types)
|
||||
return
|
||||
element = extract_list_element(annotation)
|
||||
if element is not None:
|
||||
return {"kind": "list", "of": _shape(element, refs)}
|
||||
|
||||
# Literal[a, b, c] → enum
|
||||
if origin is Literal:
|
||||
args = get_args(annotation)
|
||||
if all(isinstance(a, str) for a in args):
|
||||
quoted = " ".join(_kdl_string(a) for a in args)
|
||||
block.lines.append(f"{block._prefix()}enum {quoted}")
|
||||
return
|
||||
values = get_args(annotation)
|
||||
if all(isinstance(v, str) for v in values):
|
||||
return {"kind": "enum", "values": list(values)}
|
||||
|
||||
# Pydantic model → reference by name.
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
type_name = annotation.__name__
|
||||
named_types.setdefault(type_name, _StructShape(annotation))
|
||||
block.leaf("ref", _kdl_string(type_name))
|
||||
return
|
||||
refs.append(annotation)
|
||||
return {"kind": "ref", "name": annotation.__name__}
|
||||
|
||||
# Primitives
|
||||
if annotation is int:
|
||||
block.leaf("primitive", _kdl_string("integer"))
|
||||
return
|
||||
return {"kind": "primitive", "name": "integer"}
|
||||
if annotation is float:
|
||||
block.leaf("primitive", _kdl_string("number"))
|
||||
return
|
||||
return {"kind": "primitive", "name": "number"}
|
||||
if annotation is bool:
|
||||
block.leaf("primitive", _kdl_string("boolean"))
|
||||
return
|
||||
if annotation is str:
|
||||
block.leaf("primitive", _kdl_string("string"))
|
||||
return
|
||||
return {"kind": "primitive", "name": "boolean"}
|
||||
|
||||
# Open-shape fallback (dict / Any / etc).
|
||||
block.leaf("primitive", _kdl_string("string"))
|
||||
# str, dict, Any and every other open shape collapse to string.
|
||||
return {"kind": "primitive", "name": "string"}
|
||||
|
||||
|
||||
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
|
||||
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper."""
|
||||
with block.node("alias") as alias_block:
|
||||
_emit_type_child(alias_block, annotation, named_types)
|
||||
def _struct_fields(
|
||||
model: type[BaseModel], refs: list[type[BaseModel]]
|
||||
) -> list[dict[str, Any]]:
|
||||
fields: list[dict[str, Any]] = []
|
||||
for field_name, field_info in model.model_fields.items():
|
||||
# `is_required()` covers both the explicit Required marker and the
|
||||
# presence of a default.
|
||||
required = field_info.is_required()
|
||||
default = field_info.default
|
||||
has_default = (
|
||||
not required
|
||||
and default is not None
|
||||
and default is not PydanticUndefined
|
||||
and default is not ...
|
||||
)
|
||||
fields.append(
|
||||
{
|
||||
"name": field_name,
|
||||
"required": required,
|
||||
"default": _default_literal(default) if has_default else None,
|
||||
"shape": _shape(field_info.annotation, refs),
|
||||
}
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
||||
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
||||
with block.node("struct") as struct_block:
|
||||
for field_name, field_info in model.model_fields.items():
|
||||
props: dict[str, str] = {}
|
||||
# `field_info.is_required()` checks both the explicit Required
|
||||
# marker and the presence of a default.
|
||||
required = field_info.is_required()
|
||||
if not required:
|
||||
props["required"] = _kdl_bool(False)
|
||||
default = field_info.default
|
||||
if default is not None and default is not PydanticUndefined and default is not ...:
|
||||
props["default"] = _kdl_value(default)
|
||||
|
||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
# ─── Named types ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _StructShape:
|
||||
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
|
||||
__slots__ = ("model",)
|
||||
def __init__(self, model: type[BaseModel]):
|
||||
self.model = model
|
||||
|
||||
|
||||
class _AliasShape:
|
||||
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`."""
|
||||
__slots__ = ("annotation",)
|
||||
def __init__(self, annotation: Any):
|
||||
self.annotation = annotation
|
||||
|
||||
|
||||
def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]:
|
||||
"""First pass: collect every named type the IR's `function` section references.
|
||||
|
||||
Two kinds:
|
||||
- Pydantic BaseModels seen anywhere in Input/Output traversal — emit
|
||||
as `type "X" { struct { ... } }`.
|
||||
- Function-output wrapper aliases (`<CamelName>Output = list[T]` /
|
||||
`<CamelName>Output = T | None`) — emit as `type "X" { alias { ... } }`
|
||||
so the consumer has a single named type to reference.
|
||||
"""
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def visit_model(model: type[BaseModel]) -> None:
|
||||
if model.__name__ in seen:
|
||||
return
|
||||
seen[model.__name__] = _StructShape(model)
|
||||
for field_info in model.model_fields.values():
|
||||
for nested in _nested_models(field_info.annotation):
|
||||
visit_model(nested)
|
||||
|
||||
def visit_annotation(ann: Any) -> None:
|
||||
for nested in _nested_models(ann):
|
||||
visit_model(nested)
|
||||
|
||||
for fn_class in functions.values():
|
||||
input_cls = getattr(fn_class, "Input", None)
|
||||
if _has_input(input_cls):
|
||||
input_named = _name_input_model(fn_class)
|
||||
visit_model(input_named)
|
||||
|
||||
output_cls = getattr(fn_class, "Output", None)
|
||||
if output_cls is None:
|
||||
continue
|
||||
camel = _snake_to_camel(fn_class.name)
|
||||
output_name = f"{camel}Output"
|
||||
|
||||
inner, _ = extract_optional(output_cls)
|
||||
elem = extract_list_element(inner)
|
||||
|
||||
if elem is not None:
|
||||
# `list[T]` (possibly wrapped in Optional) — emit a list alias.
|
||||
# Visit the element type so its struct shape gets emitted too.
|
||||
visit_annotation(output_cls)
|
||||
if output_name not in seen:
|
||||
seen[output_name] = _AliasShape(output_cls)
|
||||
elif isinstance(inner, type) and issubclass(inner, BaseModel):
|
||||
# `<Model>` or `Optional[<Model>]` — emit the model under the
|
||||
# canonical name (rename if necessary).
|
||||
output_named = _name_output_model(fn_class, inner)
|
||||
visit_model(output_named)
|
||||
# If the Optional wrapper differs from the bare model, emit an
|
||||
# alias under the canonical output name too.
|
||||
if output_named.__name__ != output_name:
|
||||
seen.setdefault(output_name, _AliasShape(output_cls))
|
||||
else:
|
||||
# Primitive-wrapped output (`result: int`) — emit as alias.
|
||||
seen.setdefault(output_name, _AliasShape(output_cls))
|
||||
|
||||
return seen
|
||||
|
||||
|
||||
def _nested_models(annotation: Any) -> list[type[BaseModel]]:
|
||||
"""All Pydantic models that appear anywhere inside `annotation`."""
|
||||
out: list[type[BaseModel]] = []
|
||||
inner, _ = extract_optional(annotation)
|
||||
elem = extract_list_element(inner)
|
||||
if elem is not None:
|
||||
out.extend(_nested_models(elem))
|
||||
return out
|
||||
if isinstance(inner, type) and issubclass(inner, BaseModel):
|
||||
out.append(inner)
|
||||
return out
|
||||
def _snake_to_camel(name: str) -> str:
|
||||
parts = name.replace(".", "_").replace("-", "_").split("_")
|
||||
return parts[0] + "".join(p.title() for p in parts[1:] if p)
|
||||
|
||||
|
||||
def _has_input(input_cls: Any) -> bool:
|
||||
@@ -359,206 +230,135 @@ def _has_input(input_cls: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _snake_to_camel(name: str) -> str:
|
||||
parts = name.replace(".", "_").replace("-", "_").split("_")
|
||||
return parts[0] + "".join(p.title() for p in parts[1:] if p)
|
||||
|
||||
|
||||
def _name_input_model(fn_class: Any) -> type[BaseModel]:
|
||||
"""Return a copy of the function's Input model named `<CamelName>Input`."""
|
||||
from pydantic import create_model
|
||||
|
||||
camel = _snake_to_camel(fn_class.name)
|
||||
canonical = f"{camel}Input"
|
||||
"""The function's Input model under the canonical `<CamelName>Input` name."""
|
||||
canonical = f"{_snake_to_camel(fn_class.name)}Input"
|
||||
src = fn_class.Input
|
||||
if src.__name__ == canonical:
|
||||
return src
|
||||
# Re-derive under the canonical name so codegen consumers see a stable name.
|
||||
return create_model(canonical, __base__=src)
|
||||
|
||||
|
||||
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
|
||||
"""Return a copy of the model named `<CamelName>Output`."""
|
||||
from pydantic import create_model
|
||||
|
||||
camel = _snake_to_camel(fn_class.name)
|
||||
canonical = f"{camel}Output"
|
||||
"""`base` under the canonical `<CamelName>Output` name."""
|
||||
canonical = f"{_snake_to_camel(fn_class.name)}Output"
|
||||
if base.__name__ == canonical:
|
||||
return base
|
||||
return create_model(canonical, __base__=base)
|
||||
|
||||
|
||||
# ─── Function / context / channel emission ──────────────────────────────────
|
||||
def _bind(
|
||||
sources: dict[str, tuple[str, Any]], name: str, source: tuple[str, Any]
|
||||
) -> None:
|
||||
"""Claim `name` for one declaration. Two different declarations under one
|
||||
name would emit two `type` blocks that no `ref` can tell apart, so the
|
||||
second claim raises."""
|
||||
claimed = sources.setdefault(name, source)
|
||||
if claimed != source:
|
||||
raise ValueError(
|
||||
f"named type '{name}' is claimed twice, by {claimed[1]!r} "
|
||||
f"and by {source[1]!r}"
|
||||
)
|
||||
|
||||
|
||||
def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]:
|
||||
"""Collect every value that goes inside a `function` block."""
|
||||
def _seed_named_types(
|
||||
functions: dict[str, Any], channel_models: list[tuple[str, type[BaseModel]]]
|
||||
) -> dict[str, tuple[str, Any]]:
|
||||
"""Name → ("struct", model) | ("alias", annotation) for every type the
|
||||
function and channel sections reference directly."""
|
||||
seeds: dict[str, tuple[str, Any]] = {}
|
||||
|
||||
for fn_class in functions.values():
|
||||
if _has_input(getattr(fn_class, "Input", None)):
|
||||
named_input = _name_input_model(fn_class)
|
||||
_bind(seeds, named_input.__name__, ("struct", named_input))
|
||||
|
||||
output_cls = getattr(fn_class, "Output", None)
|
||||
if output_cls is None:
|
||||
continue
|
||||
output_name = f"{_snake_to_camel(fn_class.name)}Output"
|
||||
inner, _ = extract_optional(output_cls)
|
||||
wraps_model = (
|
||||
extract_list_element(inner) is None
|
||||
and isinstance(inner, type)
|
||||
and issubclass(inner, BaseModel)
|
||||
)
|
||||
if wraps_model:
|
||||
_bind(seeds, output_name, ("struct", _name_output_model(fn_class, inner)))
|
||||
else:
|
||||
_bind(seeds, output_name, ("alias", output_cls))
|
||||
|
||||
for type_name, model in channel_models:
|
||||
_bind(seeds, type_name, ("struct", model))
|
||||
|
||||
return seeds
|
||||
|
||||
|
||||
def _resolve_named_types(seeds: dict[str, tuple[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Resolve seeds to a fixed point — resolving one type discovers the models
|
||||
it references, which are themselves resolved — then order by name."""
|
||||
sources = dict(seeds)
|
||||
resolved: dict[str, dict[str, Any]] = {}
|
||||
|
||||
while True:
|
||||
unresolved = [name for name in sources if name not in resolved]
|
||||
if not unresolved:
|
||||
break
|
||||
for name in unresolved:
|
||||
kind, payload = sources[name]
|
||||
refs: list[type[BaseModel]] = []
|
||||
if kind == "struct":
|
||||
resolved[name] = {
|
||||
"name": name,
|
||||
"kind": "struct",
|
||||
"fields": _struct_fields(payload, refs),
|
||||
}
|
||||
else:
|
||||
resolved[name] = {
|
||||
"name": name,
|
||||
"kind": "alias",
|
||||
"shape": _shape(payload, refs),
|
||||
}
|
||||
for model in refs:
|
||||
_bind(sources, model.__name__, ("struct", model))
|
||||
|
||||
return [resolved[name] for name in sorted(resolved)]
|
||||
|
||||
|
||||
# ─── Functions, contexts, channels ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _is_emitted(fn_class: Any) -> bool:
|
||||
meta = getattr(fn_class, "_meta", {})
|
||||
name = fn_class.name
|
||||
camel = _snake_to_camel(name)
|
||||
input_cls = getattr(fn_class, "Input", None)
|
||||
has_input = _has_input(input_cls)
|
||||
is_context = meta.get("context")
|
||||
is_form = meta.get("form", False)
|
||||
return not (meta.get("private") or meta.get("view_path"))
|
||||
|
||||
|
||||
def _function_entry(fn_class: Any) -> dict[str, Any]:
|
||||
meta = getattr(fn_class, "_meta", {})
|
||||
camel = _snake_to_camel(fn_class.name)
|
||||
has_input = _has_input(getattr(fn_class, "Input", None))
|
||||
_, output_nullable = extract_optional(getattr(fn_class, "Output", None))
|
||||
context = meta.get("context")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"name": fn_class.name,
|
||||
"camel": camel,
|
||||
"has_input": has_input,
|
||||
"input_type": f"{camel}Input" if has_input else None,
|
||||
"output_type": output_type_name,
|
||||
"output_type": f"{camel}Output",
|
||||
"output_nullable": output_nullable,
|
||||
"transport": "websocket" if meta.get("websocket") else "http",
|
||||
"context": is_context if isinstance(is_context, str) else None,
|
||||
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"],
|
||||
"context": context if isinstance(context, str) else None,
|
||||
"affects": [
|
||||
a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"
|
||||
],
|
||||
"merge": list(meta.get("merge") or []),
|
||||
"is_form": bool(is_form),
|
||||
"is_form": bool(meta.get("form", False)),
|
||||
"form_name": meta.get("form_name"),
|
||||
"form_role": meta.get("form_role"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_output(fn_class: Any) -> tuple[str, bool]:
|
||||
"""Return `(output_type_name, output_nullable)` for an emitted function block."""
|
||||
camel = _snake_to_camel(fn_class.name)
|
||||
canonical = f"{camel}Output"
|
||||
output_cls = getattr(fn_class, "Output", None)
|
||||
if output_cls is None:
|
||||
return canonical, False
|
||||
_, nullable = extract_optional(output_cls)
|
||||
return canonical, nullable
|
||||
|
||||
|
||||
def _collect_channels() -> list[dict[str, Any]]:
|
||||
"""Pull channel registrations from the optional `channels` registry extension."""
|
||||
from mizan_core.registry import _extensions # type: ignore[attr-defined]
|
||||
|
||||
ext = _extensions.get("channels")
|
||||
if ext is None:
|
||||
return []
|
||||
schema = ext.schema()
|
||||
return list(schema or [])
|
||||
|
||||
|
||||
# ─── Top-level builder ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_ir() -> str:
|
||||
"""Build the Mizan IR for every registered function. Returns KDL source."""
|
||||
functions = get_all_functions()
|
||||
context_groups = get_context_groups()
|
||||
channels = _collect_channels()
|
||||
|
||||
named_types = _collect_named_types(functions)
|
||||
|
||||
lines: list[str] = []
|
||||
root = _Block(lines, indent=0)
|
||||
|
||||
# ── Type definitions ──
|
||||
for type_name in sorted(named_types):
|
||||
shape = named_types[type_name]
|
||||
with root.node("type", _kdl_string(type_name)) as type_block:
|
||||
if isinstance(shape, _StructShape):
|
||||
_emit_struct_type(type_block, shape.model, named_types)
|
||||
elif isinstance(shape, _AliasShape):
|
||||
_emit_alias_type(type_block, shape.annotation, named_types)
|
||||
else:
|
||||
raise TypeError(f"unknown named-type shape: {type(shape).__name__}")
|
||||
|
||||
if named_types:
|
||||
lines.append("")
|
||||
|
||||
# ── Functions ──
|
||||
# Alphabetical by wire name — the IR is a canonical contract, not a
|
||||
# transcript of registration order. Both Python and Rust emitters sort
|
||||
# so byte-equivalence holds across language-backed backends.
|
||||
for fn_name in sorted(functions):
|
||||
fn_class = functions[fn_name]
|
||||
meta = getattr(fn_class, "_meta", {})
|
||||
if meta.get("private") or meta.get("view_path"):
|
||||
continue
|
||||
output_type_name, output_nullable = _resolve_output(fn_class)
|
||||
props = _function_props(fn_class, output_type_name, output_nullable)
|
||||
_emit_function(root, props)
|
||||
|
||||
if functions:
|
||||
lines.append("")
|
||||
|
||||
# ── Contexts ──
|
||||
# Alphabetical by context name — same reason as functions above.
|
||||
for ctx_name in sorted(context_groups):
|
||||
_emit_context(root, ctx_name, context_groups[ctx_name])
|
||||
|
||||
if context_groups:
|
||||
lines.append("")
|
||||
|
||||
# ── Channels ──
|
||||
for channel in channels:
|
||||
_emit_channel(root, channel)
|
||||
|
||||
# Trim trailing blanks then add a single terminating newline.
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _emit_function(root: _Block, props: dict[str, Any]) -> None:
|
||||
with root.node("function", _kdl_string(props["name"])) as block:
|
||||
block.leaf("camel", _kdl_string(props["camel"]))
|
||||
block.leaf("has-input", _kdl_bool(props["has_input"]))
|
||||
if props["input_type"]:
|
||||
block.leaf("input", _kdl_string(props["input_type"]))
|
||||
block.leaf("output", _kdl_string(props["output_type"]))
|
||||
if props["output_nullable"]:
|
||||
block.leaf("output-nullable", _kdl_bool(True))
|
||||
block.leaf("transport", _kdl_string(props["transport"]))
|
||||
if props["context"]:
|
||||
block.leaf("context", _kdl_string(props["context"]))
|
||||
for affect_name in props["affects"]:
|
||||
block.leaf("affects", _kdl_string(affect_name))
|
||||
for merge_name in props["merge"]:
|
||||
block.leaf("merge", _kdl_string(merge_name))
|
||||
if props["is_form"]:
|
||||
block.leaf("is-form", _kdl_bool(True))
|
||||
if props["form_name"]:
|
||||
block.leaf("form-name", _kdl_string(props["form_name"]))
|
||||
if props["form_role"]:
|
||||
block.leaf("form-role", _kdl_string(props["form_role"]))
|
||||
|
||||
|
||||
def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
|
||||
# First pass: collect param info across every function in the context.
|
||||
param_info: dict[str, dict[str, Any]] = {}
|
||||
for fn_name in fn_names:
|
||||
fn_class = get_function(fn_name)
|
||||
if fn_class is None:
|
||||
continue
|
||||
input_cls = getattr(fn_class, "Input", None)
|
||||
if not _has_input(input_cls):
|
||||
continue
|
||||
for param_name, field_info in input_cls.model_fields.items():
|
||||
slot = param_info.setdefault(param_name, {"type": None, "shared_by": []})
|
||||
slot["type"] = _annotation_to_primitive(field_info.annotation)
|
||||
slot["shared_by"].append(fn_name)
|
||||
|
||||
# A param is required iff every function in the context declares it.
|
||||
for slot in param_info.values():
|
||||
slot["required"] = len(slot["shared_by"]) == len(fn_names)
|
||||
|
||||
with root.node("context", _kdl_string(ctx_name)) as block:
|
||||
# Members alphabetical — canonical order.
|
||||
for fn_name in sorted(fn_names):
|
||||
block.leaf("function", _kdl_string(fn_name))
|
||||
for param_name in sorted(param_info):
|
||||
slot = param_info[param_name]
|
||||
with block.node("param", _kdl_string(param_name)) as param_block:
|
||||
param_block.leaf("type", _kdl_string(slot["type"]))
|
||||
param_block.leaf("required", _kdl_bool(slot["required"]))
|
||||
# `shared-by` follows the same canonical ordering.
|
||||
for sharer in sorted(slot["shared_by"]):
|
||||
param_block.leaf("shared-by", _kdl_string(sharer))
|
||||
|
||||
|
||||
def _annotation_to_primitive(annotation: Any) -> str:
|
||||
inner, _ = extract_optional(annotation)
|
||||
if inner is int:
|
||||
@@ -570,13 +370,95 @@ def _annotation_to_primitive(annotation: Any) -> str:
|
||||
return "string"
|
||||
|
||||
|
||||
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None:
|
||||
name = channel["name"]
|
||||
with root.node("channel", _kdl_string(name)) as block:
|
||||
block.leaf("pascal-name", _kdl_string(channel["pascalName"]))
|
||||
if channel.get("hasParams") and channel.get("paramsType"):
|
||||
block.leaf("params", _kdl_string(channel["paramsType"]))
|
||||
if channel.get("hasReactMessage") and channel.get("reactMessageType"):
|
||||
block.leaf("react-message", _kdl_string(channel["reactMessageType"]))
|
||||
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"):
|
||||
block.leaf("django-message", _kdl_string(channel["djangoMessageType"]))
|
||||
def _context_entry(ctx_name: str, fn_names: list[str]) -> dict[str, Any]:
|
||||
param_info: dict[str, dict[str, Any]] = {}
|
||||
for fn_name in fn_names:
|
||||
input_cls = getattr(get_function(fn_name), "Input", None)
|
||||
if not _has_input(input_cls):
|
||||
continue
|
||||
for param_name, field_info in input_cls.model_fields.items():
|
||||
slot = param_info.setdefault(param_name, {"shared_by": []})
|
||||
slot["type"] = _annotation_to_primitive(field_info.annotation)
|
||||
slot["shared_by"].append(fn_name)
|
||||
|
||||
return {
|
||||
"name": ctx_name,
|
||||
"functions": sorted(fn_names),
|
||||
"params": [
|
||||
{
|
||||
"name": param_name,
|
||||
"type": param_info[param_name]["type"],
|
||||
# A param is required iff every function in the context takes it.
|
||||
"required": len(param_info[param_name]["shared_by"]) == len(fn_names),
|
||||
"shared_by": sorted(param_info[param_name]["shared_by"]),
|
||||
}
|
||||
for param_name in sorted(param_info)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
_CHANNEL_SLOTS = (
|
||||
("Params", "params"),
|
||||
("ClientMessage", "client_message"),
|
||||
("ServerMessage", "server_message"),
|
||||
)
|
||||
|
||||
|
||||
def _collect_channels() -> tuple[
|
||||
list[dict[str, Any]], list[tuple[str, type[BaseModel]]]
|
||||
]:
|
||||
"""Channel blocks in wire-name order, plus the (emitted type name, model)
|
||||
pair each declared slot resolves to."""
|
||||
channel_classes = get_registry().get("channels", {})
|
||||
records: list[dict[str, Any]] = []
|
||||
models: list[tuple[str, type[BaseModel]]] = []
|
||||
|
||||
for wire_name in sorted(channel_classes):
|
||||
channel_class = channel_classes[wire_name]
|
||||
pascal = wire_to_pascal(wire_name)
|
||||
record: dict[str, Any] = {"name": wire_name, "pascal_name": pascal}
|
||||
for attribute, slot in _CHANNEL_SLOTS:
|
||||
declared = getattr(channel_class, attribute, None)
|
||||
if declared is None:
|
||||
record[slot] = None
|
||||
continue
|
||||
if not (isinstance(declared, type) and issubclass(declared, BaseModel)):
|
||||
raise TypeError(
|
||||
f"channel '{wire_name}' declares {attribute} as {declared!r}, "
|
||||
f"which is not a pydantic BaseModel subclass"
|
||||
)
|
||||
type_name = f"{pascal}{attribute}"
|
||||
record[slot] = type_name
|
||||
models.append((type_name, declared))
|
||||
records.append(record)
|
||||
|
||||
return records, models
|
||||
|
||||
|
||||
# ─── Top-level builder ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_ir() -> str:
|
||||
"""Build the Mizan IR for every registered function. Returns KDL source.
|
||||
|
||||
An empty registry renders the empty document — zero bytes, zero nodes.
|
||||
"""
|
||||
functions = get_all_functions()
|
||||
context_groups = get_context_groups()
|
||||
channels, channel_models = _collect_channels()
|
||||
|
||||
# Every section is sorted by name, so the document does not depend on the
|
||||
# order things were registered in.
|
||||
return _ENV.get_template("ir/document.kdl.j2").render(
|
||||
types=_resolve_named_types(_seed_named_types(functions, channel_models)),
|
||||
functions=[
|
||||
_function_entry(functions[name])
|
||||
for name in sorted(functions)
|
||||
if _is_emitted(functions[name])
|
||||
],
|
||||
contexts=[
|
||||
_context_entry(name, context_groups[name])
|
||||
for name in sorted(context_groups)
|
||||
],
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
"""
|
||||
Mizan core registry — function and composition registration with an
|
||||
extension hook for backend-specific registries (channels, forms, etc.)
|
||||
to plug into.
|
||||
|
||||
This is the framework-agnostic registry. Backends own their own
|
||||
type-specific registries (channels in Django Channels, forms in Django
|
||||
Forms, websockets in FastAPI, etc.) and register them as extensions
|
||||
here so the unified schema export can include them.
|
||||
Mizan core registry — function and composition registration, plus an
|
||||
extension hook backend-specific registries (channels, forms, …) plug into.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Protocol
|
||||
import abc
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
# ─── Core registries ────────────────────────────────────────────────────────
|
||||
@@ -22,17 +17,23 @@ _compositions: dict[str, Any] = {}
|
||||
|
||||
# ─── Extension hook ─────────────────────────────────────────────────────────
|
||||
|
||||
class RegistryExtension(Protocol):
|
||||
class RegistryExtension(abc.ABC):
|
||||
"""
|
||||
Backend-specific registries plug into core via this Protocol.
|
||||
|
||||
Each extension owns its own registry of backend-shaped registrations
|
||||
(channels, forms, websocket consumers, etc.) and contributes a schema
|
||||
subdict to the unified schema export.
|
||||
A backend registry of its own registrations (channels, forms, websocket
|
||||
consumers, …) contributing one subdict to the unified schema export.
|
||||
"""
|
||||
|
||||
def schema(self) -> dict[str, Any]: ...
|
||||
def clear(self) -> None: ...
|
||||
@abc.abstractmethod
|
||||
def all(self) -> dict[str, Any]:
|
||||
"""The live registry: registered name → registered class."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def schema(self) -> dict[str, Any]:
|
||||
"""The schema subdict exported under this extension's name."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Drop every registration held by this extension."""
|
||||
|
||||
|
||||
_extensions: dict[str, RegistryExtension] = {}
|
||||
@@ -146,10 +147,7 @@ def get_registry() -> dict[str, Any]:
|
||||
"compositions": _compositions.copy(),
|
||||
}
|
||||
for name, ext in _extensions.items():
|
||||
# Extensions optionally expose their backing dict via .all()
|
||||
# (Protocol doesn't require it; only schema() and clear() are mandatory)
|
||||
if hasattr(ext, "all"):
|
||||
out[name] = ext.all()
|
||||
out[name] = ext.all()
|
||||
return out
|
||||
|
||||
|
||||
|
||||
121
cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2
Normal file
121
cores/mizan-python/src/mizan_core/templates/ir/document.kdl.j2
Normal file
@@ -0,0 +1,121 @@
|
||||
{% macro literal(lit) %}
|
||||
{%- if lit.kind == "string" -%}
|
||||
{{ lit.value | kdl }}
|
||||
{%- elif lit.kind == "bool" -%}
|
||||
{{ lit.value | kdlbool }}
|
||||
{%- else -%}
|
||||
{{ lit.value }}
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
{% macro shape(node, depth) %}
|
||||
{%- set pad = " " * depth %}
|
||||
{%- if node.kind == "primitive" %}
|
||||
{{ pad }}primitive {{ node.name | kdl }}
|
||||
{%- elif node.kind == "ref" %}
|
||||
{{ pad }}ref {{ node.name | kdl }}
|
||||
{%- elif node.kind == "enum" %}
|
||||
{{ pad }}enum {{ node["values"] | map("kdl") | join(" ") }}
|
||||
{%- elif node.kind == "list" %}
|
||||
{{ pad }}list {
|
||||
{{ shape(node.of, depth + 1) }}
|
||||
{{ pad }}}
|
||||
{%- elif node.kind == "optional" %}
|
||||
{{ pad }}optional {
|
||||
{{ shape(node.of, depth + 1) }}
|
||||
{{ pad }}}
|
||||
{%- elif node.kind == "union" %}
|
||||
{{ pad }}union {
|
||||
{% for branch in node.branches %}
|
||||
{{ shape(branch, depth + 1) }}
|
||||
{% endfor %}
|
||||
{{ pad }}}
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
{% for type in types %}
|
||||
type {{ type.name | kdl }} {
|
||||
{% if type.kind == "struct" %}
|
||||
struct {
|
||||
{% for field in type.fields %}
|
||||
field {{ field.name | kdl }}{% if not field.required %} required={{ field.required | kdlbool }}{% endif %}{% if field.default %} default={{ literal(field.default) }}{% endif %} {
|
||||
{{ shape(field.shape, 3) }}
|
||||
}
|
||||
{% endfor %}
|
||||
}
|
||||
{% else %}
|
||||
alias {
|
||||
{{ shape(type.shape, 2) }}
|
||||
}
|
||||
{% endif %}
|
||||
}
|
||||
{% endfor %}
|
||||
{% if types and (functions or contexts or channels) %}
|
||||
|
||||
{% endif %}
|
||||
{% for fn in functions %}
|
||||
function {{ fn.name | kdl }} {
|
||||
camel {{ fn.camel | kdl }}
|
||||
has-input {{ fn.has_input | kdlbool }}
|
||||
{% if fn.input_type %}
|
||||
input {{ fn.input_type | kdl }}
|
||||
{% endif %}
|
||||
output {{ fn.output_type | kdl }}
|
||||
{% if fn.output_nullable %}
|
||||
output-nullable {{ fn.output_nullable | kdlbool }}
|
||||
{% endif %}
|
||||
transport {{ fn.transport | kdl }}
|
||||
{% if fn.context %}
|
||||
context {{ fn.context | kdl }}
|
||||
{% endif %}
|
||||
{% for affected in fn.affects %}
|
||||
affects {{ affected | kdl }}
|
||||
{% endfor %}
|
||||
{% for merged in fn.merge %}
|
||||
merge {{ merged | kdl }}
|
||||
{% endfor %}
|
||||
{% if fn.is_form %}
|
||||
is-form {{ fn.is_form | kdlbool }}
|
||||
{% if fn.form_name %}
|
||||
form-name {{ fn.form_name | kdl }}
|
||||
{% endif %}
|
||||
{% if fn.form_role %}
|
||||
form-role {{ fn.form_role | kdl }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
}
|
||||
{% endfor %}
|
||||
{% if functions and (contexts or channels) %}
|
||||
|
||||
{% endif %}
|
||||
{% for context in contexts %}
|
||||
context {{ context.name | kdl }} {
|
||||
{% for fn_name in context.functions %}
|
||||
function {{ fn_name | kdl }}
|
||||
{% endfor %}
|
||||
{% for param in context.params %}
|
||||
param {{ param.name | kdl }} {
|
||||
type {{ param.type | kdl }}
|
||||
required {{ param.required | kdlbool }}
|
||||
{% for sharer in param.shared_by %}
|
||||
shared-by {{ sharer | kdl }}
|
||||
{% endfor %}
|
||||
}
|
||||
{% endfor %}
|
||||
}
|
||||
{% endfor %}
|
||||
{% if contexts and channels %}
|
||||
|
||||
{% endif %}
|
||||
{% for channel in channels %}
|
||||
channel {{ channel.name | kdl }} {
|
||||
pascal-name {{ channel.pascal_name | kdl }}
|
||||
{% if channel.params %}
|
||||
params {{ channel.params | kdl }}
|
||||
{% endif %}
|
||||
{% if channel.client_message %}
|
||||
client-message {{ channel.client_message | kdl }}
|
||||
{% endif %}
|
||||
{% if channel.server_message %}
|
||||
server-message {{ channel.server_message | kdl }}
|
||||
{% endif %}
|
||||
}
|
||||
{% endfor %}
|
||||
@@ -1,10 +1,4 @@
|
||||
"""
|
||||
Type-introspection helpers shared across backend adapters.
|
||||
|
||||
Both mizan-django and mizan-fastapi need to walk @client-decorated function
|
||||
annotations the same way during schema export. Drift here breaks AFI parity,
|
||||
so the helpers live in core.
|
||||
"""
|
||||
"""Annotation-introspection helpers used when walking @client function signatures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,9 +22,8 @@ def extract_optional(annotation: Any) -> tuple[Any, bool]:
|
||||
Returns `(T, True)` for a union containing exactly one non-None member
|
||||
and `None` itself. For anything else, returns `(annotation, False)`.
|
||||
|
||||
Multi-arm unions like `A | B | None` are returned as-is — protocol-level
|
||||
discriminated unions aren't supported yet, and silently picking one arm
|
||||
would hide that.
|
||||
A multi-arm union like `A | B | None` is returned as-is — picking one arm
|
||||
would silently discard the others.
|
||||
"""
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union or isinstance(annotation, types.UnionType):
|
||||
@@ -80,10 +73,9 @@ def is_structured_output(annotation: Any) -> bool:
|
||||
|
||||
|
||||
def types_match_for_merge(slot_type: Any, value_type: Any) -> bool:
|
||||
"""True if a `value_type` mutation return can splice into a `slot_type` context slot.
|
||||
"""True if a `value_type` mutation return can splice into a `slot_type` slot.
|
||||
|
||||
Used by backend dispatch to resolve `@client(merge=ctx)` to a concrete
|
||||
function-name slot inside the context bundle. Three shapes match:
|
||||
Three shapes match:
|
||||
|
||||
- direct: slot is `T`, value is `T` → replace
|
||||
- upsert: slot is `list[T]`, value is `T` → upsert by id
|
||||
|
||||
107
cores/mizan-python/tests/test_ir.py
Normal file
107
cores/mizan-python/tests/test_ir.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Unit tests for KDL IR emission. Every assertion runs on a real KDL parse tree."""
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
import ckdl
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.ir import build_ir
|
||||
from mizan_core.registry import clear_registry, register
|
||||
|
||||
|
||||
class Prefs(BaseModel):
|
||||
live: bool = True
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
meta: Prefs = Prefs()
|
||||
label: str = "plain"
|
||||
quoted: str = 'a "b" \\ c\nd\te'
|
||||
retries: int = 3
|
||||
ratio: float = 0.5
|
||||
enabled: bool = False
|
||||
tags: list[str] = []
|
||||
mapping: dict[str, str] = {}
|
||||
note: str | None = None
|
||||
who: str
|
||||
|
||||
|
||||
def _struct_fields(document: ckdl.Document, type_name: str) -> dict[str, ckdl.Node]:
|
||||
"""Field nodes of the named struct, keyed by field name."""
|
||||
for node in document.nodes:
|
||||
if node.name == "type" and node.args[0] == type_name:
|
||||
for child in node.children:
|
||||
if child.name == "struct":
|
||||
return {field.args[0]: field for field in child.children}
|
||||
raise AssertionError(f"no struct type {type_name!r} in:\n{document}")
|
||||
|
||||
|
||||
class EmptyDocumentTests(TestCase):
|
||||
"""The IR of an empty registry."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def test_empty_registry_emits_zero_bytes(self):
|
||||
"""Nothing registered renders the empty document, not a blank line."""
|
||||
self.assertEqual(build_ir(), "")
|
||||
|
||||
def test_empty_document_parses_to_zero_nodes(self):
|
||||
"""The empty document is valid KDL carrying no nodes."""
|
||||
self.assertEqual(len(ckdl.parse(build_ir()).nodes), 0)
|
||||
|
||||
|
||||
class FieldDefaultTests(TestCase):
|
||||
"""Which Pydantic field defaults reach the document as KDL literals."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
|
||||
@client
|
||||
def get_settings(request) -> Settings:
|
||||
return Settings(who="anyone")
|
||||
|
||||
register(get_settings, "get_settings")
|
||||
self.fields = _struct_fields(
|
||||
ckdl.parse(build_ir()), "getSettingsOutput"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def test_scalar_defaults_survive_the_round_trip(self):
|
||||
"""bool, int, float and str defaults parse back to the Python values."""
|
||||
self.assertEqual(self.fields["label"].properties["default"], "plain")
|
||||
self.assertEqual(self.fields["retries"].properties["default"], 3)
|
||||
self.assertEqual(self.fields["ratio"].properties["default"], 0.5)
|
||||
self.assertEqual(self.fields["enabled"].properties["default"], False)
|
||||
|
||||
def test_string_default_escapes_round_trip(self):
|
||||
"""Quotes, backslashes and control characters survive KDL escaping."""
|
||||
self.assertEqual(
|
||||
self.fields["quoted"].properties["default"], Settings.model_fields["quoted"].default
|
||||
)
|
||||
|
||||
def test_model_valued_default_carries_no_literal(self):
|
||||
"""A nested-model default has no KDL scalar form, so no `default` is emitted."""
|
||||
meta = self.fields["meta"]
|
||||
self.assertNotIn("default", meta.properties)
|
||||
self.assertIs(meta.properties["required"], False)
|
||||
|
||||
def test_container_defaults_carry_no_literal(self):
|
||||
"""List and dict defaults have no KDL scalar form either."""
|
||||
self.assertNotIn("default", self.fields["tags"].properties)
|
||||
self.assertNotIn("default", self.fields["mapping"].properties)
|
||||
|
||||
def test_none_default_carries_no_literal(self):
|
||||
"""`= None` leaves the optional shape to say the field may be absent."""
|
||||
self.assertNotIn("default", self.fields["note"].properties)
|
||||
|
||||
def test_required_field_has_no_required_property(self):
|
||||
"""A required field is the default, so the property is left off entirely."""
|
||||
self.assertNotIn("required", self.fields["who"].properties)
|
||||
self.assertNotIn("default", self.fields["who"].properties)
|
||||
Reference in New Issue
Block a user