From 08a47339561cd168e8431e21662299016a06c007 Mon Sep 17 00:00:00 2001 From: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:44:17 -0400 Subject: [PATCH 1/2] fix: job --dry-run would_slice matches real-run slicing decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plate job/send --dry-run` predicted `would_slice` from the raw URL-path extension, which is absent for Printables model pages and extension-less direct links. In those cases the predictor returned `would_slice: false`, but the real run downloads a model file (the downloader falls back to `.stl` when it cannot infer an extension) and slices it — so the dry-run pre-check disagreed with what actually happened. Fix: share one slicing predicate between the predictor and the doer. - `_ext_would_slice(ext)` in bambu_cli/job/predict.py is now the single rule (`ext in SLICEABLE_EXTENSIONS`, applied to the single-suffix `_file_extension` so `foo.gcode.3mf` stays print-ready). The real-run local, ZIP-member, and post-download branches in orchestrate.py all route through it. - `_predicted_download_slice_extension(url, args)` resolves the URL's predicted extension by reusing the downloader's own `_download_source_extension`, which falls back to `.stl` for sources whose extension is unknowable offline. `--name` is intentionally ignored because the real download overrides the `--name` extension with the URL/resolved-name extension. `would_slice` remains a boolean; no JSON shape or schema change. Tests: extended the direct-URL dry-run matrix (.stl/.step/.obj/.zip/.3mf and .gcode.3mf), added Printables-model-URL and extension-less-URL predictions, a predictor/doer shared-predicate equivalence test, local .gcode.3mf print-ready, and a ZIP-member matrix (.stl slices, .3mf/.gcode.3mf do not). Strengthened the Printables dry-run agent smoke to assert would_slice=True. --- CHANGELOG.md | 10 +++ bambu_cli/job/__init__.py | 2 + bambu_cli/job/orchestrate.py | 18 +++-- bambu_cli/job/predict.py | 36 ++++++++++ tests/agent_cli_smoke.py | 5 ++ tests/test_job.py | 129 +++++++++++++++++++++++++++++++++-- 6 files changed, 188 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfdd00..3caa97b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Fixed + +- `plate job/send --dry-run` now reports `would_slice` consistently with + the real run. The dry-run predictor previously returned `would_slice: false` + for sources whose extension it could not read from the URL path (Printables + model pages, extension-less direct links), even though the real run downloads + a model file (falling back to `.stl`) and slices it. The prediction and the + real run now share one slicing predicate, so a dry-run pre-check no longer + disagrees with what actually happens. + ## [0.4.0] - 2026-07-29 ### Added diff --git a/bambu_cli/job/__init__.py b/bambu_cli/job/__init__.py index 8c94cd3..e06f7bb 100644 --- a/bambu_cli/job/__init__.py +++ b/bambu_cli/job/__init__.py @@ -14,6 +14,8 @@ generate_print_payload, ) from bambu_cli.job.predict import ( # noqa: F401 + _ext_would_slice, + _predicted_download_slice_extension, _predicted_sliced_remote_name, _predicted_url_download_extension, _predicted_url_remote_name, diff --git a/bambu_cli/job/orchestrate.py b/bambu_cli/job/orchestrate.py index dcf22ac..a373ed7 100644 --- a/bambu_cli/job/orchestrate.py +++ b/bambu_cli/job/orchestrate.py @@ -46,8 +46,9 @@ from bambu_cli.errors import BambuError from bambu_cli.job.payload import _parse_print_options, _print_next_command from bambu_cli.job.predict import ( + _ext_would_slice, + _predicted_download_slice_extension, _predicted_sliced_remote_name, - _predicted_url_download_extension, _predicted_url_remote_name, _slice_args_for_job, ) @@ -175,10 +176,17 @@ def _run_job(ctx, args, steps=None): logger.info(" Re-run without --dry-run to download and prepare the model.") summary["status"] = "dry_run_url_skipped" summary["would_download"] = True - source_ext = _predicted_url_download_extension(source, args) + # Predict the extension the download will actually hand to the + # pipeline, then decide via the SAME predicate the real run uses + # below (`_ext_would_slice`). `_predicted_download_slice_extension` + # mirrors the downloader's own fallback to ".stl" for sources whose + # extension is unknowable offline (Printables pages, ?id= links), so + # would_slice here matches what the real run does instead of + # defaulting to False. + source_ext = _predicted_download_slice_extension(source, args) if source_ext in ARCHIVE_DOWNLOAD_EXTENSIONS: summary["would_extract"] = True - elif source_ext in SLICEABLE_EXTENSIONS: + elif _ext_would_slice(source_ext): summary["would_slice"] = True summary["remote_name"] = predicted_remote_name summary["would_upload"] = True @@ -296,7 +304,7 @@ def _run_job(ctx, args, steps=None): ) summary["status"] = "dry_run_local_skipped" summary["archive_entry"] = member_filename - summary["would_slice"] = member_ext in SLICEABLE_EXTENSIONS + summary["would_slice"] = _ext_would_slice(member_ext) summary["remote_name"] = predicted_remote_name summary["would_upload"] = True summary["would_print"] = bool(getattr(args, "confirm", False)) and not bool( @@ -320,7 +328,7 @@ def _run_job(ctx, args, steps=None): summary["extracted_path"] = extracted_path summary["archive_entry"] = archive_entry - if ext in SLICEABLE_EXTENSIONS: + if _ext_would_slice(ext): summary["would_slice"] = True predicted_remote_name = _predicted_sliced_remote_name(source_path, getattr(args, "copies", 1)) if _safe_remote_name(predicted_remote_name) is None: diff --git a/bambu_cli/job/predict.py b/bambu_cli/job/predict.py index 5aa5c57..d45d422 100644 --- a/bambu_cli/job/predict.py +++ b/bambu_cli/job/predict.py @@ -13,6 +13,7 @@ SLICEABLE_EXTENSIONS, ) from bambu_cli.download import ( + _download_source_extension, _download_target_filename, _file_extension, _is_printables_model_url, @@ -22,6 +23,41 @@ from bambu_cli.slicer import _sliced_output_path +def _ext_would_slice(ext): + """The one slicing predicate shared by the dry-run predictor and the real run. + + Given the extension of the file that will actually be handed to the + upload/print pipeline — the downloaded file, the selected ZIP member, or the + local file — return whether job/send will slice it. The ``--dry-run`` + prediction and the real run both route through this, so ``would_slice`` in a + dry-run payload cannot disagree with what the real run does. ``ext`` must be a + single-suffix, lowercased extension from ``_file_extension`` so that, e.g., + ``foo.gcode.3mf`` is treated as print-ready ``.3mf`` on both sides. + """ + return ext in SLICEABLE_EXTENSIONS + + +def _predicted_download_slice_extension(url, args): + """Predict the extension a URL download hands to the slicer, matching the doer. + + The real run decides ``would_slice`` from the *downloaded* file's extension + (orchestrate.py). Offline we cannot resolve Printables pages, redirects, or + Content-Disposition, so we reuse the downloader's own inference, + ``_download_source_extension``: it takes an extension from the URL path when + one is present and otherwise falls back to ``.stl`` — exactly what the real + download lands for a model source with no determinable extension (Printables + model pages, ``?id=`` links). Predicting that same ``.stl`` fallback is what + makes ``would_slice`` agree with the real run instead of defaulting to False. + + ``--name`` is deliberately ignored here: the real download overrides the + ``--name`` extension with the URL/resolved-name extension + (``_download_filename_with_extension``), so honoring it in the predictor would + reintroduce a divergence. Archive (``.zip``) URLs keep their real extension so + callers can report ``would_extract`` instead. + """ + return _download_source_extension(url) + + def _slice_args_for_job(filepath, args, output_dir): """Build a slice command namespace from job-level arguments.""" return argparse.Namespace( diff --git a/tests/agent_cli_smoke.py b/tests/agent_cli_smoke.py index 3ab0ad7..36337fc 100644 --- a/tests/agent_cli_smoke.py +++ b/tests/agent_cli_smoke.py @@ -411,6 +411,11 @@ def smoke_url_job_dry_run_json(root): assert False, f"URL dry-run payload did not report planned download/upload: {payload}" if payload.get("would_print") is not True: assert False, f"URL dry-run payload did not report planned print: {payload}" + # A Printables model page cannot be resolved offline, but the real run + # downloads a model file (STL/STEP preferred) and slices it, so the dry-run + # prediction must report would_slice=True rather than defaulting to False. + if payload.get("would_slice") is not True or payload.get("would_extract") is not False: + assert False, f"Printables model URL dry-run did not predict slicing: {payload}" if payload.get("downloaded_path") or payload.get("uploaded") or payload.get("printed"): assert False, f"URL dry-run payload reported side effects: {payload}" print("url-job-dry-run-json smoke ok") diff --git a/tests/test_job.py b/tests/test_job.py index 1b45e7f..2a3868b 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -347,15 +347,21 @@ def test_printed_success(tmp_path, capsys): @pytest.mark.parametrize( - "ext,would_slice,would_extract", + "filename,would_slice,would_extract", [ - (".stl", True, False), - (".zip", False, True), - (".3mf", False, False), + ("model.stl", True, False), + ("model.step", True, False), + ("model.obj", True, False), + ("model.zip", False, True), + ("model.3mf", False, False), + # ".gcode.3mf" is this project's primary printer-ready format; the + # last-suffix extension is ".3mf", so it must NOT be predicted as + # sliceable — the real run treats it as print-ready and skips slicing. + ("plate.gcode.3mf", False, False), ], ) -def test_dry_run_direct_url(ext, would_slice, would_extract, capsys): - url = f"https://example.com/model{ext}" +def test_dry_run_direct_url(filename, would_slice, would_extract, capsys): + url = f"https://example.com/{filename}" args = _parse(["job", url, "--dry-run", "--json"]) _run_job(_ctx(), args, JobSteps()) payload = _read_json(capsys) @@ -365,10 +371,79 @@ def test_dry_run_direct_url(ext, would_slice, would_extract, capsys): assert payload["would_extract"] is would_extract assert payload["would_upload"] is True assert payload["would_print"] is False - if ext != ".zip": + if not would_extract: assert payload["remote_name"] is not None +def test_dry_run_printables_model_url_predicts_slice(capsys): + """Regression: a Printables model page dry-run must report would_slice=True. + + The predictor cannot resolve the page offline, but the real run downloads a + model file (STL/STEP preferred) and slices it. The shared predicate now + falls back to ".stl" — the same fallback the downloader lands — so the + dry-run prediction matches the real run instead of defaulting to False. + """ + url = "https://printables.com/model/12345-agent-smoke" + args = _parse(["job", url, "--dry-run", "--json"]) + _run_job(_ctx(), args, JobSteps()) + payload = _read_json(capsys) + assert payload["status"] == "dry_run_url_skipped" + assert payload["would_download"] is True + assert payload["would_slice"] is True + assert payload["would_extract"] is False + assert payload["would_upload"] is True + # Remote name stays unpredictable offline for a Printables page; would_slice + # being True must not fabricate one. + assert payload["remote_name"] is None + + +def test_predictor_and_doer_share_slice_predicate(): + """The dry-run URL prediction routes through the same predicate as the real run. + + For any URL, the predicted-download extension fed to _ext_would_slice must + equal the decision the doer would make for that same extension. Guards + against the two paths drifting apart again (the original bug: predictor said + would_slice=False for sources the doer sliced). + """ + from bambu_cli.download import _file_extension + from bambu_cli.job import _ext_would_slice, _predicted_download_slice_extension + + cases = { + "https://printables.com/model/12345-foo": True, + "https://example.com/model.stl": True, + "https://example.com/model.step": True, + "https://example.com/model.obj": True, + "https://example.com/model.3mf": False, + "https://example.com/plate.gcode.3mf": False, + "https://example.com/bundle.zip": False, + "https://example.com/download?id=1": True, + } + for url, expected in cases.items(): + args = _parse(["job", url, "--dry-run", "--json"]) + predicted_ext = _predicted_download_slice_extension(url, args) + # Predictor's decision. + assert _ext_would_slice(predicted_ext) is expected, url + # Doer applies the identical predicate to the file it actually handles; + # simulate by feeding the predicted extension back through it. + assert _ext_would_slice(_file_extension(f"x{predicted_ext}")) is expected, url + + +def test_dry_run_extensionless_url_predicts_slice(capsys): + """An extension-less direct link predicts slicing, matching the doer's fallback. + + The real download resolves the filename via Content-Disposition/content and + falls back to ".stl" when nothing determines it, then slices. The dry-run + prediction must agree. + """ + url = "https://example.com/download?id=1" + args = _parse(["job", url, "--dry-run", "--json"]) + _run_job(_ctx(), args, JobSteps()) + payload = _read_json(capsys) + assert payload["status"] == "dry_run_url_skipped" + assert payload["would_slice"] is True + assert payload["would_extract"] is False + + def test_dry_run_local_model_file(tmp_path, capsys): stl = tmp_path / "model.stl" stl.write_bytes(b"solid x") @@ -407,6 +482,46 @@ def test_dry_run_local_printer_ready_file(tmp_path, capsys): assert payload["printable_path"] == _display_path(str(ready)) +def test_dry_run_local_gcode_3mf_is_print_ready_not_sliced(tmp_path, capsys): + """A local .gcode.3mf is print-ready: dry-run must report would_slice=False.""" + ready = tmp_path / "plate.gcode.3mf" + ready.write_bytes(b"x" * 10) + args = _parse(["job", str(ready), "--dry-run", "--json"]) + _run_job(_ctx(), args, JobSteps()) + payload = _read_json(capsys) + assert payload["status"] == "dry_run_local_skipped" + assert payload["would_slice"] is False + assert payload["would_upload"] is True + assert payload["remote_name"] == "plate.gcode.3mf" + + +@pytest.mark.parametrize( + "member,would_slice", + [ + ("model.stl", True), + ("model.3mf", False), + # Last-suffix extension of a ".gcode.3mf" member is ".3mf": print-ready. + ("plate.gcode.3mf", False), + ], +) +def test_dry_run_local_zip_member_slice_matches_predicate(tmp_path, member, would_slice, capsys): + """ZIP dry-run reports would_slice per the selected member's extension. + + Uses the same _ext_would_slice predicate as the real run, so a .3mf or + .gcode.3mf member is reported print-ready while an .stl member is sliceable. + """ + zpath = tmp_path / "bundle.zip" + with zipfile.ZipFile(zpath, "w") as zf: + zf.writestr(member, b"content") + args = _parse(["job", str(zpath), "--dry-run", "--json"]) + _run_job(_ctx(), args, JobSteps()) + payload = _read_json(capsys) + assert payload["status"] == "dry_run_local_skipped" + assert payload["archive_entry"] == member + assert payload["would_slice"] is would_slice + assert payload["would_upload"] is True + + def test_dry_run_local_printer_ready_empty_file_fails(tmp_path): ready = tmp_path / "model.3mf" ready.write_bytes(b"") From 7afb8b0c2e3fc2f48f41e2f75be25072de809c40 Mon Sep 17 00:00:00 2001 From: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:00:50 -0400 Subject: [PATCH 2/2] refactor: extract paths/jsonio/argutils from cli.py Extract the filesystem-path, JSON-mode/URL-redaction, and argparse-coercion helpers out of bambu_cli/cli.py into three focused modules (paths.py, jsonio.py, argutils.py) so domain modules no longer import private _underscore helpers from the CLI entrypoint (roadmap B.4). Pure move-and-rewire: symbols keep their names minus the leading underscore in their new public homes; cli.py re-imports them under the old aliases for internal use. Behaviour, JSON envelopes, output, and exit codes are unchanged. Domain -> cli private-helper imports: 93 symbol imports across 32 statements before, 0 after; only the public build_parser/main imports remain. Docs updated: AGENTS.md module table + debt list, quality-roadmap Architecture grade and B.4 row, CHANGELOG [Unreleased] Changed. --- AGENTS.md | 6 +- CHANGELOG.md | 10 ++ bambu_cli/argutils.py | 42 ++++++++ bambu_cli/camera.py | 10 +- bambu_cli/cli.py | 142 ++------------------------ bambu_cli/commands/device.py | 2 +- bambu_cli/commands/doctor.py | 12 +-- bambu_cli/commands/files.py | 10 +- bambu_cli/commands/gcode.py | 2 +- bambu_cli/commands/print_cmd.py | 3 +- bambu_cli/commands/status.py | 2 +- bambu_cli/config.py | 17 +-- bambu_cli/context.py | 4 +- bambu_cli/download/downloader.py | 12 +-- bambu_cli/download/extract.py | 3 +- bambu_cli/download/naming.py | 3 +- bambu_cli/download/validation.py | 10 +- bambu_cli/interactive/session.py | 2 +- bambu_cli/job/orchestrate.py | 12 +-- bambu_cli/job/payload.py | 2 +- bambu_cli/job/predict.py | 2 +- bambu_cli/job/support.py | 8 +- bambu_cli/jsonio.py | 63 ++++++++++++ bambu_cli/netsafety.py | 2 +- bambu_cli/paths.py | 80 +++++++++++++++ bambu_cli/protocols/ftps.py | 2 +- bambu_cli/protocols/mqtt.py | 2 +- bambu_cli/setup_cmd/common.py | 3 +- bambu_cli/setup_cmd/config_cmd.py | 5 +- bambu_cli/setup_cmd/migrate.py | 5 +- bambu_cli/setup_cmd/preflight.py | 9 +- bambu_cli/setup_cmd/wizard.py | 10 +- bambu_cli/slicer/cmd.py | 6 +- bambu_cli/slicer/options.py | 5 +- bambu_cli/slicer/output.py | 10 +- bambu_cli/slicer/profiles.py | 3 +- bambu_cli/slicer/step_convert.py | 3 +- bambu_cli/utils.py | 5 +- docs/quality-roadmap.md | 8 +- tests/test_access_code_deprecation.py | 2 +- tests/test_job.py | 3 +- 41 files changed, 308 insertions(+), 234 deletions(-) create mode 100644 bambu_cli/argutils.py create mode 100644 bambu_cli/jsonio.py create mode 100644 bambu_cli/paths.py diff --git a/AGENTS.md b/AGENTS.md index 6c9039c..dec700c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,10 @@ Logic lives in focused packages; `bambu_cli/bambu.py` is a **thin entrypoint** ( | Module / package | Role | |------------------|------| -| `cli.py` | argparse, `main()` dispatch, path/JSON message helpers (also imported by domain — see tech debt below) | +| `cli.py` | argparse, `main()` dispatch; re-imports the shared helpers from `paths`/`jsonio`/`argutils` | +| `paths.py` | Filesystem path helpers (`expand_path`, `display_path`, `path_for_message`, `exception_for_message`) shared by CLI and domain | +| `jsonio.py` | JSON-mode detection + URL-credential redaction for logs/JSON output | +| `argutils.py` | argparse/`Namespace` coercion helpers (`namespace_get`, `exit_code_from_system_exit`, `setup_args_provided`) | | `commands/` | Printer subcommand handlers (`status`, `device`, `files`, `print_cmd`, `doctor`, `gcode`, thin `setup_wrappers`) | | `download/` | URL/filename validation, HTML scraping, ZIP extraction, `download` command | | `job/` | One-shot `job`/`send` orchestration, dry-run predict, print payloads, injectable `JobSteps` | @@ -59,7 +62,6 @@ When adding tests, follow [docs/test-backlog.md](docs/test-backlog.md) and the q ### Known architecture debt (honest) -- Domain modules still import private helpers from `bambu_cli.cli` (`_expand_path`, `_namespace_get`, path/JSON helpers). Target: extract `paths` / `jsonio` / `argutils` (roadmap Phase B.4). - TLS fingerprint verification is reimplemented in mqtt, ftps, and camera rather than one shared helper (roadmap B.5). ## Camera snapshots for agents diff --git a/CHANGELOG.md b/CHANGELOG.md index cd617c7..e8c92cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Changed + +- Internal refactor only — no user-visible change. Extracted the path, + JSON-mode/redaction, and argparse-coercion helpers out of `bambu_cli/cli.py` + into three focused modules (`bambu_cli/paths.py`, `bambu_cli/jsonio.py`, + `bambu_cli/argutils.py`), so domain modules no longer import private + `_underscore` helpers from the CLI entrypoint (roadmap B.4). Behaviour, + output, JSON envelopes, and exit codes are unchanged; `cli.py` re-imports the + same helpers from their new homes. + ### Fixed - `plate job/send --dry-run` now reports `would_slice` consistently with diff --git a/bambu_cli/argutils.py b/bambu_cli/argutils.py new file mode 100644 index 0000000..32f2ee0 --- /dev/null +++ b/bambu_cli/argutils.py @@ -0,0 +1,42 @@ +"""argparse / argument-coercion helpers shared by the CLI and domain modules. + +Extracted from ``bambu_cli.cli`` (roadmap B.4). These read values off an +``argparse.Namespace`` (or a stand-in) and normalize exit codes for +machine-readable summaries. They never terminate the process; domain callers +raise ``BambuError`` / ``abort`` instead. +""" + +from .constants import EXIT_COMMAND_ERROR, EXIT_SUCCESS + +__all__ = [ + "namespace_get", + "exit_code_from_system_exit", + "setup_args_provided", +] + + +def namespace_get(args, name, default=None): + """Read argparse.Namespace values without treating MagicMock attributes as set.""" + try: + return vars(args).get(name, default) + except TypeError: + return default + + +def exit_code_from_system_exit(exc, default=EXIT_COMMAND_ERROR): + """Normalize SystemExit / BambuError codes for machine-readable summaries.""" + code = getattr(exc, "exit_code", None) + if code is None: + code = getattr(exc, "code", None) + if isinstance(code, int): + return code + if code is None: + return EXIT_SUCCESS + return default + + +def setup_args_provided(args): + return any( + namespace_get(args, attr) is not None + for attr in ("printer_ip", "serial", "access_code", "access_code_env", "access_code_file", "model", "nozzle") + ) diff --git a/bambu_cli/camera.py b/bambu_cli/camera.py index 0434783..93a5e66 100644 --- a/bambu_cli/camera.py +++ b/bambu_cli/camera.py @@ -22,12 +22,7 @@ import urllib.request from urllib.parse import urlparse -from bambu_cli.cli import ( - _exception_for_message, - _expand_path, - _namespace_get, - _path_for_message, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.config import load_access_code from bambu_cli.constants import ( DEFAULT_NETWORK_TIMEOUT, @@ -39,6 +34,9 @@ from bambu_cli.context import RuntimeContext from bambu_cli.errors import BambuError, abort from bambu_cli.logging_utils import logger, safe_log_error +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.utils import _ensure_parent_dir, emit_json, emit_json_error # The port-6000 camera stream's first frames can be stale (buffered from a diff --git a/bambu_cli/cli.py b/bambu_cli/cli.py index 8b4d056..1291304 100644 --- a/bambu_cli/cli.py +++ b/bambu_cli/cli.py @@ -1,9 +1,7 @@ import argparse import logging -import os import socket import sys -from urllib.parse import urlparse, urlunparse import bambu_cli.utils as utils from bambu_cli.errors import BambuError @@ -11,6 +9,11 @@ # Logging from bambu_cli.logging_utils import logger, safe_log_error +# Path / JSON / argparse helpers extracted from this module (roadmap B.4). +# Aliased to their historical underscore names for internal use here. +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 from .constants import ( DEFAULT_MAX_DOWNLOAD_MB, EXIT_COMMAND_ERROR, @@ -18,6 +21,7 @@ EXIT_SUCCESS, PRINTER_NETWORK_COMMANDS, ) +from .jsonio import json_mode_requested as _json_mode_requested from .utils import emit_json, emit_json_error @@ -97,129 +101,6 @@ def error(self, message): self.exit(EXIT_COMMAND_ERROR) -def _expand_path(path): - """Expand user and environment variables in local filesystem paths.""" - if path is None: - return None - return os.path.expandvars(os.path.expanduser(str(path))) - - -_HOME_DIR = os.path.expanduser("~") -_NORM_HOME_DIR = None - - -def _get_norm_home_dir(): - global _NORM_HOME_DIR - if _NORM_HOME_DIR is None: - try: - _NORM_HOME_DIR = os.path.normcase(os.path.abspath(_HOME_DIR)) - except (TypeError, ValueError, OSError): - _NORM_HOME_DIR = _HOME_DIR - return _NORM_HOME_DIR - - -def _display_path(path): - """Return a user-facing path with the current home directory compacted.""" - if path is None: - return None - text = str(path) - - # Inline _expand_path for speed and caching - expanded = os.path.expandvars(os.path.expanduser(text)) - if not os.path.isabs(expanded): - return text - - norm_home = _get_norm_home_dir() - try: - norm_expanded = os.path.normcase(os.path.abspath(expanded)) - except (TypeError, ValueError, OSError): - return text - - if norm_expanded == norm_home: - return "~" - prefix = norm_home + os.sep - if norm_expanded.startswith(prefix): - return "~" + os.sep + os.path.relpath(expanded, _HOME_DIR) - return text - - -def _path_for_message(path): - """Return a local path suitable for human and agent-facing messages.""" - display = _display_path(path) - if display is None or os.sep == "/": - return display - return display.replace(os.sep, "/") - - -def _exception_for_message(exc): - """Return exception text with local filesystem paths compacted for output.""" - message = str(exc) - for attr in ("filename", "filename2"): - path = getattr(exc, attr, None) - if path is not None: - message = message.replace(str(path), _display_path(path)) - return message - - -def _looks_like_schemeless_credential_url(value): - """Detect userinfo-bearing URLs where the user omitted https://.""" - text = str(value or "") - if "\\" in text or any(char.isspace() for char in text): - return False - if "@" not in text or text.startswith(("/", ".", "~", "$")): - return False - try: - parsed = urlparse(f"https://{text}") - host = parsed.hostname or "" - return bool(parsed.netloc and (parsed.username is not None or parsed.password is not None) and "." in host) - except Exception: - return False - - -def _redact_url_credentials(value): - """Return URL text with any userinfo removed before logging or JSON output.""" - text = str(value or "") - if "@" not in text: - return value - parsed = urlparse(text) - if "://" not in text and _looks_like_schemeless_credential_url(text): - redacted = _redact_url_credentials(f"https://{text}") - prefix = "https://" - return redacted[len(prefix) :] if isinstance(redacted, str) and redacted.startswith(prefix) else redacted - if not parsed.scheme or not parsed.netloc or (parsed.username is None and parsed.password is None): - return value - host = parsed.hostname or "" - if ":" in host and not host.startswith("["): - host = f"[{host}]" - try: - port = parsed.port - except ValueError: - port = None - if port is not None: - host = f"{host}:{port}" - return urlunparse((parsed.scheme, host, parsed.path, parsed.params, parsed.query, parsed.fragment)) - - -def _namespace_get(args, name, default=None): - """Read argparse.Namespace values without treating MagicMock attributes as set.""" - try: - return vars(args).get(name, default) - except TypeError: - return default - - -def _exit_code_from_system_exit(exc, default=EXIT_COMMAND_ERROR): - """Normalize SystemExit / BambuError codes for machine-readable summaries.""" - code = getattr(exc, "exit_code", None) - if code is None: - code = getattr(exc, "code", None) - if isinstance(code, int): - return code - if code is None: - return EXIT_SUCCESS - return default - - def _add_slice_override_args(parser): """Generic OrcaSlicer setting overrides shared by `slice` and `job`/`send`. @@ -577,10 +458,6 @@ def build_parser(): return parser -def _json_mode_requested(args): - return bool(getattr(args, "json", False)) - - def _bare_plate_should_launch_wizard(args): """Decide whether bare `plate` (no subcommand) launches the guided wizard. @@ -617,13 +494,6 @@ def _json_setup_should_be_noninteractive(args): ) -def _setup_args_provided(args): - return any( - _namespace_get(args, attr) is not None - for attr in ("printer_ip", "serial", "access_code", "access_code_env", "access_code_file", "model", "nozzle") - ) - - def _resolve_command(name): """Look up the cmd_* handler for a command on bambu_cli.commands. diff --git a/bambu_cli/commands/device.py b/bambu_cli/commands/device.py index db552a4..5c1bc39 100644 --- a/bambu_cli/commands/device.py +++ b/bambu_cli/commands/device.py @@ -2,7 +2,7 @@ import json -from bambu_cli.cli import _namespace_get +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.errors import abort diff --git a/bambu_cli/commands/doctor.py b/bambu_cli/commands/doctor.py index 67f6b88..a1b3a3b 100644 --- a/bambu_cli/commands/doctor.py +++ b/bambu_cli/commands/doctor.py @@ -5,18 +5,16 @@ import sys import tempfile -from bambu_cli.cli import ( - _display_path, - _exception_for_message, - _expand_path, - _namespace_get, - _path_for_message, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.config import CONFIG_PATH, MODEL_MAPPING, _expected_fingerprint, get_network_timeout, load_config from bambu_cli.constants import EXIT_CONFIG_ERROR, EXIT_FILE_ERROR, EXIT_NETWORK_ERROR from bambu_cli.context import RuntimeContext from bambu_cli.errors import BambuError, abort from bambu_cli.logging_utils import logger +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.utils import _ensure_parent_dir, _redacted_serial # Printer families whose camera is captured with the direct port-6000 TLS grab diff --git a/bambu_cli/commands/files.py b/bambu_cli/commands/files.py index 0839916..a66b5be 100644 --- a/bambu_cli/commands/files.py +++ b/bambu_cli/commands/files.py @@ -3,12 +3,7 @@ import os import sys -from bambu_cli.cli import ( - _exception_for_message, - _expand_path, - _namespace_get, - _path_for_message, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.config import get_upload_timeout from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR, EXIT_NETWORK_ERROR from bambu_cli.context import RuntimeContext @@ -21,6 +16,9 @@ ) from bambu_cli.errors import abort from bambu_cli.logging_utils import logger, safe_log_error +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.slicer import _directory_input_message, _is_directory_input from bambu_cli.utils import emit_json, emit_json_error diff --git a/bambu_cli/commands/gcode.py b/bambu_cli/commands/gcode.py index 7eff3a0..24f6cd5 100644 --- a/bambu_cli/commands/gcode.py +++ b/bambu_cli/commands/gcode.py @@ -2,7 +2,7 @@ import json -from bambu_cli.cli import _namespace_get +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.download.naming import _has_command_injection_chars diff --git a/bambu_cli/commands/print_cmd.py b/bambu_cli/commands/print_cmd.py index 8bec154..10ccd58 100644 --- a/bambu_cli/commands/print_cmd.py +++ b/bambu_cli/commands/print_cmd.py @@ -1,7 +1,8 @@ """Start a print of a file already on the printer.""" from bambu_cli import utils -from bambu_cli.cli import _exit_code_from_system_exit, _namespace_get +from bambu_cli.argutils import exit_code_from_system_exit as _exit_code_from_system_exit +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR from bambu_cli.context import RuntimeContext from bambu_cli.download.naming import ( diff --git a/bambu_cli/commands/status.py b/bambu_cli/commands/status.py index f582196..f1abf32 100644 --- a/bambu_cli/commands/status.py +++ b/bambu_cli/commands/status.py @@ -1,6 +1,6 @@ """Printer status command.""" -from bambu_cli.cli import _namespace_get +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.context import RuntimeContext from bambu_cli.errors import PrinterConnectionError from bambu_cli.logging_utils import logger diff --git a/bambu_cli/config.py b/bambu_cli/config.py index ccbc9d0..da3b1cf 100644 --- a/bambu_cli/config.py +++ b/bambu_cli/config.py @@ -10,7 +10,7 @@ def _default_config_path(): """Return the platform-native default config path, preferring an existing legacy ``~/.config/bambu/config.json`` for back-compat across installs.""" - from bambu_cli.cli import _expand_path + from bambu_cli.paths import expand_path as _expand_path if os.environ.get("XDG_CONFIG_HOME") and sys.platform not in ("win32", "darwin"): return os.path.join(_expand_path(os.environ["XDG_CONFIG_HOME"]), "bambu", "config.json") @@ -35,9 +35,10 @@ def load_config(exit_on_fail=True): Uses the module-level ``CONFIG_PATH`` (tests may patch ``bambu_cli.config.CONFIG_PATH``). """ - from bambu_cli.cli import _display_path, _exception_for_message from bambu_cli.constants import EXIT_CONFIG_ERROR from bambu_cli.errors import abort + from bambu_cli.paths import display_path as _display_path + from bambu_cli.paths import exception_for_message as _exception_for_message config_path = CONFIG_PATH if not os.path.exists(config_path): @@ -117,7 +118,7 @@ def load_config(exit_on_fail=True): def _first_existing_path(candidates): """Return the first existing path, otherwise the first candidate expanded.""" - from bambu_cli.cli import _expand_path + from bambu_cli.paths import expand_path as _expand_path expanded = [_expand_path(path) for path in candidates if path] if not expanded: @@ -231,7 +232,7 @@ def detect_orca_slicer(): Unlike :func:`_default_orca_path` this never falls back to a non-existent first candidate, so a truthy result is a real, actionable path to suggest. """ - from bambu_cli.cli import _expand_path + from bambu_cli.paths import expand_path as _expand_path for path in _orca_binary_candidates(): if path and os.path.exists(_expand_path(path)): @@ -241,7 +242,7 @@ def detect_orca_slicer(): def detect_profiles_dir(): """Return the first OrcaSlicer BBL profiles directory that exists, or None.""" - from bambu_cli.cli import _expand_path + from bambu_cli.paths import expand_path as _expand_path for path in _profiles_dir_candidates(): if path and os.path.isdir(_expand_path(path)): @@ -376,9 +377,11 @@ def _enforce_secret_file_permissions(path, display): def load_access_code(): - from bambu_cli.cli import _display_path, _exception_for_message, _expand_path from bambu_cli.constants import EXIT_CONFIG_ERROR from bambu_cli.context import current_config + from bambu_cli.paths import display_path as _display_path + from bambu_cli.paths import exception_for_message as _exception_for_message + from bambu_cli.paths import expand_path as _expand_path cfg = current_config() if "access_code" in cfg: @@ -450,7 +453,7 @@ def fingerprint_sha256(der_cert): def _timeout_from(args, key, default): """Resolve a timeout from CLI args, then config, then the default.""" - from bambu_cli.cli import _namespace_get + from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.context import current_config if args: diff --git a/bambu_cli/context.py b/bambu_cli/context.py index 6e87518..42e7d2c 100644 --- a/bambu_cli/context.py +++ b/bambu_cli/context.py @@ -74,7 +74,7 @@ def from_config(cls, cfg: dict[str, Any]) -> Settings: """Build a Settings from a config dict, using the same key names and parsing that ``bambu_cli.config.apply_config`` uses. """ - from bambu_cli.cli import _expand_path + from bambu_cli.paths import expand_path as _expand_path cfg = cfg or {} @@ -173,7 +173,7 @@ def for_request(cls, args: Any = None) -> RuntimeContext: """ ctx = get_current() if args is not None: - from bambu_cli.cli import _json_mode_requested + from bambu_cli.jsonio import json_mode_requested as _json_mode_requested ctx.json_mode = _json_mode_requested(args) return ctx diff --git a/bambu_cli/download/downloader.py b/bambu_cli/download/downloader.py index 28a0975..efc71b9 100644 --- a/bambu_cli/download/downloader.py +++ b/bambu_cli/download/downloader.py @@ -7,13 +7,7 @@ from typing import cast from urllib.parse import urlparse -from bambu_cli.cli import ( - _exception_for_message, - _expand_path, - _namespace_get, - _path_for_message, - _redact_url_credentials, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import ( DOWNLOAD_TIMEOUT, EXIT_COMMAND_ERROR, @@ -40,8 +34,12 @@ _validate_max_download_mb_or_exit, ) from bambu_cli.errors import BambuError, abort +from bambu_cli.jsonio import redact_url_credentials as _redact_url_credentials from bambu_cli.logging_utils import logger, safe_log_error from bambu_cli.netsafety import build_safe_opener, polite_open, user_agent_for_url +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.printables import _is_printables_model_url, resolve_printables_url from bambu_cli.protocols.ftps import _download_partial_path, _noncolliding_path, _remove_partial_file from bambu_cli.utils import _ensure_output_dir, _record_download_success, emit_json_error diff --git a/bambu_cli/download/extract.py b/bambu_cli/download/extract.py index 205c8d7..032c039 100644 --- a/bambu_cli/download/extract.py +++ b/bambu_cli/download/extract.py @@ -4,7 +4,7 @@ import zipfile from urllib.parse import unquote, urlparse -from bambu_cli.cli import _namespace_get, _path_for_message +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import ( ARCHIVE_DOWNLOAD_EXTENSIONS, DEFAULT_MAX_DOWNLOAD_MB, @@ -18,6 +18,7 @@ _sanitize_download_filename, ) from bambu_cli.logging_utils import logger +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.protocols.ftps import _download_partial_path, _noncolliding_path, _remove_partial_file diff --git a/bambu_cli/download/naming.py b/bambu_cli/download/naming.py index 19241c5..8d6184b 100644 --- a/bambu_cli/download/naming.py +++ b/bambu_cli/download/naming.py @@ -6,7 +6,7 @@ import re from urllib.parse import unquote, urlparse -from bambu_cli.cli import _namespace_get, _redact_url_credentials +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import ( ARCHIVE_DOWNLOAD_EXTENSIONS, DOWNLOADABLE_EXTENSIONS, @@ -16,6 +16,7 @@ WINDOWS_RESERVED_FILENAMES, ) from bambu_cli.errors import abort +from bambu_cli.jsonio import redact_url_credentials as _redact_url_credentials from bambu_cli.logging_utils import logger diff --git a/bambu_cli/download/validation.py b/bambu_cli/download/validation.py index f60d640..3469ed4 100644 --- a/bambu_cli/download/validation.py +++ b/bambu_cli/download/validation.py @@ -4,12 +4,7 @@ import re from urllib.parse import unquote, urlparse -from bambu_cli.cli import ( - _expand_path, - _looks_like_schemeless_credential_url, - _namespace_get, - _redact_url_credentials, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import ( ARCHIVE_DOWNLOAD_EXTENSIONS, DEFAULT_MAX_DOWNLOAD_MB, @@ -21,7 +16,10 @@ ) from bambu_cli.download.naming import _file_extension, _portable_basename from bambu_cli.errors import BambuError, abort +from bambu_cli.jsonio import looks_like_schemeless_credential_url as _looks_like_schemeless_credential_url +from bambu_cli.jsonio import redact_url_credentials as _redact_url_credentials from bambu_cli.logging_utils import safe_log_error +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.utils import emit_json_error diff --git a/bambu_cli/interactive/session.py b/bambu_cli/interactive/session.py index cc4668c..f5cafff 100644 --- a/bambu_cli/interactive/session.py +++ b/bambu_cli/interactive/session.py @@ -26,7 +26,6 @@ from typing import Any, Callable from bambu_cli import utils -from bambu_cli.cli import _expand_path from bambu_cli.constants import ( ARCHIVE_DOWNLOAD_EXTENSIONS, DEFAULT_MAX_DOWNLOAD_MB, @@ -39,6 +38,7 @@ from bambu_cli.errors import BambuError, abort from bambu_cli.interactive.presets import parse_args_or_abort, preset_to_job_args from bambu_cli.interactive.prompts import Prompts, is_cancelled +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.slicer.estimate import format_estimate, read_3mf_estimate _NON_TTY_MESSAGE = "plate go is interactive; use 'plate job --confirm' for scripts." diff --git a/bambu_cli/job/orchestrate.py b/bambu_cli/job/orchestrate.py index a373ed7..59cbb06 100644 --- a/bambu_cli/job/orchestrate.py +++ b/bambu_cli/job/orchestrate.py @@ -10,13 +10,7 @@ from urllib.parse import urlparse from bambu_cli import utils -from bambu_cli.cli import ( - _exception_for_message, - _expand_path, - _namespace_get, - _path_for_message, - _redact_url_credentials, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import ( ARCHIVE_DOWNLOAD_EXTENSIONS, DEFAULT_MAX_DOWNLOAD_MB, @@ -61,7 +55,11 @@ _prepare_job_output_dir, _validate_predicted_remote_name_or_fail, ) +from bambu_cli.jsonio import redact_url_credentials as _redact_url_credentials from bambu_cli.logging_utils import logger +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.slicer import _directory_input_message, _is_directory_input, _validate_slice_options from bambu_cli.utils import emit_json diff --git a/bambu_cli/job/payload.py b/bambu_cli/job/payload.py index 2b94b46..f88d279 100644 --- a/bambu_cli/job/payload.py +++ b/bambu_cli/job/payload.py @@ -5,7 +5,7 @@ import json from urllib.parse import quote -from bambu_cli.cli import _namespace_get +from bambu_cli.argutils import namespace_get as _namespace_get def _print_next_command(args, basename): diff --git a/bambu_cli/job/predict.py b/bambu_cli/job/predict.py index d45d422..2f64e8b 100644 --- a/bambu_cli/job/predict.py +++ b/bambu_cli/job/predict.py @@ -5,7 +5,7 @@ import argparse from urllib.parse import urlparse -from bambu_cli.cli import _namespace_get +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import ( ARCHIVE_DOWNLOAD_EXTENSIONS, DOWNLOADABLE_EXTENSIONS, diff --git a/bambu_cli/job/support.py b/bambu_cli/job/support.py index ec64941..37d9a4f 100644 --- a/bambu_cli/job/support.py +++ b/bambu_cli/job/support.py @@ -5,14 +5,12 @@ import os from bambu_cli import utils -from bambu_cli.cli import ( - _expand_path, - _namespace_get, - _path_for_message, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR from bambu_cli.errors import BambuError, abort from bambu_cli.logging_utils import logger +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.utils import _ensure_output_dir diff --git a/bambu_cli/jsonio.py b/bambu_cli/jsonio.py new file mode 100644 index 0000000..ef2e410 --- /dev/null +++ b/bambu_cli/jsonio.py @@ -0,0 +1,63 @@ +"""JSON-mode detection and output-redaction helpers. + +Extracted from ``bambu_cli.cli`` (roadmap B.4) so domain modules can decide +whether machine-readable output was requested, and scrub credentials from URLs +before they reach a log line or a JSON envelope, without importing from the CLI +entrypoint. These helpers never terminate the process. + +Note: ``bambu_cli.utils`` carries its own credential-redaction pass applied +uniformly across every emitted JSON payload; the ``redact_url_credentials`` +here is the eager, single-value variant callers use when building the strings +that go into those payloads and log messages. +""" + +from urllib.parse import urlparse, urlunparse + +__all__ = [ + "json_mode_requested", + "looks_like_schemeless_credential_url", + "redact_url_credentials", +] + + +def json_mode_requested(args): + return bool(getattr(args, "json", False)) + + +def looks_like_schemeless_credential_url(value): + """Detect userinfo-bearing URLs where the user omitted https://.""" + text = str(value or "") + if "\\" in text or any(char.isspace() for char in text): + return False + if "@" not in text or text.startswith(("/", ".", "~", "$")): + return False + try: + parsed = urlparse(f"https://{text}") + host = parsed.hostname or "" + return bool(parsed.netloc and (parsed.username is not None or parsed.password is not None) and "." in host) + except Exception: + return False + + +def redact_url_credentials(value): + """Return URL text with any userinfo removed before logging or JSON output.""" + text = str(value or "") + if "@" not in text: + return value + parsed = urlparse(text) + if "://" not in text and looks_like_schemeless_credential_url(text): + redacted = redact_url_credentials(f"https://{text}") + prefix = "https://" + return redacted[len(prefix) :] if isinstance(redacted, str) and redacted.startswith(prefix) else redacted + if not parsed.scheme or not parsed.netloc or (parsed.username is None and parsed.password is None): + return value + host = parsed.hostname or "" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + try: + port = parsed.port + except ValueError: + port = None + if port is not None: + host = f"{host}:{port}" + return urlunparse((parsed.scheme, host, parsed.path, parsed.params, parsed.query, parsed.fragment)) diff --git a/bambu_cli/netsafety.py b/bambu_cli/netsafety.py index 79ea0eb..23a19a0 100644 --- a/bambu_cli/netsafety.py +++ b/bambu_cli/netsafety.py @@ -14,7 +14,7 @@ import urllib.request from urllib.parse import urlparse -from bambu_cli.cli import _redact_url_credentials +from bambu_cli.jsonio import redact_url_credentials as _redact_url_credentials from bambu_cli.logging_utils import logger _dns_cache: dict = {} diff --git a/bambu_cli/paths.py b/bambu_cli/paths.py new file mode 100644 index 0000000..6481a28 --- /dev/null +++ b/bambu_cli/paths.py @@ -0,0 +1,80 @@ +"""Filesystem/path helpers shared by the CLI and domain modules. + +These were extracted from ``bambu_cli.cli`` so domain code no longer has to +reach back into the CLI entrypoint for path expansion and display compaction +(roadmap B.4). They contain no argument parsing and never terminate the +process; domain callers that need to fail do so via ``BambuError`` / ``abort``. +""" + +import os + +__all__ = [ + "expand_path", + "display_path", + "path_for_message", + "exception_for_message", +] + + +def expand_path(path): + """Expand user and environment variables in local filesystem paths.""" + if path is None: + return None + return os.path.expandvars(os.path.expanduser(str(path))) + + +_HOME_DIR = os.path.expanduser("~") +_NORM_HOME_DIR = None + + +def _get_norm_home_dir(): + global _NORM_HOME_DIR + if _NORM_HOME_DIR is None: + try: + _NORM_HOME_DIR = os.path.normcase(os.path.abspath(_HOME_DIR)) + except (TypeError, ValueError, OSError): + _NORM_HOME_DIR = _HOME_DIR + return _NORM_HOME_DIR + + +def display_path(path): + """Return a user-facing path with the current home directory compacted.""" + if path is None: + return None + text = str(path) + + # Inline expand_path for speed and caching + expanded = os.path.expandvars(os.path.expanduser(text)) + if not os.path.isabs(expanded): + return text + + norm_home = _get_norm_home_dir() + try: + norm_expanded = os.path.normcase(os.path.abspath(expanded)) + except (TypeError, ValueError, OSError): + return text + + if norm_expanded == norm_home: + return "~" + prefix = norm_home + os.sep + if norm_expanded.startswith(prefix): + return "~" + os.sep + os.path.relpath(expanded, _HOME_DIR) + return text + + +def path_for_message(path): + """Return a local path suitable for human and agent-facing messages.""" + display = display_path(path) + if display is None or os.sep == "/": + return display + return display.replace(os.sep, "/") + + +def exception_for_message(exc): + """Return exception text with local filesystem paths compacted for output.""" + message = str(exc) + for attr in ("filename", "filename2"): + path = getattr(exc, attr, None) + if path is not None: + message = message.replace(str(path), display_path(path)) + return message diff --git a/bambu_cli/protocols/ftps.py b/bambu_cli/protocols/ftps.py index b2a00e3..2da9e68 100644 --- a/bambu_cli/protocols/ftps.py +++ b/bambu_cli/protocols/ftps.py @@ -179,7 +179,7 @@ def _download_partial_path(outpath): def _noncolliding_path(path): - from bambu_cli.cli import _path_for_message + from bambu_cli.paths import path_for_message as _path_for_message try: fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) diff --git a/bambu_cli/protocols/mqtt.py b/bambu_cli/protocols/mqtt.py index 2dfefda..1de5a8e 100644 --- a/bambu_cli/protocols/mqtt.py +++ b/bambu_cli/protocols/mqtt.py @@ -504,7 +504,7 @@ def monitor_status(args): can follow a print in real time. Otherwise a live human-readable progress bar is shown. """ - from bambu_cli.cli import _namespace_get + from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.printer import get_printer from bambu_cli.utils import emit_json_line diff --git a/bambu_cli/setup_cmd/common.py b/bambu_cli/setup_cmd/common.py index d922397..5934400 100644 --- a/bambu_cli/setup_cmd/common.py +++ b/bambu_cli/setup_cmd/common.py @@ -9,11 +9,12 @@ import sys import tempfile -from bambu_cli.cli import _display_path, _expand_path from bambu_cli.config import CONFIG_PATH, MODEL_MAPPING from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR, EXIT_FILE_ERROR from bambu_cli.errors import abort from bambu_cli.logging_utils import logger +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.utils import _secure_makedirs, emit_json_error diff --git a/bambu_cli/setup_cmd/config_cmd.py b/bambu_cli/setup_cmd/config_cmd.py index 20d4e5a..2d04782 100644 --- a/bambu_cli/setup_cmd/config_cmd.py +++ b/bambu_cli/setup_cmd/config_cmd.py @@ -3,10 +3,13 @@ import json import os -from bambu_cli.cli import _display_path, _exception_for_message, _expand_path, _namespace_get +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_CONFIG_ERROR, EXIT_SUCCESS from bambu_cli.errors import abort from bambu_cli.logging_utils import logger, safe_log_error +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.setup_cmd.common import _config_path from bambu_cli.setup_cmd.preflight import collect_preflight_checks from bambu_cli.utils import emit_json, emit_json_error diff --git a/bambu_cli/setup_cmd/migrate.py b/bambu_cli/setup_cmd/migrate.py index 1fbea51..9eaa122 100644 --- a/bambu_cli/setup_cmd/migrate.py +++ b/bambu_cli/setup_cmd/migrate.py @@ -3,10 +3,13 @@ import json import os -from bambu_cli.cli import _display_path, _exception_for_message, _expand_path, _namespace_get +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_CONFIG_ERROR from bambu_cli.errors import abort from bambu_cli.logging_utils import logger +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.setup_cmd.common import ( _config_path, _default_access_code_file_path, diff --git a/bambu_cli/setup_cmd/preflight.py b/bambu_cli/setup_cmd/preflight.py index 8e0dddb..be4d0ae 100644 --- a/bambu_cli/setup_cmd/preflight.py +++ b/bambu_cli/setup_cmd/preflight.py @@ -7,13 +7,16 @@ import stat import sys -# load_config, _config_path, and _display_path are bound at module level so -# tests can patch them here (bambu_cli.setup_cmd.preflight.). -from bambu_cli.cli import _display_path, _exception_for_message, _expand_path from bambu_cli.config import _access_code_value_problem, load_config from bambu_cli.constants import EXIT_CONFIG_ERROR, EXIT_SUCCESS from bambu_cli.errors import abort from bambu_cli.logging_utils import logger + +# load_config, _config_path, and _display_path are bound at module level so +# tests can patch them here (bambu_cli.setup_cmd.preflight.). +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.setup_cmd.common import _config_path, _looks_like_placeholder from bambu_cli.utils import emit_json diff --git a/bambu_cli/setup_cmd/wizard.py b/bambu_cli/setup_cmd/wizard.py index 42a5645..153d3ef 100644 --- a/bambu_cli/setup_cmd/wizard.py +++ b/bambu_cli/setup_cmd/wizard.py @@ -6,16 +6,14 @@ import sys import threading -from bambu_cli.cli import ( - _display_path, - _exception_for_message, - _namespace_get, - _setup_args_provided, -) +from bambu_cli.argutils import namespace_get as _namespace_get +from bambu_cli.argutils import setup_args_provided as _setup_args_provided from bambu_cli.config import MODEL_MAPPING, _access_code_value_problem from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR, EXIT_FILE_ERROR, EXIT_NETWORK_ERROR from bambu_cli.errors import abort from bambu_cli.logging_utils import logger, safe_log_error +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import exception_for_message as _exception_for_message from bambu_cli.setup_cmd.common import ( _build_setup_config, _config_path, diff --git a/bambu_cli/slicer/cmd.py b/bambu_cli/slicer/cmd.py index 16a5c63..a57267f 100644 --- a/bambu_cli/slicer/cmd.py +++ b/bambu_cli/slicer/cmd.py @@ -7,12 +7,16 @@ import shutil import subprocess -from bambu_cli.cli import _display_path, _exception_for_message, _expand_path, _namespace_get, _path_for_message +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.config import MODEL_MAPPING, get_slicer_timeout from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR, EXIT_FILE_ERROR, EXIT_TIMEOUT from bambu_cli.context import current_settings from bambu_cli.errors import BambuError, abort from bambu_cli.logging_utils import logger, safe_log_error +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.slicer.options import ( _directory_input_message, _is_directory_input, diff --git a/bambu_cli/slicer/options.py b/bambu_cli/slicer/options.py index 8026a10..86cede5 100644 --- a/bambu_cli/slicer/options.py +++ b/bambu_cli/slicer/options.py @@ -14,9 +14,10 @@ from functools import lru_cache from typing import Any -from bambu_cli.cli import _namespace_get, _path_for_message +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.download.naming import _portable_basename from bambu_cli.logging_utils import logger +from bambu_cli.paths import path_for_message as _path_for_message # Profile bookkeeping fields that are not user-tunable print settings; excluded # from discovery (``--list-settings``) and unknown-key validation. @@ -232,7 +233,7 @@ def _directory_input_message(path: str) -> str: def _validate_slice_options(args: argparse.Namespace) -> str | None: - from bambu_cli.cli import _namespace_get + from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import ( MAX_BED_TEMP_C, MAX_NOZZLE_TEMP_C, diff --git a/bambu_cli/slicer/output.py b/bambu_cli/slicer/output.py index 2128da2..066163d 100644 --- a/bambu_cli/slicer/output.py +++ b/bambu_cli/slicer/output.py @@ -7,15 +7,13 @@ import subprocess import zipfile -from bambu_cli.cli import ( - _exception_for_message, - _expand_path, - _namespace_get, - _path_for_message, -) +from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR from bambu_cli.errors import abort from bambu_cli.logging_utils import logger, safe_log_error +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path +from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.protocols.ftps import _remove_partial_file from bambu_cli.utils import emit_json, emit_json_error diff --git a/bambu_cli/slicer/profiles.py b/bambu_cli/slicer/profiles.py index 15dbd16..482392d 100644 --- a/bambu_cli/slicer/profiles.py +++ b/bambu_cli/slicer/profiles.py @@ -10,8 +10,9 @@ from functools import lru_cache from typing import IO -from bambu_cli.cli import _display_path, _expand_path from bambu_cli.logging_utils import logger +from bambu_cli.paths import display_path as _display_path +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.slicer.options import ( _coerce_override_value, _generic_section_overrides, diff --git a/bambu_cli/slicer/step_convert.py b/bambu_cli/slicer/step_convert.py index 9602a4f..1735726 100644 --- a/bambu_cli/slicer/step_convert.py +++ b/bambu_cli/slicer/step_convert.py @@ -8,8 +8,9 @@ import sys import tempfile -from bambu_cli.cli import _exception_for_message, _expand_path from bambu_cli.logging_utils import logger +from bambu_cli.paths import exception_for_message as _exception_for_message +from bambu_cli.paths import expand_path as _expand_path from bambu_cli.slicer.options import _safe_temp_prefix GMSH_MESH_SCALE = "0.5" diff --git a/bambu_cli/utils.py b/bambu_cli/utils.py index fd80457..5939521 100644 --- a/bambu_cli/utils.py +++ b/bambu_cli/utils.py @@ -20,7 +20,8 @@ def _ensure_output_dir(path): try: _secure_makedirs(path, exist_ok=True) except OSError as e: - from bambu_cli.cli import _exception_for_message, _path_for_message + from bambu_cli.paths import exception_for_message as _exception_for_message + from bambu_cli.paths import path_for_message as _path_for_message logger.error(f"Could not create output directory {_path_for_message(path)}: {_exception_for_message(e)}") abort("", exit_code=EXIT_FILE_ERROR) @@ -28,7 +29,7 @@ def _ensure_output_dir(path): def _ensure_parent_dir(path): """Create the parent directory for an output file when one was supplied.""" - from bambu_cli.cli import _expand_path + from bambu_cli.paths import expand_path as _expand_path parent = os.path.dirname(_expand_path(path)) if parent: diff --git a/docs/quality-roadmap.md b/docs/quality-roadmap.md index a5b7fb6..ab2baaf 100644 --- a/docs/quality-roadmap.md +++ b/docs/quality-roadmap.md @@ -51,7 +51,7 @@ security is not yet **A+**. | Area | Score | Evidence | |------|-------|----------| | Security mindset | **A** | allow-private-ips fixed; TLS pin suite (mismatch + handshake SSLError both fail closed); SSRF/redirect tests; bandit blocking; security markers; honest known-limitations table in SECURITY.md. A+ needs single pin helper | -| Architecture | **A−** | `@mockable` = 0; abort error model; thin entrypoint; domain ↛ `sys.exit`. **Still open:** domain→`cli` private helper imports (~40 sites); Phase B.4 extract not done; pin verify not single-sourced (B.5) | +| Architecture | **A** | `@mockable` = 0; abort error model; thin entrypoint; domain ↛ `sys.exit`. B.4 done: path/JSON/argparse helpers extracted to `paths`/`jsonio`/`argutils`, so no domain module imports private `_underscore` helpers from `cli` (only public `build_parser`/`main` remain). B.5 done: single `verify_cert_fingerprint` (PR #89) | | Agent JSON UX | **A** | ok/error envelopes + many per-command schemas + contract harness. Gaps: dedicated `status` success schema, upload/files/stop/setup | | Correctness / bugs | **A** | dead flags fixed (global `--json` before subcommand); structured errors; purity greps; version single-sourced | | Typing | **A** | `uvx mypy -p bambu_cli` full package with `check_untyped_defs = true`; no residual excludes | @@ -380,8 +380,8 @@ pytest -W error::ResourceWarning --cov=bambu_cli --cov-fail-under=85 | B.1 | Expand `BambuError` usage: config, download, slice, upload, connect, auth | Keep exit codes identical | | B.2 | Replace `sys.exit` in `commands`, `job`, `download`, `slicer`, `setup_cmd`, `camera`, `config` with raises | Protocol layer returns errors or raises; no exit in mqtt/ftps | | B.3 | Ensure `main()` maps `BambuError` → JSON + exit (already mostly there) | | -| B.4 | Extract `bambu_cli/paths.py`, `bambu_cli/jsonio.py`, `bambu_cli/argutils.py` from `cli.py` | Break domain → cli imports | -| B.5 | Single `verify_cert_fingerprint(der, expected)` helper | mqtt + ftps + camera | +| B.4 | **Done.** Extracted `bambu_cli/paths.py`, `bambu_cli/jsonio.py`, `bambu_cli/argutils.py` from `cli.py` | Domain → cli private-helper imports broken (only public `build_parser`/`main` remain) | +| B.5 | **Done (PR #89).** Single `verify_cert_fingerprint(der, expected)` helper | mqtt + ftps + camera | | B.6 | Start deleting `@mockable` call sites as tests re-patch real modules | No new `@mockable` | #### Tests @@ -621,7 +621,7 @@ If **full A+** is the goal, follow phases 0→A→B→C→D in order; skip ahead |-------|--------|-------|----------------|-------| | 0 Trust & truth | **done** | local | 2026-07-08 | allow-private-ips, bare except, version single-source | | A Testing foundation | **done** | local | 2026-07-08 | TLS suite, markers, transport tests, cov~80% | -| B Error model & seams | **partial** | #11 | 2026-07-08 | abort/BambuError; sys.exit entry-only; mockable removed. **B.4** paths/jsonio/argutils extract and **B.5** single pin helper still open (2026-07-17 audit) | +| B Error model & seams | **done** | #11 | 2026-07-08 | abort/BambuError; sys.exit entry-only; mockable removed. **B.4** paths/jsonio/argutils extract done (domain no longer imports private cli helpers); **B.5** single pin helper done (PR #89) | | C Coverage & typing | **in progress** | #18 | 2026-07-09 | full-package mypy + `check_untyped_defs` done; cov ~84% with CI floor **83** (target 92); per-module floors not enforced | | D Contracts & 1.0 | **in progress** | local | — | schemas + contract harness + stability policy; remaining agent `--json` schemas land in follow-up PRs | | E Stretch | not started | | | fuzz job, SBOM, dependabot, scheduled live-printer | diff --git a/tests/test_access_code_deprecation.py b/tests/test_access_code_deprecation.py index 5de907a..1938708 100644 --- a/tests/test_access_code_deprecation.py +++ b/tests/test_access_code_deprecation.py @@ -44,7 +44,7 @@ def test_warns_once_when_inline_only(self): def test_no_warning_when_access_code_file_used(self): with ( config_ctx({"access_code_file": "/tmp/does-not-matter"}), - patch("bambu_cli.cli._expand_path", return_value="/tmp/does-not-matter"), + patch("bambu_cli.paths.expand_path", return_value="/tmp/does-not-matter"), patch("builtins.open", side_effect=lambda *a, **k: _fake_file("filecode\n")), ): # assertNoLogs needs Python 3.10+; assert via the logger mock instead. diff --git a/tests/test_job.py b/tests/test_job.py index 2a3868b..606556d 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -44,7 +44,8 @@ def emit(self, record): from bambu_cli import bambu # noqa: E402 from bambu_cli import job # noqa: E402 from bambu_cli import utils # noqa: E402 -from bambu_cli.cli import _display_path, build_parser # noqa: E402 +from bambu_cli.cli import build_parser # noqa: E402 +from bambu_cli.paths import display_path as _display_path # noqa: E402 from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR # noqa: E402 from bambu_cli.context import RuntimeContext # noqa: E402 from bambu_cli.job import JobSteps, _run_job # noqa: E402