Skip to content

refactor: sandbox Printables behind an adapter that cannot raise - #103

Merged
DLANSAMA merged 2 commits into
mainfrom
refactor/printables-adapter
Aug 5, 2026
Merged

refactor: sandbox Printables behind an adapter that cannot raise#103
DLANSAMA merged 2 commits into
mainfrom
refactor/printables-adapter

Conversation

@DLANSAMA

@DLANSAMA DLANSAMA commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Step 2 of 4. Replaces #100, which GitHub auto-closed when its base branch was deleted on merging #99 — same branch, same commits, rebased onto main. Reviewed there.

Why

Printables has no public, documented, or versioned API. platecli talks to their GraphQL endpoint against an observed schema, which makes it the most likely part of the tool to break through no fault of ours. It was not fenced off:

  • No public surface. Four call sites imported the private _is_printables_model_url.

  • Lossy failures. Everything collapsed to (None, None), so a caller could not distinguish "not a Printables URL" from "they changed the schema" from "network down" from "this model has no STL". All four printed the same Failed to resolve Printables model URL.

  • A latent crash. get_download_link did result.get("data", {}).get("getDownloadLink", {}). For the standard {"errors": [...], "data": null} envelope that returns None, not {} — so it raised AttributeError, and the broad except showed the user:

    Failed to get download link: 'NoneType' object has no attribute 'get'
    

    The sibling function had a comment warning about this exact trap; this call site never got the fix.

Structure

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. Sealedcheck_layers.py now fails CI if anything outside the package imports it
adapter.py The containment boundary
__init__.py The entire public surface

The guarantee

No Printables failure escapes the adapter as an exception.

resolve() always returns a PrintablesResolution. A renamed field, a redesigned error envelope, an HTML error page, a 4 MB junk body — all become a typed result with an actionable message, never a traceback inside plate job. KeyboardInterrupt/SystemExit are deliberately the only things that still propagate, so Ctrl-C keeps working.

That distinction is now user-visible: a contract_changed result says "Printables appears to have changed their API. Download the file from the model page in a browser and pass the local path instead" — because retrying will not help and the fix is a code change here, not anywhere else in the tool.

Also fixed

  • The null-data AttributeError above.
  • resp.read() on the metadata path was unbounded; now capped at 4 MB. The file download path had size 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 — no module-global patching. It keeps every behavior the old class covered and adds the containment sweep:

  • 16 malformed payloads (null data, wrong types, top-level list/string/null, binary garbage, empty body…)
  • 7 unexpected exception types (KeyError, AttributeError, MemoryError, HTTPError…)

each asserted not to raise. That sweep is the regression net for "Printables changed their API", and it is the part worth reviewing.

Gates

1177 passed, 1 deselected     (was 1146 on #99)
coverage 86.5%               (was 86.2%)
ruff / ruff format / mypy / bandit / layers / syntax / help / workflow / compat   green
uv build + package_contents_smoke: new subpackage ships in the wheel

Draft, same as #99 — the stack lands after #97.

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.
…on 3.9

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.
@DLANSAMA
DLANSAMA merged commit 9e4b1e2 into main Aug 5, 2026
6 checks passed
@DLANSAMA
DLANSAMA deleted the refactor/printables-adapter branch August 5, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant