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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ jobs:
run: uvx mypy@2.3.0 -p bambu_cli
- name: no test-awareness in production code (blocking)
run: |
if grep -rnE 'isinstance\([^)]*\bMock\b|["'"'"']unittest["'"'"'] in sys\.modules' bambu_cli/; then
if grep -rnE 'isinstance\([^)]*\bMock\b|["'"'"']unittest["'"'"'] in sys\.modules|^\s*(import|from)\s+(unittest|pytest|mock)\b' bambu_cli/; then
echo "::error::Test-awareness branch found in production code (see docs/test-backlog.md ground rules)."
exit 1
fi
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
then B1–B4.** This applies in the TUI, and in `plate go`'s AMS-changed
messages and Feed row (`AMS A3 (PETG)`), which said `AMS0·2` before.
`plate status` and the JSON output are unchanged.
- `bambu_cli.logging_utils.patched_logger` is removed: it imported `unittest.mock` in the shipped package. `set_logger` / `reset_logger` still swap the logger backend. CI now rejects any `unittest`, `pytest` or `mock` import in `bambu_cli/`.

## [0.6.0] - 2026-11-06

Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ that CI's clean checkout does not have — expected locally, not a failure to ch
- **Mutation baseline** (safety modules): `./scripts/run_mutation_baseline.sh` —
[docs/mutation-baseline.md](docs/mutation-baseline.md). Nightly CI only (when `main` has changed), not every PR.
- CI enforces a "no test-awareness in production code" rule: production modules must not
branch on `Mock` / `unittest in sys.modules`. Prefer injecting collaborators over
branch on `Mock` / `unittest in sys.modules` or import `unittest` / `pytest` / `mock`
(`tests/test_production_purity.py` checks the same on the AST). Prefer injecting collaborators over
patching module globals. Domain code raises `BambuError` / `abort`; `sys.exit` only in
`bambu_cli/cli.py`.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<!-- Tests/coverage are hand-updated static badges (no coverage service wired up).
Re-measure and edit both after any release:
uv run python -m pytest tests/ -q -m "not live" --cov=bambu_cli --cov-report=term -->
[![Tests](https://img.shields.io/badge/tests-2217%20passing-brightgreen)](#tested-and-hardened)
[![Tests](https://img.shields.io/badge/tests-2309%20passing-brightgreen)](#tested-and-hardened)
[![Coverage](https://img.shields.io/badge/coverage-93%25-brightgreen)](#tested-and-hardened)

[Install](#install) · [Print something](#print-something) · [Try it without a printer](#try-it-in-30-seconds) · [Quality](#tested-and-hardened) · [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)
Expand Down Expand Up @@ -160,7 +160,7 @@ The numbers in the badges are measured on this repo, not aspirational:
| | |
|---|---|
| **Tests** | 2,217 passing (`pytest -m "not live"`, v0.6.0) — unit, contract, property-based (Hypothesis), and Textual pilot tests |
| **Coverage** | **93.5%** line+branch across the package; CI fails the build below **90%** |
| **Coverage** | **94.1%** line+branch across the package; CI fails the build below **90%** |
| **Platforms** | Linux, macOS, and Windows in CI, on Python 3.10 / 3.12 / 3.14 |
| **Blocking gates** | `ruff`, `ruff format`, `mypy` (whole package, `check_untyped_defs`), `bandit`, `pip-audit`, plus layer-boundary and JSON-schema-drift checks |
| **Mutation testing** | Nightly whenever `main` has changed, scoped to the safety-critical modules (download validation, SSRF guards, slicer options, job payload) |
Expand Down
2 changes: 1 addition & 1 deletion bambu_cli/argutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


def namespace_get(args, name, default=None):
"""Read argparse.Namespace values without treating MagicMock attributes as set."""
"""Read a value from an argparse.Namespace; ``default`` for anything without a ``__dict__``."""
try:
return vars(args).get(name, default)
except TypeError:
Expand Down
21 changes: 3 additions & 18 deletions bambu_cli/logging_utils.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Process-wide logger used by production modules.

``LoggerProxy`` always delegates to a replaceable backend so tests can install
a ``MagicMock`` (via ``set_logger`` / the ``mock_bambu_logger`` fixture) without
``LoggerProxy`` always delegates to a replaceable backend. A caller (a test, an
embedding application) installs its own logger with ``set_logger`` without
patching ``bambu_cli.bambu.logger`` or each consumer module's import binding.
"""

from __future__ import annotations

import logging
import sys
from contextlib import contextmanager
from typing import Any

_BACKEND: Any = logging.getLogger("bambu")
Expand All @@ -31,7 +30,7 @@ def __getattr__(self, name: str) -> Any:


def set_logger(backend: Any) -> None:
"""Replace the process logger backend (tests pass a ``MagicMock``)."""
"""Replace the process logger backend with any object that has the logging methods."""
global _BACKEND
_BACKEND = backend

Expand All @@ -42,20 +41,6 @@ def reset_logger() -> None:
_BACKEND = logging.getLogger("bambu")


@contextmanager
def patched_logger(backend: Any | None = None):
"""Context manager that installs ``backend`` (default: ``MagicMock``) for the block."""
from unittest.mock import MagicMock

mock = backend if backend is not None else MagicMock()
prev = _BACKEND
set_logger(mock)
try:
yield mock
finally:
set_logger(prev)


def safe_log_error(message: Any, **kwargs: Any) -> None:
"""Log an error without ever letting the logging layer abort the process.

Expand Down
5 changes: 2 additions & 3 deletions bambu_cli/netsafety.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,8 @@ def _default_user_agent() -> str:
def _host_of(url) -> str:
"""Best-effort lowercase hostname. Never raises.

Defensive on purpose: callers pass ``req.full_url``, and the test suite
patches ``urllib.request.Request`` with a MagicMock whose ``full_url`` is
not a string. ``urlparse`` on such a value raises ``TypeError``. Returning
Defensive on purpose: callers pass ``req.full_url``, which is not always a
string, and ``urlparse`` on a non-string raises ``TypeError``. Returning
"" means "no host policy, no throttling", which is the safe degradation.
"""
try:
Expand Down
22 changes: 11 additions & 11 deletions docs/quality-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ Historical baseline (do not read as current), from the audit + full
`pytest --cov=bambu_cli` on 2026-07-08: **368 tests**, **78%** line coverage
(1105 / 4973 stmts missed), **130** `sys.exit` sites in `bambu_cli/`, **7**
`@mockable` sites (def + 6 uses), **1** `BambuError` raise in production.
The "Baseline" column below is that snapshot. **Current measured (2026-08-13,
PR #119): 1499 passed / 1 live deselected, 90.99% branch coverage over 8368
statements (local Linux).** Full CI matrix: Linux 90.99% / Windows 90.68% (still
the binding leg) / macOS passing, on the 3.10 / 3.12 / 3.14 matrix (Python 3.9
was dropped in #115). The prior `5b08720` matrix (run `31044588411`, 2026-08-05)
measured 88.8–89.3%; CI runs a clean checkout, so its numbers differ slightly
from a local run — do not reconcile one to the other.
The "Baseline" column below is that snapshot. The current local measurement is in
the "Scoreboard (current)" table below. The last full CI-matrix measurement
(2026-08-13, PR #119) was Linux 90.99% / Windows 90.68% (the binding leg) /
macOS passing, on the 3.10 / 3.12 / 3.14 matrix (Python 3.9 was dropped in
#115). CI runs a clean checkout, so its numbers differ slightly from a local
run — do not reconcile one to the other. Windows has not been re-measured
since, so raising the CI floor waits on a fresh matrix reading.

| Area | Baseline | Gate to A | Gate to A+ | Primary evidence |
|------|----------|-----------|------------|------------------|
Expand Down Expand Up @@ -60,15 +60,15 @@ items are **accepted 1.0 residuals** in [SECURITY.md](../SECURITY.md), not open
| Correctness / bugs | **A+** | No known dead flags; property tests on filenames/SSRF/ZIP/temps in `tests/test_properties_safety.py` |
| Typing | **A** | Full-package mypy + `check_untyped_defs`. A+ (`mypy --strict`) is **1521** errors — not this release |
| Error model | **A+** | `sys.exit` only in `cli.py`; `emit_json_error` is `NoReturn`; domain uses `abort` / `BambuError` |
| Tests | **A+** | **2217** non-live tests passing (2026-11-05, local Linux; 1 skipped, 1 live deselected). A column cleared. Measured **93.5%** branch coverage over 9446 statements; A+ threshold met |
| Tests | **A+** | **2309** non-live tests passing (2026-11-07, local Linux; 1 skipped, 1 live deselected). A column cleared. Measured **94.1%** branch coverage over 9914 statements; A+ threshold met. `protocols/ftps.py` and `netsafety.py` are at 100% (`tests/test_transport_edges.py`) |
| CI / release | **A** | `--cov-fail-under=90` (floor raised 86→90 with comfortable margin). A+ is fail-under 92 |
| Docs / governance | **A+** | Roadmap + backlog match this measurement; stability policy + support matrix in `docs/api.md`; docs-consistency pins floor / % / count |
| Product polish | **A−** | Support matrix + hermetic Orca stub; still **Beta / pre-1.0** (no Production/Stable, no v1.0.0 tag). A+ is the §5 checklist |

**Overall:** **A** — none below A−. **Not A+ across the board.** Remaining: coverage 91.0→92, CI floor 92, Typing strict, Product `v1.0.0` tag.
**Overall:** **A** — none below A−. **Not A+ across the board.** Remaining: CI floor 92 (needs a fresh Windows reading first; the `ftps.py` / `netsafety.py` per-module targets in §3.1 are now met), Typing strict, Product `v1.0.0` tag.

**Coverage floor history:** 79 (honest post-Phase-1 gate) → **81** (2026-07-09) → **83** (2026-07-26) → **86** (2026-08-13) → **90** (2026-09-16; measured 92.8%).
Measured branch total is **92.8%** on local Linux; the floor is set
**Coverage floor history:** 79 (honest post-Phase-1 gate) → **81** (2026-07-09) → **83** (2026-07-26) → **86** (2026-08-13) → **90** (2026-09-16; measured 92.8% then, 94.1% on 2026-11-07).
Measured branch total is **94.1%** on local Linux (2026-11-07); the floor is set
at the multi-OS minimum so the matrix does not flake while still denying points
of silent rot vs the old 79 gate.

Expand Down
8 changes: 4 additions & 4 deletions docs/test-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
This file is a short **remaining-gaps** list only. Refresh after each phase or audit.
Do not treat historical “≥98% coverage” claims as current — see the snapshot below.

## Snapshot (2026-09-16; measured on Linux)
## Snapshot (2026-11-07; measured on Linux)

| Metric | Current (honest) | A+ / 1.0 target |
|--------|------------------|-----------------|
| Non-live tests collected | **2217** passing (measured 2026-11-05 on Linux; 1 skipped, 1 live test deselected) | ≥550 with zero known flakes ✅ size |
| Line/branch coverage (CI) | **92.8%** branch coverage over 8396 statements (local Linux, 2026-09-16; Windows / macOS passing on CI); prior CI matrix on `5b08720` was 88.8–89.3%; **floor 90** | **≥92%** total; optional module floors |
| Non-live tests collected | **2309** passing (measured 2026-11-07 on Linux; 1 skipped, 1 live test deselected) | ≥550 with zero known flakes ✅ size |
| Line/branch coverage (CI) | **94.1%** branch coverage over 9914 statements (local Linux, 2026-11-07; Windows / macOS were passing on the last CI matrix); prior CI matrix on `5b08720` was 88.8–89.3%; **floor 90** | **≥92%** total; optional module floors |
| Typing | Full package mypy + `check_untyped_defs` | keep; optional full `strict` later |
| Error model | `sys.exit` only in `cli.py` | keep |
| `@mockable` / test-awareness | **0** (CI greps) | keep |
| `@mockable` / test-awareness | **0** (CI greps plus `tests/test_production_purity.py`, which also rejects `unittest` / `pytest` / `Mock` imports) | keep |
| JSON schemas | **27** files under `docs/schemas/`, **generated** from `bambu_cli/contracts/`; coverage is derived from `build_parser()`, so a new subcommand cannot ship schema-less | monitor goldens; field-level api.md ↔ schema sync |
| Mutation baseline | Pure safety modules; score **50.7%** measured 2026-08-04, CI floor raised 40 → **48** | the C.4 re-run happened and **disproved** the prediction: `slicer/output.py` stayed at 21.8% while its line coverage went 79.8% → 92.7%. See [mutation-baseline.md](mutation-baseline.md); raising that row needs a production refactor, not more tests |
| Live printer | Documented opt-in harness | manual pre-release (optional scheduled lab) |
Expand Down
16 changes: 15 additions & 1 deletion tests/test_primitives_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,30 @@
from __future__ import annotations

from argparse import Namespace
from contextlib import contextmanager
from types import SimpleNamespace

from bambu_cli.argutils import exit_code_from_system_exit, namespace_get, setup_args_provided
from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_SUCCESS
from bambu_cli.errors import BambuError
from bambu_cli.job.support import _exit_code_from_error, _last_error_for
from bambu_cli.logging_utils import patched_logger, reset_logger, set_logger
from bambu_cli.logging_utils import reset_logger, set_logger
from bambu_cli.paths import display_path, exception_for_message, expand_path, path_for_message


@contextmanager
def patched_logger(backend):
"""Install ``backend`` as the process logger for the block, then restore it."""
from bambu_cli import logging_utils

prev = logging_utils._BACKEND
set_logger(backend)
try:
yield backend
finally:
set_logger(prev)


def test_namespace_get_reads_vars_and_falls_back_on_typeerror():
assert namespace_get(Namespace(serial="SN"), "serial") == "SN"
assert namespace_get(Namespace(), "missing", "d") == "d"
Expand Down
75 changes: 75 additions & 0 deletions tests/test_production_purity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Production code must not know it is being tested.

The CI grep catches the two classic shapes (``isinstance(..., Mock)`` and
``"unittest" in sys.modules``). This test closes the rest on the AST, so a
docstring or comment may still *mention* a test double but code cannot import
or name one: no import of ``unittest`` / ``pytest`` / ``mock``, no use of a
``Mock`` class, and no ``sys.modules`` probe for a test framework.
"""

from __future__ import annotations

import ast
from pathlib import Path

import pytest

PKG = Path(__file__).resolve().parent.parent / "bambu_cli"

TEST_MODULES = {"unittest", "pytest", "mock", "_pytest"}
TEST_NAMES = {"Mock", "MagicMock", "AsyncMock", "NonCallableMock", "PropertyMock", "patch", "create_autospec"}


def _is_modules_probe(node: ast.Constant) -> bool:
parent = getattr(node, "_parent", None)
return isinstance(parent, (ast.Compare, ast.Subscript))


def _scan(source: str) -> list[str]:
tree = ast.parse(source)
for parent in ast.walk(tree):
for child in ast.iter_child_nodes(parent):
child._parent = parent # type: ignore[attr-defined]
found: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
found += [f"import {a.name}" for a in node.names if a.name.split(".")[0] in TEST_MODULES]
elif isinstance(node, ast.ImportFrom):
if node.module and node.module.split(".")[0] in TEST_MODULES:
found.append(f"from {node.module} import ...")
elif isinstance(node, ast.Name) and node.id in TEST_NAMES:
found.append(f"name {node.id}")
elif isinstance(node, ast.Attribute) and node.attr in TEST_NAMES:
found.append(f"attribute {node.attr}")
elif isinstance(node, ast.Constant) and node.value in TEST_MODULES and _is_modules_probe(node):
found.append(f"probe for {node.value!r}")
return found


def test_no_production_module_references_a_test_framework():
bad = {}
for path in sorted(PKG.rglob("*.py")):
hits = _scan(path.read_text(encoding="utf-8"))
if hits:
bad[str(path.relative_to(PKG.parent))] = hits
assert not bad, f"test-awareness in production code: {bad}"


@pytest.mark.parametrize(
"source",
[
"import unittest",
"from unittest.mock import MagicMock",
"import pytest",
"x = MagicMock()",
"import unittest.mock as m\nm.patch('a')",
"import sys\nif 'unittest' in sys.modules: pass",
"import sys\nsys.modules['pytest']",
],
)
def test_scanner_flags_the_shapes_it_exists_to_catch(source):
assert _scan(source)


def test_scanner_ignores_docstrings_and_comments():
assert not _scan('"""Tests may install a MagicMock here."""\n# pytest capture\nx = 1\n')
Loading
Loading