Skip to content

refactor: generate docs/schemas from typed contracts instead of hand-writing them - #101

Merged
DLANSAMA merged 3 commits into
mainfrom
refactor/generated-schemas
Aug 5, 2026
Merged

refactor: generate docs/schemas from typed contracts instead of hand-writing them#101
DLANSAMA merged 3 commits into
mainfrom
refactor/generated-schemas

Conversation

@DLANSAMA

@DLANSAMA DLANSAMA commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Step 3 of 4. Stacked on #100 (which is stacked on #99) — this PR targets refactor/printables-adapter, so its diff shows only the schema work.

Why

The 25 files in docs/schemas/ were hand-maintained. Nothing stopped them drifting from what the commands actually emit, and two of them had already drifted in ways nobody could see.

Now they are generated from frozen dataclasses, and CI regenerates and diffs them. Drift is a build failure.

bambu_cli/contracts/base.py Contract base + spec() field constraints
bambu_cli/contracts/models.py 25 published contracts + 8 nested structures
scripts/gen_schemas.py The generator; --check is the blocking CI gate

Fidelity was proven, not assumed

I did not overwrite the hand-written schemas and hope. The generator was diffed against them until it produced zero losses — every constraint the old files expressed is reproduced: minLength, minimum, nested required, field descriptions, and the entire status.printer shape including AMS.

LOSSES: 0   ADDITIONS: 78

All 78 additions are strictly more precise — type alongside const, item types on arrays, explicit nullability.

Two real defects it surfaced

  1. download.json required 7 fields, but a naive model demoted 4 to optional (dataclass ordering forces defaults last). The loss diff caught it; fixed with spec(required=True).
  2. error_envelope typed next_command as {} — "anything" — which hid that job/send emits it as null. The generated schema is explicit, and the contract test failed until the model admitted it.

Neither was visible while the schemas were hand-written.

Pydantic is dev-only

This is the part worth checking. Pydantic derives JSON Schema from the dataclass annotations at build time and is never imported at runtime:

  • A test subprocess-imports the package and asserts pydantic is absent from sys.modules.
  • uv.lock shows runtime deps unchanged at 3 (paho-mqtt, rich, zeroconf), with pydantic under the test extra behind python_full_version >= '3.10'.

Serialization stays in emit_json, because that pass applies the credential redaction a model_dump_json() would bypass. emit_json/emit_json_line now accept a contract or a plain dict.

The 3.9 floor

Contracts annotate optionals as X | None, which only evaluates on 3.10+. Nothing at runtime resolves them — dataclasses keeps annotations as strings — so the floor is unaffected. Both halves are asserted by tests, and the generator refuses to run below 3.10 with an explanatory message rather than failing obscurely. Verified against a real CPython 3.9.25.

Scope: what is wired

version, light, pause, resume, stop construct contracts today. The remaining commands still emit dicts — and every one of them is validated against its generated schema by tests/contracts/, so the published contract holds either way. Converting the rest (especially job's incrementally-built summary dict) is follow-up work rather than something to rush into this diff.

Gates

1214 passed, 1 deselected     (was 1177 on #100)
coverage 87.0%               (was 86.5%)
ruff / ruff format / mypy / bandit / layers / drift-check         green
syntax / help / workflow / compat / package-contents smokes       green

Docs: test counts and coverage refreshed to measured values rather than left stale.

Rebase note for whoever lands this

docs/schemas/tui.json lives on feat/tui, not main. When #97 merges, the drift gate will correctly fail with "no contract generates: tui.json" until a Tui contract is added to models.py — it is the same shape as Go (interactive command, --json always errors). Deliberate: the gate fails in both directions.

@DLANSAMA
DLANSAMA force-pushed the refactor/printables-adapter branch from afac656 to 20a1557 Compare August 5, 2026 12:50
@DLANSAMA
DLANSAMA force-pushed the refactor/generated-schemas branch from 95f985c to a040d25 Compare August 5, 2026 12:52
@DLANSAMA
DLANSAMA force-pushed the refactor/printables-adapter branch from 20a1557 to bf873e0 Compare August 5, 2026 14:20
Base automatically changed from refactor/printables-adapter to main August 5, 2026 14:30
…writing them

The 25 files in docs/schemas were hand-maintained, which meant nothing stopped
them drifting from what the commands actually emit. They are now generated from
frozen dataclasses in bambu_cli/contracts/, and CI regenerates and diffs, so
drift is a build failure.

  bambu_cli/contracts/base.py     Contract base + spec() field constraints
  bambu_cli/contracts/models.py   25 published contracts + 8 nested structures
  scripts/gen_schemas.py          the generator (--check is the CI gate)

Fidelity was verified before overwriting anything: the generator was diffed
against the committed schemas until it produced ZERO losses — every constraint
the hand-written files expressed (minLength, minimum, nested required, field
descriptions, the whole status.printer shape) is reproduced. The 78 differences
that remain are all strictly more precise: types alongside consts, item types
on arrays, and explicit nullability.

Two real defects surfaced while doing it, both previously invisible:

  - download.json required 7 fields; a naive model made 4 of them optional.
    Caught by the loss diff, fixed with spec(required=True).
  - error_envelope typed next_command as `{}` (anything), which hid that
    job/send emits it as null. The generated schema is explicit, and the
    contract test failed until the model said so.

Pydantic is a DEV dependency only. It derives JSON Schema from the dataclass
annotations at build time and is never imported at runtime — verified by a test
that subprocess-imports the package and asserts pydantic is absent from
sys.modules, and visible in uv.lock: runtime deps stay at 3, with pydantic under
the test extra behind a python_version >= '3.10' marker. Serialization stays in
emit_json because that pass applies the credential redaction a model_dump_json()
would bypass; emit_json/emit_json_line now accept a contract or a plain dict.

The contracts annotate optionals as `X | None`, which only evaluates on 3.10+.
Nothing at runtime resolves them (dataclasses keeps annotations as strings), so
the 3.9 floor is unaffected — asserted by a test, and the generator refuses to
run below 3.10 with an explanatory message rather than failing obscurely.

Wired so far: version, light, pause, resume, stop. The remaining commands still
emit dicts; every one of them is validated against its generated schema by
tests/contracts/, so the contract holds either way. Converting the rest --
especially job's incrementally-built summary -- is follow-up work.

Docs: schema counts and coverage refreshed to measured values (1215 collected /
1214 passing, 87.0% Linux) rather than left stale.

Gates: 1214 passed (was 1177), coverage 87.0% (was 86.5%), ruff/ruff-format/
mypy/bandit/layers/drift-check/syntax/help/workflow/compat/package smokes green.
Verified on a real CPython 3.9.25 that contracts import, payloads render, and
pydantic stays absent.
docs/schemas/tui.json arrived on main with the TUI. The drift gate did exactly
what it was built to do and failed with "no contract generates: tui.json" —
the check works in both directions, so a published schema with no model behind
it is a failure, not a silent pass.

Same shape as Go: both are human-only front-ends with no machine contract, so
the only payload either emits is the --json refusal. Verified the generated
schema loses nothing from the hand-written one.
1374 collected / 1373 passing, 89.0% Linux — measured on this branch after the
rebase onto main-with-TUI, not inherited from either side of the doc conflict.

These were placeholders until measured: each stacked PR has to stand up green on
its own, and test_docs_consistency rejects a placeholder exactly as it rejects a
stale number. PR #102 re-measures on top.
@DLANSAMA
DLANSAMA force-pushed the refactor/generated-schemas branch from ca24471 to a6a9bf9 Compare August 5, 2026 14:31
@DLANSAMA
DLANSAMA marked this pull request as ready for review August 5, 2026 14:40
@DLANSAMA
DLANSAMA merged commit 9ed9cd2 into main Aug 5, 2026
6 checks passed
@DLANSAMA
DLANSAMA deleted the refactor/generated-schemas branch August 5, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant