Source code for nexusLIMS.exporters.registry

"""Plugin registry for export destinations.

This module provides the ExporterRegistry singleton that auto-discovers
and manages export destination plugins from the destinations/ directory.
"""

from __future__ import annotations

import importlib
import inspect
import logging
import pkgutil
from pathlib import Path
from typing import TYPE_CHECKING, Literal

from nexusLIMS.exporters.strategies import execute_strategy

if TYPE_CHECKING:
    from nexusLIMS.exporters.base import ExportContext, ExportDestination, ExportResult

_logger = logging.getLogger(__name__)

#: Export strategy type: controls how records are exported to multiple destinations.
#:
#: - ``"all"``: Export to all destinations (default)
#: - ``"first_success"``: Stop after first successful export
#: - ``"best_effort"``: Continue exporting even if some destinations fail
ExportStrategy = Literal["all", "first_success", "best_effort"]


[docs] class ExporterRegistry: """Singleton registry for export destination plugins. Auto-discovers plugins from exporters/destinations/ directory by examining all modules for classes that match the ExportDestination protocol (duck typing). Attributes ---------- _destinations : dict[str, ExportDestination] Registered destination plugins, keyed by name _discovered : bool Whether plugin discovery has been performed """ def __init__(self): """Initialize an empty registry.""" self._destinations: dict[str, ExportDestination] = {} self._discovered = False
[docs] def discover_plugins(self) -> None: """Auto-discover plugins from exporters/destinations/ directory. Walks the destinations/ directory and examines all Python modules for classes matching the ExportDestination protocol. Discovered plugins are instantiated and registered by name. """ if self._discovered: return _logger.info("Discovering export destination plugins...") # Get path to destinations directory destinations_path = Path(__file__).parent / "destinations" if not destinations_path.exists(): _logger.warning( "Destinations directory not found: %s", destinations_path, ) self._discovered = True return # Walk all modules in destinations directory for module_info in pkgutil.iter_modules([str(destinations_path)]): module_name = f"nexusLIMS.exporters.destinations.{module_info.name}" try: module = importlib.import_module(module_name) self._register_from_module(module) except Exception: _logger.exception( "Failed to load destination module: %s", module_name, ) continue self._discovered = True _logger.info( "Discovered %d export destination(s): %s", len(self._destinations), ", ".join(self._destinations.keys()), )
def _register_from_module(self, module) -> None: """Register plugins from a module. Parameters ---------- module Python module to scan for ExportDestination implementations """ for name, obj in inspect.getmembers(module, inspect.isclass): # Skip imported classes from other modules if obj.__module__ != module.__name__: continue # Check if class matches ExportDestination protocol if self._matches_protocol(obj): try: instance = obj() self._destinations[instance.name] = instance _logger.debug( "Registered export destination: %s (priority=%d)", instance.name, instance.priority, ) except Exception: _logger.exception( "Failed to instantiate destination plugin: %s", name, ) def _matches_protocol(self, cls) -> bool: """Check if a class matches the ExportDestination protocol. Uses duck typing to check for required attributes and methods: - name (attribute) - priority (attribute) - enabled (property) - validate_config (method) - export (method) Parameters ---------- cls Class to check Returns ------- bool True if class matches protocol, False otherwise """ # Check for required attributes (must be class attributes, not instance) try: # Check if name and priority exist as class-level attributes, # not just in __init__ if not hasattr(cls, "name") or not hasattr(cls, "priority"): return False # Check for required methods required_methods = ["enabled", "validate_config", "export"] return all(hasattr(cls, method_name) for method_name in required_methods) except Exception: return False
[docs] def get_enabled_destinations(self) -> list[ExportDestination]: """Get enabled destinations sorted by priority (descending). Returns only destinations where .enabled is True, sorted by priority (higher priority first). Returns ------- list[ExportDestination] Enabled destinations in priority order """ self.discover_plugins() enabled = [d for d in self._destinations.values() if d.enabled] return sorted(enabled, key=lambda d: d.priority, reverse=True)
[docs] def export_to_all( self, context: ExportContext, *, strategy: ExportStrategy = "all", ) -> list[ExportResult]: """Export to destinations according to strategy. Parameters ---------- context Export context with file path and session metadata strategy Export strategy to use (default: "all") Returns ------- list[ExportResult] Results from each destination that was attempted """ return execute_strategy(strategy, self.get_enabled_destinations(), context)
# Singleton instance stored in a dict to avoid using `global` statement _registry_holder: dict[str, ExporterRegistry] = {}
[docs] def get_registry() -> ExporterRegistry: """Get the global ExporterRegistry singleton. Returns ------- ExporterRegistry The singleton registry instance """ if "instance" not in _registry_holder: _registry_holder["instance"] = ExporterRegistry() return _registry_holder["instance"]