Files
mizan/backends/mizan-django
Ryth Azhur 3aafec6dd4 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>
2026-07-27 14:03:19 -04:00
..

mizan-django

Django backend adapter for the Mizan protocol. One decorator on a server function. Typed React client generated. Invalidation automatic.

Install

uv add "mizan[channels]"

Setup

# settings.py
INSTALLED_APPS = ["mizan", "myapp", ...]

MIZAN_CACHE_SECRET   = "..."   # 32-byte HMAC signing key
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0"
MIZAN_MWT_SECRET     = "..."   # MWT signing key (separate from cache + JWT)
# urls.py
from django.urls import include, path

urlpatterns = [
    path("api/mizan/", include("mizan.urls")),
]
# asgi.py — for WebSocket / Channels support
from django.core.asgi import get_asgi_application
from mizan import wrap_asgi

application = wrap_asgi(get_asgi_application())

Define server functions

# myapp/clients.py
from mizan.client import client
from mizan.setup import register
from pydantic import BaseModel


class EchoOutput(BaseModel):
    message: str


@client
def echo(request, text: str) -> EchoOutput:
    return EchoOutput(message=text)


register(echo, "echo")

Auto-discover clients.py modules from each Django app:

# myapp/apps.py
from django.apps import AppConfig


class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self) -> None:
        from mizan.setup import mizan_clients
        mizan_clients("myapp")  # imports myapp/clients.py — triggers @client side effects

@client parameters

@client                              # plain RPC function
@client(context="global")            # singleton context — fetched once, SSR-hydrated
@client(context="user")              # named context — fetched per provider mount
@client(affects="user")              # mutation — invalidates the user context
@client(affects=user_profile)        # mutation — invalidates a specific function
@client(websocket=True)              # WebSocket transport (requires channels)
@client(auth=True)                   # requires authentication
@client(auth="staff")                # requires is_staff
@client(auth="superuser")            # requires is_superuser
@client(auth=lambda req: ...)        # custom predicate
@client(route="/profile/<id>/")      # view-path function (returns HttpResponse)
@client(rev=2)                       # cache revision (busts on bump)

Forms

Django Forms become server functions + typed React hooks with Zod validation:

from django import forms
from mizan.forms import mizanFormMixin, mizanFormMeta


class ContactForm(mizanFormMixin, forms.Form):
    mizan = mizanFormMeta(name="contact", title="Contact Us", submit_label="Send")

    name    = forms.CharField()
    email   = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)

    def on_submit_success(self, request):
        send_email(self.cleaned_data)
        return {"sent": True}

Auto-registers contact.schema, contact.validate, contact.submit. Frontend gets useContactForm().

Auth-provider forms (django-allauth login, signup, MFA, WebAuthn) live in the dedicated mizan-allauth repository, built on this mixin.

Channels

WebSocket-native RPC via a flag flip:

from pydantic import BaseModel
from mizan.channels import ReactChannel


class ChatChannel(ReactChannel):
    class Params(BaseModel):
        room: str

    class DjangoMessage(BaseModel):
        text: str
        user: str

    def authorize(self, params):
        return self.user.is_authenticated

    def group(self, params):
        return f"chat_{params.room}"

Frontend gets useChatChannel({ room }).

Generate the frontend

The codegen is the mizan-generate Rust binary (source at protocol/mizan-codegen/; protocol/mizan-generate/ is a thin npm launcher that dispatches to the platform binary). From your frontend project, point a mizan.toml at the Django backend and run the CLI:

# frontend/mizan.toml
output = "src/api"
targets = ["react"]

[source.django]
manage_path = "../backend/manage.py"
command = ["uv", "run", "python"]    # optional — defaults to ["python"]

[source.django.env]
PYTHONPATH = "../backend"
DJANGO_SETTINGS_MODULE = "myproject.settings"
mizan-generate --config mizan.toml

The codegen drives Django's management command (export_mizan_ir) under the hood, parses the emitted KDL IR, then emits Stage 1 (typed callXxx/fetchXxx over the runtime kernel) + Stage 2 (<MizanContext> provider, per-context providers, use{Hook}() hooks) into src/api/.

// app.tsx
import { MizanContext } from "./api"

export default function App({ children }) {
    return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
}
// any component
import { useEcho, useCurrentUser } from "./api"

const echo = useEcho()
echo.mutate({ text: "hi" }).then(r => console.log(r.message))

const user = useCurrentUser()  // global context — auto-fetched, auto-refreshed on mutation

Running tests

uv sync --extra dev --extra channels
uv run pytest

Architecture

mizan-django is one of two reference backend adapters (the other is backends/mizan-fastapi). Both implement the same Mizan protocol on top of the shared cores/mizan-python core (@client, registry, MWT, HMAC cache keys). See docs/AFI_ARCHITECTURE.md.