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:
@@ -1,18 +1,9 @@
|
||||
"""
|
||||
Server functions and channels for integration tests.
|
||||
|
||||
Registers everything the React integration test suite expects:
|
||||
- echo, add (HTTP + WebSocket RPC)
|
||||
- login, signup, add_email forms
|
||||
- chat, notifications, presence channels
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.channels import Channel
|
||||
from mizan.setup import register, register_form, register_as
|
||||
from mizan.channels import register as register_channel
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
@@ -85,7 +76,7 @@ class LoginForm(forms.Form):
|
||||
|
||||
|
||||
def handle_login(request, form):
|
||||
"""Login form submit handler."""
|
||||
"""Authenticate the credentials and open a session."""
|
||||
from django.contrib.auth import authenticate, login
|
||||
|
||||
user = authenticate(
|
||||
@@ -109,7 +100,7 @@ class SignupForm(forms.Form):
|
||||
|
||||
|
||||
def handle_signup(request, form):
|
||||
"""Signup form submit handler."""
|
||||
"""Create the user, surfacing any creation failure as a form error."""
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
@@ -118,11 +109,12 @@ def handle_signup(request, form):
|
||||
email=form.cleaned_data["email"],
|
||||
password=form.cleaned_data["password1"],
|
||||
)
|
||||
return {"success": True, "data": {"user_id": user.pk}}
|
||||
except Exception as e:
|
||||
form.add_error(None, str(e))
|
||||
return None
|
||||
|
||||
return {"success": True, "data": {"user_id": user.pk}}
|
||||
|
||||
|
||||
register_form(SignupForm, "signup", submit_handler=handle_signup)
|
||||
|
||||
@@ -139,36 +131,35 @@ register_form(AddEmailForm, "add_email")
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class ChatChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
return bool(params and params.room)
|
||||
|
||||
def group(self, params=None):
|
||||
room = params.room if params else "default"
|
||||
return f"chat_{room}"
|
||||
return f"chat_{params.room}"
|
||||
|
||||
def receive(self, params, msg):
|
||||
return self.DjangoMessage(text=msg.text)
|
||||
return self.ServerMessage(text=msg.text)
|
||||
|
||||
|
||||
register_channel(ChatChannel, "chat")
|
||||
|
||||
|
||||
class NotificationsChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class NotificationsChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
return self.user.is_authenticated
|
||||
|
||||
def group(self, params=None):
|
||||
return "notifications_global"
|
||||
@@ -177,12 +168,12 @@ class NotificationsChannel(ReactChannel):
|
||||
register_channel(NotificationsChannel, "notifications")
|
||||
|
||||
|
||||
class PresenceChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class PresenceChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
value: int
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
return self.user.is_authenticated
|
||||
|
||||
def group(self, params=None):
|
||||
return "presence_global"
|
||||
@@ -290,24 +281,34 @@ class Multiply(ServerFunction):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Error-producing Functions
|
||||
# Functions carrying error branches
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@client
|
||||
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise NotImplementedError("This feature is not yet implemented")
|
||||
|
||||
|
||||
register(not_implemented_fn, "not_implemented_fn")
|
||||
_ECHO_TRANSFORMS = {"upper": str.upper, "lower": str.lower}
|
||||
|
||||
|
||||
@client
|
||||
def buggy_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise RuntimeError("Unexpected internal failure")
|
||||
def echo_transform(request: HttpRequest, text: str, mode: str) -> EchoOutput:
|
||||
transform = _ECHO_TRANSFORMS.get(mode)
|
||||
if transform is None:
|
||||
raise NotImplementedError(f"echo mode {mode!r} has no transform")
|
||||
return EchoOutput(message=transform(text))
|
||||
|
||||
|
||||
register(buggy_fn, "buggy_fn")
|
||||
register(echo_transform, "echo_transform")
|
||||
|
||||
|
||||
class DivideOutput(BaseModel):
|
||||
quotient: float
|
||||
|
||||
|
||||
@client
|
||||
def divide(request: HttpRequest, numerator: int, denominator: int) -> DivideOutput:
|
||||
return DivideOutput(quotient=numerator / denominator)
|
||||
|
||||
|
||||
register(divide, "divide")
|
||||
|
||||
|
||||
@client
|
||||
@@ -384,16 +385,16 @@ class ItemForm(mizanFormMixin, forms.Form):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth-gated Channel
|
||||
# Staff-gated Channel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PrivateChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class PrivateChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return getattr(self.user, "is_authenticated", False)
|
||||
return self.user.is_staff
|
||||
|
||||
def group(self, params=None):
|
||||
return "private_global"
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""
|
||||
Django settings for the integration test backend.
|
||||
|
||||
Provides:
|
||||
- HTTP server functions (echo, add)
|
||||
- WebSocket channels (chat, notifications, presence)
|
||||
- JWT authentication
|
||||
- Form integration (login, signup, add_email)
|
||||
"""
|
||||
"""Django settings for the integration-test backend the E2E harness talks to."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# The directory holding manage.py and the app packages; mizan's client
|
||||
# discovery walks the installed apps beneath it.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = "integration-test-secret-key-not-for-production"
|
||||
|
||||
@@ -41,7 +38,7 @@ TEMPLATES = [
|
||||
"OPTIONS": {
|
||||
"worker": os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "..",
|
||||
"packages", "mizan-ssr", "src", "worker.tsx",
|
||||
"workers", "mizan-ssr", "src", "worker.tsx",
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user