Coverage for nexusLIMS/exporters/registry.py: 100%
62 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
1"""Plugin registry for export destinations.
3This module provides the ExporterRegistry singleton that auto-discovers
4and manages export destination plugins from the destinations/ directory.
5"""
7from __future__ import annotations
9import importlib
10import inspect
11import logging
12import pkgutil
13from pathlib import Path
14from typing import TYPE_CHECKING, Literal
16from nexusLIMS.exporters.strategies import execute_strategy
18if TYPE_CHECKING:
19 from nexusLIMS.exporters.base import ExportContext, ExportDestination, ExportResult
21_logger = logging.getLogger(__name__)
23#: Export strategy type: controls how records are exported to multiple destinations.
24#:
25#: - ``"all"``: Export to all destinations (default)
26#: - ``"first_success"``: Stop after first successful export
27#: - ``"best_effort"``: Continue exporting even if some destinations fail
28ExportStrategy = Literal["all", "first_success", "best_effort"]
31class ExporterRegistry:
32 """Singleton registry for export destination plugins.
34 Auto-discovers plugins from exporters/destinations/ directory by
35 examining all modules for classes that match the ExportDestination
36 protocol (duck typing).
38 Attributes
39 ----------
40 _destinations : dict[str, ExportDestination]
41 Registered destination plugins, keyed by name
42 _discovered : bool
43 Whether plugin discovery has been performed
44 """
46 def __init__(self):
47 """Initialize an empty registry."""
48 self._destinations: dict[str, ExportDestination] = {}
49 self._discovered = False
51 def discover_plugins(self) -> None:
52 """Auto-discover plugins from exporters/destinations/ directory.
54 Walks the destinations/ directory and examines all Python modules
55 for classes matching the ExportDestination protocol. Discovered
56 plugins are instantiated and registered by name.
57 """
58 if self._discovered:
59 return
61 _logger.info("Discovering export destination plugins...")
63 # Get path to destinations directory
64 destinations_path = Path(__file__).parent / "destinations"
65 if not destinations_path.exists():
66 _logger.warning(
67 "Destinations directory not found: %s",
68 destinations_path,
69 )
70 self._discovered = True
71 return
73 # Walk all modules in destinations directory
74 for module_info in pkgutil.iter_modules([str(destinations_path)]):
75 module_name = f"nexusLIMS.exporters.destinations.{module_info.name}"
76 try:
77 module = importlib.import_module(module_name)
78 self._register_from_module(module)
79 except Exception:
80 _logger.exception(
81 "Failed to load destination module: %s",
82 module_name,
83 )
84 continue
86 self._discovered = True
87 _logger.info(
88 "Discovered %d export destination(s): %s",
89 len(self._destinations),
90 ", ".join(self._destinations.keys()),
91 )
93 def _register_from_module(self, module) -> None:
94 """Register plugins from a module.
96 Parameters
97 ----------
98 module
99 Python module to scan for ExportDestination implementations
100 """
101 for name, obj in inspect.getmembers(module, inspect.isclass):
102 # Skip imported classes from other modules
103 if obj.__module__ != module.__name__:
104 continue
106 # Check if class matches ExportDestination protocol
107 if self._matches_protocol(obj):
108 try:
109 instance = obj()
110 self._destinations[instance.name] = instance
111 _logger.debug(
112 "Registered export destination: %s (priority=%d)",
113 instance.name,
114 instance.priority,
115 )
116 except Exception:
117 _logger.exception(
118 "Failed to instantiate destination plugin: %s",
119 name,
120 )
122 def _matches_protocol(self, cls) -> bool:
123 """Check if a class matches the ExportDestination protocol.
125 Uses duck typing to check for required attributes and methods:
126 - name (attribute)
127 - priority (attribute)
128 - enabled (property)
129 - validate_config (method)
130 - export (method)
132 Parameters
133 ----------
134 cls
135 Class to check
137 Returns
138 -------
139 bool
140 True if class matches protocol, False otherwise
141 """
142 # Check for required attributes (must be class attributes, not instance)
143 try:
144 # Check if name and priority exist as class-level attributes,
145 # not just in __init__
146 if not hasattr(cls, "name") or not hasattr(cls, "priority"):
147 return False
149 # Check for required methods
150 required_methods = ["enabled", "validate_config", "export"]
151 return all(hasattr(cls, method_name) for method_name in required_methods)
152 except Exception:
153 return False
155 def get_enabled_destinations(self) -> list[ExportDestination]:
156 """Get enabled destinations sorted by priority (descending).
158 Returns only destinations where .enabled is True, sorted by
159 priority (higher priority first).
161 Returns
162 -------
163 list[ExportDestination]
164 Enabled destinations in priority order
165 """
166 self.discover_plugins()
167 enabled = [d for d in self._destinations.values() if d.enabled]
168 return sorted(enabled, key=lambda d: d.priority, reverse=True)
170 def export_to_all(
171 self,
172 context: ExportContext,
173 *,
174 strategy: ExportStrategy = "all",
175 ) -> list[ExportResult]:
176 """Export to destinations according to strategy.
178 Parameters
179 ----------
180 context
181 Export context with file path and session metadata
182 strategy
183 Export strategy to use (default: "all")
185 Returns
186 -------
187 list[ExportResult]
188 Results from each destination that was attempted
189 """
190 return execute_strategy(strategy, self.get_enabled_destinations(), context)
193# Singleton instance stored in a dict to avoid using `global` statement
194_registry_holder: dict[str, ExporterRegistry] = {}
197def get_registry() -> ExporterRegistry:
198 """Get the global ExporterRegistry singleton.
200 Returns
201 -------
202 ExporterRegistry
203 The singleton registry instance
204 """
205 if "instance" not in _registry_holder:
206 _registry_holder["instance"] = ExporterRegistry()
207 return _registry_holder["instance"]