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