Skip to content
Draft
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`:
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version

## [Unreleased]

### Fixed

- `plate print <local path>` 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 <path> --confirm` for a model file, or `plate upload <path>`
then `plate print <name> --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
Expand Down
50 changes: 25 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

</div>

Expand Down Expand Up @@ -52,30 +52,6 @@ pip install platecli

<sub>Previously published on PyPI as `bambu-local-cli` (yanked). The project is now `platecli`; the installed command is `plate`.</sub>

## 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%
```

<sub>Timestamps and log-level prefixes trimmed for brevity.</sub>

## Print something

Four steps, no flags to learn:
Expand Down Expand Up @@ -129,6 +105,30 @@ It is a front-end, not new machinery: it slices and builds the `job` request thr
<img alt="plate tui: a live printer dashboard — state, temperatures, progress and AMS trays — and the two-column prepare screen it starts prints from" src="https://raw.githubusercontent.com/DLANSAMA/platecli/main/docs/tui.gif">
</p>

## 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%
```

<sub>Timestamps and log-level prefixes trimmed for brevity.</sub>

## Why platecli

- **One command, whole pipeline** — `plate go` asks the questions; `plate job <url> --confirm` takes flags. Both download, slice, upload, and print in one shot; or run `download` / `slice` / `upload` / `print` individually.
Expand Down
39 changes: 39 additions & 0 deletions bambu_cli/commands/print_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`.
"""
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 <name> --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."""

Expand All @@ -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,
Expand Down
47 changes: 27 additions & 20 deletions docs/quality-roadmap.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).

---

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 52 additions & 4 deletions scripts/check_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(".")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading