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:
230
cores/mizan-python/src/mizan_core/registry.py
Normal file
230
cores/mizan-python/src/mizan_core/registry.py
Normal 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()
|
||||
Reference in New Issue
Block a user