feat(integrations): AppLookupService aggregator — Phase 3 complete#53
Conversation
…ispatch Routes lookup(source_app, identifier) calls to the registered per-app client (Snipe-IT, Grocy, Spoolman) using a Protocol-typed _LookupClient contract. UnknownAppError is kept intentionally distinct from AppLookupNotFoundError — one is a caller configuration mistake, the other is a missing entity. Keyword-only __init__, AVAILABLE_APPS constant, and available_apps property support future clients and API-layer discovery without signature changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ute available_apps, document Literal limitation - Fix A: AVAILABLE_APPS now derived from get_args(_AppName) — eliminates string duplication and drift risk between the Literal and the constant. - Fix B: available_apps converted from @Property (re-sorts on every call) to a plain attribute computed once in __init__; _clients is immutable after construction so this is always safe. - Fix C: lookup() docstring extended to clarify that _AppName is for static-analysis tooling only and does not restrict runtime callers; UnknownAppError covers the runtime mismatch case. 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 completes Phase 3 of the label printer hub project by introducing the 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 a new backend “App Lookup” aggregation layer that dispatches lookup(source_app, identifier) to the appropriate per-integration client (Snipe-IT, Grocy, Spoolman), providing a single entrypoint for Phase 3 of the label lookup pipeline.
Changes:
- Introduces
AppLookupServicewith Protocol-typed client dispatch,AVAILABLE_APPS, and anUnknownAppErrorfor unregistered apps. - Adds unit tests covering routing, error semantics, and invariants (including non-inheritance from
AppLookupNotFoundError).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| backend/app/services/lookup_service.py | New aggregator service that routes lookups to the correct integration client and exposes available-app metadata. |
| backend/tests/unit/services/test_lookup_service.py | New unit tests verifying dispatch behavior and error semantics for the aggregator. |
| self._clients: dict[str, _LookupClient] = { | ||
| "snipeit": snipeit, | ||
| "grocy": grocy, | ||
| "spoolman": spoolman, | ||
| } | ||
| # Computed once at construction — _clients never mutates after __init__. | ||
| self.available_apps: tuple[str, ...] = tuple(sorted(self._clients)) |
There was a problem hiding this comment.
Code Review
This pull request introduces the AppLookupService to route lookup requests to specific application clients (Snipe-IT, Grocy, and Spoolman) using an asynchronous protocol, supported by comprehensive unit tests. Feedback identifies opportunities to improve type safety by using the _AppName literal for internal attributes and to optimize an error message by utilizing a pre-computed list of available apps.
| self._clients: dict[str, _LookupClient] = { | ||
| "snipeit": snipeit, | ||
| "grocy": grocy, | ||
| "spoolman": spoolman, | ||
| } | ||
| # Computed once at construction — _clients never mutates after __init__. | ||
| self.available_apps: tuple[str, ...] = tuple(sorted(self._clients)) |
There was a problem hiding this comment.
The type hints for _clients and available_apps can be made more precise by using the _AppName Literal instead of str. This improves type safety and aligns with the project's emphasis on strict typing and mypy compliance.
| self._clients: dict[str, _LookupClient] = { | |
| "snipeit": snipeit, | |
| "grocy": grocy, | |
| "spoolman": spoolman, | |
| } | |
| # Computed once at construction — _clients never mutates after __init__. | |
| self.available_apps: tuple[str, ...] = tuple(sorted(self._clients)) | |
| self._clients: dict[_AppName, _LookupClient] = { | |
| "snipeit": snipeit, | |
| "grocy": grocy, | |
| "spoolman": spoolman, | |
| } | |
| # Computed once at construction — _clients never mutates after __init__. | |
| self.available_apps: tuple[_AppName, ...] = tuple(sorted(self._clients)) |
References
- The repository style guide prioritizes type safety and strict mypy compliance (Rule 7). (link)
| """ | ||
| client = self._clients.get(source_app) | ||
| if client is None: | ||
| raise UnknownAppError(f"Unknown app {source_app!r}. Available: {sorted(self._clients)}") |
There was a problem hiding this comment.
The error message re-calculates sorted(self._clients), which is redundant as this value is already pre-computed and stored in self.available_apps during initialization. Using the existing attribute is more efficient and maintains consistency within the class.
| raise UnknownAppError(f"Unknown app {source_app!r}. Available: {sorted(self._clients)}") | |
| raise UnknownAppError(f"Unknown app {source_app!r}. Available: {self.available_apps}") |
…l + reuse available_apps in error message - _clients dict and available_apps tuple are now typed dict[_AppName, _LookupClient] and tuple[_AppName, ...] respectively; string literals in the dict literal satisfy mypy without cast at construction - cast(_AppName, source_app) at the .get() call site bridges the intentionally wider str parameter (runtime validation) to the narrower key type - UnknownAppError message now uses precomputed self.available_apps instead of re-sorting self._clients on every error path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## 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 final PR. Adds the AppLookupService aggregator that routes
lookup(source_app, id)calls to the right per-app client (Snipe-IT, Grocy, Spoolman). Phase 3 (App-Lookup-Service) closes here.The aggregator is dependency-free of its clients via a
_LookupClientProtocol — no concrete-class imports, no circular dependencies. New apps (e.g. OpenFoodFacts) plug in by extending_AppNameand the constructor.What's in this PR
backend/app/services/lookup_service.py_LookupClient(Protocol)— minimalasync lookup(identifier) -> LabelDatacontract. All three concrete clients satisfy it structurally._AppName = Literal["snipeit", "grocy", "spoolman"]— static-analysis aid.AVAILABLE_APPS = get_args(_AppName)— runtime constant derived from the Literal, so adding a new app touches one place.UnknownAppError(Exception)— explicitly does NOT inherit fromAppLookupNotFoundError. The two errors are semantically distinct: "misconfigured request" vs "entity doesn't exist".AppLookupService(*, snipeit, grocy, spoolman)— keyword-only init, dict-based dispatch,available_appsprecomputed in__init__.backend/tests/unit/services/test_lookup_service.py— 8 testsassert_awaited_once_with).UnknownAppErrorwith bad app name in message.UnknownAppErrorlists all available apps.AppLookupNotFoundErrorfrom underlying client propagates unchanged.AVAILABLE_APPSandservice.available_appsagree (cross-check).UnknownAppErroris NOT a subclass ofAppLookupNotFoundError.What's NOT in this PR
Test plan
pytest -q→ 111/111 (103 prior + 8 new).ruff format --check .clean.ruff check .clean.mypy app/(strict) clean — 22 source files.Review history (subagent-driven)
3feae15— initial commit with Protocol-typed dispatch + Literal + AVAILABLE_APPS + UnknownAppError + 8 tests._AppNameandAVAILABLE_APPS,available_appsre-sorting on every call, and absence of docstring note about Literal being doc-only.29ba1ad—AVAILABLE_APPS = get_args(_AppName)eliminates duplication,available_appsprecomputed as plain attribute,lookupdocstring extended.Phase 3 status
With this merge, the entire Phase 3 App-Lookup-Service layer is in place:
Phase 4 (LabelRenderer + TemplateService) builds on
LabelData.Linked plan
docs/superpowers/plans/2026-05-11-label-printer-hub.mdTask 3.5 closes Phase 3.