Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:
#[derive(Mizan, Serialize, Deserialize)]
pub struct ProfileOutput { pub user_id: i64, pub name: String }
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }
…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.
New crates:
- cores/mizan-rust/ (Cargo: mizan-core) — IR types, KDL emitter, traits, registry,
runtime (compute_invalidation / compute_merges
ported from mizan-fastapi), graph_check with
structural type-matching
- cores/mizan-rust-macros/ (Cargo: mizan-macros) — #[derive(Mizan)], #[mizan::context],
#[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum) — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/ — AFI fixture port + server / export-ir binaries
Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
now sort alphabetically in both Python and Rust emitters. The IR is a
contract; linkme doesn't preserve declaration order, so canonical sort
is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
the #[mizan::client] macro registers canonical-named entries from
fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
by name — matches the Python types_match_for_merge semantics.
Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py — 12/12 probes against FastAPI + Rust (EXIT=0)
Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
mizan-django/setup/discovery.py (referenced a function that no longer
exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
probe parity (wire-parity harness now polls /api/mizan/session/ on both
backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
don't need per-crate gitignore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.3 KiB
Python
136 lines
4.3 KiB
Python
"""Three-way wire-parity check.
|
|
|
|
For each backend (FastAPI, Rust axum):
|
|
1. Boot the fixture server on a free port.
|
|
2. Poll /api/mizan/session/ until the server responds.
|
|
3. Run the Rust `drive_kernel` binary (raw kernel calls) against it.
|
|
4. Run the Rust `drive_emitted` binary (typed codegen functions) against it.
|
|
5. Tear the server down.
|
|
|
|
Any non-zero driver exit propagates as the script's exit code. Adding the
|
|
Rust backend here proves that mizan-axum honors the same wire contract as
|
|
mizan-fastapi — same JSON shapes, same invalidate/merge semantics — beyond
|
|
the static IR equivalence the codegen-parity test gates.
|
|
|
|
Readiness probe is `/api/mizan/session/` (Mizan-protocol-shaped) rather
|
|
than `/openapi.json` (FastAPI-feature-shaped) so the harness reads the
|
|
same surface across backends.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
AFI_DIR = REPO_ROOT / "tests" / "afi"
|
|
RUST_DIR = REPO_ROOT / "tests" / "rust"
|
|
RUST_APP_DIR = AFI_DIR / "rust_app"
|
|
BOOT_TIMEOUT_S = 15.0
|
|
POLL_INTERVAL_S = 0.25
|
|
|
|
|
|
def pick_free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def wait_for_server(port: int, timeout_s: float, label: str) -> bool:
|
|
deadline = time.monotonic() + timeout_s
|
|
url = f"http://127.0.0.1:{port}/api/mizan/session/"
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=1.0) as resp:
|
|
if resp.status == 200:
|
|
return True
|
|
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
|
sys.stderr.write(f"[wire_parity:{label}] waiting: {type(e).__name__}\n")
|
|
time.sleep(POLL_INTERVAL_S)
|
|
return False
|
|
|
|
|
|
def run_driver(name: str, base_url: str, label: str) -> int:
|
|
sys.stdout.write(f"\n=== {label} :: {name} ===\n")
|
|
sys.stdout.flush()
|
|
return subprocess.run(
|
|
["cargo", "run", "--quiet", "--bin", name, "--", base_url],
|
|
cwd=RUST_DIR,
|
|
).returncode
|
|
|
|
|
|
def boot_fastapi(port: int) -> subprocess.Popen:
|
|
return subprocess.Popen(
|
|
[
|
|
"uv", "run", "uvicorn", "fastapi_app:make_app",
|
|
"--factory", "--port", str(port), "--log-level", "warning",
|
|
],
|
|
cwd=AFI_DIR,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
|
|
def boot_rust(port: int) -> subprocess.Popen:
|
|
return subprocess.Popen(
|
|
[
|
|
"cargo", "run", "--quiet", "--release",
|
|
"--manifest-path", str(RUST_APP_DIR / "Cargo.toml"),
|
|
"--bin", "server",
|
|
],
|
|
env={**os.environ, "PORT": str(port)},
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
|
|
def run_against(label: str, server: subprocess.Popen, port: int) -> int:
|
|
failures = 0
|
|
try:
|
|
if not wait_for_server(port, BOOT_TIMEOUT_S, label):
|
|
sys.stderr.write(f"[wire_parity:{label}] server boot timed out after {BOOT_TIMEOUT_S}s\n")
|
|
if server.stderr:
|
|
tail = server.stderr.read(4096)
|
|
if tail:
|
|
sys.stderr.write(tail.decode("utf-8", errors="replace"))
|
|
return 1
|
|
base_url = f"http://127.0.0.1:{port}/api/mizan"
|
|
for driver in ("drive_kernel", "drive_emitted"):
|
|
rc = run_driver(driver, base_url, label)
|
|
if rc != 0:
|
|
sys.stderr.write(f"[wire_parity:{label}] {driver} exited {rc}\n")
|
|
failures += 1
|
|
finally:
|
|
server.terminate()
|
|
try:
|
|
server.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
server.kill()
|
|
server.wait()
|
|
return failures
|
|
|
|
|
|
def main() -> int:
|
|
total_failures = 0
|
|
|
|
fastapi_port = pick_free_port()
|
|
sys.stdout.write(f"[wire_parity] booting fastapi on port {fastapi_port}\n")
|
|
total_failures += run_against("fastapi", boot_fastapi(fastapi_port), fastapi_port)
|
|
|
|
rust_port = pick_free_port()
|
|
sys.stdout.write(f"[wire_parity] booting rust on port {rust_port}\n")
|
|
total_failures += run_against("rust", boot_rust(rust_port), rust_port)
|
|
|
|
return 0 if total_failures == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|