Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> --dry-run` now reports `would_slice` consistently with
Expand Down
42 changes: 42 additions & 0 deletions bambu_cli/argutils.py
Original file line number Diff line number Diff line change
@@ -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")
)
10 changes: 4 additions & 6 deletions bambu_cli/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
142 changes: 6 additions & 136 deletions bambu_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
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

# 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,
EXIT_CONFIG_ERROR,
EXIT_SUCCESS,
PRINTER_NETWORK_COMMANDS,
)
from .jsonio import json_mode_requested as _json_mode_requested
from .utils import emit_json, emit_json_error


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

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

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

Expand Down
2 changes: 1 addition & 1 deletion bambu_cli/commands/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions bambu_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions bambu_cli/commands/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion bambu_cli/commands/gcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion bambu_cli/commands/print_cmd.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion bambu_cli/commands/status.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading