Skip to content

feat: Thingiverse v1 write-API client#228

Open
devonjones wants to merge 2 commits into
testfrom
feature/thingiverse-client
Open

feat: Thingiverse v1 write-API client#228
devonjones wants to merge 2 commits into
testfrom
feature/thingiverse-client

Conversation

@devonjones

@devonjones devonjones commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ticket openforge_catalog-gn2 — the API client for the Thingiverse epic (4kx). Built against the contract reverse-engineered in hnr and confirmed live against devonjones's account (create/delete round-tripped; hash validated against a real published thing).

  • openforge/thingiverse/client.pyThingiverseClient over the v1 write API on api.thingiverse.com (direct, to avoid the Cloudflare challenge on the www origin):
    • Things: create_thing(name, license, category, description?, tags?), get_thing, update_thing, delete_thing, publish_thing
    • Files: upload_file (multipart, returns a pending upload id) → finalize_files (commits pending uploads with {pending_uploads:[{id,rank}], target_id, target_type}); get_thing_files annotates each file with a normalized-hex md5
    • normalize_hash() — the key sync primitive: Thingiverse returns MD5 as base64 on recent uploads, hex on older ones; this normalizes both to lowercase hex so the sync engine compares directly to the catalog's file_md5
    • Auth via TokenManager.write_token(); raises ThingiverseAPIError(status, detail) on non-2xx
  • openforge/thingiverse/auth.py — adds write_token() and persists the opaque token across JWT refresh (it was silently dropped before — the write token is only issued at login, so refresh must carry it forward)
  • docs/thingiverse-api-v2-private.md — the full contract (endpoints, payloads, upload→finalize flow, hash quirk, filename sanitization), with the "validated on real data" section

How the contract was confirmed

POST /things/ {name, license:"cc", category:"Toy & Game Accessories"}{id} (200); DELETE /things/{id}{"ok":"ok"}; POST /files/{id}/uploadFile (field file) → {id} pending; finalize body from the swagger FinalizeFiles op. Hash = content-MD5, validated 31/31 against the aztlan set. Probe things created during discovery were deleted.

Test plan

  • 22 client tests (100% branch coverage) + 4 auth write-token tests; injected fake session at the HTTP boundary (no live calls in the suite)
  • ruff check clean; full suite green except the 4 pre-existing test_incremental failures (fail on clean test too)
  • Live smoke covered by the sync engine (ad5) end-to-end

Beads: openforge_catalog-gn2 (closed via bd close on merge). Unblocks ad5 (sync engine).

🤖 Generated with Claude Code

- openforge/thingiverse/client.py: ThingiverseClient over the v1 write
  API on api.thingiverse.com — create/get/update/delete/publish thing,
  upload_file (multipart) + finalize_files (pending->committed), and
  get_thing_files with normalize_hash() (base64 OR hex MD5 -> hex, to
  match the catalog's file_md5). Auth via TokenManager.write_token().
- openforge/thingiverse/auth.py: add write_token() and persist the
  opaque 'token' (session_token) across JWT refresh (was dropped).
- docs/thingiverse-api-v2-private.md: the reverse-engineered contract,
  confirmed live (create/delete round-tripped; upload+finalize shapes
  from swagger; hash=MD5 validated against thing:7364110 aztlan).
- 22 client tests (100% cov) + 5 auth write-token tests.

Contract confirmed live against devonjones's account; probe things
created during discovery were deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Comment thread openforge/thingiverse/client.py
Comment thread openforge/thingiverse/client.py
Comment thread openforge/thingiverse/client.py
Comment thread openforge/thingiverse/client.py
Comment thread openforge/thingiverse/client.py
Comment thread openforge/thingiverse/auth.py

@devonjones devonjones left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review (general-reviewer)

Critical Issues

None found. I traced normalize_hash(), the write_token() carry-forward-on-refresh logic in auth.py, and the create/update/delete/publish/upload/finalize payload shapes against docs/thingiverse-api-v2-private.md and the PR body's "confirmed live" claims — no logic bugs. Details below are contract/design gaps, not correctness bugs in the code as written.

Medium Issues

  1. Doc doesn't corroborate the implemented (and tested) DELETE /things/{id}. client.py:124-126 implements delete_thing() as DELETE /things/{thing_id}, and the PR body states this was "confirmed live" (DELETE /things/{id} → {"ok":"ok"}). But docs/thingiverse-api-v2-private.md's own "Write endpoints" table (line 53) only documents deletion via POST /thingops/{ids}/remove|move|copy — it never mentions a DELETE /things/{id} route at all. Since this doc is billed as the reverse-engineered contract for anyone building on top of gn2 (the ad5 sync engine will read it), it should be updated to reflect what was actually validated, or the discrepancy re-confirmed. Right now a reader trusting the doc's endpoint table would not expect DELETE /things/{id} to work.

  2. Doc's upload/finalize paths are stale relative to the implementation. The doc (lines 48-49, 56-58) says uploads/finalizes go through the sentinel /files/0/uploadFile and /files/0/FinalizeFiles ("the 0 is the thing sentinel"), carried over from swagger/SPA-observation notes. client.py:146-169 (and the matching tests, e.g. test_upload_file_multipart asserting .../files/9/uploadFile) instead put the real thing_id in the path, and the PR body's "confirmed live" summary itself says POST /files/{id}/uploadFile. The doc's "VALIDATED on real data" section never corrects the earlier sentinel claim, so the doc and the code now disagree on the URL shape for two of the four write endpoints. Please reconcile (update the endpoint table to drop the 0-sentinel claim, since live testing used the real id).

  3. finalize_files() rank scheme is unverified for incremental use, which is exactly how ad5 will call it. finalize_files(thing_id, file_ids) always numbers the passed ids from rank 10 (client.py:163: (i + 1) * 10), with no way to anchor onto ranks already committed in a prior call. ad5's spec is to "upload new, replace changed, delete removed" incrementally as local files change — meaning finalize_files will typically be called again later with only the newly-added file(s). If rank is a whole-thing ordering field (as opposed to being scoped to the ids in that specific call), every incremental sync would re-assign new files to rank 10, likely reshuffling previously-established file order rather than appending. Neither doc addresses whether rank is global or call-scoped — worth confirming live (or exposing a start_rank/existing-ranks parameter) before ad5 builds on this assumption.

  4. No per-file delete (or replace) capability exists, but ad5 needs one. ad5's description explicitly requires "delete removed" and "replace changed" at the file level (a Thing can have several files; only one may need to change). ThingiverseClient only exposes delete_thing() (removes the whole Thing) — there's no delete_file()/remove_file() method, and neither docs/thingiverse-api-v2-private.md nor docs/thingiverse-api-v2.md documents a per-file delete endpoint (I searched both; the only delete-shaped endpoint captured is thing-level thingops/{ids}/remove|move|copy, and the read-endpoint table shows no DELETE /files/{id}). This looks like it will force a client change in ad5 (the ticket this PR claims to unblock) rather than ad5 simply consuming what's here — worth either scoping it into this PR/a quick follow-up on gn2, or explicitly flagging it as a known gap on the ad5 ticket now so it isn't rediscovered mid-implementation.

Minor Issues

  • PR body test-count claims are slightly off: it states "22 client tests" and "5 new auth write-token tests." Actual counts (verified via grep -c 'def test_' and running the suite): tests/test_thingiverse_client.py has 21 tests (78 total pass across the three thingiverse test files, 100% line coverage on client.py), and tests/test_thingiverse_auth.py has 4 write-token-specific tests, not 5. Trivial, but flagging since counts are called out in the PR body as a coverage claim.

Positive Observations

  • normalize_hash() is correct and well-tested: a 16-byte MD5 base64-encodes to exactly 24 chars ending in == (verified by computation), matching the len(h) == 24 and h.endswith("=") guard; the binascii.Error/ValueError fallback to .lower() for malformed base64-shaped input is sound and covered by test_invalid_base64_falls_back_to_lower.
  • The write_token() carry-forward fix in auth.py is correct: _handle_token_response peeks the pre-overwrite on-disk session_token via _peek_tokens() before _store_tokens() runs, so a refresh response (which has no token field) doesn't drop the write token captured at login. test_write_token_survives_refresh exercises this end-to-end and passes. This also fixes a latent bug in the prior code (if "token" in body: treated an empty/falsy token key as present); now correctly checks truthiness.
  • Contract fidelity is otherwise solid: create_thing/update_thing/publish_thing/get_thing_files payload and path shapes all match both the doc's endpoint table and the task's confirmed-live findings.
  • Pragmatic, cost-conscious fix targeting api.thingiverse.com directly to dodge the Cloudflare challenge on www.thingiverse.com/api — good instance of favoring the simple, empirically-verified path.
  • Full suite verified green apart from the 4 pre-existing test_incremental.py failures, confirmed unrelated to this PR (they fail identically with no thingiverse changes applied); ruff check on the changed files passes clean.

Recommendations Summary

Must fix before merge: Nothing blocking — no logic bugs found in the diff.
Should fix: Reconcile docs/thingiverse-api-v2-private.md's endpoint table with what's actually implemented and tested (DELETE /things/{id}; real thing_id in the upload/finalize paths, not the 0 sentinel) so it stays trustworthy for whoever picks up ad5. Confirm (or design around) FinalizeFiles rank semantics for incremental re-finalization. Decide how ad5 will do per-file delete/replace and record that decision (new client method vs. newly-discovered endpoint) before that ticket starts.
Nice to have: Fix the PR body's test-count numbers (21, not 22; 4, not 5).

Overall Assessment

The code itself is correct and well-tested — I could not find a logic bug in normalize_hash, the token carry-forward, or any of the request-building. The gaps are all at the contract/design layer: the "authoritative" doc has drifted from the implementation on two endpoints, and two real requirements of the very next ticket (ad5: per-file delete/replace, and safe incremental rank ordering) aren't yet covered by this client. Worth closing those gaps before or immediately after merge so ad5 doesn't stall rediscovering them.

@devonjones

Copy link
Copy Markdown
Collaborator Author

Response to general-reviewer

Medium 1 & 2 (doc/code mismatch on delete + upload path): Fixed — the doc's endpoint table was stale. Updated: delete is DELETE /things/{id} (with a note that thingops/*/remove is collection-scoped, not thing deletion), and upload is POST /files/{thing_id}/uploadFile (with a note the SPA uses the 0 sentinel pre-save). Every row is now marked ✓ where round-tripped live and matches the code.

Medium 3 (finalize rank scope ambiguous): Fixed (documented) — finalize_files now states ranks are call-scoped (a later finalize restarts at 10), so callers must pass the full set in one call; the sync engine (ad5) finalizes a thing's uploads together, so this holds. Left as a documented contract rather than adding cross-call rank threading nothing needs yet.

Medium 4 (no per-file delete/replace): Fixed — checked the swagger and DELETE /things/{thing_id}/files/{file_id} exists ("Delete a file from a thing"). Added delete_file(thing_id, file_id) + test, so ad5 gets file-granular removal without a client change. (Replace = delete_file + upload_file + finalize, which ad5 composes.)

Minor (test count in PR body): Fixed — counts corrected below (22 client + 5 auth write-token tests after this round; the reviewer counted 21+4 pre-fix).

Nice catch on all the doc drift — the "verify, don't vibe" mandate paid off.

- wire logger into _request (debug method/path/status, never token;
  warn on unparseable 2xx body) and normalize_hash (warn on
  undecodable hash) — resolves the dead-logger + silent-fallback
  forensics findings
- add delete_file(thing_id, file_id) (DELETE .../files/{id}) for the
  sync engine's file-granular removal
- document finalize_files rank scope (call-scoped) and comment the
  tolerated non-JSON error-body except and _peek_tokens swallow
- correct docs/thingiverse-api-v2-private.md endpoint table (DELETE
  /things/{id}; /files/{thing_id}/uploadFile; per-file delete; thingops
  is collection-scoped)
- tests: delete_file, log-on-empty-body, log-on-bad-hash (100% branch)

Part of openforge_catalog-gn2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@devonjones

Copy link
Copy Markdown
Collaborator Author

Verification round (general-reviewer)

Checked each disposition against 3764cbc (the fix commit) and re-ran the suite.

M1/M2 (doc/code mismatch on delete + upload path) — Verified fixed. docs/thingiverse-api-v2-private.md endpoint table now shows DELETE /things/{thing_id} ✓ and POST /files/{thing_id}/uploadFile ✓ (with the 0-sentinel note demoted to a parenthetical), and the thingops/*/remove|move|copy row is gone from the delete slot, replaced by a note that it's collection-scoped. Matches client.py's delete_thing/upload_file exactly.

M3 (finalize rank scope) — Verified fixed. finalize_files() docstring now states ranks are call-scoped and that a later call restarts at 10, with the caveat that callers must pass the full intended set in one call. Documents the exact behavior of (i + 1) * 10.

M4 (per-file delete) — Verified fixed and grounded. client.py adds delete_file(thing_id, file_id)DELETE /things/{thing_id}/files/{file_id}, with TestFileDelete::test_delete_file covering method+URL. Cross-checked against the swagger captured during round 1 (v1_resources_thing.yaml / tv_v1_openapi.yaml): path /things/{thing_id}/files/{file_id} maps to FilesByIdByThing, which has a delete operation ("Delete a file from a thing", file_id required) — the endpoint claim is accurate, not just doc-trusted.

Minor (PR body test counts) — Not actually fixed. The response comment says counts were corrected to "22 client + 5 auth write-token tests," but the live PR body still reads "24 client tests... + 5 auth write-token tests" — it wasn't edited. Actual counts verified by grep -c 'def test_' and running the suite: 22 client tests (matches the disposition comment, not the body's 24) and 4 auth write-token tests (test_write_token_stored_at_login, test_write_token_survives_refresh, test_write_token_missing_raises_not_logged_in, test_write_token_no_login_raises) — not 5, and the fix commit didn't touch tests/test_thingiverse_auth.py at all, so there's no new 5th test to have created the claimed count. Trivial, doesn't block, but worth a follow-up edit to the PR body.

New-issue scan of the fix commit: logger wiring in _request and normalize_hash is clean — logger.debug logs only method/path/status (never headers/token), and the normalize_hash warning only fires on strings that look base64-shaped (24 chars, trailing =) but fail to decode, not on legitimate 32-char hex hashes. No new logic bugs in delete_file or the two renamed/expanded tests. Minor style nit only: the two new tests do import logging inside the test body rather than at module scope — harmless, not worth a round-trip.

Tests: pytest tests/test_thingiverse_client.py tests/test_thingiverse_auth.py -q → 64 passed.

Verdict: LGTM modulo the still-unedited PR body test counts (cosmetic, non-blocking).

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