mizan-tauri + Pydantic-aware codegen: Tauri-as-Mizan-backend substrate
Tauri now joins FastAPI/Django/axum as a first-class Mizan backend. The
React frontend calls Mizan-registered functions through Tauri's IPC
with the same {result, invalidate, merge} envelope the HTTP path uses;
the schema flows Pydantic → decoru → Rust → KDL → TS in one
mizan-generate invocation.
New packages:
* backends/mizan-tauri — Tauri plugin exposing a single `mizan_invoke`
command that routes through mizan-core's FUNCTIONS / CONTEXTS
registries. No per-function tauri::command; the linkme slice IS the
dispatch table.
* frontends/mizan-tauri-transport — TS package exporting
tauriTransport() that wraps invoke('plugin:mizan|mizan_invoke', ...)
and re-shapes errors into MizanError. Pairs with mizan-tauri.
@mizan/base — pluggable transport:
* Adds MizanTransport interface + transport config field.
* Existing fetch-based body factored into httpTransport() (default).
* mizanCall/mizanFetch delegate to config.transport; merge/invalidate
side-effects stay in the kernel (transport-agnostic).
* Consumers swap via configure({ transport: tauriTransport() }).
mizan-codegen — Rust source + Pydantic pre-step:
* [source.rust] runs a Cargo bin (cargo run --bin <name>) and parses
KDL from stdout. The bin uses mizan_core::build_ir() after
force-linking the consumer's #[derive(Mizan)] / #[mizan::client]
registrations.
* [source.rust.pydantic] is an optional pre-step that pipes an
embedded Python bridge (scripts/run_decoru.py) to python and writes
decoru-emitted Rust types into the consumer crate. The bridge
auto-discovers BaseModel subclasses AND Enum subclasses
(last-variant-is-default convention so decoru's impl Default keeps
compiling against enum-typed fields without explicit Pydantic
defaults).
* Pure-Rust usage stays intact — omit pydantic block and write Rust
types by hand.
mizan-macros:
* #[mizan::client] now supports Result<T, MizanError> returns. The
dispatch wrapper `?`-unwraps the user fn so server-side errors
surface as the protocol's standard {code, message, details?}
envelope; T-returning functions stay unchanged.
* #[derive(Mizan)] strips the r# raw-identifier prefix and honors
field-level #[serde(rename = "...")] when emitting IR field names.
Matches serde's wire shape — fixes IR-vs-JSON drift for Rust-keyword
fields (e.g. `r#type` → `type`).
react.tsx template:
* Conditionally emits context-related imports / useContextSubscription
helper based on has_global || !named_contexts.is_empty(). Consumers
without contexts (mutation/RPC-only apps like claude-manage) no
longer get dead imports that trip noUnusedLocals.
Verified end-to-end: cargo build clean across mizan-tauri,
mizan-codegen, AFI rust_app; AFI three-way KDL parity tests pass;
claude-manage migration drives the full stack (Pydantic schema →
generated TS api → Tauri-IPC transport → mizan-core dispatch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
136
protocol/mizan-codegen/scripts/run_decoru.py
Normal file
136
protocol/mizan-codegen/scripts/run_decoru.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pydantic → Rust codegen helper invoked by mizan-codegen's
|
||||
`[source.rust.pydantic]` step.
|
||||
|
||||
Reads a JSON payload from argv[1] with keys:
|
||||
- module: Python module to import (e.g. "claude_manage.schema")
|
||||
- output: Path to write the generated Rust file
|
||||
- derives: List of derive identifiers to apply to every emitted item
|
||||
- header: Optional file prefix (e.g. an AUTO-GENERATED warning)
|
||||
|
||||
Discovers every BaseModel subclass declared in the module (handled by
|
||||
decoru) AND every Enum subclass declared there (handled inline — decoru
|
||||
itself is scoped to BaseModel). Writes one Rust file containing both.
|
||||
|
||||
Bundled with the mizan-codegen binary (include_str!) and piped to
|
||||
`python -` at codegen time — no install step beyond decoru being
|
||||
importable in the python environment.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel # type: ignore[import-untyped]
|
||||
|
||||
from decoru import emit_rust_struct, walk_pydantic_model # type: ignore[import-untyped]
|
||||
|
||||
|
||||
def _declared_in(module, obj) -> bool:
|
||||
return getattr(obj, "__module__", None) == module.__name__
|
||||
|
||||
|
||||
def discover_models(module) -> list[type[BaseModel]]:
|
||||
"""BaseModel subclasses declared in this module. Imported helpers are
|
||||
skipped — only own-module declarations qualify."""
|
||||
return [
|
||||
obj
|
||||
for _, obj in inspect.getmembers(module, inspect.isclass)
|
||||
if issubclass(obj, BaseModel)
|
||||
and obj is not BaseModel
|
||||
and _declared_in(module, obj)
|
||||
]
|
||||
|
||||
|
||||
def discover_enums(module) -> list[type[Enum]]:
|
||||
"""Enum subclasses declared in this module. Filters out the
|
||||
framework's own Enum class and anything imported from elsewhere."""
|
||||
return [
|
||||
obj
|
||||
for _, obj in inspect.getmembers(module, inspect.isclass)
|
||||
if issubclass(obj, Enum) and obj is not Enum and _declared_in(module, obj)
|
||||
]
|
||||
|
||||
|
||||
def _pascal_case(s: str) -> str:
|
||||
return "".join(part.capitalize() for part in s.split("_") if part)
|
||||
|
||||
|
||||
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
|
||||
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
|
||||
# emits `impl Default` unconditionally on every BaseModel, so any
|
||||
# enum-typed field that lacks a Pydantic default must still satisfy
|
||||
# `EntryType::default()`. Forcing #[default] on the last member keeps
|
||||
# the generated structs compilable without per-enum config.
|
||||
_ENUM_TEMPLATE = textwrap.dedent("""\
|
||||
#[derive({derives})]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum {name} {{
|
||||
{variants}
|
||||
}}
|
||||
""")
|
||||
|
||||
|
||||
def _render_variant(member: Enum, *, is_default: bool) -> str:
|
||||
pascal = _pascal_case(member.name)
|
||||
default_attr = " #[default]\n" if is_default else ""
|
||||
return f"{default_attr} {pascal},"
|
||||
|
||||
|
||||
def emit_rust_enum(enum_class: type[Enum], derives: tuple[str, ...]) -> str:
|
||||
"""Render a Rust enum with PascalCase variants from Python member
|
||||
names. Pairs `#[serde(rename_all = "snake_case")]` so the wire form
|
||||
matches each member's `value`. Adds `Default` to the derives and
|
||||
marks the last member `#[default]` — see `_ENUM_TEMPLATE` for the
|
||||
rationale."""
|
||||
name = enum_class.__name__
|
||||
members = list(enum_class)
|
||||
full_derives = ", ".join((*derives, "Default"))
|
||||
variants = "\n".join(
|
||||
_render_variant(m, is_default=(i == len(members) - 1))
|
||||
for i, m in enumerate(members)
|
||||
)
|
||||
return _ENUM_TEMPLATE.format(derives=full_derives, name=name, variants=variants)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("run_decoru.py: missing JSON payload argument\n")
|
||||
return 2
|
||||
|
||||
payload = json.loads(sys.argv[1])
|
||||
module_name: str = payload["module"]
|
||||
output_path = Path(payload["output"]).resolve()
|
||||
derives = tuple(payload.get("derives", ()))
|
||||
header = payload.get("header") or ""
|
||||
|
||||
sys.path.insert(0, str(Path.cwd()))
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
enums = discover_enums(module)
|
||||
models = discover_models(module)
|
||||
if not enums and not models:
|
||||
sys.stderr.write(
|
||||
f"run_decoru.py: no Enum or BaseModel subclasses declared in {module_name!r}\n"
|
||||
)
|
||||
return 3
|
||||
|
||||
enum_blocks = [emit_rust_enum(e, derives) for e in enums]
|
||||
struct_blocks = [emit_rust_struct(walk_pydantic_model(m), derives=derives) for m in models]
|
||||
body = "\n".join((*enum_blocks, *struct_blocks))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(header + body)
|
||||
|
||||
sys.stderr.write(
|
||||
f"run_decoru.py: wrote {len(enums)} enum(s) + {len(models)} struct(s) to {output_path}\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -62,6 +62,13 @@ pub struct SourceConfig {
|
||||
|
||||
#[serde(default)]
|
||||
pub django: Option<DjangoSource>,
|
||||
|
||||
/// Canonical "Pydantic + Rust" DX path. The Rust crate is the IR
|
||||
/// authority; an optional `pydantic` sub-block invokes decoru as a
|
||||
/// pre-step to author Rust types from Pydantic models. Pure-Rust
|
||||
/// usage (no Pydantic) just omits the sub-block.
|
||||
#[serde(default)]
|
||||
pub rust: Option<RustSource>,
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +105,107 @@ pub struct DjangoSource {
|
||||
}
|
||||
|
||||
|
||||
/// `[source.rust]` — spawn a Cargo binary that emits the Mizan IR (KDL)
|
||||
/// to stdout. The binary uses `mizan_core::build_ir()` after force-linking
|
||||
/// the consumer crate's `#[derive(Mizan)]` types and `#[mizan::client]`
|
||||
/// functions.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RustSource {
|
||||
/// Path to the consumer's Cargo.toml, relative to the codegen config
|
||||
/// directory. Defaults to `Cargo.toml` (i.e. config_dir/Cargo.toml).
|
||||
#[serde(default)]
|
||||
pub manifest_path: Option<PathBuf>,
|
||||
|
||||
/// Name of the binary under `[[bin]]` that exports the IR. Defaults
|
||||
/// to `emit-mizan-ir` — the convention this substrate documents.
|
||||
#[serde(default = "default_rust_bin")]
|
||||
pub bin: String,
|
||||
|
||||
/// Cargo features to enable when building the bin.
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
|
||||
/// Build in release mode. Defaults to false (dev mode is faster for
|
||||
/// codegen, and the binary is throwaway).
|
||||
#[serde(default)]
|
||||
pub release: bool,
|
||||
|
||||
/// Environment overrides for the cargo subprocess.
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
|
||||
/// Optional pre-step — invoke decoru on a Pydantic source module
|
||||
/// before running the Cargo bin. When present, the pipeline becomes:
|
||||
/// 1. python + decoru → write Rust types to `pydantic.output`
|
||||
/// 2. cargo run --bin <bin> → emit IR to stdout
|
||||
/// Omit for pure-Rust usage (hand-authored or otherwise-generated
|
||||
/// Rust types with `#[derive(Mizan)]`).
|
||||
#[serde(default)]
|
||||
pub pydantic: Option<PydanticPreStep>,
|
||||
}
|
||||
|
||||
|
||||
fn default_rust_bin() -> String {
|
||||
"emit-mizan-ir".to_string()
|
||||
}
|
||||
|
||||
|
||||
/// Pydantic → Rust pre-step. Runs an embedded Python helper that walks
|
||||
/// the named module for `BaseModel` subclasses and invokes decoru's
|
||||
/// `walk_pydantic_model` + `emit_rust_struct` to produce a Rust file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PydanticPreStep {
|
||||
/// Python module name to import (e.g. `claude_manage.schema`).
|
||||
pub module: String,
|
||||
|
||||
/// Path to write the generated Rust file, relative to the codegen
|
||||
/// config directory.
|
||||
pub output: PathBuf,
|
||||
|
||||
/// Working directory for the python subprocess, relative to the
|
||||
/// codegen config directory. Defaults to the config directory itself.
|
||||
/// The script prepends this to `sys.path` so the module imports.
|
||||
#[serde(default)]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Python executable. Defaults to `python`.
|
||||
#[serde(default)]
|
||||
pub python: Option<String>,
|
||||
|
||||
/// Full command override (e.g. `["uv", "run", "python"]`). Wins over
|
||||
/// `python` when present.
|
||||
#[serde(default)]
|
||||
pub command: Option<Vec<String>>,
|
||||
|
||||
/// Derive macros to apply to every generated struct. The default
|
||||
/// matches the Mizan-canonical set used in `cores/rust/blazr/session`
|
||||
/// — serde + mizan_core::Mizan for end-to-end RPC participation.
|
||||
#[serde(default = "default_pydantic_derives")]
|
||||
pub derives: Vec<String>,
|
||||
|
||||
/// Optional prelude inserted at the top of the generated file
|
||||
/// (typically a "// AUTO-GENERATED" warning + `use` statements for
|
||||
/// referenced types not produced by decoru itself).
|
||||
#[serde(default)]
|
||||
pub header: Option<String>,
|
||||
|
||||
/// Environment overrides for the python subprocess.
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
|
||||
fn default_pydantic_derives() -> Vec<String> {
|
||||
vec![
|
||||
"Debug".to_string(),
|
||||
"Clone".to_string(),
|
||||
"::serde::Serialize".to_string(),
|
||||
"::serde::Deserialize".to_string(),
|
||||
"::mizan_core::Mizan".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum RustKernelSpec {
|
||||
|
||||
@@ -4,24 +4,48 @@
|
||||
//! Backends:
|
||||
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
|
||||
//! - Django: `python manage.py export_mizan_ir`
|
||||
//! - Rust: `cargo run --bin <bin>` (consumer-side binary that
|
||||
//! force-links its `#[derive(Mizan)]` types and
|
||||
//! `#[mizan::client]` functions, then calls
|
||||
//! `mizan_core::build_ir()`).
|
||||
//!
|
||||
//! The Rust source supports an optional `[source.rust.pydantic]`
|
||||
//! pre-step that invokes decoru on a Pydantic module to author the
|
||||
//! Rust types before the cargo bin runs — the "Pydantic + Rust"
|
||||
//! canonical DX.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::{Config, DjangoSource, FastapiSource};
|
||||
use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource};
|
||||
use crate::ir::{parse_ir, MizanIR};
|
||||
|
||||
|
||||
/// Embedded decoru bridge script — piped to `python -` at codegen time
|
||||
/// when `[source.rust.pydantic]` is set. The script imports decoru,
|
||||
/// walks the named module's BaseModel subclasses, and writes a Rust
|
||||
/// file. See `scripts/run_decoru.py` for the full body.
|
||||
const DECORU_BRIDGE_SCRIPT: &str = include_str!("../scripts/run_decoru.py");
|
||||
|
||||
|
||||
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
|
||||
let raw = if let Some(fa) = &config.source.fastapi {
|
||||
let raw = if let Some(rs) = &config.source.rust {
|
||||
if let Some(py) = &rs.pydantic {
|
||||
run_pydantic_prestep(py, config_dir)
|
||||
.context("running [source.rust.pydantic] pre-step")?;
|
||||
}
|
||||
run_rust(rs, config_dir)?
|
||||
} else if let Some(fa) = &config.source.fastapi {
|
||||
run_fastapi(fa, config_dir)?
|
||||
} else if let Some(dj) = &config.source.django {
|
||||
run_django(dj, config_dir)?
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"config.source must declare either [source.fastapi] or [source.django]"
|
||||
"config.source must declare one of [source.rust], [source.fastapi], or [source.django]"
|
||||
));
|
||||
};
|
||||
|
||||
@@ -111,6 +135,96 @@ fn run_subprocess(
|
||||
}
|
||||
|
||||
|
||||
fn run_rust(src: &RustSource, config_dir: &Path) -> Result<String> {
|
||||
let manifest = config_dir.join(
|
||||
src.manifest_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from("Cargo.toml")),
|
||||
);
|
||||
|
||||
let mut args: Vec<String> = vec![
|
||||
"run".to_string(),
|
||||
"--quiet".to_string(),
|
||||
"--manifest-path".to_string(),
|
||||
manifest.to_string_lossy().into_owned(),
|
||||
"--bin".to_string(),
|
||||
src.bin.clone(),
|
||||
];
|
||||
if src.release {
|
||||
args.push("--release".to_string());
|
||||
}
|
||||
if !src.features.is_empty() {
|
||||
args.push("--features".to_string());
|
||||
args.push(src.features.join(","));
|
||||
}
|
||||
|
||||
run_subprocess("cargo", &args, config_dir, &src.env, "Rust IR export")
|
||||
}
|
||||
|
||||
|
||||
fn run_pydantic_prestep(src: &PydanticPreStep, config_dir: &Path) -> Result<()> {
|
||||
let cwd = match &src.cwd {
|
||||
Some(rel) => config_dir.join(rel),
|
||||
None => config_dir.to_path_buf(),
|
||||
};
|
||||
|
||||
let output_abs = if src.output.is_absolute() {
|
||||
src.output.clone()
|
||||
} else {
|
||||
config_dir.join(&src.output)
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"module": &src.module,
|
||||
"output": output_abs.to_string_lossy(),
|
||||
"derives": &src.derives,
|
||||
"header": &src.header,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let (program, mut args) = resolve_command(&src.command, &src.python);
|
||||
// `python -` reads the script body from stdin; the JSON payload is
|
||||
// passed as argv[1] (which lands on sys.argv[1] inside the script).
|
||||
args.push("-".to_string());
|
||||
args.push(payload);
|
||||
|
||||
let mut cmd = Command::new(&program);
|
||||
cmd.args(&args).current_dir(&cwd);
|
||||
for (k, v) in &src.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::inherit());
|
||||
cmd.stderr(Stdio::inherit());
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning decoru bridge ({program})"))?;
|
||||
|
||||
{
|
||||
let stdin = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow!("failed to acquire decoru bridge stdin"))?;
|
||||
stdin
|
||||
.write_all(DECORU_BRIDGE_SCRIPT.as_bytes())
|
||||
.context("piping decoru bridge script to python")?;
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.context("waiting for decoru bridge to complete")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"[source.rust.pydantic]: decoru bridge exited with status {:?}",
|
||||
status.code()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Library helper: parse a KDL IR from a string.
|
||||
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
|
||||
parse_ir(source)
|
||||
|
||||
@@ -2,30 +2,38 @@
|
||||
|
||||
// AUTO-GENERATED by mizan — do not edit
|
||||
|
||||
{% set has_contexts = has_global || !named_contexts.is_empty() -%}
|
||||
import {
|
||||
{% if has_contexts -%}
|
||||
createContext,
|
||||
{% endif -%}
|
||||
useCallback,
|
||||
{% if has_contexts -%}
|
||||
useContext,
|
||||
useEffect,
|
||||
{% endif -%}
|
||||
useRef,
|
||||
useState,
|
||||
{% if has_contexts -%}
|
||||
useSyncExternalStore,
|
||||
{% endif -%}
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
configure,
|
||||
initSession,
|
||||
mizanCall,
|
||||
mizanFetch,
|
||||
MizanError,
|
||||
{% if has_contexts -%}
|
||||
registerContext,
|
||||
type ContextState,
|
||||
{% endif -%}
|
||||
} from '@mizan/base'
|
||||
|
||||
{% if !stage1_imports.is_empty() -%}
|
||||
import { {{ stage1_imports|join(", ") }} } from './index'
|
||||
|
||||
{% endif -%}
|
||||
{% if has_contexts -%}
|
||||
// Internal — runs inside a Provider, registers with the kernel exactly once.
|
||||
function useContextSubscription<T>(
|
||||
name: string,
|
||||
@@ -46,6 +54,7 @@ function useContextSubscription<T>(
|
||||
|
||||
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
// Internal — wraps an imperative call() with isPending / error state.
|
||||
interface MutationHook<TArgs, TResult> {
|
||||
|
||||
Reference in New Issue
Block a user