Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
630fcd4
feat(integrations): add IntegrationPlugin Protocol scaffold
strausmann May 15, 2026
618b664
feat(integrations): add IntegrationRegistry with register/get/all/names
strausmann May 15, 2026
b2f0edd
feat(integrations): harden IntegrationRegistry name validation
strausmann May 15, 2026
54a118e
feat(integrations): entry_points discovery with defensive plugin loading
strausmann May 15, 2026
6a6708a
docs(integrations): align IntegrationPlugin Protocol docstring with d…
strausmann May 15, 2026
ac4494e
feat(integrations): preserve tracebacks + catch TypeError in plugin d…
strausmann May 15, 2026
febd765
refactor(integrations): relocate snipeit client to plugin
strausmann May 15, 2026
2ec83b1
refactor(integrations): relocate spoolman client to plugin
strausmann May 15, 2026
103a85a
refactor(integrations): relocate grocy client to plugin
strausmann May 15, 2026
a496ca7
refactor(integrations): plugins read config from settings module
strausmann May 15, 2026
2ded455
refactor(integrations): replace AppLookupService constructor-injectio…
strausmann May 15, 2026
97e993d
test(integrations): use monkeypatch.setenv instead of direct os.envir…
strausmann May 15, 2026
7549957
build: register bundled integration plugins as entry_points
strausmann May 15, 2026
3d9358a
docs(integrations): explain plugin contract and how to add integrations
strausmann May 15, 2026
7d75806
style(integrations): apply ruff format to all Phase-3.5 files
strausmann May 15, 2026
e938ae8
fix(integrations): address PR #55 review feedback
strausmann May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,16 @@ class Settings(BaseSettings):
# Snipe-IT integration (optional)
snipeit_url: str = ""
snipeit_api_key: SecretStr = SecretStr("")
snipeit_timeout: float = 5.0

# Grocy integration (optional)
grocy_url: str = ""
grocy_api_key: SecretStr = SecretStr("")
grocy_timeout: float = 5.0

# Spoolman integration (no API key needed)
spoolman_url: str = ""
spoolman_timeout: float = 5.0

# Server
server_port: int = 8090
Expand Down
124 changes: 124 additions & 0 deletions backend/app/integrations/README.md
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("/")
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.


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. |
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.

| `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()
```
70 changes: 70 additions & 0 deletions backend/app/integrations/__init__.py
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()
31 changes: 31 additions & 0 deletions backend/app/integrations/base.py
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.
"""
1 change: 1 addition & 0 deletions backend/app/integrations/grocy/__init__.py
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
@@ -1,8 +1,8 @@
"""Grocy REST API client — product lookup by id.
"""Grocy integration plugin — product lookup by id.

Grocy uses a custom `GROCY-API-KEY` header (not Bearer) and returns 400
with `{"error_message": "..."}` for missing products instead of 404 —
both quirks are explicit in the client's mapping logic.
both quirks are explicit in the plugin's mapping logic.
"""

from __future__ import annotations
Expand All @@ -20,19 +20,25 @@ class GrocyNotFoundError(AppLookupNotFoundError):
"""Raised when no Grocy product matches the given id."""


class GrocyClient:
"""Async client for Grocy's REST API."""
class GrocyPlugin:
"""Grocy integration plugin.

def __init__(
self,
*,
base_url: str,
api_key: str,
timeout: float = 5.0,
) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._timeout = timeout
Implements the IntegrationPlugin protocol — exposes `name`,
`display_name`, and an async `lookup` resolving product-id →
LabelData. Configuration injection follows the same pattern as
SnipeITPlugin and SpoolmanPlugin.
"""

name = "grocy"
display_name = "Grocy"

def __init__(self) -> None:
from app.config import get_settings

settings = get_settings()
self._base_url = settings.grocy_url.rstrip("/")
self._api_key = settings.grocy_api_key.get_secret_value()
self._timeout = settings.grocy_timeout

async def lookup(self, product_id: str) -> LabelData:
"""Return LabelData for `product_id`, or raise GrocyNotFoundError."""
Expand Down
64 changes: 64 additions & 0 deletions backend/app/integrations/registry.py
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)
1 change: 1 addition & 0 deletions backend/app/integrations/snipeit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Snipe-IT integration plugin."""
Loading