Split the registry — function/composition core + backend extensions

The original registry tangled function, channel, composition, and form
registration in a single file with polymorphic register() dispatch.
That predates the household discipline; it was the design that was
supposed to ship but didn't. Re-implementing the original intent.

cores/mizan-python/src/mizan_core/registry.py (new):
- _functions, _compositions dicts
- register() — ServerFunction-only, no polymorphic dispatch
- register_as(), register_compose()
- register_extension(name, extension) — hook interface
- get_function/get_compose/get_all_functions/get_all_compositions
- get_contexts, get_context_groups
- get_registry, get_schema — aggregate extension contributions
- validate_registry, clear_registry — cascade-clear extensions

RegistryExtension Protocol:
- schema() returns the extension's schema subdict (keyed under its name)
- clear() resets extension state (called by clear_registry)

mizan-django/src/mizan/channels/__init__.py:
- _ChannelsExtension wraps the channel _registry, plugs into core via
  register_extension('channels', ...). Schema output preserves the
  same shape codegen consumed before (snake_case keys, type+bidirectional).

mizan-django/src/mizan/forms/__init__.py:
- register_form() and get_forms() helpers moved here (were in setup/registry.py)
- Both use mizan_core.registry under the hood. Forms don't need a
  separate extension because form sub-functions register as regular
  ServerFunctions with meta.form set.

mizan-django/src/mizan/setup/registry.py: deleted.
mizan-django/src/mizan/setup/__init__.py: re-exports the registry helpers
from mizan_core.registry / mizan.channels / mizan.forms — the Django
adapter's curated public API surface stays stable for users.

Consumers updated: ~10 files imported `from mizan.setup.registry`;
all switched to direct imports from mizan_core.registry, mizan.channels,
or mizan.forms as appropriate. ChannelTests in test_core.py rewritten
to use mizan.channels.register directly (no more polymorphic
@register_as on ReactChannel subclasses).

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts edge-compat: 34/34 (cross-language pin holds)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:21:16 -04:00
parent 9150cdc5ee
commit 76fce2dc85
17 changed files with 363 additions and 416 deletions

View File

@@ -0,0 +1,230 @@
"""
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.
"""
from __future__ import annotations
from typing import Any, Callable, Protocol
# ─── Core registries ────────────────────────────────────────────────────────
_functions: dict[str, Any] = {}
_compositions: dict[str, Any] = {}
# ─── Extension hook ─────────────────────────────────────────────────────────
class RegistryExtension(Protocol):
"""
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.
"""
def schema(self) -> dict[str, Any]: ...
def clear(self) -> None: ...
_extensions: dict[str, RegistryExtension] = {}
def register_extension(name: str, extension: RegistryExtension) -> None:
"""
Register a backend extension. The extension contributes to schema
output under its name (e.g. 'channels', 'forms').
"""
_extensions[name] = extension
# ─── Function registration ──────────────────────────────────────────────────
def register(view_class: Any, name: str) -> Any:
"""
Register a server function class. Used by the @client decorator.
Idempotent for the same class (supports module reloads).
"""
view_class.name = name
if name in _functions:
existing = _functions[name]
if existing.__name__ == view_class.__name__:
_functions[name] = view_class
return view_class
raise ValueError(
f"Function '{name}' already registered by {existing.__name__}"
)
_functions[name] = view_class
return view_class
def register_as(name: str) -> Callable[[Any], Any]:
"""Decorator form of register()."""
def decorator(view_class: Any) -> Any:
return register(view_class, name)
return decorator
def register_compose(composed: Any, name: str) -> Any:
"""Register a composed context."""
if name in _compositions:
existing = _compositions[name]
if existing.name == composed.name:
_compositions[name] = composed
return composed
raise ValueError(
f"Composition '{name}' already registered by {existing.name}"
)
_compositions[name] = composed
return composed
# ─── Lookups ────────────────────────────────────────────────────────────────
def get_function(name: str) -> Any | None:
"""Get a registered server function by name."""
return _functions.get(name)
def get_compose(name: str) -> Any | None:
"""Get a registered composition by name."""
return _compositions.get(name)
def get_all_functions() -> dict[str, Any]:
"""Get all registered functions."""
return _functions.copy()
def get_all_compositions() -> dict[str, Any]:
"""Get all registered compositions."""
return _compositions.copy()
def get_contexts() -> dict[str, Any]:
"""Get all server functions marked as contexts (meta.context truthy)."""
return {
name: cls
for name, cls in _functions.items()
if getattr(cls, "_meta", {}).get("context")
}
def get_context_groups() -> dict[str, list[str]]:
"""Group function names by their context string."""
groups: dict[str, list[str]] = {}
for name, cls in _functions.items():
ctx = getattr(cls, "_meta", {}).get("context")
if ctx:
groups.setdefault(ctx, []).append(name)
return groups
def get_registry() -> dict[str, Any]:
"""
Full registry organized by type, including extension contributions.
Returns:
{
"functions": {...},
"compositions": {...},
"<extension>": {...}, # one per registered extension
}
"""
out: dict[str, Any] = {
"functions": _functions.copy(),
"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()
return out
def get_schema() -> dict[str, Any]:
"""
Export the unified schema for codegen consumption.
Aggregates function and composition schemas plus contributions from
each registered backend extension under its name.
"""
schema: dict[str, Any] = {
"functions": {
name: cls.get_schema_export() for name, cls in _functions.items()
},
"compositions": {
name: {
"name": composed.name,
"type": "compose",
"meta": composed._meta,
"children": composed._meta.get("children", []),
"leaves": composed._meta.get("leaves", []),
}
for name, composed in _compositions.items()
},
}
for name, ext in _extensions.items():
schema[name] = ext.schema()
return schema
# ─── Validation ─────────────────────────────────────────────────────────────
def validate_registry() -> list[str]:
"""
Check that all `affects` targets resolve to known contexts or functions.
Emits warnings for unresolved targets (e.g. typos in string-based affects).
Returns the list of warning messages (empty if all resolve).
"""
import warnings
issues: list[str] = []
groups = get_context_groups()
all_fn_names = set(_functions.keys())
for fn_name, fn_cls in _functions.items():
meta = getattr(fn_cls, "_meta", {})
affects = meta.get("affects")
if not affects:
continue
for target in affects:
target_name = target.get("name", "")
target_type = target.get("type", "")
if target_type == "context" and target_name not in groups:
issues.append(
f"@client function '{fn_name}' declares affects='{target_name}', "
f"but no context named '{target_name}' is registered."
)
elif target_type == "function" and target_name not in all_fn_names:
issues.append(
f"@client function '{fn_name}' targets function '{target_name}', "
f"but no function named '{target_name}' is registered."
)
for msg in issues:
warnings.warn(msg, stacklevel=2)
return issues
# ─── Clear (testing) ────────────────────────────────────────────────────────
def clear_registry() -> None:
"""Clear all registrations, including extension state. For testing."""
_functions.clear()
_compositions.clear()
for ext in _extensions.values():
ext.clear()