Files
spiele-redaktion/src/redaktionskern/plugin_loader.py
ox-alpha 56cc5943b1 Plugin neuheiten: BGG-Sync mit APScheduler, Filtern und deutscher UI
- Datenmodell + eigene Migration 0001_neuheiten_tabelle (Tabelle neuheiten:
  Titel, Verlag, Autor, Erscheinungsjahr, BGG-ID, Status 'neuheit', Quelle,
  Zeitstempel; bgg_id eindeutig als Merge-Kriterium)
- BoardGameGeek XML API2-Client (search + thing, Batches à 20 IDs):
  Rate-Limit >= 1 s zwischen Requests, Retry mit exponentiellem Backoff bei
  5xx/429/Netzwerkfehlern, HTTP 202 gemäß Retry-After, robustes XML-Parsing;
  Transport/Uhr/Sleep injizierbar (keine echten Calls in Tests)
- Filter: Erweiterungen (boardgameexpansion) auf Request- und Elementebene
  ausgeschlossen; Prototypen per Titel-Heuristik (BGG hat keinen Marker)
- Sync-Service mit Update-statt-Duplikat-Logik über die eindeutige BGG-ID
  (Status bleibt erhalten); Fehler je Suchbegriff brechen den Lauf nicht ab
- APScheduler-Hintergrundjob (Standard 24 h) mit Überlappungsschutz,
  abschaltbar/intervallkonfigurierbar per Env; manueller
  'Jetzt synchronisieren'-Endpunkt nur für Admin/Redakteur, optional mit
  Sofort-Suchbegriff
- UI /neuheiten: sortier-/filterbare Tabelle mit Volltextsuche (HTMX-Teilladung,
  noscript-fähig), deutsche Oberfläche, BGG-Links, Ergebnis-Banner
- Plugin-Loader: idempotentes Laden (Modul-Caching), damit mehrere
  create_app()-Aufrufe dieselben Plugin-Klassen/Tabellen nutzen
- Tests: Parsing, Erweiterungs-/Prototyp-Filter, Rate-Limit/Backoff/202,
  Update-statt-Duplikat, Rollen am Sync-Endpunkt, Scheduler-Lifecycle —
  ausschließlich mit gemockten BGG-Antworten (uv run pytest: 96 grün)
- README/AGENTS: Plugin-Doku, Env-Variablen, Fortschrittstabelle aktualisiert
2026-08-21 19:10:43 +00:00

128 lines
4.4 KiB
Python

"""Plugin-Loader.
Plugins werden aus zwei Quellen geladen:
1. Installierte Pakete mit Entry-Points der Gruppe `spiele_redaktion.plugins`
(via importlib.metadata) — für später ausgelieferte Plugin-Pakete.
2. Ein lokales `plugins/`-Verzeichnis — jeder Unterordner mit __init__.py
ist ein Plugin (Modulattribut `plugin` = BasePlugin-Instanz).
"""
from __future__ import annotations
import importlib.metadata
import importlib.util
import sys
from importlib.metadata import entry_points
from pathlib import Path
from redaktionskern.contracts import BasePlugin
ENTRY_POINT_GROUP = "spiele_redaktion.plugins"
_MODUL_PRAEFIX = "spiele_redaktion_plugins"
class PluginError(Exception):
"""Fehler beim Laden oder Registrieren eines Plugins."""
def _als_plugin(obj: object) -> BasePlugin:
"""Akzeptiert eine Instanz oder eine Klasse (wird instanziert)."""
if isinstance(obj, type) and issubclass(obj, BasePlugin):
obj = obj()
if not isinstance(obj, BasePlugin):
raise PluginError(
f"Erwartet wurde eine BasePlugin-Instanz, erhalten: {type(obj).__name__}"
)
return obj
def _aus_entry_points(group: str) -> list[BasePlugin]:
plugins: list[BasePlugin] = []
for entry_point in entry_points(group=group):
try:
plugins.append(_als_plugin(entry_point.load()))
except PluginError:
raise
except Exception as exc: # Entry-Point kann nicht geladen werden
raise PluginError(
f"Entry-Point '{entry_point.name}' (Gruppe {group}) "
f"konnte nicht geladen werden: {exc}"
) from exc
return plugins
def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
plugins: list[BasePlugin] = []
if not verzeichnis.is_dir():
return plugins
for eintrag in sorted(verzeichnis.iterdir()):
if not eintrag.is_dir() or eintrag.name.startswith(("_", ".")):
continue
init = eintrag / "__init__.py"
if not init.is_file():
continue
modulname = f"{_MODUL_PRAEFIX}.{eintrag.name}"
# Bereits geladenes Modul wiederverwenden (idempotentes Laden):
# Mehrere create_app()-Aufrufe im selben Prozess teilen sich sonst
# doppelte Klassen/Tabellen auf derselben SQLAlchemy-MetaData.
zwischengespeichert = sys.modules.get(modulname)
if (
zwischengespeichert is not None
and hasattr(zwischengespeichert, "plugin")
and getattr(zwischengespeichert.__spec__, "origin", None)
and Path(zwischengespeichert.__spec__.origin).resolve() == init.resolve()
):
plugins.append(_als_plugin(zwischengespeichert.plugin))
continue
spec = importlib.util.spec_from_file_location(
modulname, init, submodule_search_locations=[str(eintrag)]
)
if spec is None or spec.loader is None:
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
modul = importlib.util.module_from_spec(spec)
sys.modules[modulname] = modul
spec.loader.exec_module(modul)
if not hasattr(modul, "plugin"):
raise PluginError(
f"Plugin '{eintrag.name}' definiert kein Modulattribut 'plugin'."
)
plugins.append(_als_plugin(modul.plugin))
return plugins
class PluginRegistry:
"""Verwaltet geladene Plugins; Plugin-Namen sind eindeutig."""
def __init__(self) -> None:
self._plugins: dict[str, BasePlugin] = {}
def register(self, plugin: BasePlugin) -> None:
if plugin.name in self._plugins:
raise PluginError(
f"Doppeltes Plugin '{plugin.name}' — Plugin-Namen müssen eindeutig sein."
)
self._plugins[plugin.name] = plugin
def all(self) -> list[BasePlugin]:
return list(self._plugins.values())
def get(self, name: str) -> BasePlugin | None:
return self._plugins.get(name)
def __len__(self) -> int:
return len(self._plugins)
def discover_plugins(
plugins_dir: Path | None, entry_point_group: str = ENTRY_POINT_GROUP
) -> PluginRegistry:
"""Lädt Plugins aus Entry-Points und dem lokalen Verzeichnis."""
registry = PluginRegistry()
for plugin in _aus_entry_points(entry_point_group):
registry.register(plugin)
if plugins_dir is not None:
for plugin in _aus_verzeichnis(plugins_dir):
registry.register(plugin)
return registry