From 3e9c6f937768534f076f620f2627b33be9f3423f Mon Sep 17 00:00:00 2001 From: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:32:10 -0400 Subject: [PATCH 1/2] refactor: sandbox Printables behind an adapter that cannot raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Printables has no public, documented, or versioned API — platecli talks to their GraphQL endpoint against an observed schema. It is the most likely part of the tool to break through no fault of ours, and it was not fenced off: - four call sites imported the *private* _is_printables_model_url, so there was no public surface to hold stable - failures collapsed to a (None, None) sentinel, so a caller could not tell "not a Printables URL" from "they changed the schema" from "network down" from "this model has no STL" — every one of them printed the same "Failed to resolve Printables model URL." - get_download_link read result.get("data", {}).get("getDownloadLink", {}). For the standard {"errors": [...], "data": null} envelope that returns None, not {}, so it raised AttributeError. The broad except caught it and showed the user: "'NoneType' object has no attribute 'get'". The sibling function had a comment warning about this exact trap. printables.py becomes a package with a hard inside/outside line: errors.py typed failures: unavailable / contract_changed / model_unavailable, each carrying a reason token and a remedy client.py the only module that knows the wire format. Raises typed errors instead of sentinels. SEALED: check_layers.py now fails CI if anything outside the package imports it adapter.py the containment boundary __init__.py the entire public surface The adapter's guarantee: no Printables failure escapes as an exception. resolve() always returns a PrintablesResolution. A renamed field, a redesigned error envelope, an HTML error page, a 4MB junk body — all become a typed result with an actionable message, never a traceback inside plate job. KeyboardInterrupt and SystemExit are the only things still allowed to propagate, so Ctrl-C works. Also fixed while in here: the null-data AttributeError above, and an unbounded resp.read() on the metadata path (now capped at 4MB; the file download path had limits, this one had none). Tests: the 19 tests that drove private functions and patched bambu_cli.printables.build_safe_opener are replaced by tests/test_printables_adapter.py (50 tests) driving the public surface with an injected opener. It keeps every behavior the old class covered and adds the containment sweep: 16 malformed payloads and 7 unexpected exception types, each asserted not to raise. That sweep is the regression net for "Printables changed their API". Gates: 1177 passed (was 1146), coverage 86.5% (was 86.2%), ruff/ruff-format/ mypy/bandit/layers/syntax/help/workflow/compat smokes green; uv build + package_contents_smoke confirm the new subpackage ships. --- AGENTS.md | 3 + bambu_cli/download/__init__.py | 2 +- bambu_cli/download/downloader.py | 4 +- bambu_cli/job/orchestrate.py | 4 +- bambu_cli/job/predict.py | 4 +- bambu_cli/printables.py | 187 ------------- bambu_cli/printables/__init__.py | 68 +++++ bambu_cli/printables/adapter.py | 120 ++++++++ bambu_cli/printables/client.py | 237 ++++++++++++++++ bambu_cli/printables/errors.py | 67 +++++ scripts/check_layers.py | 49 +++- tests/test_download_cmd.py | 459 ------------------------------- tests/test_printables_adapter.py | 364 ++++++++++++++++++++++++ tests/test_printables_headers.py | 13 +- 14 files changed, 922 insertions(+), 659 deletions(-) delete mode 100644 bambu_cli/printables.py create mode 100644 bambu_cli/printables/__init__.py create mode 100644 bambu_cli/printables/adapter.py create mode 100644 bambu_cli/printables/client.py create mode 100644 bambu_cli/printables/errors.py create mode 100644 tests/test_printables_adapter.py diff --git a/AGENTS.md b/AGENTS.md index 17fb187..315edcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Logic lives in focused packages; `bambu_cli/bambu.py` is a **thin entrypoint** ( | `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 | +| `printables/` | Printables.com integration behind a strict adapter. `client.py` (the undocumented GraphQL wire format) is **sealed** — import only from `bambu_cli.printables`. `adapter.py` guarantees no Printables failure escapes as an exception | | `job/` | One-shot `job`/`send` orchestration, dry-run predict, print payloads, injectable `JobSteps` | | `setup_cmd/` | Guided/non-interactive setup, mDNS, config show/validate, preflight | | `slicer/` | OrcaSlicer integration | @@ -76,6 +77,8 @@ The rule exists because directories alone never held it: `protocols/`, `slicer/` Accepted debt lives in `ALLOWED` in that script, each entry with a reason. Shrink it; do not grow it. One edge is currently allowlisted: `context -> printer` (`RuntimeContext` lazily constructs a `BambuPrinter`; the real fix is a composition root that installs a printer factory). +The same script also enforces `SEALED` — package internals no outside module may import. `bambu_cli.printables.client` is sealed because an adapter is only a sandbox if callers cannot reach past it. **Third-party integrations go behind an adapter that cannot raise:** `PrintablesAdapter.resolve()` returns a `PrintablesResolution` for every outcome, converting a renamed field or a redesigned error envelope into a typed `printables_contract_changed` result instead of a traceback in the middle of `plate job`. `KeyboardInterrupt`/`SystemExit` are deliberately the only things that still propagate. + **Package inventory is derived:** setuptools finds `bambu_cli*`; syntax smoke and CLI help smoke auto-discover modules/commands (`scripts/syntax_smoke.py`, `scripts/cli_help_smoke.py`). Adding a module under `bambu_cli/` or a subcommand in `cli.py` is enough — no triplicated lists. **Typing (mypy):** CI runs `uvx mypy@ -p bambu_cli` over the **whole package** with `check_untyped_defs = true` (CI pins the tool version in `.github/workflows/ci.yml`; running it unpinned locally is fine). There is **no residual exclude blocklist** — `printer.py` and `slicer/` are included. New modules are type-checked automatically. diff --git a/bambu_cli/download/__init__.py b/bambu_cli/download/__init__.py index 7d65500..23078db 100644 --- a/bambu_cli/download/__init__.py +++ b/bambu_cli/download/__init__.py @@ -74,6 +74,6 @@ user_agent_for_url, ) from bambu_cli.printables import ( # noqa: F401 - _is_printables_model_url, + is_printables_url, resolve_printables_url, ) diff --git a/bambu_cli/download/downloader.py b/bambu_cli/download/downloader.py index 4b080e7..d92b6f1 100644 --- a/bambu_cli/download/downloader.py +++ b/bambu_cli/download/downloader.py @@ -40,7 +40,7 @@ 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.printables import is_printables_url, resolve_printables_url from bambu_cli.utils import _ensure_output_dir, _record_download_success, emit_json_error @@ -88,7 +88,7 @@ def _cmd_download( normalized_source_report = _redact_url_credentials(normalized_source) max_download_bytes = _validate_max_download_mb_or_exit(args) _validate_download_url_or_exit(args, source_url, normalized_source, url, "validate", "Invalid URL source") - is_printables_model = _is_printables_model_url(url) + is_printables_model = is_printables_url(url) if not is_printables_model: _reject_unsupported_download_extension(args, source_url, normalized_source, url, urlparse(url).path) diff --git a/bambu_cli/job/orchestrate.py b/bambu_cli/job/orchestrate.py index 2c78edc..4f1378c 100644 --- a/bambu_cli/job/orchestrate.py +++ b/bambu_cli/job/orchestrate.py @@ -25,7 +25,6 @@ _extract_zip_model, _file_extension, _is_http_url, - _is_printables_model_url, _known_unsupported_download_extension, _looks_like_url, _max_download_mb_error, @@ -60,6 +59,7 @@ 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_url from bambu_cli.slicer import _directory_input_message, _is_directory_input, _validate_slice_options from bambu_cli.utils import emit_json @@ -165,7 +165,7 @@ def _run_job(ctx, args, steps=None): try: if getattr(args, "dry_run", False) and _is_http_url(source): - if not _is_printables_model_url(source): + if not is_printables_url(source): unsupported_ext = _known_unsupported_download_extension(urlparse(source).path) if unsupported_ext: summary["extension"] = unsupported_ext diff --git a/bambu_cli/job/predict.py b/bambu_cli/job/predict.py index 2f64e8b..92393fb 100644 --- a/bambu_cli/job/predict.py +++ b/bambu_cli/job/predict.py @@ -16,10 +16,10 @@ _download_source_extension, _download_target_filename, _file_extension, - _is_printables_model_url, _portable_basename, _sanitize_download_filename, ) +from bambu_cli.printables import is_printables_url from bambu_cli.slicer import _sliced_output_path @@ -122,7 +122,7 @@ def _predicted_url_remote_name(url, args): not resolve Printables pages, HTML pages, redirects, or ZIP members because doing so would require network I/O or archive extraction. """ - if _is_printables_model_url(url): + if is_printables_url(url): return None predicted_ext = _predicted_url_download_extension(url, args) if predicted_ext in ARCHIVE_DOWNLOAD_EXTENSIONS: diff --git a/bambu_cli/printables.py b/bambu_cli/printables.py deleted file mode 100644 index 565ed64..0000000 --- a/bambu_cli/printables.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Printables.com-specific model resolution: detect Printables model page -URLs and resolve them to a direct downloadable file URL via the Printables -GraphQL API.""" - -import json -import re -import urllib.error -import urllib.request -from urllib.parse import urlparse - -from bambu_cli.constants import DEFAULT_NETWORK_TIMEOUT -from bambu_cli.logging_utils import logger -from bambu_cli.netsafety import build_safe_opener, platecli_user_agent, polite_open - - -def _is_printables_model_url(value): - parsed = urlparse(value) - host = (parsed.hostname or "").lower() - return host in ("printables.com", "www.printables.com") and bool(re.search(r"/model/(\d+)", parsed.path)) - - -def _select_printables_file(files, file_desc, type_key="stl"): - if len(files) > 1: - logger.info(f" Found {len(files)} {file_desc} files:") - for s in files: - logger.info(f" • {s.get('name', '?')} ({s.get('fileSize', 0) // 1024}KB)") - file_to_use = max(files, key=lambda x: x.get("fileSize", 0)) - logger.info(f" → Using {file_desc}: {file_to_use.get('name', '?')} ({file_to_use.get('fileSize', 0) // 1024}KB)") - return file_to_use, type_key - - -def _get_printables_file_info(model_id, gql_headers, opener): - """Helper to fetch file info from Printables API.""" - - payload = json.dumps( - { - "variables": {"id": model_id}, - "query": "query($id: ID!){print(id: $id){name stls{name fileSize id} gcodes{name fileSize id}}}", - } - ) - req = urllib.request.Request("https://api.printables.com/graphql/", data=payload.encode(), headers=gql_headers) - - file_type = "stl" - try: - with polite_open(opener, req, timeout=DEFAULT_NETWORK_TIMEOUT) as resp: - response_data = resp.read() - except urllib.error.URLError as e: - logger.error(f"Network error querying Printables API: {e}") - return None, None, None - except Exception as e: - logger.error(f"Failed to query Printables API: {e}") - return None, None, None - - try: - result = json.loads(response_data) - except Exception as e: - logger.error(f"Failed to parse Printables API response: {e}") - return None, None, None - - if not isinstance(result, dict): - logger.error("Invalid Printables API response structure.") - return None, None, None - - # The standard GraphQL error envelope is {"errors": [...], "data": null}: the - # "data" key EXISTS with value None, so `result.get("data", {})` returns None, - # not {}. Coerce with `or {}` (and the nested lists with `or []`) so an - # error-shaped response degrades to a clean "not found" instead of an - # AttributeError/TypeError that escapes the caller as a raw traceback. - if result.get("errors"): - first_err = result["errors"][0] if isinstance(result["errors"], list) and result["errors"] else None - detail = "" - if isinstance(first_err, dict) and first_err.get("message"): - detail = f": {first_err['message']}" - logger.error(f"Printables API returned an error for model #{model_id}{detail}") - return None, None, None - - model = (result.get("data") or {}).get("print") - if not isinstance(model, dict): - logger.error(f"Model #{model_id} not found on Printables") - return None, None, None - - stls_raw = model.get("stls") or [] - gcodes_raw = model.get("gcodes") or [] - if not isinstance(stls_raw, list): - stls_raw = [] - if not isinstance(gcodes_raw, list): - gcodes_raw = [] - - stls, steps, threemfs = [], [], [] - for s in stls_raw: - ext = s.get("name", "").lower().rpartition(".")[-1] - if ext == "stl": - stls.append(s) - elif ext in ("step", "stp"): - steps.append(s) - elif ext == "3mf": - threemfs.append(s) - for g in gcodes_raw: - ext = g.get("name", "").lower().rpartition(".")[-1] - if ext == "3mf": - threemfs.append(g) - - logger.info(f" Model: {model.get('name', '?')}") - if stls: - file_to_use, file_type = _select_printables_file(stls, "STL", "stl") - elif steps: - file_to_use, file_type = _select_printables_file(steps, "STEP", "stl") - elif threemfs: - logger.warning(" ⚠️ No STL/STEP files — falling back to 3MF (cannot re-slice with custom settings)") - file_to_use = max(threemfs, key=lambda x: x.get("fileSize", 0)) - file_type = "gcode" if file_to_use in gcodes_raw else "stl" - logger.info(f" → Using 3MF: {file_to_use.get('name', '?')} ({file_to_use.get('fileSize', 0) // 1024}KB)") - else: - logger.error("No STL, STEP, or 3MF files found for this model") - return None, None, None - - file_id = file_to_use.get("id") - file_name = file_to_use.get("name") - if not file_id or not file_name: - logger.error(f"Selected Printables file for model #{model_id} is missing an id or name.") - return None, None, None - return file_id, file_type, file_name - - -def _get_printables_download_link(file_id, model_id, file_type, stl_name, gql_headers, opener): - """Helper to fetch download link from Printables API.""" - - payload = json.dumps( - { - "operationName": "GetDownloadLink", - "variables": {"id": file_id, "printId": model_id, "source": "model_detail", "fileType": file_type}, - "query": "mutation GetDownloadLink($id: ID!, $printId: ID!, $source: DownloadSourceEnum!, $fileType: DownloadFileTypeEnum!) { getDownloadLink(id: $id, printId: $printId, source: $source, fileType: $fileType) { ok output { link } errors { field messages } } }", - } - ) - req = urllib.request.Request("https://api.printables.com/graphql/", data=payload.encode(), headers=gql_headers) - - try: - with polite_open(opener, req, timeout=DEFAULT_NETWORK_TIMEOUT) as resp: - result = json.loads(resp.read()) - dl = result.get("data", {}).get("getDownloadLink", {}) - if dl.get("ok") and dl.get("output", {}).get("link"): - download_url = dl["output"]["link"] - return download_url, stl_name - else: - errs = dl.get("errors", []) - msg = errs[0]["messages"][0] if errs else "unknown error" - logger.error(f"Failed to get download link: {msg}") - return None, None - except urllib.error.URLError as e: - logger.error(f"Network error getting download link: {e}") - return None, None - except Exception as e: - logger.error(f"Failed to get download link: {e}") - return None, None - - -def resolve_printables_url(url): - """Resolve a Printables model URL to a direct file download URL and filename. - Returns (download_url, filename) or (None, None) if resolution fails. - """ - if not _is_printables_model_url(url): - return None, None - - printables_match = re.search(r"/model/(\d+)", urlparse(url).path) - if not printables_match: - return None, None - - model_id = printables_match.group(1) - logger.info(f"🔍 Detected Printables model #{model_id}, resolving files...") - - # platecli identifies itself honestly to Printables and does not forge - # browser-only headers (Origin/Referer). Verified against - # https://api.printables.com/graphql/ on 2026-07-25: both the file-info - # query and the GetDownloadLink mutation return HTTP 200 with data using an - # honest User-Agent and no Origin/Referer. - gql_headers = { - "User-Agent": platecli_user_agent(), - "Accept": "*/*", - "Content-Type": "application/json", - } - - opener = build_safe_opener() - file_id, file_type, stl_name = _get_printables_file_info(model_id, gql_headers, opener) - if not file_id: - return None, None - - return _get_printables_download_link(file_id, model_id, file_type, stl_name, gql_headers, opener) diff --git a/bambu_cli/printables/__init__.py b/bambu_cli/printables/__init__.py new file mode 100644 index 0000000..a7c8afb --- /dev/null +++ b/bambu_cli/printables/__init__.py @@ -0,0 +1,68 @@ +"""Printables.com integration, behind a strict adapter. + +Printables has no public, documented, versioned API — platecli talks to their +GraphQL endpoint against an observed schema. That makes this the most likely +part of the tool to break through no fault of its own, so it is fenced off: + +* ``client.py`` is the only module that knows the wire format. Nothing outside + this package may import it. +* ``adapter.py`` guarantees that no Printables failure escapes as an exception. + A schema change becomes a typed, reportable outcome — never a traceback in + the middle of ``plate job``. +* This module is the entire public surface. Import from here. + +The names below are what the rest of the codebase may use: + +``is_printables_url(url)`` + Cheap URL test. Never raises, never touches the network. + +``resolve_printables_url(url)`` + ``(download_url, filename)``, or ``(None, None)`` on any failure. The + long-standing shape, kept so callers that only branch on "did it resolve" + need no changes. + +``PrintablesAdapter`` / ``PrintablesResolution`` + The richer surface: *why* a resolution failed, and what to tell the user. +""" + +from __future__ import annotations + +from bambu_cli.printables.adapter import PrintablesAdapter, PrintablesResolution +from bambu_cli.printables.errors import ( + PrintablesContractChanged, + PrintablesError, + PrintablesModelUnavailable, + PrintablesUnavailable, +) + +__all__ = [ + "PrintablesAdapter", + "PrintablesContractChanged", + "PrintablesError", + "PrintablesModelUnavailable", + "PrintablesResolution", + "PrintablesUnavailable", + "is_printables_url", + "resolve_printables", + "resolve_printables_url", +] + + +def is_printables_url(url): + """True if *url* is a printables.com model page. Never raises.""" + return PrintablesAdapter.handles(url) + + +def resolve_printables(url, adapter=None): + """Resolve *url*, returning a :class:`PrintablesResolution` with failure detail.""" + return (adapter or PrintablesAdapter()).resolve(url) + + +def resolve_printables_url(url, adapter=None): + """Resolve *url* to ``(download_url, filename)``, or ``(None, None)``. + + Kept as the default entry point because every existing caller branches on + ``if not resolved_url``. Use :func:`resolve_printables` when you want to + report *why* it failed. + """ + return resolve_printables(url, adapter=adapter).as_tuple() diff --git a/bambu_cli/printables/adapter.py b/bambu_cli/printables/adapter.py new file mode 100644 index 0000000..6aca956 --- /dev/null +++ b/bambu_cli/printables/adapter.py @@ -0,0 +1,120 @@ +"""The Printables containment boundary. + +Printables' API is undocumented, unversioned, and scraped. The guarantee this +module makes is narrow and load-bearing: + + **No exception raised while talking to Printables escapes the adapter.** + +Not a ``PrintablesError``, not a ``KeyError`` from a renamed field, not an +``AttributeError`` from a null envelope, not a ``MemoryError`` from a huge +body. ``resolve()`` always returns a :class:`PrintablesResolution`. If +Printables changes their schema tomorrow, ``plate download`` and ``plate job`` +report a clean, specific failure and keep working for every other source — +which is the whole point of the adapter. + +The one thing that *is* allowed to propagate is ``KeyboardInterrupt`` / +``SystemExit``: swallowing those would make Ctrl-C stop working. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from bambu_cli.logging_utils import logger +from bambu_cli.printables import client +from bambu_cli.printables.errors import PrintablesContractChanged, PrintablesError + + +@dataclass(frozen=True) +class PrintablesResolution: + """The outcome of resolving one Printables model URL. + + ``ok`` resolutions carry ``url``/``filename``. Failures carry a ``reason`` + token, a human ``error`` message, and a ``remedy`` — enough for a caller to + build a JSON envelope or print a useful line without knowing anything about + GraphQL. + """ + + ok: bool + url: str | None = None + filename: str | None = None + reason: str | None = None + error: str | None = None + remedy: str | None = None + + def as_tuple(self): + """``(url, filename)`` — the legacy shape callers already handle.""" + return (self.url, self.filename) if self.ok else (None, None) + + +class PrintablesAdapter: + """Resolves printables.com model pages to a direct file URL. + + Collaborators are injected rather than imported at call time so tests can + drive the whole surface without patching module globals: + + * ``opener_factory`` — builds the SSRF-guarded urllib opener + * ``headers_factory`` — builds the outbound headers + """ + + def __init__(self, opener_factory=None, headers_factory=None): + self._opener_factory = opener_factory or client.default_opener + self._headers_factory = headers_factory or client.gql_headers + + @staticmethod + def handles(url): + """True if this adapter recognises *url*. Never raises.""" + try: + return client.is_printables_model_url(url) + except Exception: # pragma: no cover -- defensive; is_printables_model_url guards internally + return False + + def resolve(self, url): + """Resolve *url*, returning a :class:`PrintablesResolution`. + + Never raises for a Printables-side problem. See the module docstring. + """ + if not self.handles(url): + return PrintablesResolution( + ok=False, + reason="not_a_printables_url", + error="Not a Printables model URL.", + remedy="Pass a printables.com/model/ URL.", + ) + + try: + return self._resolve_inner(url) + except PrintablesError as exc: + return self._failure(exc.reason, str(exc), exc.remedy) + except (KeyboardInterrupt, SystemExit): + # Never swallow process control flow. + raise + except BaseException as exc: # noqa: BLE001 -- the containment boundary is the point + # Anything unanticipated is, by definition, the API not matching what + # this adapter was written against. Report it as a contract change + # rather than letting a bare KeyError escape into the download path. + return self._failure( + PrintablesContractChanged.reason, + f"Unexpected failure talking to Printables ({type(exc).__name__}: {exc}).", + PrintablesContractChanged.remedy, + ) + + def _resolve_inner(self, url): + model_id = client.model_id_from_url(url) + if model_id is None: # pragma: no cover -- handles() already established the shape + raise PrintablesContractChanged("Could not read a model id from a URL that looked like one.") + + logger.info(f"🔍 Detected Printables model #{model_id}, resolving files...") + headers = self._headers_factory() + opener = self._opener_factory() + + file_id, file_type, file_name = client.get_file_info(model_id, headers, opener) + link, name = client.get_download_link(file_id, model_id, file_type, file_name, headers, opener) + return PrintablesResolution(ok=True, url=link, filename=name) + + @staticmethod + def _failure(reason, message, remedy): + logger.error(message) + if remedy: + logger.info(f" {remedy}") + return PrintablesResolution(ok=False, reason=reason, error=message, remedy=remedy) diff --git a/bambu_cli/printables/client.py b/bambu_cli/printables/client.py new file mode 100644 index 0000000..7f3bf7b --- /dev/null +++ b/bambu_cli/printables/client.py @@ -0,0 +1,237 @@ +"""Raw Printables GraphQL calls — the fragile part, quarantined. + +Everything that knows the shape of Printables' undocumented API lives in this +module and nowhere else. It raises the typed errors from ``errors.py`` rather +than returning sentinels, so the distinction between "network down", "they +changed the schema", and "this model has no STL" survives long enough to be +reported. ``adapter.py`` turns those back into a safe return value. + +Nothing outside ``bambu_cli.printables`` should import this module. +""" + +from __future__ import annotations + +import json +import re +import urllib.error +import urllib.request +from urllib.parse import urlparse + +from bambu_cli.constants import DEFAULT_NETWORK_TIMEOUT +from bambu_cli.logging_utils import logger +from bambu_cli.netsafety import build_safe_opener, platecli_user_agent, polite_open +from bambu_cli.printables.errors import ( + PrintablesContractChanged, + PrintablesModelUnavailable, + PrintablesUnavailable, +) + +_API_URL = "https://api.printables.com/graphql/" + +# A GraphQL metadata response is a few KB. Cap the read so a hostile or broken +# endpoint cannot stream unbounded data into memory before we even parse it — +# the file download path has size limits, this metadata path had none. +_MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + +_MODEL_PATH_RE = re.compile(r"/model/(\d+)") + + +def is_printables_model_url(value): + """True if *value* is a printables.com model page URL.""" + try: + parsed = urlparse(value) + except Exception: + return False + host = (parsed.hostname or "").lower() + return host in ("printables.com", "www.printables.com") and bool(_MODEL_PATH_RE.search(parsed.path)) + + +def model_id_from_url(value): + """Extract the numeric model id from a Printables model URL, or None.""" + if not is_printables_model_url(value): + return None + match = _MODEL_PATH_RE.search(urlparse(value).path) + return match.group(1) if match else None + + +def gql_headers(): + """Headers for API calls. + + platecli identifies itself honestly to Printables and does not forge + browser-only headers (Origin/Referer). Verified against + https://api.printables.com/graphql/ on 2026-07-25: both the file-info query + and the GetDownloadLink mutation return HTTP 200 with data using an honest + User-Agent and no Origin/Referer. + """ + return { + "User-Agent": platecli_user_agent(), + "Accept": "*/*", + "Content-Type": "application/json", + } + + +def _post_graphql(payload, opener, headers): + """POST a GraphQL document and return the decoded JSON body. + + Raises ``PrintablesUnavailable`` if the endpoint could not be reached, and + ``PrintablesContractChanged`` if what came back is not a JSON object. + """ + req = urllib.request.Request(_API_URL, data=json.dumps(payload).encode(), headers=headers) + try: + with polite_open(opener, req, timeout=DEFAULT_NETWORK_TIMEOUT) as resp: + raw = resp.read(_MAX_RESPONSE_BYTES) + except urllib.error.URLError as exc: + raise PrintablesUnavailable(f"Network error querying the Printables API: {exc}") from exc + except Exception as exc: + raise PrintablesUnavailable(f"Could not query the Printables API: {exc}") from exc + + try: + result = json.loads(raw) + except Exception as exc: + raise PrintablesContractChanged(f"Printables API returned a body that is not JSON: {exc}") from exc + + if not isinstance(result, dict): + raise PrintablesContractChanged(f"Printables API returned {type(result).__name__}, expected a JSON object.") + return result + + +def _raise_for_graphql_errors(result, model_id): + """Raise if *result* carries a GraphQL error envelope. + + The standard envelope is ``{"errors": [...], "data": null}`` — note "data" + EXISTS with value None, so ``result.get("data", {})`` yields None rather + than {}. Every read of "data" in this module coerces with ``or {}`` for + exactly that reason. + """ + errors = result.get("errors") + if not errors: + return + first = errors[0] if isinstance(errors, list) and errors else None + detail = "" + if isinstance(first, dict) and first.get("message"): + detail = f": {first['message']}" + raise PrintablesModelUnavailable(f"Printables API returned an error for model #{model_id}{detail}") + + +def _select_file(files, file_desc, type_key="stl"): + if len(files) > 1: + logger.info(f" Found {len(files)} {file_desc} files:") + for entry in files: + logger.info(f" • {entry.get('name', '?')} ({entry.get('fileSize', 0) // 1024}KB)") + chosen = max(files, key=lambda x: x.get("fileSize", 0)) + logger.info(f" → Using {file_desc}: {chosen.get('name', '?')} ({chosen.get('fileSize', 0) // 1024}KB)") + return chosen, type_key + + +def _bucket_files(stls_raw, gcodes_raw): + """Sort the API's two file lists into stl / step / 3mf buckets.""" + stls, steps, threemfs = [], [], [] + for entry in stls_raw: + if not isinstance(entry, dict): + continue + ext = str(entry.get("name", "")).lower().rpartition(".")[-1] + if ext == "stl": + stls.append(entry) + elif ext in ("step", "stp"): + steps.append(entry) + elif ext == "3mf": + threemfs.append(entry) + for entry in gcodes_raw: + if not isinstance(entry, dict): + continue + if str(entry.get("name", "")).lower().rpartition(".")[-1] == "3mf": + threemfs.append(entry) + return stls, steps, threemfs + + +def get_file_info(model_id, headers, opener): + """Resolve a model id to ``(file_id, file_type, file_name)``.""" + result = _post_graphql( + { + "variables": {"id": model_id}, + "query": "query($id: ID!){print(id: $id){name stls{name fileSize id} gcodes{name fileSize id}}}", + }, + opener, + headers, + ) + _raise_for_graphql_errors(result, model_id) + + model = (result.get("data") or {}).get("print") + if not isinstance(model, dict): + raise PrintablesModelUnavailable(f"Model #{model_id} not found on Printables.") + + stls_raw = model.get("stls") or [] + gcodes_raw = model.get("gcodes") or [] + if not isinstance(stls_raw, list): + stls_raw = [] + if not isinstance(gcodes_raw, list): + gcodes_raw = [] + + stls, steps, threemfs = _bucket_files(stls_raw, gcodes_raw) + + logger.info(f" Model: {model.get('name', '?')}") + if stls: + chosen, file_type = _select_file(stls, "STL", "stl") + elif steps: + chosen, file_type = _select_file(steps, "STEP", "stl") + elif threemfs: + logger.warning(" ⚠️ No STL/STEP files — falling back to 3MF (cannot re-slice with custom settings)") + chosen = max(threemfs, key=lambda x: x.get("fileSize", 0)) + file_type = "gcode" if chosen in gcodes_raw else "stl" + logger.info(f" → Using 3MF: {chosen.get('name', '?')} ({chosen.get('fileSize', 0) // 1024}KB)") + else: + raise PrintablesModelUnavailable("No STL, STEP, or 3MF files found for this model.") + + file_id = chosen.get("id") + file_name = chosen.get("name") + if not file_id or not file_name: + raise PrintablesContractChanged( + f"The Printables file chosen for model #{model_id} has no id or name — " + f"the API's file records changed shape." + ) + return file_id, file_type, file_name + + +def get_download_link(file_id, model_id, file_type, file_name, headers, opener): + """Exchange a file id for a time-limited direct download URL.""" + result = _post_graphql( + { + "operationName": "GetDownloadLink", + "variables": {"id": file_id, "printId": model_id, "source": "model_detail", "fileType": file_type}, + "query": ( + "mutation GetDownloadLink($id: ID!, $printId: ID!, $source: DownloadSourceEnum!, " + "$fileType: DownloadFileTypeEnum!) { getDownloadLink(id: $id, printId: $printId, " + "source: $source, fileType: $fileType) { ok output { link } errors { field messages } } }" + ), + }, + opener, + headers, + ) + _raise_for_graphql_errors(result, model_id) + + # `or {}` at every hop. The previous code used `result.get("data", {})`, + # which returns None (not {}) for the `{"errors": [...], "data": null}` + # envelope and then raised AttributeError — surfacing to the user as + # "Failed to get download link: 'NoneType' object has no attribute 'get'". + link_payload = (result.get("data") or {}).get("getDownloadLink") or {} + if not isinstance(link_payload, dict): + raise PrintablesContractChanged("Printables returned an unexpected getDownloadLink payload.") + + if link_payload.get("ok"): + link = (link_payload.get("output") or {}).get("link") + if link: + return link, file_name + raise PrintablesContractChanged("Printables reported success but returned no download link.") + + errs = link_payload.get("errors") or [] + message = "unknown error" + if isinstance(errs, list) and errs and isinstance(errs[0], dict): + messages = errs[0].get("messages") or [] + if isinstance(messages, list) and messages: + message = str(messages[0]) + raise PrintablesModelUnavailable(f"Printables refused the download link: {message}") + + +def default_opener(): + """The SSRF-guarded opener used unless a caller injects its own.""" + return build_safe_opener() diff --git a/bambu_cli/printables/errors.py b/bambu_cli/printables/errors.py new file mode 100644 index 0000000..5cdda25 --- /dev/null +++ b/bambu_cli/printables/errors.py @@ -0,0 +1,67 @@ +"""Typed failures for the Printables adapter. + +Printables' GraphQL API is undocumented and unversioned: the schema, the error +envelope, and the file lists can all change without notice, and a scraped +integration is the first thing to break when they do. These types let the +adapter say *which* kind of breakage happened, so the CLI can tell a user +"Printables changed their API" instead of a generic "download failed" — and so +a schema change never surfaces as a raw ``AttributeError`` traceback. + +Every one of these is raised inside the adapter and converted to a +``PrintablesResolution`` before it reaches a caller. They are part of the +adapter's internal vocabulary, not its public contract; see ``adapter.py``. +""" + +from __future__ import annotations + +from bambu_cli.errors import BambuError + + +class PrintablesError(BambuError): + """Base for every Printables-specific failure. + + Subclasses ``BambuError`` so that if one ever does escape the adapter it + still lands on the CLI's normal error path with an exit code, rather than + crashing as an unhandled exception. + """ + + #: Short, stable token for JSON envelopes / logs. + reason = "printables_error" + + #: What the user should do about it. + remedy = "Try the download again, or download the file from Printables manually." + + +class PrintablesUnavailable(PrintablesError): + """The API could not be reached: DNS, TLS, timeout, connection reset, 5xx.""" + + reason = "printables_unavailable" + remedy = "Check your network connection and retry; Printables may also be down." + + +class PrintablesContractChanged(PrintablesError): + """A response arrived but did not have the shape this adapter expects. + + This is the "they changed their API / DOM" case, and the reason the adapter + exists. It is deliberately distinct from :class:`PrintablesUnavailable`: + retrying will not help, and the fix is a code change here — not anywhere + else in the tool. + """ + + reason = "printables_contract_changed" + remedy = ( + "Printables appears to have changed their API. Download the file from the " + "model page in a browser and pass the local path instead. Please report this " + "so the Printables adapter can be updated." + ) + + +class PrintablesModelUnavailable(PrintablesError): + """The API answered correctly, but this model has nothing we can print. + + A well-formed "no STL/STEP/3MF here" or "no such model" — the integration is + working, the model just is not usable. Not a breakage. + """ + + reason = "printables_model_unavailable" + remedy = "Pick a model that publishes an STL, STEP, or 3MF file." diff --git a/scripts/check_layers.py b/scripts/check_layers.py index 83ca732..1b44b98 100644 --- a/scripts/check_layers.py +++ b/scripts/check_layers.py @@ -91,6 +91,21 @@ } +# --------------------------------------------------------------------------- +# Package internals that must not be imported from outside their own package. +# An adapter is only a sandbox if callers cannot reach past it. +# --------------------------------------------------------------------------- +SEALED: dict[str, str] = { + "bambu_cli.printables.client": ( + "the raw Printables GraphQL wire format — import from bambu_cli.printables instead, " + "so a schema change stays contained in the adapter" + ), + "bambu_cli.printables.adapter": ( + "internal; the public names are re-exported from bambu_cli.printables" + ), +} + + def unit_of(module: str) -> str | None: """Map a dotted module path to the layer unit that owns it.""" parts = module.split(".") @@ -106,6 +121,34 @@ def source_unit(path: Path) -> str: return rel[0] if (PKG / rel[0]).is_dir() else path.stem +def iter_raw_imports(): + """Yield (file, lineno, dotted_module) for every bambu_cli import.""" + for file in sorted(PKG.rglob("*.py")): + if "__pycache__" in file.parts: + continue + tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and not node.level and node.module: + if node.module.startswith("bambu_cli"): + yield file, node.lineno, node.module + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("bambu_cli"): + yield file, node.lineno, alias.name + + +def sealed_violations(): + """Imports that reach into another package's sealed internals.""" + out = [] + for file, lineno, module in iter_raw_imports(): + for sealed, why in SEALED.items(): + if module == sealed or module.startswith(sealed + "."): + owner = PKG / sealed.split(".")[1] + if owner not in file.parents: + out.append((file, lineno, module, why)) + return out + + def iter_edges(): """Yield (src_unit, dst_unit, file, lineno, deferred).""" for file in sorted(PKG.rglob("*.py")): @@ -176,11 +219,15 @@ def main() -> int: rel = file.relative_to(ROOT) print(f"VIOLATION {rel}:{lineno}: {src} -> {dst} ({why}, {kind})") + sealed = sealed_violations() + for file, lineno, module, why in sealed: + print(f"SEALED {file.relative_to(ROOT)}:{lineno}: imports {module} — {why}") + stale = set(ALLOWED) - used_allowances for src, dst in sorted(stale): print(f"STALE allowance {src} -> {dst} is no longer needed; remove it from ALLOWED") - failures = len(violations) + len(unknown) + len(stale) + failures = len(violations) + len(unknown) + len(stale) + len(sealed) if failures: print(f"\n{failures} layering problem(s). See the rank table in {Path(__file__).name}.") return 1 diff --git a/tests/test_download_cmd.py b/tests/test_download_cmd.py index bcdb195..2fb1e7d 100644 --- a/tests/test_download_cmd.py +++ b/tests/test_download_cmd.py @@ -31,465 +31,6 @@ def __exit__(self, *exc): return False -class TestResolvePrintablesUrl(unittest.TestCase): - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_model_not_found(self, mock_logger): - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps({"data": {"print": None}}).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertIsNone(fid) - self.assertIsNone(ftype) - self.assertIsNone(fname) - mock_logger.error.assert_called_with("Model #123 not found on Printables") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_no_valid_files(self, mock_logger): - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test", - "stls": [{"id": "1", "name": "part1.txt", "fileSize": 1024}], - "gcodes": [], - } - } - } - ).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertIsNone(fid) - self.assertIsNone(ftype) - self.assertIsNone(fname) - mock_logger.error.assert_called_with("No STL, STEP, or 3MF files found for this model") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_url_error(self, mock_logger): - from bambu_cli.printables import _get_printables_file_info - import urllib.error - - mock_opener = MagicMock() - mock_opener.open.side_effect = urllib.error.URLError("Network unreachable") - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertIsNone(fid) - self.assertIsNone(ftype) - self.assertIsNone(fname) - mock_logger.error.assert_called_with( - "Network error querying Printables API: " - ) - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_multiple_stls(self, mock_logger): - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test", - "stls": [ - {"id": "1", "name": "part1.stl", "fileSize": 1024}, - {"id": "2", "name": "part2.stl", "fileSize": 2048}, - ], - } - } - } - ).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertEqual(fid, "2") - self.assertEqual(ftype, "stl") - mock_logger.info.assert_any_call(" Found 2 STL files:") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_multiple_steps(self, mock_logger): - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test", - "stls": [ - {"id": "1", "name": "part1.step", "fileSize": 1024}, - {"id": "2", "name": "part2.step", "fileSize": 2048}, - ], - } - } - } - ).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertEqual(fid, "2") - self.assertEqual(ftype, "stl") - mock_logger.info.assert_any_call(" Found 2 STEP files:") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_3mf_fallback(self, mock_logger): - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test", - "stls": [{"id": "1", "name": "part1.3mf", "fileSize": 1024}], - "gcodes": [{"id": "2", "name": "part2.3mf", "fileSize": 2048}], - } - } - } - ).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertEqual(fid, "2") - # 3MF from gcodes sets type="gcode" - self.assertEqual(ftype, "gcode") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_generic_exception(self, mock_logger): - from bambu_cli.printables import _get_printables_file_info - - mock_opener = MagicMock() - mock_opener.open.side_effect = Exception("Generic Fetch Error") - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertIsNone(fid) - mock_logger.error.assert_called_with("Failed to query Printables API: Generic Fetch Error") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_graphql_error_envelope(self, mock_logger): - """A GraphQL error envelope {"errors":[...], "data": null} must degrade to - (None, None, None), NOT raise AttributeError. `data` key exists with value - null, so `.get("data", {})` returns None and `.get("print")` would crash.""" - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps( - {"errors": [{"message": "Model not accessible"}], "data": None} - ).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - # Before the fix this raised AttributeError; assert it returns cleanly. - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertIsNone(fid) - self.assertIsNone(ftype) - self.assertIsNone(fname) - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_data_null_no_errors_key(self, mock_logger): - """`{"data": null}` with no top-level errors must also degrade cleanly.""" - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps({"data": None}).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertIsNone(fid) - mock_logger.error.assert_called_with("Model #123 not found on Printables") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_null_stls_field(self, mock_logger): - """A model with `stls: null` must not raise TypeError when iterated.""" - from bambu_cli.printables import _get_printables_file_info - import json - - mock_opener = MagicMock() - mock_resp = MagicMock() - mock_resp.read.return_value = json.dumps( - {"data": {"print": {"name": "Test", "stls": None, "gcodes": None}}} - ).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - fid, ftype, fname = _get_printables_file_info("123", {}, mock_opener) - self.assertIsNone(fid) - mock_logger.error.assert_called_with("No STL, STEP, or 3MF files found for this model") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_get_printables_download_link_error(self, mock_logger): - from bambu_cli.printables import _get_printables_download_link - import json - - mock_opener = MagicMock() - - mock_resp = MagicMock() - # Mock API returning None link - mock_resp.read.return_value = json.dumps({"data": {"fileDownloadLink": None}}).encode() - mock_opener.open.return_value.__enter__.return_value = mock_resp - - result = _get_printables_download_link("1", "1", "stl", "name.stl", {}, mock_opener) - self.assertEqual(result, (None, None)) - mock_logger.error.assert_called_with("Failed to get download link: unknown error") - - # Test exception path - mock_opener.open.side_effect = Exception("Link Fetch Error") - result = _get_printables_download_link("1", "1", "stl", "name.stl", {}, mock_opener) - self.assertEqual(result, (None, None)) - mock_logger.error.assert_called_with("Failed to get download link: Link Fetch Error") - - @patch("bambu_cli.printables.build_safe_opener") - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_url_success(self, mock_logger, mock_safe_opener): - mock_urlopen = mock_safe_opener.return_value.open - from bambu_cli.printables import resolve_printables_url - import json - - # First call: GraphQL query for model details - mock_response_1 = MagicMock() - mock_response_1.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test Model", - "stls": [{"name": "part1.stl", "fileSize": 1024, "id": "file_123"}], - "gcodes": [], - } - } - } - ).encode() - - # Second call: GraphQL mutation for download link - mock_response_2 = MagicMock() - mock_response_2.read.return_value = json.dumps( - {"data": {"getDownloadLink": {"ok": True, "output": {"link": "https://download.example.com/part1.stl"}}}} - ).encode() - - # Set side effect for urlopen context manager - mock_urlopen.return_value.__enter__.side_effect = [mock_response_1, mock_response_2] - - url = "https://www.printables.com/model/12345-test-model" - download_url, filename = resolve_printables_url(url) - - self.assertEqual(download_url, "https://download.example.com/part1.stl") - self.assertEqual(filename, "part1.stl") - - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_url_not_printables(self, mock_logger): - from bambu_cli.printables import resolve_printables_url - - url = "https://www.thingiverse.com/thing:12345" - download_url, filename = resolve_printables_url(url) - - self.assertIsNone(download_url) - self.assertIsNone(filename) - - @patch("bambu_cli.printables.build_safe_opener") - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_model_not_found(self, mock_logger, mock_safe_opener): - mock_urlopen = mock_safe_opener.return_value.open - from bambu_cli.printables import resolve_printables_url - import json - - mock_response = MagicMock() - mock_response.read.return_value = json.dumps({"data": {"print": None}}).encode() - mock_urlopen.return_value.__enter__.return_value = mock_response - - url = "https://www.printables.com/model/12345-test-model" - download_url, filename = resolve_printables_url(url) - - self.assertIsNone(download_url) - self.assertIsNone(filename) - - self.assertTrue( - any("Model #12345 not found on Printables" in call[0][0] for call in mock_logger.error.call_args_list) - ) - - @patch("bambu_cli.printables.build_safe_opener") - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_no_valid_files(self, mock_logger, mock_safe_opener): - mock_urlopen = mock_safe_opener.return_value.open - from bambu_cli.printables import resolve_printables_url - import json - - mock_response = MagicMock() - mock_response.read.return_value = json.dumps( - {"data": {"print": {"name": "Test Model", "stls": [], "gcodes": []}}} - ).encode() - mock_urlopen.return_value.__enter__.return_value = mock_response - - url = "https://www.printables.com/model/12345-test-model" - download_url, filename = resolve_printables_url(url) - - self.assertIsNone(download_url) - self.assertIsNone(filename) - - self.assertTrue( - any( - "No STL, STEP, or 3MF files found for this model" in call[0][0] - for call in mock_logger.error.call_args_list - ) - ) - - @patch("bambu_cli.printables.build_safe_opener") - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_prioritize_step(self, mock_logger, mock_safe_opener): - mock_urlopen = mock_safe_opener.return_value.open - from bambu_cli.printables import resolve_printables_url - import json - - mock_response_1 = MagicMock() - mock_response_1.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test Model", - "stls": [{"name": "part1.step", "fileSize": 1024, "id": "file_123"}], - "gcodes": [], - } - } - } - ).encode() - - mock_response_2 = MagicMock() - mock_response_2.read.return_value = json.dumps( - {"data": {"getDownloadLink": {"ok": True, "output": {"link": "https://download.example.com/part1.step"}}}} - ).encode() - - mock_urlopen.return_value.__enter__.side_effect = [mock_response_1, mock_response_2] - - url = "https://www.printables.com/model/12345-test-model" - download_url, filename = resolve_printables_url(url) - - self.assertEqual(download_url, "https://download.example.com/part1.step") - self.assertEqual(filename, "part1.step") - - self.assertTrue(any("→ Using STEP: part1.step (1KB)" in call[0][0] for call in mock_logger.info.call_args_list)) - - @patch("bambu_cli.printables.build_safe_opener") - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_prioritize_3mf(self, mock_logger, mock_safe_opener): - mock_urlopen = mock_safe_opener.return_value.open - from bambu_cli.printables import resolve_printables_url - import json - - mock_response_1 = MagicMock() - mock_response_1.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test Model", - "stls": [], - "gcodes": [{"name": "part1.3mf", "fileSize": 1024, "id": "file_123"}], - } - } - } - ).encode() - - mock_response_2 = MagicMock() - mock_response_2.read.return_value = json.dumps( - {"data": {"getDownloadLink": {"ok": True, "output": {"link": "https://download.example.com/part1.3mf"}}}} - ).encode() - - mock_urlopen.return_value.__enter__.side_effect = [mock_response_1, mock_response_2] - - url = "https://www.printables.com/model/12345-test-model" - download_url, filename = resolve_printables_url(url) - - self.assertEqual(download_url, "https://download.example.com/part1.3mf") - self.assertEqual(filename, "part1.3mf") - - self.assertTrue(any("falling back to 3MF" in call[0][0] for call in mock_logger.warning.call_args_list)) - self.assertTrue(any("→ Using 3MF: part1.3mf (1KB)" in call[0][0] for call in mock_logger.info.call_args_list)) - - @patch("bambu_cli.printables.build_safe_opener") - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_download_link_error(self, mock_logger, mock_safe_opener): - mock_urlopen = mock_safe_opener.return_value.open - from bambu_cli.printables import resolve_printables_url - import json - - mock_response_1 = MagicMock() - mock_response_1.read.return_value = json.dumps( - { - "data": { - "print": { - "name": "Test Model", - "stls": [{"name": "part1.stl", "fileSize": 1024, "id": "file_123"}], - "gcodes": [], - } - } - } - ).encode() - - mock_response_2 = MagicMock() - mock_response_2.read.return_value = json.dumps( - { - "data": { - "getDownloadLink": { - "ok": False, - "errors": [{"field": "link", "messages": ["Download limit reached"]}], - } - } - } - ).encode() - - mock_urlopen.return_value.__enter__.side_effect = [mock_response_1, mock_response_2] - - url = "https://www.printables.com/model/12345-test-model" - download_url, filename = resolve_printables_url(url) - - self.assertIsNone(download_url) - self.assertIsNone(filename) - - self.assertTrue( - any( - "Failed to get download link: Download limit reached" in call[0][0] - for call in mock_logger.error.call_args_list - ) - ) - - @patch("bambu_cli.printables.build_safe_opener") - @patch("bambu_cli.logging_utils._BACKEND") - def test_resolve_printables_exception(self, mock_logger, mock_safe_opener): - mock_urlopen = mock_safe_opener.return_value.open - from bambu_cli.printables import resolve_printables_url - - mock_urlopen.return_value.__enter__.side_effect = urllib.error.URLError("Network failure") - - url = "https://www.printables.com/model/12345-test-model" - download_url, filename = resolve_printables_url(url) - - self.assertIsNone(download_url) - self.assertIsNone(filename) - - self.assertTrue( - any("Network error querying Printables API" in call[0][0] for call in mock_logger.error.call_args_list) - ) - - class TestBambuCmdDownload(unittest.TestCase): @patch("bambu_cli.logging_utils._BACKEND") def test_cmd_download_invalid_output_dir(self, mock_logger): diff --git a/tests/test_printables_adapter.py b/tests/test_printables_adapter.py new file mode 100644 index 0000000..82989e8 --- /dev/null +++ b/tests/test_printables_adapter.py @@ -0,0 +1,364 @@ +"""Tests for the Printables adapter — the sandbox around an undocumented API. + +These drive the **public** surface (``bambu_cli.printables``) with an injected +opener. Nothing here patches a module global, and nothing imports +``printables.client``: if a test needs to reach past the adapter to be written, +the adapter is not doing its job. + +Two things are under test: + +1. **Behavior** — URL detection, file preference (STL > STEP > 3MF), and the + failure taxonomy (unavailable / contract-changed / model-unavailable). +2. **Containment** — the guarantee in ``adapter.py``: no Printables failure + escapes as an exception. The malformed-payload sweep at the bottom is the + real point of the package; it is what stops a schema change from taking down + ``plate job``. + +Ground rules (docs/test-backlog.md): never touch the network. +""" + +from __future__ import annotations + +import json +import sys +import urllib.error +from unittest.mock import MagicMock, patch + +import pytest + +_mock_mqtt = MagicMock() +sys.modules.setdefault("paho", _mock_mqtt) +sys.modules.setdefault("paho.mqtt", _mock_mqtt) +sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) + +from bambu_cli.printables import ( # noqa: E402 + PrintablesAdapter, + is_printables_url, + resolve_printables, + resolve_printables_url, +) + +MODEL_URL = "https://www.printables.com/model/12345-test-model" + + +# --- fakes ------------------------------------------------------------------- + + +class _FakeResponse: + def __init__(self, body): + self._body = body if isinstance(body, bytes) else json.dumps(body).encode() + + def read(self, *_args): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_exc): + return False + + +class _FakeOpener: + """Yields the queued responses in order; raises if one is an Exception.""" + + def __init__(self, *responses): + self._queue = list(responses) + self.requests = [] + + def open(self, req, timeout=None): + self.requests.append(req) + if not self._queue: + raise AssertionError("adapter made more API calls than the test queued") + nxt = self._queue.pop(0) + if isinstance(nxt, BaseException): + raise nxt + return _FakeResponse(nxt) + + +def _adapter(*responses): + opener = _FakeOpener(*responses) + return PrintablesAdapter(opener_factory=lambda: opener), opener + + +def _model(stls=None, gcodes=None, name="Test Model"): + return {"data": {"print": {"name": name, "stls": stls or [], "gcodes": gcodes or []}}} + + +def _link(url): + return {"data": {"getDownloadLink": {"ok": True, "output": {"link": url}}}} + + +# --- URL detection ----------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "https://www.printables.com/model/12345-test-model", + "https://printables.com/model/1", + "https://www.printables.com/model/999/files", + ], +) +def test_recognises_model_urls(url): + assert is_printables_url(url) is True + + +@pytest.mark.parametrize( + "url", + [ + "https://www.thingiverse.com/thing:12345", + "https://www.printables.com/social/12345", + # Lookalike hosts must not be treated as Printables (exact-host match). + "https://printables.com.evil.example/model/1", + "https://evil.printables.com.attacker.net/model/1", + "", + None, + 12345, + ], +) +def test_rejects_non_model_urls(url): + assert is_printables_url(url) is False + + +def test_non_printables_url_resolves_to_a_typed_refusal_without_network(): + adapter, opener = _adapter() # no responses queued: any call would assert + result = adapter.resolve("https://www.thingiverse.com/thing:12345") + assert result.ok is False + assert result.reason == "not_a_printables_url" + assert opener.requests == [] + + +# --- happy paths ------------------------------------------------------------- + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_resolves_stl_to_a_download_url(_log): + adapter, opener = _adapter( + _model(stls=[{"name": "part1.stl", "fileSize": 1024, "id": "file_123"}]), + _link("https://download.example.com/part1.stl"), + ) + result = adapter.resolve(MODEL_URL) + assert result.ok is True + assert result.url == "https://download.example.com/part1.stl" + assert result.filename == "part1.stl" + assert len(opener.requests) == 2 + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_picks_the_largest_of_several_stls(_log): + adapter, _ = _adapter( + _model( + stls=[ + {"id": "1", "name": "part1.stl", "fileSize": 1024}, + {"id": "2", "name": "part2.stl", "fileSize": 2048}, + ] + ), + _link("https://download.example.com/part2.stl"), + ) + assert adapter.resolve(MODEL_URL).filename == "part2.stl" + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_falls_back_to_step_when_no_stl(_log): + adapter, _ = _adapter( + _model(stls=[{"name": "part1.step", "fileSize": 1024, "id": "file_123"}]), + _link("https://download.example.com/part1.step"), + ) + result = adapter.resolve(MODEL_URL) + assert result.ok is True + assert result.filename == "part1.step" + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_falls_back_to_3mf_and_warns_it_cannot_be_resliced(mock_log): + adapter, _ = _adapter( + _model(gcodes=[{"name": "part1.3mf", "fileSize": 1024, "id": "file_123"}]), + _link("https://download.example.com/part1.3mf"), + ) + result = adapter.resolve(MODEL_URL) + assert result.ok is True + assert result.filename == "part1.3mf" + assert any("falling back to 3MF" in c[0][0] for c in mock_log.warning.call_args_list) + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_stl_is_preferred_over_step_and_3mf(_log): + adapter, _ = _adapter( + _model( + stls=[ + {"id": "s", "name": "big.step", "fileSize": 9999}, + {"id": "m", "name": "big.3mf", "fileSize": 9999}, + {"id": "t", "name": "small.stl", "fileSize": 1}, + ] + ), + _link("https://download.example.com/small.stl"), + ) + assert adapter.resolve(MODEL_URL).filename == "small.stl" + + +# --- failure taxonomy -------------------------------------------------------- + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_network_error_is_reported_as_unavailable(_log): + adapter, _ = _adapter(urllib.error.URLError("Network unreachable")) + result = adapter.resolve(MODEL_URL) + assert result.ok is False + assert result.reason == "printables_unavailable" + assert "Network" in result.error + assert result.remedy + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_missing_model_is_reported_as_model_unavailable(_log): + adapter, _ = _adapter({"data": {"print": None}}) + result = adapter.resolve(MODEL_URL) + assert result.ok is False + assert result.reason == "printables_model_unavailable" + assert "12345" in result.error + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_model_without_printable_files_is_model_unavailable(_log): + adapter, _ = _adapter(_model(stls=[{"id": "1", "name": "readme.txt", "fileSize": 10}])) + result = adapter.resolve(MODEL_URL) + assert result.ok is False + assert result.reason == "printables_model_unavailable" + assert "No STL, STEP, or 3MF" in result.error + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_refused_download_link_surfaces_the_servers_reason(_log): + adapter, _ = _adapter( + _model(stls=[{"name": "part1.stl", "fileSize": 1024, "id": "file_123"}]), + {"data": {"getDownloadLink": {"ok": False, "errors": [{"field": "link", "messages": ["Download limit reached"]}]}}}, + ) + result = adapter.resolve(MODEL_URL) + assert result.ok is False + assert result.reason == "printables_model_unavailable" + assert "Download limit reached" in result.error + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_non_json_body_is_reported_as_a_contract_change(_log): + adapter, _ = _adapter(b"we redesigned our API") + result = adapter.resolve(MODEL_URL) + assert result.ok is False + assert result.reason == "printables_contract_changed" + # The remedy must tell the user this is not something retrying will fix. + assert "manually" in result.remedy or "browser" in result.remedy + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_file_record_without_id_is_a_contract_change_not_a_crash(_log): + # id/name are what the download step needs; losing them means the schema moved. + adapter, _ = _adapter(_model(stls=[{"name": "part1.stl", "fileSize": 1024}])) + result = adapter.resolve(MODEL_URL) + assert result.ok is False + assert result.reason == "printables_contract_changed" + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_ok_link_with_no_url_is_a_contract_change(_log): + adapter, _ = _adapter( + _model(stls=[{"name": "part1.stl", "fileSize": 1024, "id": "file_123"}]), + {"data": {"getDownloadLink": {"ok": True, "output": {}}}}, + ) + result = adapter.resolve(MODEL_URL) + assert result.ok is False + assert result.reason == "printables_contract_changed" + + +# --- containment: the reason this package exists ----------------------------- + +# Every payload here is something Printables could plausibly start returning. +# None of them may raise. This is a regression net for "they changed the API". +HOSTILE_PAYLOADS = [ + pytest.param({"errors": [{"message": "Model not accessible"}], "data": None}, id="graphql-error-envelope"), + pytest.param({"data": None}, id="data-null-no-errors-key"), + pytest.param({"errors": [], "data": None}, id="empty-errors-list"), + pytest.param({"errors": "not-a-list", "data": None}, id="errors-not-a-list"), + pytest.param({"data": {"print": {"name": "T", "stls": None, "gcodes": None}}}, id="null-file-lists"), + pytest.param({"data": {"print": {"name": "T", "stls": "nope", "gcodes": 7}}}, id="file-lists-wrong-type"), + pytest.param({"data": {"print": {"stls": [None, 42, "x"]}}}, id="file-entries-wrong-type"), + pytest.param({"data": {"print": {"stls": [{"name": None, "fileSize": None, "id": None}]}}}, id="null-file-fields"), + pytest.param({"data": {"print": []}}, id="print-is-a-list"), + pytest.param({"data": []}, id="data-is-a-list"), + pytest.param([], id="top-level-list"), + pytest.param("just a string", id="top-level-string"), + pytest.param(None, id="top-level-null"), + pytest.param({}, id="empty-object"), + pytest.param(b"", id="empty-body"), + pytest.param(b"\x00\x01\x02 not json", id="binary-garbage"), +] + + +@pytest.mark.parametrize("payload", HOSTILE_PAYLOADS) +@patch("bambu_cli.logging_utils._BACKEND") +def test_malformed_api_response_never_raises(_log, payload): + adapter, _ = _adapter(payload, payload) + result = adapter.resolve(MODEL_URL) # must not raise + assert result.ok is False + assert result.reason + assert result.error + assert result.as_tuple() == (None, None) + + +@pytest.mark.parametrize( + "boom", + [ + ValueError("bad value"), + KeyError("renamed_field"), + AttributeError("'NoneType' object has no attribute 'get'"), + TypeError("unhashable"), + RuntimeError("something odd"), + MemoryError("body too large"), + urllib.error.HTTPError("https://api.printables.com/graphql/", 500, "Server Error", {}, None), + ], +) +@patch("bambu_cli.logging_utils._BACKEND") +def test_unexpected_exception_is_contained_not_propagated(_log, boom): + adapter, _ = _adapter(boom) + result = adapter.resolve(MODEL_URL) # must not raise + assert result.ok is False + assert result.error + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_keyboard_interrupt_is_never_swallowed(_log): + # Containment must not break Ctrl-C. + adapter, _ = _adapter(KeyboardInterrupt()) + with pytest.raises(KeyboardInterrupt): + adapter.resolve(MODEL_URL) + + +# --- legacy tuple surface ---------------------------------------------------- + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_resolve_printables_url_keeps_the_tuple_contract(_log): + adapter, _ = _adapter( + _model(stls=[{"name": "part1.stl", "fileSize": 1024, "id": "file_123"}]), + _link("https://download.example.com/part1.stl"), + ) + assert resolve_printables_url(MODEL_URL, adapter=adapter) == ( + "https://download.example.com/part1.stl", + "part1.stl", + ) + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_resolve_printables_url_returns_none_pair_on_failure(_log): + adapter, _ = _adapter({"data": {"print": None}}) + assert resolve_printables_url(MODEL_URL, adapter=adapter) == (None, None) + + +@patch("bambu_cli.logging_utils._BACKEND") +def test_resolve_printables_exposes_the_reason_the_tuple_hides(_log): + # Same failure, two surfaces: the tuple can only say "no", the resolution + # says *why*. One adapter each — the fake opener queues one call per resolve. + tuple_adapter, _ = _adapter(b"") + rich_adapter, _ = _adapter(b"") + + assert resolve_printables_url(MODEL_URL, adapter=tuple_adapter) == (None, None) + assert resolve_printables(MODEL_URL, adapter=rich_adapter).reason == "printables_contract_changed" diff --git a/tests/test_printables_headers.py b/tests/test_printables_headers.py index 16af077..fdc293d 100644 --- a/tests/test_printables_headers.py +++ b/tests/test_printables_headers.py @@ -18,7 +18,7 @@ sys.modules.setdefault("paho.mqtt", _mock_mqtt) from bambu_cli import constants, netsafety # noqa: E402 -from bambu_cli.printables import resolve_printables_url # noqa: E402 +from bambu_cli.printables import PrintablesAdapter, resolve_printables_url # noqa: E402 def _clear_ua_caches(): @@ -32,10 +32,12 @@ def _gql_response(payload): return resp -@patch("bambu_cli.printables.build_safe_opener") @patch("bambu_cli.logging_utils._BACKEND") -def test_printables_gql_headers_are_honest_and_unforged(mock_logger, mock_safe_opener): - mock_open = mock_safe_opener.return_value.open +def test_printables_gql_headers_are_honest_and_unforged(mock_logger): + # The opener is injected rather than patched: the adapter's whole point is + # that callers (and tests) never reach into its internals. + mock_opener = MagicMock() + mock_open = mock_opener.open mock_open.return_value.__enter__.side_effect = [ _gql_response( { @@ -61,7 +63,8 @@ def test_printables_gql_headers_are_honest_and_unforged(mock_logger, mock_safe_o ), ] - url, name = resolve_printables_url("https://www.printables.com/model/3161-3d-benchy") + adapter = PrintablesAdapter(opener_factory=lambda: mock_opener) + url, name = resolve_printables_url("https://www.printables.com/model/3161-3d-benchy", adapter=adapter) assert url == "https://files.printables.com/media/x/3dbenchy.stl" assert name == "3dbenchy.stl" From 20a1557746d0ceb905dbe6d87a54abf9bfbfebf1 Mon Sep 17 00:00:00 2001 From: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:51:09 -0400 Subject: [PATCH 2/2] fix: build parametrized exceptions lazily so collection works on Python 3.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The containment sweep passed HTTPError instances directly in the parametrize list. pytest derives parameter ids from the values at collection time, and probing a 3.9 HTTPError built with fp=None raised KeyError: 'file' out of tempfile.__getattr__ — so the whole module failed to collect on the 3.9 leg while every other Python passed. Pass factories with explicit ids instead: nothing is constructed until the test body runs, and pytest never inspects the objects. Verified against a real CPython 3.9.25: full suite 1177 passed. --- tests/test_printables_adapter.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/test_printables_adapter.py b/tests/test_printables_adapter.py index 82989e8..abccf0a 100644 --- a/tests/test_printables_adapter.py +++ b/tests/test_printables_adapter.py @@ -304,21 +304,28 @@ def test_malformed_api_response_never_raises(_log, payload): assert result.as_tuple() == (None, None) +# Factories, not instances: pytest derives parameter ids from the values at +# collection time, and building an HTTPError up here made it probe attributes +# that blow up on Python 3.9 (KeyError: 'file' via tempfile.__getattr__). +# Explicit ids keep the report readable without pytest inspecting the objects. @pytest.mark.parametrize( - "boom", + "make_error", [ - ValueError("bad value"), - KeyError("renamed_field"), - AttributeError("'NoneType' object has no attribute 'get'"), - TypeError("unhashable"), - RuntimeError("something odd"), - MemoryError("body too large"), - urllib.error.HTTPError("https://api.printables.com/graphql/", 500, "Server Error", {}, None), + pytest.param(lambda: ValueError("bad value"), id="ValueError"), + pytest.param(lambda: KeyError("renamed_field"), id="KeyError-renamed-field"), + pytest.param(lambda: AttributeError("'NoneType' object has no attribute 'get'"), id="AttributeError"), + pytest.param(lambda: TypeError("unhashable"), id="TypeError"), + pytest.param(lambda: RuntimeError("something odd"), id="RuntimeError"), + pytest.param(lambda: MemoryError("body too large"), id="MemoryError"), + pytest.param( + lambda: urllib.error.HTTPError("https://api.printables.com/graphql/", 500, "Server Error", {}, None), + id="HTTPError-500", + ), ], ) @patch("bambu_cli.logging_utils._BACKEND") -def test_unexpected_exception_is_contained_not_propagated(_log, boom): - adapter, _ = _adapter(boom) +def test_unexpected_exception_is_contained_not_propagated(_log, make_error): + adapter, _ = _adapter(make_error()) result = adapter.resolve(MODEL_URL) # must not raise assert result.ok is False assert result.error