Skip to content

feat(integrations): plugin architecture for lookup clients (Phase 3.5)#55

Merged
strausmann merged 16 commits into
mainfrom
feat/plugin-architecture
May 15, 2026
Merged

feat(integrations): plugin architecture for lookup clients (Phase 3.5)#55
strausmann merged 16 commits into
mainfrom
feat/plugin-architecture

Conversation

@strausmann
Copy link
Copy Markdown
Owner

Phase 3.5 — Integration Plugin Architecture

Refactors the three hardcoded lookup-clients (snipeit, spoolman, grocy)
into a plugin pattern analogous to the existing PrinterModel registry.
Plugins register via setuptools entry-points (group
label_hub.integrations) so third-party integrations install via
pip install without touching the core repo.

Changes

New

  • app/integrations/base.pyIntegrationPlugin Protocol
    (@runtime_checkable)
  • app/integrations/registry.pyIntegrationRegistry analogous to
    ModelRegistry, with hardened input validation
  • app/integrations/__init__.pyentry_points discovery at import
    time, defensive against four failure modes
  • app/integrations/{snipeit,spoolman,grocy}/plugin.py — relocated and
    renamed; classes implement the Protocol
  • app/integrations/README.md — how to add a plugin (bundled or
    third-party), plugin contract, defensive-loading model, test fixture
    pattern

Modified

  • pyproject.toml — bundled plugins declared under
    [project.entry-points."label_hub.integrations"]
  • app/services/lookup_service.py — uses IntegrationRegistry.get(name)
    instead of constructor-injection; drops _AppName Literal,
    AVAILABLE_APPS constant, _LookupClient Protocol
  • app/config.py — adds *_timeout fields for the three integrations
  • Plugin __init__ reads config from app.config.get_settings() so
    the no-arg constructor plugin_cls() (used by _discover_plugins)
    works at import time

Deleted

  • app/services/{snipeit,spoolman,grocy}_client.py
  • Their test files (relocated to tests/unit/integrations/)

How to add a new integration

Bundled: drop plugin.py in app/integrations/<name>/, register in
pyproject.toml. Third-party: ship a Python package with the same
entry-points group — pip install <pkg> registers it automatically.
See app/integrations/README.md.

Test plan

  • pytest — 148/148 green
  • pytest --cov=app — 91.95% (well above the 80% threshold)
  • ruff check . — no errors
  • mypy app/integrations app/services tests/unit/integrations --strict
    — clean (no issues in 24 source files)
  • Smoke: with PRINTER_HUB_* env vars set, importing
    app.integrations discovers all three plugins via entry_points;
    IntegrationRegistry.names() and
    AppLookupService().available_apps both return
    ['grocy', 'snipeit', 'spoolman']
  • All three plugin test suites (relocated from services/) pass
  • Cross-app inheritance test still passes
    (*NotFoundError subclasses AppLookupNotFoundError for all
    three plugins)

Notes for reviewers

  • _AppName Literal is gone, but the public API surface is unchanged:
    AppLookupService().lookup(source_app, identifier) returns
    LabelData; UnknownAppError still raised on unknown apps.
  • IntegrationRegistry is hardened against empty, whitespace-only, and
    non-string name values (Phase-1 review feedback).
  • _discover_plugins catches four failure modes (load exception,
    instantiation exception, Protocol mismatch, name collision /
    type-error from registry). All failures log via logging (level
    ERROR) with the entry-point name — a single broken third-party
    plugin cannot prevent the rest from registering.
  • The five plugin tests that previously mutated os.environ directly
    now use monkeypatch.setenv for proper test isolation
    (pytest-xdist compatible).

Refs #22 — sets the stage for Phase 4 (default templates), which will
reference TemplateSchema.app strings against this registry.

strausmann added 14 commits May 15, 2026 12:25
- Reject whitespace-only names (was: passed silently)
- Reject non-string name types with TypeError (was: crashed later inside sorted())
- Test fixture clears registry on both setup and teardown

Refs #22
Loads plugins from the 'label_hub.integrations' entry-points group.
Each plugin is loaded, instantiated, validated against the
IntegrationPlugin Protocol, and registered independently — a single
broken third-party plugin logs an error and is skipped without
preventing the others from loading.

Failure modes covered: load-time exception, non-Plugin export,
name collision with an already-registered plugin.

Refs #22
…iscovery enforcement

The Protocol's @runtime_checkable property is now enforced by
_discover_plugins in app.integrations.__init__, not by the registry
itself. Update the docstring to reflect where the isinstance check
actually lives.

Refs #22
…iscovery

- _logger.exception() instead of error() on load/instantiate guards
  to capture stack traces for production debugging
- except (ValueError, TypeError) on register() guard — non-string
  plugin name no longer escapes and kills the discovery loop
- Add regression test for the TypeError-escape path
- Docstring updated: four failure modes (was three)

Refs #22
Renames SnipeITClient → SnipeITPlugin and adds name/display_name
attributes so the class satisfies the IntegrationPlugin Protocol.
File moves preserve git history.

lookup_service.py has no direct SnipeITClient import — it uses the
_LookupClient structural Protocol — so no alias shim is needed there.
The test top-level import uses `SnipeITPlugin as SnipeITClient` to
keep all call-site names in the test body unchanged.

Refs #22
Renames SpoolmanClient → SpoolmanPlugin and adds name/display_name
attributes so the class satisfies the IntegrationPlugin Protocol.
File moves preserve git history.

lookup_service.py has no direct SpoolmanClient import — it uses the
_LookupClient structural Protocol — so no alias shim is needed there.
Test imports use SpoolmanPlugin directly (clean, no alias) since all
call-site names were updated in place.

Also fixes test_snipeit_plugin.py: the cross-app inheritance test
(test_not_found_error_is_app_lookup_not_found) imported SpoolmanNotFoundError
from the now-deleted app.services.spoolman_client path; updated to use
app.integrations.spoolman.plugin.

Refs #22
Renames GrocyClient → GrocyPlugin and adds name/display_name attributes
so the class satisfies the IntegrationPlugin Protocol. File moves
preserve git history.

With this commit all three integration clients live under
app/integrations/ and the cross-app inheritance test in
test_snipeit_plugin.py now covers all three plugins through their
new import paths. The next step (Phase 6) drops AppLookupService's
constructor-injection in favor of IntegrationRegistry lookup.

Refs #22
Each plugin's __init__ now takes no arguments and reads its config
(base_url, api_key, timeout) from app.config.get_settings(). This is
required for the entry_points discovery, which instantiates plugins
via plugin_cls() with no args.

Three timeout fields were added to Settings (snipeit_timeout,
grocy_timeout, spoolman_timeout, all PRINTER_HUB_-prefixed, default 5.0).
The snipeit_api_key and grocy_api_key fields are SecretStr — the plugin
constructors call .get_secret_value() internally.

Test fixtures replace constructor-arg injection with monkeypatch.setenv()
+ get_settings.cache_clear(). Each module gets an autouse fixture that
wires the PRINTER_HUB_* env vars to the fake host used by respx mocks.
Tests that need a different value (e.g. trailing-slash, bearer-header)
override the env var directly before constructing the plugin.

Refs #22
…n with Registry

Drops the _AppName Literal, AVAILABLE_APPS constant, _LookupClient
Protocol, and the three-positional-argument constructor. Service now
dispatches through IntegrationRegistry.get() — new plugins registered
via entry_points become available without touching this module.

UnknownAppError wraps IntegrationNotFoundError and preserves the
operationally distinct contract: unknown app = caller bug (not data
state). The error message propagates from the Registry so it naturally
lists registered plugin names.

The available_apps property delegates to IntegrationRegistry.names(),
returning a sorted list of currently registered plugin names.

Test suite replaces MagicMock constructor injection with a _StubPlugin
fixture that populates and cleans the Registry around each test. All
seven prior test cases are preserved as equivalent Registry-based tests.

Refs #22
…on writes

Five tests previously wrote to os.environ[...] directly. The mutations
were not undone at test-end; they only happened to work because the
_stub_settings autouse fixture + cache_clear() pattern restored
consistent state before the next test. Under pytest-xdist or any
test-reordering this would become flaky.

Switching to monkeypatch.setenv() makes each test fully self-contained —
pytest restores the previous env-var state automatically at teardown.

Also removes the in-function `import os` statements that are no longer
needed.

Refs #22
Snipe-IT, Spoolman, and Grocy plugins are declared under the
label_hub.integrations entry-points group, so importing
app.integrations at app start triggers automatic discovery and
registration via IntegrationRegistry. Third-party plugins follow
the same pattern in their own pyproject.toml.

Refs #22
README covers three areas:
- Adding a bundled plugin (in this repo)
- Adding a third-party plugin (external pip package)
- Plugin contract, defensive loading, and the test fixture pattern

Refs #22
Copilot AI review requested due to automatic review settings May 15, 2026 13:39
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, 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 introduces a flexible plugin architecture for external lookup integrations. By moving from hardcoded clients to a registry-based system, the application now supports extensible third-party plugins that register automatically via entry-points. This change improves maintainability, enables modular deployments, and simplifies the integration of new external services.

Highlights

  • Plugin Architecture: Refactored hardcoded lookup clients (Snipe-IT, Spoolman, Grocy) into a dynamic plugin system using setuptools entry-points.
  • Registry Pattern: Introduced an IntegrationRegistry to manage plugin discovery and registration, allowing third-party integrations to be added via pip install without core repository changes.
  • Defensive Loading: Implemented robust plugin discovery that handles multiple failure modes (load errors, instantiation issues, protocol mismatches) by logging errors without crashing the application.
  • API Cleanup: Simplified AppLookupService by removing constructor-injection and hardcoded app constants in favor of dynamic registry lookups.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a plugin-based architecture for external integrations (Snipe-IT, Grocy, and Spoolman) using Python entry points and a central registry. It refactors the existing clients into standalone plugins and updates the AppLookupService to dynamically discover and route requests through the IntegrationRegistry. Feedback suggests improving the documentation to explicitly mention that bundled plugins require corresponding fields in the core configuration and to advise third-party plugins to manage their own settings via environment variables or local Pydantic classes, as they cannot extend the core Settings object.

# Local import avoids a load-time cycle.
from app.config import get_settings
settings = get_settings()
self._base_url = settings.myapp_url.rstrip("/")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This example assumes myapp_url exists on the Settings object. When adding a bundled plugin, the author must also add the corresponding fields to backend/app/config.py. It would be helpful to explicitly mention this step in the guide.

|---|---|---|
| `name` | `str` | Canonical id used in templates (`TemplateSchema.app`) and audit logs. Must be unique across all registered plugins. |
| `display_name` | `str` | UI label, e.g. shown in template-picker dropdowns. |
| `__init__(self)` | `None` | Must accept no positional or keyword arguments — entry-points discovery instantiates plugins with `plugin_cls()`. Read configuration from `app.config.get_settings()` via a local import. |
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The instruction to read configuration from app.config.get_settings() is only applicable to bundled plugins. Third-party plugins cannot extend the core Settings class without modifying the core repository (due to extra="ignore" in Settings.model_config). For external plugins, the documentation should recommend reading configuration directly from environment variables or using a local Pydantic BaseSettings class to maintain proper decoupling.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors the built-in Snipe-IT / Grocy / Spoolman lookup clients into an integration plugin architecture discovered via setuptools entry points (label_hub.integrations), and switches AppLookupService to dispatch through a central IntegrationRegistry.

Changes:

  • Added IntegrationPlugin Protocol + IntegrationRegistry, plus entry-point discovery in app.integrations (defensive loading and error logging).
  • Converted the three integrations into plugins with no-arg constructors that read config via get_settings().
  • Updated tests and settings to support the new plugin + registry behavior (including discovery tests and per-integration settings fixtures).

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
backend/app/config.py Adds per-integration timeout settings used by plugin constructors.
backend/app/integrations/init.py Implements entry-point discovery and registers plugins defensively at import time.
backend/app/integrations/README.md Documents the plugin contract, entry-point registration, defensive loading, and test patterns.
backend/app/integrations/base.py Defines the IntegrationPlugin runtime-checkable Protocol.
backend/app/integrations/grocy/init.py Marks Grocy as a plugin package.
backend/app/integrations/grocy/plugin.py Converts Grocy client into a GrocyPlugin reading settings via get_settings().
backend/app/integrations/registry.py Adds the central IntegrationRegistry + IntegrationNotFoundError.
backend/app/integrations/snipeit/init.py Marks Snipe-IT as a plugin package.
backend/app/integrations/snipeit/plugin.py Converts Snipe-IT client into a SnipeITPlugin reading settings via get_settings().
backend/app/integrations/spoolman/init.py Marks Spoolman as a plugin package.
backend/app/integrations/spoolman/plugin.py Converts Spoolman client into a SpoolmanPlugin reading settings via get_settings().
backend/app/services/lookup_service.py Switches dispatch to IntegrationRegistry.get() and exposes available_apps via registry.
backend/pyproject.toml Declares bundled plugins under [project.entry-points."label_hub.integrations"].
backend/tests/unit/integrations/init.py Initializes the integrations unit test package.
backend/tests/unit/integrations/test_base.py Adds Protocol smoke tests (runtime-checkable + type hints contract).
backend/tests/unit/integrations/test_discovery.py Adds entry-point discovery tests (success + multiple failure modes).
backend/tests/unit/integrations/test_grocy_plugin.py Updates Grocy tests to use GrocyPlugin() + settings via env vars.
backend/tests/unit/integrations/test_registry.py Adds unit tests for registry validation, sorting, and runtime protocol checks.
backend/tests/unit/integrations/test_snipeit_plugin.py Updates Snipe-IT tests to use SnipeITPlugin() + settings via env vars.
backend/tests/unit/integrations/test_spoolman_plugin.py Updates Spoolman tests to use SpoolmanPlugin() + settings via env vars.
backend/tests/unit/services/test_lookup_service.py Updates lookup service tests to use registry-based dispatch with stub plugins.
Comments suppressed due to low confidence (1)

backend/app/integrations/spoolman/plugin.py:33

  • The SpoolmanPlugin docstring mentions an "optional API key", but this plugin does not use or accept an API key (and Settings has no spoolman_api_key). Please adjust the docstring to match the actual configuration surface so plugin authors/users aren’t misled.

Comment on lines +17 to +20
from app.integrations.registry import (
IntegrationNotFoundError,
IntegrationRegistry,
)
Comment on lines +26 to +42
"""Add `plugin` under its `.name`.

Rejects empty, whitespace-only, or non-string names and duplicates.
"""
if not isinstance(plugin.name, str):
raise TypeError(
f"IntegrationPlugin name must be a string; got {type(plugin.name).__name__}"
)
if not plugin.name.strip():
raise ValueError(
f"IntegrationPlugin requires a non-empty name; got {plugin.name!r}"
)
if plugin.name in cls._plugins:
raise ValueError(
f"Plugin {plugin.name!r} already registered"
)
cls._plugins[plugin.name] = plugin
CI's ruff format --check step caught formatting drift in the 12
files added/modified during Phase 3.5. Local pre-checks ran ruff
check (lint) but not ruff format (formatting); fixed by running
ruff format on the affected files. No logic changes.

Refs #22
- README: document the config.py step for bundled plugins and the
  external-plugin-config pattern for third-party plugins (Gemini)
- lookup_service.py: side-effect-import app.integrations to ensure
  the registry is populated even when main.py is not on the call
  path (Copilot)
- registry.py: reject plugin names with leading/trailing whitespace
  in addition to whitespace-only; whitespace in a canonical id is
  always wrong (Copilot, plus regression test)
- spoolman/plugin.py: docstring no longer claims an api_key field
  that does not exist (Copilot suppressed)

Refs #22
@strausmann
Copy link
Copy Markdown
Owner Author

All 5 review findings addressed in e938ae8:

# Reviewer File Fix
1 Gemini README.md:25 Added explicit config.py Settings-fields step to the bundled-plugin tutorial
2 Gemini README.md:62 Documented that third-party plugins cannot extend the core Settings class (extra="ignore"); recommends local BaseSettings or direct env-var reads with their own namespace
3 Copilot lookup_service.py:20 Added import app.integrations side-effect import so the registry is populated regardless of caller path; explanatory comment included
4 Copilot registry.py:38 Padded names (" snipeit ") now rejected; new test test_register_rejects_padded_name regression-covers it
5 Copilot (suppressed) spoolman/plugin.py:33 Docstring no longer claims an api_key field — Spoolman is unauthenticated by design

Tests: 149/149 (148 + new padded-name test). Ruff check + format + mypy strict all clean.

@strausmann strausmann merged commit 488256b into main May 15, 2026
9 checks passed
@strausmann strausmann deleted the feat/plugin-architecture branch May 15, 2026 14:03
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.

2 participants