Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ jobs:
echo "::error::@mockable/test indirection found in production code."
exit 1
fi
- name: JSON schemas match their contracts (blocking)
# docs/schemas/*.json is generated from bambu_cli/contracts/. This is
# the anti-drift gate: edit a payload, regenerate, commit both.
#
# Pinned to 3.12 on purpose. The generator needs 3.10+ to evaluate the
# contracts' `X | None` annotations, and pydantic is only installed
# above that floor (see the marker in pyproject). The package itself
# still runs on 3.9 — that leg is covered by the test matrix.
run: uv run --python 3.12 --with pydantic python scripts/gen_schemas.py --check
- name: layer boundaries (blocking)
# Directories alone never held: protocols/, slicer/ and download/ already
# existed as packages and still drifted (slicer imported a private FTPS
Expand Down
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Logic lives in focused packages; `bambu_cli/bambu.py` is a **thin entrypoint** (
| `commands/` | Printer subcommand handlers (`status`, `device`, `files`, `print_cmd`, `doctor`, `gcode`, thin `setup_wrappers`) |
| `download/` | URL/filename validation, HTML scraping, ZIP extraction, `download` command |
| `printables/` | Printables.com integration behind a strict adapter. `client.py` (the undocumented GraphQL wire format) is **sealed** — import only from `bambu_cli.printables`. `adapter.py` guarantees no Printables failure escapes as an exception |
| `contracts/` | Typed `--json` payload shapes (frozen dataclasses). **Generates `docs/schemas/*.json`** via `scripts/gen_schemas.py`; do not hand-edit a schema |
| `job/` | One-shot `job`/`send` orchestration, dry-run predict, print payloads, injectable `JobSteps` |
| `setup_cmd/` | Guided/non-interactive setup, mDNS, config show/validate, preflight |
| `slicer/` | OrcaSlicer integration |
Expand Down Expand Up @@ -79,6 +80,17 @@ Accepted debt lives in `ALLOWED` in that script, each entry with a reason. Shrin

The same script also enforces `SEALED` — package internals no outside module may import. `bambu_cli.printables.client` is sealed because an adapter is only a sandbox if callers cannot reach past it. **Third-party integrations go behind an adapter that cannot raise:** `PrintablesAdapter.resolve()` returns a `PrintablesResolution` for every outcome, converting a renamed field or a redesigned error envelope into a typed `printables_contract_changed` result instead of a traceback in the middle of `plate job`. `KeyboardInterrupt`/`SystemExit` are deliberately the only things that still propagate.

**JSON schemas are generated, never hand-written.** `docs/schemas/*.json` comes from the dataclasses in `bambu_cli/contracts/`:

```bash
python scripts/gen_schemas.py # regenerate after changing a payload
python scripts/gen_schemas.py --check # what CI runs (blocking)
```

Editing a schema by hand will be overwritten and will red CI. Change the model, regenerate, commit both. The gate fails in *both* directions — a stale schema, and a schema with no contract behind it.

**Pydantic is a dev/build dependency only** (`[test]` extra, `python_version >= '3.10'`). `bambu_cli` never imports it, and a test asserts that. Serialization stays in `emit_json`, because that pass applies the credential redaction a `model_dump_json()` would bypass. The contracts annotate optionals as `X | None`, which only *evaluates* on 3.10+ — safe because nothing at runtime resolves those annotations (also asserted by a test). Only the generator does, and it refuses to run below 3.10 with an explanatory message.

**Package inventory is derived:** setuptools finds `bambu_cli*`; syntax smoke and CLI help smoke auto-discover modules/commands (`scripts/syntax_smoke.py`, `scripts/cli_help_smoke.py`). Adding a module under `bambu_cli/` or a subcommand in `cli.py` is enough — no triplicated lists.

**Typing (mypy):** CI runs `uvx mypy@<pinned> -p bambu_cli` over the **whole package** with `check_untyped_defs = true` (CI pins the tool version in `.github/workflows/ci.yml`; running it unpinned locally is fine). There is **no residual exclude blocklist** — `printer.py` and `slicer/` are included. New modules are type-checked automatically.
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ SECURITY.md.
`get_printer()` / `RuntimeContext` — do not grow `bambu_cli/bambu.py` beyond the thin entrypoint.
- Prefer dependency injection over patching module globals (see `download/` for the pattern).
- JSON success and error payloads: assert full shapes (`status`, `command`, `failed_step`,
`exit_code`, `next_command` where applicable); add or extend a schema under `docs/schemas/`
when introducing agent-facing fields.
`exit_code`, `next_command` where applicable). When introducing agent-facing fields, edit the
dataclass in `bambu_cli/contracts/` and run `python scripts/gen_schemas.py` — **never hand-edit
`docs/schemas/*.json`**, it is generated and CI diffs it.
- Follow `docs/quality-roadmap.md` and `docs/test-backlog.md` when adding tests.
- Do not add Claude-Session or similar trailers to commits or PRs.

Expand Down
19 changes: 7 additions & 12 deletions bambu_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@
from .argutils import exit_code_from_system_exit as _exit_code_from_system_exit
from .argutils import namespace_get as _namespace_get
from .argutils import setup_args_provided as _setup_args_provided

# The argparse tree lives in bambu_cli.cliparse so domain code can build a
# namespace without importing this entrypoint (audit item A1). Re-exported here
# because build_parser() is the documented source of truth for the command set,
# and the help/workflow smokes plus several tests import it from this module.
from .cliparse import ( # noqa: F401
JsonArgumentParser,
_add_job_arguments,
Expand All @@ -35,6 +30,12 @@
EXIT_SUCCESS,
PRINTER_NETWORK_COMMANDS,
)

# The argparse tree lives in bambu_cli.cliparse so domain code can build a
# namespace without importing this entrypoint (audit item A1). Re-exported here
# because build_parser() is the documented source of truth for the command set,
# and the help/workflow smokes plus several tests import it from this module.
from .contracts import Version
from .jsonio import json_mode_requested as _json_mode_requested
from .utils import emit_json, emit_json_error

Expand Down Expand Up @@ -143,13 +144,7 @@ def main():
args = parser.parse_args()
if getattr(args, "version", False):
if bool(getattr(args, "json", False)):
emit_json(
{
"status": "ok",
"command": "version",
"version": VERSION,
}
)
emit_json(Version(status="ok", command="version", version=VERSION))
else:
print(f"plate {VERSION}")
return
Expand Down
70 changes: 23 additions & 47 deletions bambu_cli/commands/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from bambu_cli.argutils import namespace_get as _namespace_get
from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_NETWORK_ERROR
from bambu_cli.context import RuntimeContext
from bambu_cli.contracts import Light, Pause, Resume, Stop
from bambu_cli.errors import abort
from bambu_cli.logging_utils import logger, safe_log_error
from bambu_cli.utils import emit_json, emit_json_error, get_sequence_id
Expand Down Expand Up @@ -36,14 +37,7 @@ def cmd_light(args, ctx=None):
abort("", exit_code=EXIT_NETWORK_ERROR)
logger.info(f"💡 Light turned {action}")
if bool(_namespace_get(args, "json", False)):
emit_json(
{
"status": "light_changed",
"command": "light",
"action": action,
"changed": True,
}
)
emit_json(Light(status="light_changed", command="light", action=action, changed=True))


def cmd_pause(args, ctx=None):
Expand All @@ -54,12 +48,12 @@ def cmd_pause(args, ctx=None):
logger.warning("⚠️ This will PAUSE the current print. Add --confirm to proceed.")
if bool(_namespace_get(args, "json", False)):
emit_json(
{
"status": "confirmation_required",
"command": "pause",
"paused": False,
"next_command": ["pause", "--confirm", "--json"],
}
Pause(
status="confirmation_required",
command="pause",
paused=False,
next_command=["pause", "--confirm", "--json"],
)
)
abort("", exit_code=EXIT_COMMAND_ERROR)
payload = json.dumps({"print": {"sequence_id": get_sequence_id(), "command": "pause"}})
Expand All @@ -71,13 +65,7 @@ def cmd_pause(args, ctx=None):
abort("", exit_code=EXIT_NETWORK_ERROR)
logger.info("⏸️ Print paused")
if bool(_namespace_get(args, "json", False)):
emit_json(
{
"status": "paused",
"command": "pause",
"paused": True,
}
)
emit_json(Pause(status="paused", command="pause", paused=True))


def cmd_resume(args, ctx=None):
Expand All @@ -88,12 +76,12 @@ def cmd_resume(args, ctx=None):
logger.warning("⚠️ This will RESUME the paused print. Add --confirm to proceed.")
if bool(_namespace_get(args, "json", False)):
emit_json(
{
"status": "confirmation_required",
"command": "resume",
"resumed": False,
"next_command": ["resume", "--confirm", "--json"],
}
Resume(
status="confirmation_required",
command="resume",
resumed=False,
next_command=["resume", "--confirm", "--json"],
)
)
abort("", exit_code=EXIT_COMMAND_ERROR)
payload = json.dumps({"print": {"sequence_id": get_sequence_id(), "command": "resume"}})
Expand All @@ -105,13 +93,7 @@ def cmd_resume(args, ctx=None):
abort("", exit_code=EXIT_NETWORK_ERROR)
logger.info("▶️ Print resumed")
if bool(_namespace_get(args, "json", False)):
emit_json(
{
"status": "resumed",
"command": "resume",
"resumed": True,
}
)
emit_json(Resume(status="resumed", command="resume", resumed=True))


def cmd_stop(args, ctx=None):
Expand All @@ -122,12 +104,12 @@ def cmd_stop(args, ctx=None):
logger.warning("⚠️ This will STOP the current print. Add --confirm to proceed.")
if bool(_namespace_get(args, "json", False)):
emit_json(
{
"status": "confirmation_required",
"command": "stop",
"stopped": False,
"next_command": ["stop", "--confirm", "--json"],
}
Stop(
status="confirmation_required",
command="stop",
stopped=False,
next_command=["stop", "--confirm", "--json"],
)
)
abort("", exit_code=EXIT_COMMAND_ERROR)
payload = json.dumps({"print": {"sequence_id": get_sequence_id(), "command": "stop"}})
Expand All @@ -139,10 +121,4 @@ def cmd_stop(args, ctx=None):
abort("", exit_code=EXIT_NETWORK_ERROR)
logger.info("⏹️ Print stopped")
if bool(_namespace_get(args, "json", False)):
emit_json(
{
"status": "stopped",
"command": "stop",
"stopped": True,
}
)
emit_json(Stop(status="stopped", command="stop", stopped=True))
89 changes: 89 additions & 0 deletions bambu_cli/contracts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Typed contracts for every ``--json`` payload platecli emits.

``docs/schemas/*.json`` is generated from these models by
``scripts/gen_schemas.py``; CI regenerates and diffs, so a schema can never
drift from the code that produces it. Change a payload here, regenerate, commit
both.

Serialization stays in ``bambu_cli.utils.emit_json`` — it applies credential
redaction and home-directory compaction to every emitted string, and nothing
here may bypass it. See ``base.py`` for why these are stdlib dataclasses rather
than pydantic models.
"""

from bambu_cli.contracts.base import Contract, all_contracts
from bambu_cli.contracts.models import (
AmsState,
AmsTray,
AmsUnit,
ConfigCmd,
Delete,
Doctor,
Download,
ErrorEnvelope,
FilamentSettings,
Files,
Gcode,
Go,
JobError,
JobOk,
Light,
OkEnvelope,
Pause,
Preflight,
PreflightCheck,
Print,
PrinterState,
ProcessSettings,
RemoteFile,
Resume,
Setup,
Slice,
SliceListSettings,
Snapshot,
Status,
StatusEvent,
Stop,
Tui,
Upload,
Version,
)

__all__ = [
"AmsState",
"AmsTray",
"AmsUnit",
"ConfigCmd",
"Contract",
"Delete",
"Doctor",
"Download",
"ErrorEnvelope",
"FilamentSettings",
"Files",
"Gcode",
"Go",
"JobError",
"JobOk",
"Light",
"OkEnvelope",
"Pause",
"Preflight",
"PreflightCheck",
"Print",
"PrinterState",
"ProcessSettings",
"RemoteFile",
"Resume",
"Setup",
"Slice",
"SliceListSettings",
"Snapshot",
"Status",
"StatusEvent",
"Stop",
"Tui",
"Upload",
"Version",
"all_contracts",
]
Loading