@ai-matrx/desktop-protocol 0.7.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/desktop-protocol",
3
- "version": "0.7.0",
3
+ "version": "0.11.0",
4
4
  "description": "The one wire contract between Matrx 2 (the desktop app) and every client that drives it — phone, web app, extension, server: zod schemas for the envelope and every cap.op, the 16-byte binary frame codec, a reconnecting request/stream/credit/reattach client, React hooks, and a generated JSON Schema + Pydantic twin that CI keeps byte-identical.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -28,6 +28,8 @@
28
28
  },
29
29
  "files": [
30
30
  "dist",
31
+ "generated/desktop-protocol.schema.json",
32
+ "scripts/gen_pydantic.py",
31
33
  "README.md",
32
34
  "LICENSE",
33
35
  "CHANGELOG.md"
@@ -0,0 +1,335 @@
1
+ """Generate the Pydantic v2 twin of @ai-matrx/desktop-protocol from its JSON Schema.
2
+
3
+ Called by ``pnpm generate`` (scripts/generate.ts) and by aidream's pytest contract check. Stdlib
4
+ only; the one external tool is datamodel-codegen, pinned and run through ``uvx`` so every machine
5
+ and CI job produces byte-identical output (``--formatters builtin``: no black/isort version drift).
6
+
7
+ Two post-processing steps make the generated module a faithful twin of the zod source:
8
+
9
+ 1. ``StrictModel`` — the generated base of every object model. datamodel-codegen renders an
10
+ optional, NON-nullable field (zod ``.optional()``) as ``X | None = None``, so Pydantic accepts
11
+ an explicit ``null`` that zod refuses (parity corpus case 19). The base refuses an explicit
12
+ ``None`` for every field whose default is ``None`` and whose type is not ``Any``. That rule is
13
+ exact because generate.ts refuses any schema property that is BOTH optional and nullable.
14
+ 2. ``OPS`` / ``EVENTS`` — the op and event registries (from the schema's ``x-ops``/``x-events``),
15
+ so the server routes and validates by the same table the TypeScript Router uses.
16
+ 3. ``SERVICES`` and, per supervised Python service (``x-services``), its handler ``Protocol``:
17
+ ``<Name>ServiceHandlers`` has one ``async`` method per op, typed with that op's generated
18
+ models, so ``pyright --strict`` fails a service whose handlers drift from the contract.
19
+ ``CONTRACT_SHA256`` is the schema file's hash, exchanged in the service hello.
20
+
21
+ Usage: gen_pydantic.py --schema <desktop-protocol.schema.json> --out <desktop_protocol.py>
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import hashlib
28
+ import json
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ from pathlib import Path
34
+
35
+ CODEGEN_VERSION = "0.83.0"
36
+ CODEGEN_ARGS = [
37
+ "--input-file-type",
38
+ "jsonschema",
39
+ "--output-model-type",
40
+ "pydantic_v2.BaseModel",
41
+ # The PLAN.md flags (accepted 2026-10-01):
42
+ "--strict-nullable",
43
+ "--extra-fields",
44
+ "forbid",
45
+ "--enum-field-as-literal",
46
+ "all",
47
+ "--use-annotated",
48
+ # Determinism:
49
+ "--target-python-version",
50
+ "3.13",
51
+ "--disable-timestamp",
52
+ "--formatters",
53
+ "builtin",
54
+ ]
55
+
56
+ HEADER = '''# ruff: noqa
57
+ # AUTO-GENERATED — DO NOT EDIT.
58
+ # Source: aidream/apps/shared/desktop-protocol (@ai-matrx/desktop-protocol, zod), via
59
+ # `pnpm generate` → generated/desktop-protocol.schema.json → datamodel-codegen {version}
60
+ # → scripts/gen_pydantic.py. `pnpm generate --check` and
61
+ # aidream/_generated/tests/test_desktop_protocol_contract.py fail on any hand edit.
62
+ '''
63
+
64
+ STRICT_BASE = '''
65
+
66
+ class StrictModel(BaseModel):
67
+ """Generated base of every object model: refuses an explicit ``null`` for an optional field.
68
+
69
+ zod ``.optional()`` means "may be absent", never "may be null"; datamodel-codegen still types
70
+ such a field ``X | None = None``. A field whose default is ``None`` and whose type is not
71
+ ``Any`` is optional-non-nullable by construction (generate.ts refuses optional+nullable), so
72
+ an explicit ``None`` for it is rejected exactly as zod rejects it.
73
+ """
74
+
75
+ @model_validator(mode="before")
76
+ @classmethod
77
+ def _refuse_explicit_null(cls, data: Any) -> Any:
78
+ if isinstance(data, dict):
79
+ for name, field in cls.model_fields.items():
80
+ key = field.alias or name
81
+ if (
82
+ key in data
83
+ and data[key] is None
84
+ and not field.is_required()
85
+ and field.default is None
86
+ and field.annotation is not Any
87
+ ):
88
+ raise ValueError(f"{key}: explicit null is not allowed (omit the field instead)")
89
+ return data
90
+
91
+
92
+ def _js_number(text: str) -> float | int:
93
+ value = float(text)
94
+ return int(value) if value.is_integer() and abs(value) <= 9007199254740991 else value
95
+
96
+
97
+ def validate_wire[M: BaseModel](model: type[M], raw: str | bytes) -> M:
98
+ """THE way to validate one wire message in Python — the verdict matches zod's exactly.
99
+
100
+ Strict mode (no "2" -> 2 coercion, as zod), plus JavaScript's one number type: JSON.parse
101
+ makes ``2.0`` the integer 2, so an integral float is an integer here too.
102
+ """
103
+ data = json.loads(raw, parse_float=_js_number)
104
+ return model.model_validate_json(json.dumps(data), strict=True)
105
+ '''
106
+
107
+
108
+ def run_codegen(schema: Path, out: Path) -> str:
109
+ cmd = [
110
+ "uvx",
111
+ "--from",
112
+ f"datamodel-code-generator=={CODEGEN_VERSION}",
113
+ "datamodel-codegen",
114
+ "--input",
115
+ str(schema),
116
+ "--output",
117
+ str(out),
118
+ *CODEGEN_ARGS,
119
+ ]
120
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
121
+ if proc.returncode != 0:
122
+ sys.stderr.write(proc.stdout + proc.stderr)
123
+ raise SystemExit(f"datamodel-codegen {CODEGEN_VERSION} failed (exit {proc.returncode})")
124
+ return out.read_text(encoding="utf-8")
125
+
126
+
127
+ def post_process(code: str, schema_doc: dict, contract_sha256: str) -> str:
128
+ lines = code.splitlines()
129
+ # Drop codegen's own banner (it names a temp path on some machines); ours replaces it.
130
+ while lines and lines[0].startswith("#"):
131
+ lines.pop(0)
132
+ code = "\n".join(lines).lstrip("\n") + "\n"
133
+
134
+ # Imports the base needs.
135
+ m = re.search(r"^from pydantic import (.+)$", code, flags=re.M)
136
+ if m is None:
137
+ raise SystemExit("gen_pydantic: no `from pydantic import` line — codegen output changed shape")
138
+ names = {n.strip() for n in m.group(1).split(",")} | {"BaseModel", "RootModel", "model_validator"}
139
+ code = code[: m.start()] + "from pydantic import " + ", ".join(sorted(names)) + code[m.end() :]
140
+ m = re.search(r"^from typing import (.+)$", code, flags=re.M)
141
+ if m is None:
142
+ raise SystemExit("gen_pydantic: no `from typing import` line — codegen output changed shape")
143
+ names = {n.strip() for n in m.group(1).split(",")} | {"Any", "Literal", "NamedTuple", "Protocol"}
144
+ code = code[: m.start()] + "from typing import " + ", ".join(sorted(names)) + code[m.end() :]
145
+
146
+ # `constr(...)` in a type position (record keys) is a call in a type expression — pyright
147
+ # rejects it. Rewrite to the equivalent Annotated[str, StringConstraints(...)].
148
+ code = rewrite_constr(code)
149
+
150
+ # Every object model inherits the strict base.
151
+ count = len(re.findall(r"^class \w+\(BaseModel\):", code, flags=re.M))
152
+ if count == 0:
153
+ raise SystemExit("gen_pydantic: no BaseModel classes found — codegen output changed shape")
154
+ code = re.sub(r"^(class \w+)\(BaseModel\):", r"\1(StrictModel):", code, flags=re.M)
155
+
156
+ # Insert the base right after the import block (before the first class).
157
+ first_class = re.search(r"^class ", code, flags=re.M)
158
+ assert first_class is not None
159
+ head, tail = code[: first_class.start()].rstrip("\n"), code[first_class.start() :]
160
+ if "from __future__ import annotations\n" not in head:
161
+ raise SystemExit("gen_pydantic: no __future__ import — codegen output changed shape")
162
+ head = head.replace("from __future__ import annotations\n", "from __future__ import annotations\n\nimport json\n", 1)
163
+ code = head + "\n" + STRICT_BASE + "\n\n" + tail
164
+
165
+ code = code.rstrip("\n") + "\n" + registries(schema_doc) + services(schema_doc, contract_sha256)
166
+ return HEADER.format(version=CODEGEN_VERSION) + "\n" + code
167
+
168
+
169
+ def rewrite_constr(code: str) -> str:
170
+ out: list[str] = []
171
+ i = 0
172
+ found = False
173
+ while (j := code.find("constr(", i)) != -1:
174
+ if j > 0 and (code[j - 1].isalnum() or code[j - 1] == "_"):
175
+ out.append(code[i : j + 7])
176
+ i = j + 7
177
+ continue
178
+ found = True
179
+ depth, k, quote = 0, j + 6, None
180
+ while True:
181
+ ch = code[k]
182
+ if quote:
183
+ if ch == "\\":
184
+ k += 2
185
+ continue
186
+ if ch == quote:
187
+ quote = None
188
+ elif ch in "'\"":
189
+ quote = ch
190
+ elif ch == "(":
191
+ depth += 1
192
+ elif ch == ")":
193
+ depth -= 1
194
+ if depth == 0:
195
+ break
196
+ k += 1
197
+ out.append(code[i:j] + "Annotated[str, StringConstraints(" + code[j + 7 : k] + ")]")
198
+ i = k + 1
199
+ out.append(code[i:])
200
+ code = "".join(out)
201
+ if found:
202
+ code = re.sub(r"^(from pydantic import .*?)\bconstr, ", r"\1", code, count=1, flags=re.M)
203
+ code = re.sub(r"^(from pydantic import .*?), constr$", r"\1", code, count=1, flags=re.M)
204
+ m = re.search(r"^from pydantic import (.+)$", code, flags=re.M)
205
+ assert m is not None
206
+ names = {n.strip() for n in m.group(1).split(",")} | {"StringConstraints"}
207
+ code = code[: m.start()] + "from pydantic import " + ", ".join(sorted(names)) + code[m.end() :]
208
+ return code
209
+
210
+
211
+ def registries(doc: dict) -> str:
212
+ ops = doc.get("x-ops", {})
213
+ events = doc.get("x-events", {})
214
+ out = [
215
+ "",
216
+ "",
217
+ "# ── Registries (from x-ops / x-events) ──────────────────────────────────────────────",
218
+ "",
219
+ "",
220
+ "class OpSpec(NamedTuple):",
221
+ " params: type[BaseModel]",
222
+ " result: type[BaseModel] | type[RootModel[Any]]",
223
+ " kind: Literal['unary', 'stream', 'resource']",
224
+ " since: str",
225
+ " errors: tuple[str, ...]",
226
+ "",
227
+ "",
228
+ "OPS: dict[str, OpSpec] = {",
229
+ ]
230
+ for name, spec in ops.items():
231
+ errs = ", ".join(repr(e) for e in spec["errors"])
232
+ errs = f"({errs},)" if len(spec["errors"]) == 1 else f"({errs})"
233
+ out.append(
234
+ f" {name!r}: OpSpec({spec['params']}, {spec['result']}, {spec['kind']!r}, {spec['since']!r}, {errs}),"
235
+ )
236
+ out += ["}", "", "EVENTS: dict[str, type[BaseModel]] = {"]
237
+ for name, model in events.items():
238
+ out.append(f" {name!r}: {model},")
239
+ out.append("}")
240
+ out += [
241
+ "",
242
+ f"#: The newest protocol this contract speaks (\"major.minor\"); announce it, negotiate down.",
243
+ f"PROTOCOL_VERSION = {doc.get('x-protocol-version', '1.0')!r}",
244
+ "",
245
+ "#: Fields added after x.0, by schema location (src/versioning.ts): optional on read; a reader",
246
+ "#: that needs one checks the peer's negotiated version and says so when the peer is older.",
247
+ "FIELDS_SINCE: dict[str, dict[str, str]] = {",
248
+ ]
249
+ for path, fields in doc.get("x-fields-since", {}).items():
250
+ out.append(f" {path!r}: {{{', '.join(f'{k!r}: {v!r}' for k, v in fields.items())}}},")
251
+ out.append("}")
252
+ out += [
253
+ "",
254
+ "#: 1.0 fields a later minor lets be null, and what an older peer is sent instead.",
255
+ "NULL_SINCE: dict[str, dict[str, Any]] = {",
256
+ ]
257
+ for path, rule in doc.get("x-null-since", {}).items():
258
+ out.append(f" {path!r}: {{'since': {rule['since']!r}, 'older_peers_get': {rule['olderPeersGet']!r}}},")
259
+ out.append("}")
260
+ return "\n".join(out) + "\n"
261
+
262
+
263
+ def services(doc: dict, contract_sha256: str) -> str:
264
+ out = [
265
+ "",
266
+ "",
267
+ "# ── Supervised Python services (from x-services) ────────────────────────────────────",
268
+ "",
269
+ "#: SHA-256 of the JSON Schema this module was generated from. The core sends its own in the",
270
+ "#: service hello; a service built from a different contract refuses the connection.",
271
+ f"CONTRACT_SHA256 = {contract_sha256!r}",
272
+ "",
273
+ "",
274
+ "class ServiceOpSpec(NamedTuple):",
275
+ " params: type[BaseModel]",
276
+ " result: type[BaseModel]",
277
+ " #: The handler method that serves the op (the op name after the service prefix).",
278
+ " method: str",
279
+ "",
280
+ "",
281
+ ]
282
+ table: list[str] = ["SERVICES: dict[str, dict[str, ServiceOpSpec]] = {"]
283
+ progress: list[str] = ["SERVICE_PROGRESS: dict[str, type[BaseModel]] = {"]
284
+ for service, spec in doc.get("x-services", {}).items():
285
+ cls = "".join(part.capitalize() for part in service.split("_"))
286
+ out += [
287
+ f"class {cls}ServiceCall(Protocol):",
288
+ f' """What one `{service}` handler may do besides return its result."""',
289
+ "",
290
+ " @property",
291
+ " def cancelled(self) -> bool: ...",
292
+ "",
293
+ f" def progress(self, payload: {spec['progress']}, /) -> None: ...",
294
+ "",
295
+ "",
296
+ f"class {cls}ServiceHandlers(Protocol):",
297
+ f' """The `{service}` service implements exactly these ops (pyright --strict holds it to the contract)."""',
298
+ "",
299
+ ]
300
+ table.append(f" {service!r}: {{")
301
+ for op, o in spec["ops"].items():
302
+ prefix = f"{service}."
303
+ if not op.startswith(prefix):
304
+ raise SystemExit(f"gen_pydantic: service op {op} does not start with {prefix}")
305
+ method = op[len(prefix) :]
306
+ if not method.isidentifier():
307
+ raise SystemExit(f"gen_pydantic: service op {op} is not a valid method name")
308
+ out.append(
309
+ f" async def {method}(self, params: {o['params']}, call: {cls}ServiceCall, /) -> {o['result']}: ..."
310
+ )
311
+ table.append(f" {op!r}: ServiceOpSpec({o['params']}, {o['result']}, {method!r}),")
312
+ out += ["", ""]
313
+ table.append(" },")
314
+ progress.append(f" {service!r}: {spec['progress']},")
315
+ table.append("}")
316
+ progress.append("}")
317
+ out += table + ["", ""] + progress
318
+ return "\n".join(out) + "\n"
319
+
320
+
321
+ def main() -> None:
322
+ ap = argparse.ArgumentParser()
323
+ ap.add_argument("--schema", required=True, type=Path)
324
+ ap.add_argument("--out", required=True, type=Path)
325
+ args = ap.parse_args()
326
+ raw_schema = args.schema.read_bytes()
327
+ doc = json.loads(raw_schema.decode("utf-8"))
328
+ with tempfile.TemporaryDirectory() as tmp:
329
+ raw = run_codegen(args.schema, Path(tmp) / "raw.py")
330
+ code = post_process(raw, doc, hashlib.sha256(raw_schema).hexdigest())
331
+ args.out.write_text(code, encoding="utf-8")
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()