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:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -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]

View File

@@ -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,

View File

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

View File

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

View File

@@ -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,
)

View File

@@ -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

View 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 %}

View File

@@ -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

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