-
Notifications
You must be signed in to change notification settings - Fork 0
feat(integrations): plugin architecture for lookup clients (Phase 3.5) #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
630fcd4
618b664
b2f0edd
54a118e
6a6708a
ac4494e
febd765
2ec83b1
103a85a
a496ca7
2ded455
97e993d
7549957
3d9358a
7d75806
e938ae8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # Integration Plugins | ||
|
|
||
| Lookup clients for external apps (Snipe-IT, Spoolman, Grocy, | ||
| third-party) live here. Each plugin implements the `IntegrationPlugin` | ||
| protocol from `base.py` and registers itself via setuptools | ||
| entry-points. | ||
|
|
||
| ## Adding a bundled plugin | ||
|
|
||
| 1. Create `backend/app/integrations/<name>/plugin.py` with a class that | ||
| implements `IntegrationPlugin`: | ||
|
|
||
| ```python | ||
| from app.schemas.label_data import LabelData | ||
|
|
||
|
|
||
| class MyAppPlugin: | ||
| name = "myapp" | ||
| display_name = "My App" | ||
|
|
||
| def __init__(self) -> None: | ||
| # Local import avoids a load-time cycle. | ||
| from app.config import get_settings | ||
| settings = get_settings() | ||
| self._base_url = settings.myapp_url.rstrip("/") | ||
|
|
||
| async def lookup(self, identifier: str) -> LabelData: | ||
| # Call upstream, build LabelData. | ||
| ... | ||
| ``` | ||
|
|
||
| 2. Add the plugin's configuration fields to `backend/app/config.py`: | ||
|
|
||
| ```python | ||
| class Settings(BaseSettings): | ||
| # ... existing fields ... | ||
| myapp_url: str = "" | ||
| myapp_api_key: SecretStr = SecretStr("") | ||
| myapp_timeout: float = 5.0 | ||
| ``` | ||
|
|
||
| Settings reads them from environment variables prefixed with | ||
| `PRINTER_HUB_` (e.g. `PRINTER_HUB_MYAPP_URL`). Match the field | ||
| names you read in the plugin constructor. | ||
|
|
||
| 3. Register the entry-point in `backend/pyproject.toml`: | ||
|
|
||
| ```toml | ||
| [project.entry-points."label_hub.integrations"] | ||
| myapp = "app.integrations.myapp.plugin:MyAppPlugin" | ||
| ``` | ||
|
|
||
| 4. Re-install the package (`pip install -e backend`) and the plugin | ||
| loads at app start. `IntegrationRegistry.names()` will include | ||
| `"myapp"`. | ||
|
|
||
| ## Adding a third-party plugin (external repo) | ||
|
|
||
| External plugins are standalone Python packages. Their own | ||
| `pyproject.toml` declares the same entry-points group: | ||
|
|
||
| ```toml | ||
| [project.entry-points."label_hub.integrations"] | ||
| openfoodfacts = "label_hub_openfoodfacts.plugin:OpenFoodFactsPlugin" | ||
| ``` | ||
|
|
||
| After `pip install label-hub-openfoodfacts` the plugin is registered | ||
| the same way bundled plugins are — no Label-Hub repo change needed. | ||
|
|
||
| Third-party plugins cannot add fields to the core `app.config.Settings` | ||
| class — it uses `extra="ignore"`, and pip-installed packages cannot | ||
| modify the host application's source. External plugins should manage | ||
| their own configuration: read environment variables directly (with | ||
| `os.environ` or a local `pydantic_settings.BaseSettings` subclass), | ||
| or accept a dedicated env-var prefix (e.g. `LABEL_HUB_OPENFOODFACTS_*`) | ||
| that does not collide with the core `PRINTER_HUB_*` namespace. | ||
|
|
||
| Bundled plugins use `app.config.get_settings()`; third-party plugins | ||
| must NOT, because adding a field there requires a core repo change. | ||
|
|
||
| ## Plugin contract | ||
|
|
||
| | Attribute / method | Type | Purpose | | ||
| |---|---|---| | ||
| | `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. | | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The instruction to read configuration from |
||
| | `lookup(identifier)` | `async (str) -> LabelData` | Resolves the integration's identifier to a `LabelData`. Raise `AppLookupNotFoundError` (or a subclass) when the entity does not exist. | | ||
|
|
||
| ## Defensive loading | ||
|
|
||
| Plugin discovery in `app/integrations/__init__.py` catches and logs | ||
| four failure modes so a single broken third-party package cannot | ||
| prevent the rest of the application from starting: | ||
|
|
||
| 1. `entry_point.load()` raises an exception. | ||
| 2. The plugin class's `__init__` raises. | ||
| 3. The loaded object does not satisfy `IntegrationPlugin` (missing | ||
| required attributes). | ||
| 4. The plugin's `name` collides with an already-registered plugin, or | ||
| has the wrong type (the registry rejects with | ||
| `ValueError`/`TypeError`). | ||
|
|
||
| Failures are logged via `logging` (level: ERROR) with the entry-point | ||
| name. Production sysadmins find the broken plugin in their log | ||
| aggregator without losing any well-behaved plugins. | ||
|
|
||
| ## Testing | ||
|
|
||
| Plugin tests live in | ||
| `backend/tests/unit/integrations/test_<name>_plugin.py`. Use `respx` | ||
| to mock the upstream HTTP layer (`respx` is already a dev dependency | ||
| in `pyproject.toml`). | ||
|
|
||
| To exercise plugin configuration via environment variables in tests, | ||
| use the `monkeypatch.setenv` pattern + `get_settings.cache_clear()` | ||
| in an `autouse=True` fixture: | ||
|
|
||
| ```python | ||
| @pytest.fixture(autouse=True) | ||
| def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| monkeypatch.setenv("PRINTER_HUB_MYAPP_URL", "https://example.test") | ||
| get_settings.cache_clear() | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| """Integration plugin discovery. | ||
|
|
||
| Importing this package triggers `_discover_plugins`, which scans the | ||
| `label_hub.integrations` entry-points group and registers every declared | ||
| plugin with `IntegrationRegistry`. Bundled plugins (snipeit, spoolman, | ||
| grocy) declare their entry-points in this repo's `pyproject.toml`. | ||
| Third-party plugins installed via pip register the same way without any | ||
| change to the core repo. | ||
|
|
||
| Loading is defensive: a broken third-party package logs an error and is | ||
| skipped, the remaining plugins still register. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import importlib.metadata | ||
| import logging | ||
|
|
||
| from app.integrations.base import IntegrationPlugin | ||
| from app.integrations.registry import IntegrationRegistry | ||
|
|
||
| _logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _discover_plugins() -> None: | ||
| """Load every plugin under the 'label_hub.integrations' entry-points group. | ||
|
|
||
| Each entry point is loaded independently — a failure in one does not | ||
| prevent the others from registering. Four failure modes are handled: | ||
|
|
||
| 1. The entry-point's `ep.load()` raises (broken third-party package). | ||
| 2. Instantiating the loaded class raises (constructor error). | ||
| 3. The loaded object does not satisfy the IntegrationPlugin Protocol | ||
| (missing attributes or wrong shape). | ||
| 4. The plugin's `name` collides with an already-registered plugin, or | ||
| has the wrong type (Registry rejects with ValueError / TypeError). | ||
| """ | ||
| for ep in importlib.metadata.entry_points(group="label_hub.integrations"): | ||
| try: | ||
| plugin_cls = ep.load() | ||
| except Exception: | ||
| _logger.exception("Failed to load entry-point %r", ep.name) | ||
| continue | ||
|
|
||
| try: | ||
| instance = plugin_cls() | ||
| except Exception: | ||
| _logger.exception( | ||
| "Failed to instantiate entry-point %r (class %s)", | ||
| ep.name, | ||
| getattr(plugin_cls, "__name__", repr(plugin_cls)), | ||
| ) | ||
| continue | ||
|
|
||
| if not isinstance(instance, IntegrationPlugin): | ||
| _logger.error( | ||
| "Entry-point %r exports %r which does not satisfy IntegrationPlugin " | ||
| "(missing required attributes name/display_name/lookup)", | ||
| ep.name, | ||
| type(instance).__name__, | ||
| ) | ||
| continue | ||
|
|
||
| try: | ||
| IntegrationRegistry.register(instance) | ||
| except (ValueError, TypeError) as e: | ||
| _logger.error("Entry-point %r could not register: %s", ep.name, e) | ||
|
|
||
|
|
||
| _discover_plugins() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| """Protocol contract for integration-lookup plugins. | ||
|
|
||
| Each external app (Snipe-IT, Spoolman, Grocy, future integrations) lives | ||
| in its own module under app.integrations.<name>, implements this Protocol, | ||
| and registers itself via setuptools entry-points (group | ||
| 'label_hub.integrations'). The Protocol is @runtime_checkable so the | ||
| entry-points discovery in `app.integrations.__init__` can validate each | ||
| loaded class with isinstance() before registering it, rejecting broken | ||
| third-party plugins with a clear log message. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Protocol, runtime_checkable | ||
|
|
||
| from app.schemas.label_data import LabelData | ||
|
|
||
|
|
||
| @runtime_checkable | ||
| class IntegrationPlugin(Protocol): | ||
| """Per-integration lookup contract.""" | ||
|
|
||
| name: str # canonical id, e.g. "snipeit" — matches TemplateSchema.app | ||
| display_name: str # UI-friendly, e.g. "Snipe-IT" | ||
|
|
||
| async def lookup(self, identifier: str) -> LabelData: | ||
| """Resolve an integration-specific identifier to LabelData. | ||
|
|
||
| Raises AppLookupNotFoundError (or a subclass) if the entity does not | ||
| exist on the upstream app. | ||
| """ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Grocy integration plugin.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| """Discover integration plugins by name. | ||
|
|
||
| The registry is class-level state, populated either by direct | ||
| `register()` calls (used by tests) or by the entry-points discovery in | ||
| `app.integrations.__init__` at module import time. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import ClassVar | ||
|
|
||
| from app.integrations.base import IntegrationPlugin | ||
|
|
||
|
|
||
| class IntegrationNotFoundError(Exception): | ||
| """No plugin registered for the given integration name.""" | ||
|
|
||
|
|
||
| class IntegrationRegistry: | ||
| """Class-level registry of IntegrationPlugin instances.""" | ||
|
|
||
| _plugins: ClassVar[dict[str, IntegrationPlugin]] = {} | ||
|
|
||
| @classmethod | ||
| def register(cls, plugin: IntegrationPlugin) -> None: | ||
| """Add `plugin` under its `.name`. | ||
|
|
||
| Rejects empty, whitespace-only, padded, 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__}" | ||
| ) | ||
| stripped = plugin.name.strip() | ||
| if not stripped: | ||
| raise ValueError(f"IntegrationPlugin requires a non-empty name; got {plugin.name!r}") | ||
| if stripped != plugin.name: | ||
| raise ValueError( | ||
| "IntegrationPlugin name must not have leading/trailing whitespace;" | ||
| f" got {plugin.name!r}" | ||
| ) | ||
| if plugin.name in cls._plugins: | ||
| raise ValueError(f"Plugin {plugin.name!r} already registered") | ||
| cls._plugins[plugin.name] = plugin | ||
|
Comment on lines
+26
to
+44
|
||
|
|
||
| @classmethod | ||
| def get(cls, name: str) -> IntegrationPlugin: | ||
| """Return the plugin registered under `name` or raise.""" | ||
| plugin = cls._plugins.get(name) | ||
| if plugin is None: | ||
| raise IntegrationNotFoundError( | ||
| f"Unknown integration {name!r}. Registered: {sorted(cls._plugins)}" | ||
| ) | ||
| return plugin | ||
|
|
||
| @classmethod | ||
| def all(cls) -> dict[str, IntegrationPlugin]: | ||
| """Return a shallow copy of the registry (callers may mutate safely).""" | ||
| return dict(cls._plugins) | ||
|
|
||
| @classmethod | ||
| def names(cls) -> list[str]: | ||
| """Return registered plugin names, sorted alphabetically.""" | ||
| return sorted(cls._plugins) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Snipe-IT integration plugin.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This example assumes
myapp_urlexists on theSettingsobject. When adding a bundled plugin, the author must also add the corresponding fields tobackend/app/config.py. It would be helpful to explicitly mention this step in the guide.