diff --git a/AGENTS.md b/AGENTS.md index 660189e..5a89f7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,8 @@ The rule exists because directories alone never held it: `protocols/`, `slicer/` Accepted debt lives in `ALLOWED` in that script, each entry with a reason. Shrink it; do not grow it. There are currently no allowlisted edges: `RuntimeContext.printer()` uses an injectable factory registered downward from `bambu_cli.printer`. +The same script ratchets **cross-unit imports of underscore-private names** (`PRIVATE_IMPORT_BUDGET`): a leading underscore is only a contract if something enforces it, and the rank table polices direction, not encapsulation. The budget is the measured count and may only go down — give a shared helper a public name or move it to a rank-10 module; do not raise the number. + 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/`: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d09f3f..321f801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Fixed + +- `plate print ` no longer fails with "unsafe name". A path with + a separator can never be a printer-side file, so the error now says that + `print` takes the name of a file already on the printer and points at the + fix: `plate job --confirm` for a model file, or `plate upload ` + then `plate print --confirm` for an already-sliced 3MF/G-code. The + `--json` envelope carries that suggestion in `next_command`. Exit code (3) + and `failed_step` (`validate`) are unchanged; names without a separator keep + the "unsafe name" message. + +### Changed + +- `scripts/check_layers.py` now also ratchets cross-unit imports of + underscore-private names (`from bambu_cli.x import _helper` from another + layer unit). The budget is the measured count (104); it may only go down. + New shared helpers get a public name or move to a rank-10 module. +- Version on `main` is `0.6.0.dev0` again after the 0.5.1 release, so a source + build no longer reports the released version. +- README: the "Print something" first-run steps (which start with installing + OrcaSlicer) now come before the "Try it in 30 seconds" simulation section, + and that section says plainly that `plate --sim status` needs neither a + printer nor OrcaSlicer while a real print needs both. No CLI behaviour + changed; the empty-`HOME` first-run contract is now pinned by tests: + `plate preflight` reports the missing config, OrcaSlicer binary, and BBL + profiles as three separate checks, `plate setup --sim` without a TTY prints + the non-interactive `--printer-ip`/`--serial`/`--access-code-file` hint + instead of a traceback, and `plate --sim status` still exits `0`. + ## [0.5.1] - 2026-08-28 ### Fixed diff --git a/README.md b/README.md index b83eb0b..b96c1c8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Downloads](https://static.pepy.tech/badge/platecli)](https://pepy.tech/projects/platecli) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[Install](#install) · [Try it in 30 seconds](#try-it-in-30-seconds) · [Print something](#print-something) · [User guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) · [Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md) · [For AI agents](#built-for-ai-agents) +[Install](#install) · [Print something](#print-something) · [Try it without a printer](#try-it-in-30-seconds) · [User guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) · [Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md) · [For AI agents](#built-for-ai-agents) @@ -52,30 +52,6 @@ pip install platecli Previously published on PyPI as `bambu-local-cli` (yanked). The project is now `platecli`; the installed command is `plate`. -## Try it in 30 seconds - -No printer needed — simulation mode fakes one so you can kick the tires right away: - -```bash -plate --sim status -``` - -``` -🖨️ Bambu Printer Status - State: IDLE - Bed: 25°C / 0°C - Nozzle: 25°C / 0°C - Fan: 0 | WiFi: -42dBm - AMS: - Unit 0 (humidity 5, 26.0°C) - ▶ Slot 0: PLA #F2F2F2 | 90% - Slot 1: PETG #0A0AC8 | 60% - Slot 2: empty - Slot 3: TPU #000000 | 45% -``` - -Timestamps and log-level prefixes trimmed for brevity. - ## Print something Four steps, no flags to learn: @@ -129,6 +105,30 @@ It is a front-end, not new machinery: it slices and builds the `job` request thr plate tui: a live printer dashboard — state, temperatures, progress and AMS trays — and the two-column prepare screen it starts prints from

+## Try it in 30 seconds + +No printer yet, or OrcaSlicer not installed yet? Simulation mode fakes a printer so you can kick the tires right away — it needs neither. (A real print still needs both; see [Print something](#print-something).) + +```bash +plate --sim status +``` + +``` +🖨️ Bambu Printer Status + State: IDLE + Bed: 25°C / 0°C + Nozzle: 25°C / 0°C + Fan: 0 | WiFi: -42dBm + AMS: + Unit 0 (humidity 5, 26.0°C) + ▶ Slot 0: PLA #F2F2F2 | 90% + Slot 1: PETG #0A0AC8 | 60% + Slot 2: empty + Slot 3: TPU #000000 | 45% +``` + +Timestamps and log-level prefixes trimmed for brevity. + ## Why platecli - **One command, whole pipeline** — `plate go` asks the questions; `plate job --confirm` takes flags. Both download, slice, upload, and print in one shot; or run `download` / `slice` / `upload` / `print` individually. diff --git a/bambu_cli/commands/print_cmd.py b/bambu_cli/commands/print_cmd.py index 99ef447..5ae0c2d 100644 --- a/bambu_cli/commands/print_cmd.py +++ b/bambu_cli/commands/print_cmd.py @@ -16,6 +16,32 @@ from bambu_cli.utils import emit_json +def _looks_like_local_path(value: str) -> bool: + """True when *value* carries a path separator, so it cannot be a printer-side name.""" + return "/" in value or "\\" in value + + +def _local_path_error(path: str) -> tuple[str, str]: + """Message + next command for a local path handed to `print`. + + A model file (STL/STEP/OBJ) needs slicing, so `job` is the one-step fix. + An already-sliced 3MF/G-code only needs `upload` before `print `. + """ + shown = _name_for_message(path) + if _is_print_ready_name(path): + next_command = f"plate upload {shown}" + fix = f"upload it first with `{next_command}`, then `plate print --confirm`" + else: + next_command = f"plate job {shown} --confirm" + fix = f"slice, upload and print it in one step with `{next_command}`" + message = ( + f"`print` starts a file that is already on the printer, by name " + f"(for example `plate print model.gcode.3mf --confirm`). " + f"{shown!r} looks like a local path: {fix}." + ) + return message, next_command + + def cmd_print(args, ctx=None): """Start printing a file already on the printer.""" @@ -24,6 +50,19 @@ def cmd_print(args, ctx=None): basename = str(args.file or "") if _safe_remote_name(basename) is None: + if _looks_like_local_path(basename): + # A path with separators is never a printer-side name. Blaming the + # user for an "unsafe name" hides the real mistake: `print` takes + # the name of a file already on the printer, and the local file + # needs `job` (model) or `upload` (sliced) first. + message, next_command = _local_path_error(basename) + abort( + message, + exit_code=EXIT_FILE_ERROR, + failed_step="validate", + next_command=next_command, + extra={"file": basename}, + ) message = f"Refusing to print file with unsafe name: {_name_for_message(basename)!r}" abort( message, diff --git a/docs/quality-roadmap.md b/docs/quality-roadmap.md index 8178c05..dbc17c0 100644 --- a/docs/quality-roadmap.md +++ b/docs/quality-roadmap.md @@ -1,5 +1,12 @@ # Quality roadmap: A+ across the board +> **Frozen 2026-09-01.** The 2026-09 audit found the gates below already green +> (91.1% coverage, mypy/bandit/pip-audit/layers/schemas all blocking) against +> **zero external users** — no stars, forks, or issues after eight weeks and +> 124 merged PRs. Further quality work here is deferred until real users have +> run `plate go`; the open boxes that remain are listed honestly, not as +> next steps. This file is repo-only and never ships. + Living plan to take `platecli` from **solid 0.1.x beta (B− overall)** to **A+ product + A+ testing**. Derived from the 2026-07 harsh codebase audit. @@ -358,16 +365,16 @@ pytest -W error::ResourceWarning --cov=bambu_cli --cov-fail-under=85 # plus existing smokes (agent, package, privacy, help) ``` -- [ ] Remove or shrink dedicated unittest module list; pytest collects everything. -- [ ] `bandit -ll` **blocking** (no `|| true`) or allowlist with linked issues. -- [ ] `pip-audit` blocking for high/critical (allowlist documented). +- [x] Remove or shrink dedicated unittest module list; pytest collects everything (ci.yml: "Single runner: pytest"). +- [x] `bandit -ll` **blocking** (no `|| true`) — ci.yml step "bandit (blocking medium+)". +- [x] `pip-audit` blocking for high/critical — ci.yml step "pip-audit (blocking high+)". #### DoD -- [ ] mqtt ≥85%, ftps ≥90%, camera ≥90%, netsafety ≥95%. -- [ ] Total coverage ≥85% with fail-under. -- [ ] Single primary test command documented in CONTRIBUTING + CLAUDE/AGENTS. -- [ ] Zero flakes on 10 consecutive local full runs (or CI retries ≤0 on main for a week). +- [ ] mqtt ≥85%, ftps ≥90%, camera ≥90%, netsafety ≥95% — 2026-09-01 local: mqtt 100%, camera 96.5%, netsafety 95.9% met; **ftps 89.8%** still short. +- [x] Total coverage ≥85% with fail-under (floor is **86**; 91.1% measured 2026-09-01). +- [x] Single primary test command documented in CONTRIBUTING + AGENTS. +- [ ] Zero flakes on 10 consecutive local full runs (or CI retries ≤0 on main for a week) — never measured. #### Score impact @@ -406,15 +413,15 @@ pytest -W error::ResourceWarning --cov=bambu_cli --cov-fail-under=85 #### CI -- [ ] Grep gate: `sys.exit` allowed only in `cli.py` (and maybe `bambu.py` entry). Adjust if scripts need exit. -- [ ] Coverage fail-under → **88%**. +- [x] Grep gate: `sys.exit` allowed only in `cli.py` — ci.yml step "sys.exit only at CLI entry (blocking)". +- [ ] Coverage fail-under → **88%** (floor is 86; measured 91.1%, floor deliberately not raised — see freeze note). #### DoD -- [ ] `rg 'sys\.exit' bambu_cli` only hits entrypoints. -- [ ] `errors.py` docstring no longer says “not yet converted”. -- [ ] Agent JSON error payloads byte-for-byte compatible (contract tests green). -- [ ] `@mockable` usages reduced by ≥50% (track count in this file). +- [x] `rg 'sys\.exit' bambu_cli` only hits `cli.py` (plus docstrings that mention the rule). +- [x] `errors.py` docstring no longer says “not yet converted”. +- [x] Agent JSON error payloads byte-for-byte compatible (contract tests green). +- [x] `@mockable` usages reduced by ≥50% — count is **0**. #### Score impact @@ -470,7 +477,7 @@ bandit + pip-audit blocking #### DoD -- [ ] Scorecard **A** column green for Tests and Typing (Typing A− OK if strict not yet full-package). +- [x] Scorecard **A** column green for Tests and Typing (full-package mypy with `check_untyped_defs`). - [x] Fake Orca available (`tests/fakes/orca_stub`) and used by the hermetic slice suite (`tests/test_slice_stub_integration.py`); remaining `test_slice_cmd.py` unit tests keep their mocks where they assert argv assembly / profile-resolution branches. - [ ] `docs/test-backlog.md` reduced to “nice-to-have” only (or empty P1–P5). @@ -512,9 +519,9 @@ bandit + pip-audit blocking #### DoD -- [ ] Every `--json` command has a schema + contract test. -- [ ] No `@mockable` left (or documented single exception with removal date). -- [ ] Scorecard A+ for Agent JSON, Docs, Product polish. +- [x] Every `--json` command has a schema + contract test (27 generated schemas; `tests/json_contract_base.py` loads them). +- [x] No `@mockable` left. +- [ ] Scorecard A+ for Agent JSON, Docs, Product polish — Product polish is unmeasurable without users. - [ ] Tag `v1.0.0` only when §5 checklist is complete. #### Score impact @@ -543,9 +550,9 @@ bandit + pip-audit blocking #### DoD for A+ security/correctness -- [ ] Adversarial suite green in CI on every PR. -- [ ] Pin verification is one function, three call sites, fully tested. -- [ ] No open P0/P1 issues labeled `security` or `correctness`. +- [x] Adversarial suite green in CI on every PR (`tests/test_properties_safety.py` runs in the default suite). +- [x] Pin verification is one function (`tlspin.verify_cert_fingerprint`), four call sites (camera, ftps ×2, mqtt_tls). +- [x] No open P0/P1 issues labeled `security` or `correctness` (the tracker has no open issues at all). --- diff --git a/pyproject.toml b/pyproject.toml index 1955e1a..c4bf334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "platecli" # Unreleased work carries a .devN suffix so a source build is never mistaken # for the released wheel of the same number. Drop the suffix when tagging. -version = "0.5.1" +version = "0.6.0.dev0" description = "platecli — local CLI for Bambu Lab printers (not affiliated with Bambu Lab)" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/check_layers.py b/scripts/check_layers.py index 48b117c..e4a8277 100644 --- a/scripts/check_layers.py +++ b/scripts/check_layers.py @@ -98,12 +98,42 @@ "the raw Printables GraphQL wire format — import from bambu_cli.printables instead, " "so a schema change stays contained in the adapter" ), - "bambu_cli.printables.adapter": ( - "internal; the public names are re-exported from bambu_cli.printables" - ), + "bambu_cli.printables.adapter": ("internal; the public names are re-exported from bambu_cli.printables"), } +# --------------------------------------------------------------------------- +# Cross-unit imports of underscore-private names. The rank table polices the +# *direction* of a dependency, not encapsulation: nothing stops commands/ from +# reaching into download/naming.py for a `_helper`. A leading underscore is +# only a contract if something enforces it, so this is a ratchet: the count may +# go down (lower the budget in the same PR) but never up. New shared helpers get +# a public name, or move down to a rank-10 module (see fsutil.py). +# --------------------------------------------------------------------------- +PRIVATE_IMPORT_BUDGET = 104 + + +def private_cross_unit_imports(): + """Yield (file, lineno, module, name) for `from bambu_cli.x import _name` across units.""" + for file in sorted(PKG.rglob("*.py")): + if "__pycache__" in file.parts: + continue + src = source_unit(file) + tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file)) + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or node.level or not node.module: + continue + if not node.module.startswith("bambu_cli"): + continue + dst = unit_of(node.module) + if dst is None or dst == src: + continue + for alias in node.names: + name = alias.name + if name.startswith("_") and not name.startswith("__"): + yield file, node.lineno, node.module, name + + def unit_of(module: str) -> str | None: """Map a dotted module path to the layer unit that owns it.""" parts = module.split(".") @@ -225,7 +255,25 @@ def main() -> int: for src, dst in sorted(stale): print(f"STALE allowance {src} -> {dst} is no longer needed; remove it from ALLOWED") - failures = len(violations) + len(unknown) + len(stale) + len(sealed) + private = list(private_cross_unit_imports()) + budget_problems = 0 + if len(private) > PRIVATE_IMPORT_BUDGET: + budget_problems = 1 + print( + f"PRIVATE {len(private)} cross-unit imports of underscore-private names " + f"exceed the budget of {PRIVATE_IMPORT_BUDGET}. Give the helper a public " + f"name or move it to a rank-10 module; do not raise the budget." + ) + for file, lineno, module, name in private: + print(f" {file.relative_to(ROOT)}:{lineno}: from {module} import {name}") + elif len(private) < PRIVATE_IMPORT_BUDGET: + budget_problems = 1 + print( + f"RATCHET only {len(private)} private cross-unit imports remain; lower " + f"PRIVATE_IMPORT_BUDGET from {PRIVATE_IMPORT_BUDGET} to {len(private)} so it cannot grow back." + ) + + failures = len(violations) + len(unknown) + len(stale) + len(sealed) + budget_problems if failures: print(f"\n{failures} layering problem(s). See the rank table in {Path(__file__).name}.") return 1 diff --git a/tests/test_cmd_print.py b/tests/test_cmd_print.py index 1976d44..81cbd9e 100644 --- a/tests/test_cmd_print.py +++ b/tests/test_cmd_print.py @@ -1,7 +1,10 @@ """Print command: the physical-action path and its --confirm gate.""" +import argparse + from tests.bambu_test_base import * # noqa: F401,F403 + class TestBambuCmdPrint(unittest.TestCase): @patch("bambu_cli.protocols.mqtt.get_status") @patch("bambu_cli.logging_utils._BACKEND") @@ -239,3 +242,44 @@ def test_cmd_print_with_confirm(self, mock_execute, mock_generate): "test.gcode", use_ams=False, ams_mapping=None, timelapse=False, bed_leveling=False, flow_cali=False ) mock_execute.assert_called_once_with(ANY, "test_payload", "test.gcode", dry_run=False) + + +class TestCmdPrintLocalPathHint(unittest.TestCase): + """`plate print ` must explain what `print` takes, not blame the name.""" + + def _run(self, file_arg): + from bambu_cli.commands.print_cmd import cmd_print + + args = argparse.Namespace(file=file_arg, confirm=False, dry_run=False, json=False) + with self.assertRaises(BambuError) as cm: + cmd_print(args, ctx=MagicMock()) + return cm.exception + + @patch("bambu_cli.logging_utils._BACKEND") + def test_model_path_points_at_job(self, _logger): + exc = self._run("tests/fixtures/cube.stl") + self.assertEqual(exc.exit_code, 3) + self.assertEqual(exc.failed_step, "validate") + self.assertIn("looks like a local path", str(exc)) + self.assertIn("already on the printer", str(exc)) + self.assertNotIn("unsafe name", str(exc)) + self.assertEqual(exc.next_command, "plate job tests/fixtures/cube.stl --confirm") + + @patch("bambu_cli.logging_utils._BACKEND") + def test_sliced_path_points_at_upload(self, _logger): + exc = self._run("out/cube.gcode.3mf") + self.assertEqual(exc.exit_code, 3) + self.assertEqual(exc.next_command, "plate upload out/cube.gcode.3mf") + self.assertIn("plate print --confirm", str(exc)) + + @patch("bambu_cli.logging_utils._BACKEND") + def test_windows_separator_counts_as_path(self, _logger): + exc = self._run("models\\cube.stl") + self.assertIn("looks like a local path", str(exc)) + + @patch("bambu_cli.logging_utils._BACKEND") + def test_plain_unsafe_name_keeps_old_message(self, _logger): + exc = self._run("bad:name.3mf") + self.assertEqual(exc.exit_code, 3) + self.assertIn("unsafe name", str(exc)) + self.assertIsNone(exc.next_command) diff --git a/tests/test_first_run_path.py b/tests/test_first_run_path.py index 8660519..31b2b66 100644 --- a/tests/test_first_run_path.py +++ b/tests/test_first_run_path.py @@ -344,3 +344,116 @@ def test_manual_leads_with_first_print_and_demotes_agents(): assert phrase in first, phrase assert "uploaded_not_printed" in manual assert "without touching a slicer" not in manual + + +# --------------------------------------------------------------------------- +# 6. First run with an empty HOME: preflight, `setup --sim` off a TTY, `--sim status` +# --------------------------------------------------------------------------- + + +def _empty_home(monkeypatch, tmp_path, argv): + """Route every config lookup at a fresh, empty HOME and pin the context to unconfigured. + + ``setup_cmd.common`` holds its own from-import copy of ``CONFIG_PATH``, so + the ``bambu_cli.config`` patch in ``_cli`` alone would leave preflight + reading the developer's real config. + """ + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + config_path = str(home / ".config" / "bambu" / "config.json") + _cli(monkeypatch, argv, config_path) + monkeypatch.setattr("bambu_cli.setup_cmd.common.CONFIG_PATH", config_path) + # Nothing detected anywhere, whatever the CI host has installed. + monkeypatch.setattr("bambu_cli.config.detect_orca_slicer", lambda: None) + monkeypatch.setattr("bambu_cli.config.detect_profiles_dir", lambda: None) + return config_path + + +def test_readme_names_orcaslicer_before_the_thirty_second_claim(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + # A newcomer reads about the second slicer before any "30 seconds" / sim promise. + assert readme.index("OrcaSlicer") < readme.index("30 seconds") + assert readme.index("OrcaSlicer") < readme.index("plate --sim status") + # The first-run steps (which start with installing OrcaSlicer) come before the sim section. + assert readme.index("## Print something") < readme.index("## Try it in 30 seconds") + print_something = _section(readme, "## Print something") + assert re.search(r"^1\. \*\*Install OrcaSlicer\*\*", print_something, flags=re.MULTILINE) + # The sim section itself says what it does and does not need. + sim = _section(readme, "## Try it in 30 seconds") + assert "OrcaSlicer" in sim and "neither" in sim + assert sim.index("OrcaSlicer") < sim.index("plate --sim status") + + +def test_preflight_empty_home_names_each_missing_piece_separately(monkeypatch, tmp_path): + from tests.bambu_test_base import config_ctx, settings_ctx + + _empty_home(monkeypatch, tmp_path, ["preflight"]) + # config_ctx({}) drops the test base's mock config (which points at /tmp/mock_orca); + # settings_ctx then blanks the platform-default slicer paths on top of it. + with ( + config_ctx({}), + settings_ctx(printer_ip="0.0.0.0", orca_slicer="", profiles_dir=""), + patch("bambu_cli.logging_utils._BACKEND") as log, + pytest.raises(SystemExit) as ei, + ): + main() + assert ei.value.code == 1 + failed = [m for m in _messages(log, "info") if "❌" in m] + names = [m.split("❌", 1)[1].split(":", 1)[0].strip() for m in failed] + # One line per missing piece, not one generic "run setup". + assert names == ["config", "orca-slicer", "profiles-dir"], failed + by_name = dict(zip(names, failed, strict=True)) + assert "Config not found" in by_name["config"] + assert "OrcaSlicer path is not configured" in by_name["orca-slicer"] + assert "OrcaSlicer profile directory is not configured" in by_name["profiles-dir"] + # Each names an install / config step of its own, and the summary counts all three. + for name in ("orca-slicer", "profiles-dir"): + assert "Install it with" in by_name[name] and "config.json" in by_name[name], by_name[name] + assert any("Preflight failed: 3 error(s)" in m for m in _messages(log, "error")) + + +def test_preflight_empty_home_json_lists_each_missing_piece(monkeypatch, tmp_path, capsys): + from tests.bambu_test_base import config_ctx, settings_ctx + + _empty_home(monkeypatch, tmp_path, ["--json", "preflight"]) + with ( + config_ctx({}), + settings_ctx(printer_ip="0.0.0.0", orca_slicer="", profiles_dir=""), + pytest.raises(SystemExit) as ei, + ): + main() + assert ei.value.code == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "error" + errors = {c["name"]: c["message"] for c in payload["checks"] if c["status"] == "error"} + assert set(errors) == {"config", "orca-slicer", "profiles-dir"}, errors + assert payload["errors"] == 3 + + +def test_setup_sim_without_tty_is_a_usable_error_not_a_crash(monkeypatch, tmp_path, capsys): + _empty_home(monkeypatch, tmp_path, ["setup", "--sim"]) + with patch("bambu_cli.logging_utils._BACKEND") as log, pytest.raises(SystemExit) as ei: + main() + assert ei.value.code == 1 + errors = _messages(log, "error") + assert len(errors) == 1, errors + assert "cannot run in a headless environment" in errors[0] + for flag in ("--printer-ip", "--serial", "--access-code-file"): + assert flag in errors[0], flag + assert "plate setup --printer-ip" in errors[0] + captured = capsys.readouterr() + assert "Traceback" not in captured.err and "Traceback" not in captured.out + + +def test_sim_status_with_empty_home_exits_zero_and_reports_idle(monkeypatch, tmp_path): + _empty_home(monkeypatch, tmp_path, ["--sim", "status"]) + with patch("bambu_cli.logging_utils._BACKEND") as log: + try: + main() + except SystemExit as exc: # pragma: no cover - main() may return or exit 0 + assert exc.code in (None, 0) + assert _messages(log, "error") == [] + info = _messages(log, "info") + assert any("State: IDLE" in m for m in info), info diff --git a/uv.lock b/uv.lock index 00f9722..04a1d5d 100644 --- a/uv.lock +++ b/uv.lock @@ -154,7 +154,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -411,7 +411,7 @@ wheels = [ [[package]] name = "platecli" -version = "0.5.0" +version = "0.6.0.dev0" source = { editable = "." } dependencies = [ { name = "paho-mqtt" },