feat(integrations): Grocy + Spoolman lookup clients with shared NotFoundError base#52
Conversation
…-guards - GrocyClient: GROCY-API-KEY header auth, maps both 400 and 404 to GrocyNotFoundError (Grocy quirk), raises ValueError on missing 'id' - SpoolmanClient: no-auth trusted-network client, '#'-prefixed primary_id, remaining_weight formatted with round-half-up, raises ValueError on missing 'id' - Both clients: keyword-only __init__, URL-encoded ids, base_url trailing- slash strip, TODO(phase6) httpx.AsyncClient pooling comment - 17 new tests (8 Grocy + 9 Spoolman), 101 total pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ng comments and tighter tests
- Introduce backend/app/services/errors.py with AppLookupNotFoundError base
- SnipeITNotFoundError, GrocyNotFoundError, SpoolmanNotFoundError all inherit
from the base so callers can catch any client's not-found in one clause
- Add rounding comment to spoolman_client.py explaining math.floor(x+0.5)
vs banker's rounding in round() / f"{x:.0f}"
- Tighten test_lookup_url_encodes_spool_id to assert source_app and primary_id
- Add test_lookup_spool_with_null_filament for fully-null filament path
- Add test_not_found_error_is_app_lookup_not_found cross-client inheritance check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request expands the App-Lookup-Service by introducing two new REST clients for Grocy and Spoolman. It establishes a shared exception base to simplify future aggregation logic and ensures consistent defensive programming practices across all integration clients. The changes include robust test coverage for various edge cases, including API-specific error handling and authentication requirements. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Pull request overview
Adds Grocy and Spoolman lookup clients to the backend “App-Lookup” layer, plus a shared AppLookupNotFoundError base class so the upcoming aggregator can catch all client “not found” cases uniformly.
Changes:
- Introduces
GrocyClientandSpoolmanClientasync REST clients that emit app-agnosticLabelData, including consistent URL normalization, URL-encoding of IDs, and explicit HTTP error mapping. - Adds
AppLookupNotFoundErrorand updatesSnipeITNotFoundErrorto inherit from it for cross-client not-found handling. - Adds/extends unit tests covering happy paths, not-found mapping, URL encoding, header behavior, and defensive “missing id” guards.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| backend/app/services/errors.py | Adds shared AppLookupNotFoundError base exception for lookup clients. |
| backend/app/services/grocy_client.py | Implements GrocyClient with Grocy-specific auth header + 400/404→not-found mapping. |
| backend/app/services/spoolman_client.py | Implements SpoolmanClient with title composition + half-up rounding for remaining grams. |
| backend/app/services/snipeit_client.py | Makes SnipeITNotFoundError inherit from AppLookupNotFoundError. |
| backend/tests/unit/services/test_grocy_client.py | Adds Grocy client tests for success, not-found behavior, encoding, headers, and error surfacing. |
| backend/tests/unit/services/test_spoolman_client.py | Adds Spoolman client tests for success, rounding, not-found behavior, encoding, and no-auth headers. |
| backend/tests/unit/services/test_snipeit_client.py | Adds a cross-cutting test asserting all concrete not-found errors share the common base. |
There was a problem hiding this comment.
Code Review
This pull request introduces new service clients for Grocy and Spoolman to facilitate entity lookups for label printing. It also establishes a shared exception hierarchy by introducing AppLookupNotFoundError, which existing and new clients now inherit from. The implementation correctly utilizes httpx for asynchronous requests and includes comprehensive unit tests for the new clients and exception inheritance. I have no feedback to provide as the changes align with the project's architectural patterns and style guidelines.
## 0.3.0 (2026-05-12) * feat(config): pydantic-settings module with env-driven runtime configuration (#45) ([878e9e0](878e9e0)), closes [#45](#45) * feat(integrations): AppLookupService aggregator — Phase 3 complete (#53) ([222bef4](222bef4)), closes [#53](#53) * feat(integrations): Grocy + Spoolman lookup clients with shared NotFoundError base (#52) ([b1c9c3c](b1c9c3c)), closes [#52](#52) * feat(integrations): LabelData schema + Snipe-IT lookup client (#51) ([3bc180f](3bc180f)), closes [#51](#51) * feat(label-renderer): Template schema + Pillow/qrcode renderer for 1-bit label bitmaps (#54) ([fb77028](fb77028)), closes [#54](#54) * feat(printer-models): Brother PT-Series TapeRegistry with TZe and heat-shrink specs (#47) ([7526019](7526019)), closes [#47](#47) * feat(printer-models): Job lifecycle FSM with explicit state machine (#49) ([1a8c40e](1a8c40e)), closes [#49](#49) * feat(printer-models): PrinterModel Protocol + ModelRegistry for plugin discovery (#48) ([2ae0e09](2ae0e09)), closes [#48](#48) * feat(printer-models): PrintQueue worker with pause/resume/cancel/retry (#50) ([dfdf6fe](dfdf6fe)), closes [#50](#50) [skip ci]
Summary
Phase 3 PR C2 of 3. Adds two more REST clients for the App-Lookup-Service:
GROCY-API-KEYheader. Special-cases Grocy's quirk of returning HTTP 400 (not 404) for missing products.Both follow the defensive patterns established for
SnipeITClient(PR #51): keyword-only init, normalisedbase_url, URL-encoded id,ValueErroron missingidin response, tuplesecondary, documented HTTP error policy.Also introduces the shared
AppLookupNotFoundErrorbase so PR C3 (Aggregator) can catch any client's not-found in oneexceptclause. All three concrete*NotFoundErrorclasses (incl. the already-merged Snipe-IT one) now inherit from it.What's in this PR
backend/app/services/errors.py(new)AppLookupNotFoundError(Exception)— shared base for all lookup clients.backend/app/services/grocy_client.pyGrocyClient(*, base_url, api_key, timeout=5.0)./api/objects/products/{quote(id)}with headerGROCY-API-KEY: <key>(NOT Bearer).400or404→GrocyNotFoundError(Grocy's not-found quirk).5xx→ rawhttpx.HTTPStatusError.idmissing →ValueError.backend/app/services/spoolman_client.pySpoolmanClient(*, base_url, timeout=5.0)— noapi_keyparameter, no auth header./api/v1/spool/{quote(id)}.filament.vendor.name+filament.name(graceful "Unknown" fallback).primary_id = f"#{id}"."{grams}g remaining"rounded half-up (banker's rounding would print850for850.5— commented inline).backend/app/services/snipeit_client.pySnipeITNotFoundError(Exception)→SnipeITNotFoundError(AppLookupNotFoundError). Transparent to all existing callers.Tests (+19, total 103)
GROCY-API-KEYheader sent (and noAuthorization)."851g remaining", 404, missing remaining weight, missing vendor.name, null filament, trailing slash, URL encoding, 5xx, missing-id guard, no-auth-header assertion.test_not_found_error_is_app_lookup_not_foundverifies all three concrete NotFoundError types inherit from the shared base.test_lookup_url_encodes_spool_idnow asserts the response (was a no-op).What's NOT in this PR
AppLookupServiceaggregator — PR C3.TODO(phase6)comment on each client.Test plan
pytest -q→ 103/103.ruff format --check .clean.ruff check .clean.mypy app/(strict) clean — 21 source files..example(RFC 2606 reserved).Review history (subagent-driven)
51ab508— initial Grocy + Spoolman clients (17 new tests).test_lookup_url_encodes_spool_id, no test for null filament.31ff882— introducedapp/services/errors.py+ cross-cutting inheritance fix touching the already-merged Snipe-IT client too, inline rounding comment, tightened test, null-filament test added.Linked plan
docs/superpowers/plans/2026-05-11-label-printer-hub.mdTasks 3.3 + 3.4.PR C3 (AppLookupService aggregator + per-app routing) builds on this branch.