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..abccf0a --- /dev/null +++ b/tests/test_printables_adapter.py @@ -0,0 +1,371 @@ +"""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) + + +# 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( + "make_error", + [ + 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, make_error): + adapter, _ = _adapter(make_error()) + 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"