|
| 1 | +############################################################################## |
| 2 | +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin |
| 3 | +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and |
| 4 | +# other details. No copyright assignment is required to contribute to Merlin. |
| 5 | +############################################################################## |
| 6 | + |
| 7 | +""" |
| 8 | +Base factory class for managing pluggable components in Merlin. |
| 9 | +
|
| 10 | +This module defines an abstract `MerlinBaseFactory` class that provides a reusable |
| 11 | +infrastructure for registering, discovering, and instantiating pluggable components. |
| 12 | +It supports alias resolution, entry-point-based plugin discovery, and runtime |
| 13 | +introspection of registered components. |
| 14 | +
|
| 15 | +Subclasses must define how to register built-in components, validate component classes, |
| 16 | +and identify the appropriate entry point group for plugin discovery. |
| 17 | +""" |
| 18 | + |
| 19 | +import logging |
| 20 | +from abc import ABC, abstractmethod |
| 21 | +from typing import Any, Dict, List, Type |
| 22 | + |
| 23 | +import pkg_resources |
| 24 | + |
| 25 | + |
| 26 | +LOG = logging.getLogger("merlin") |
| 27 | + |
| 28 | + |
| 29 | +class MerlinBaseFactory(ABC): |
| 30 | + """ |
| 31 | + Abstract base factory for managing and instantiating pluggable components. |
| 32 | +
|
| 33 | + This class provides the infrastructure for: |
| 34 | + - Registering components and their aliases |
| 35 | + - Discovering plugins via Python entry points |
| 36 | + - Creating instances of registered components |
| 37 | + - Listing and introspecting available components |
| 38 | +
|
| 39 | + Subclasses are required to: |
| 40 | + - Implement `_register_builtins()` to register default implementations |
| 41 | + - Implement `_validate_component()` to enforce interface/type constraints |
| 42 | + - Define `_entry_point_group()` to identify the entry point namespace for discovery |
| 43 | +
|
| 44 | + Attributes: |
| 45 | + _registry (Dict[str, Any]): Maps canonical component names to their classes. |
| 46 | + _aliases (Dict[str, str]): Maps alias names to canonical component names. |
| 47 | +
|
| 48 | + Methods: |
| 49 | + register: Register a new component and its optional aliases. |
| 50 | + list_available: Return a list of all registered component names. |
| 51 | + create: Instantiate a registered component by name or alias. |
| 52 | + get_component_info: Return introspection metadata for a registered component. |
| 53 | + _discover_plugins: Discover and register plugin components using entry points. |
| 54 | + _register_builtins: Abstract method for registering built-in/default components. |
| 55 | + _validate_component: Abstract method for enforcing type/interface constraints. |
| 56 | + _entry_point_group: Abstract method for returning the entry point namespace. |
| 57 | + """ |
| 58 | + |
| 59 | + def __init__(self): |
| 60 | + """ |
| 61 | + Initialize the base factory. |
| 62 | +
|
| 63 | + This base class provides common functionality for managing |
| 64 | + a registry of available implementations and any aliases for them. |
| 65 | + Subclasses can extend this to register built-in or default items. |
| 66 | + """ |
| 67 | + # Map canonical names to implementation classes or instances |
| 68 | + self._registry: Dict[str, Any] = {} |
| 69 | + |
| 70 | + # Map aliases to canonical names (e.g., legacy names or shorthand) |
| 71 | + self._aliases: Dict[str, str] = {} |
| 72 | + |
| 73 | + # Register built-in implementations, if any |
| 74 | + self._register_builtins() |
| 75 | + |
| 76 | + @abstractmethod |
| 77 | + def _register_builtins(self): |
| 78 | + """ |
| 79 | + Register built-in components. |
| 80 | +
|
| 81 | + Subclasses must implement this to register relevant components. |
| 82 | + """ |
| 83 | + raise NotImplementedError("Subclasses of `MerlinBaseFactory` must implement a `_register_builtins` method.") |
| 84 | + |
| 85 | + @abstractmethod |
| 86 | + def _validate_component(self, component_class: Any): |
| 87 | + """ |
| 88 | + Validate the component class before registration. |
| 89 | +
|
| 90 | + Subclasses must implement this to enforce type or interface constraints. |
| 91 | +
|
| 92 | + Args: |
| 93 | + component_class: The class to validate. |
| 94 | +
|
| 95 | + Raises: |
| 96 | + TypeError: If `component_class` is not valid. |
| 97 | + """ |
| 98 | + raise NotImplementedError("Subclasses of `MerlinBaseFactory` must implement a `_validate_component` method.") |
| 99 | + |
| 100 | + @abstractmethod |
| 101 | + def _entry_point_group(self) -> str: |
| 102 | + """ |
| 103 | + Return the entry point group used for plugin discovery. |
| 104 | +
|
| 105 | + Subclasses must override this. |
| 106 | +
|
| 107 | + Returns: |
| 108 | + The entry point group used for plugin discovery. |
| 109 | + """ |
| 110 | + raise NotImplementedError("Subclasses must define an entry point group.") |
| 111 | + |
| 112 | + def _discover_plugins_via_entry_points(self): |
| 113 | + """ |
| 114 | + Discover and register plugins via Python entry points. |
| 115 | + """ |
| 116 | + try: |
| 117 | + for entry_point in pkg_resources.iter_entry_points(self._entry_point_group()): |
| 118 | + try: |
| 119 | + plugin_class = entry_point.load() |
| 120 | + self.register(entry_point.name, plugin_class) |
| 121 | + LOG.info(f"Loaded plugin via entry point: {entry_point.name}") |
| 122 | + except Exception as e: # pylint: disable=broad-exception-caught |
| 123 | + LOG.warning(f"Failed to load plugin '{entry_point.name}': {e}") |
| 124 | + except ImportError: |
| 125 | + LOG.debug("pkg_resources not available for plugin discovery") |
| 126 | + |
| 127 | + def _discover_builtin_modules(self): |
| 128 | + """ |
| 129 | + Optional hook to discover built-in components by scanning local modules. |
| 130 | +
|
| 131 | + Default implementation does nothing. |
| 132 | +
|
| 133 | + Subclasses can override this method to implement package/module scanning. |
| 134 | + """ |
| 135 | + LOG.warning( |
| 136 | + f"Class {self.__class__.__name__} did not override _discover_builtin_modules(). " |
| 137 | + "Built-in module discovery will be skipped." |
| 138 | + ) |
| 139 | + |
| 140 | + def _discover_plugins(self): |
| 141 | + """ |
| 142 | + Discover and register plugin components via entry points. |
| 143 | +
|
| 144 | + Subclasses can override this to support more discovery mechanisms. |
| 145 | + """ |
| 146 | + self._discover_plugins_via_entry_points() |
| 147 | + self._discover_builtin_modules() |
| 148 | + |
| 149 | + def _raise_component_error_class(self, msg: str) -> Type[Exception]: |
| 150 | + """ |
| 151 | + Raise an appropriate exception when an invalid component is requested. |
| 152 | +
|
| 153 | + Subclasses should override this to raise more specific exceptions. |
| 154 | +
|
| 155 | + Args: |
| 156 | + msg: The message to add to the error being raised. |
| 157 | +
|
| 158 | + Raises: |
| 159 | + A subclass of Exception (e.g., ValueError by default). |
| 160 | + """ |
| 161 | + raise ValueError(msg) |
| 162 | + |
| 163 | + def register(self, name: str, component_class: Any, aliases: List[str] = None) -> None: |
| 164 | + """ |
| 165 | + Register a new component implementation. |
| 166 | +
|
| 167 | + Args: |
| 168 | + name: Canonical name for the component. |
| 169 | + component_class: The class or implementation to register. |
| 170 | + aliases: Optional alternative names for this component. |
| 171 | +
|
| 172 | + Raises: |
| 173 | + TypeError: If the component_class fails validation. |
| 174 | + """ |
| 175 | + self._validate_component(component_class) |
| 176 | + |
| 177 | + self._registry[name] = component_class |
| 178 | + LOG.debug(f"Registered component: {name}") |
| 179 | + |
| 180 | + if aliases: |
| 181 | + for alias in aliases: |
| 182 | + self._aliases[alias] = name |
| 183 | + LOG.debug(f"Registered alias '{alias}' for component '{name}'") |
| 184 | + |
| 185 | + def list_available(self) -> List[str]: |
| 186 | + """ |
| 187 | + Return a list of supported component names. |
| 188 | +
|
| 189 | + This includes both built-in and dynamically discovered components. |
| 190 | +
|
| 191 | + Returns: |
| 192 | + A list of canonical names for all available components. |
| 193 | + """ |
| 194 | + self._discover_plugins() |
| 195 | + return list(self._registry.keys()) |
| 196 | + |
| 197 | + def _get_component_class(self, canonical_name: str, component_type: str) -> Any: |
| 198 | + """ |
| 199 | + Retrieve a registered component class by its canonical name. |
| 200 | +
|
| 201 | + This method ensures that all plugin discovery mechanisms have been invoked |
| 202 | + before attempting to look up the component. If the requested component is |
| 203 | + not found in the registry, it raises a descriptive error with a list of |
| 204 | + available components. |
| 205 | +
|
| 206 | + Args: |
| 207 | + canonical_name: The canonical name of the component (resolved from alias). |
| 208 | + component_type: The original name or alias provided by the user (used in error messages). |
| 209 | +
|
| 210 | + Returns: |
| 211 | + The class object corresponding to the requested component. |
| 212 | +
|
| 213 | + Raises: |
| 214 | + Exception: Raises the result of `_raise_component_error_class` if the component is not registered. |
| 215 | + """ |
| 216 | + # Discover plugins if needed |
| 217 | + if canonical_name not in self._registry: |
| 218 | + self._discover_plugins() |
| 219 | + |
| 220 | + # Grab the component class from the registry and ensure it's supported |
| 221 | + component_class = self._registry.get(canonical_name) |
| 222 | + if component_class is None: |
| 223 | + available = ", ".join(self.list_available()) |
| 224 | + self._raise_component_error_class( |
| 225 | + f"Component '{component_type}' is not supported. " f"Available components: {available}" |
| 226 | + ) |
| 227 | + |
| 228 | + return component_class |
| 229 | + |
| 230 | + def create(self, component_type: str, config: Dict = None) -> Any: |
| 231 | + """ |
| 232 | + Instantiate and return a component of the specified type. |
| 233 | +
|
| 234 | + Args: |
| 235 | + component_type: The name or alias of the component to create. |
| 236 | + config: Optional configuration for initializing the component. |
| 237 | +
|
| 238 | + Returns: |
| 239 | + An instance of the requested component. |
| 240 | +
|
| 241 | + Raises: |
| 242 | + Exception: If the component is not registered or instantiation fails. |
| 243 | + """ |
| 244 | + # Resolve alias |
| 245 | + canonical_name = self._aliases.get(component_type, component_type) |
| 246 | + |
| 247 | + # Get the class associated with the name |
| 248 | + component_class = self._get_component_class(canonical_name, component_type) |
| 249 | + |
| 250 | + # Create and return an instance of the component_class |
| 251 | + try: |
| 252 | + instance = component_class() if config is None else component_class(**config) |
| 253 | + LOG.info(f"Created component '{canonical_name}'") |
| 254 | + return instance |
| 255 | + except Exception as e: |
| 256 | + raise ValueError(f"Failed to create component '{canonical_name}': {e}") from e |
| 257 | + |
| 258 | + def get_component_info(self, component_type: str) -> Dict: |
| 259 | + """ |
| 260 | + Get introspection information about a registered component. |
| 261 | +
|
| 262 | + Args: |
| 263 | + component_type: The name or alias of the component. |
| 264 | +
|
| 265 | + Returns: |
| 266 | + Dictionary containing metadata such as name, class, module, and docstring. |
| 267 | +
|
| 268 | + Raises: |
| 269 | + Exception: If the component is not registered. |
| 270 | + """ |
| 271 | + canonical_name = self._aliases.get(component_type, component_type) |
| 272 | + |
| 273 | + component_class = self._get_component_class(canonical_name, component_type) |
| 274 | + |
| 275 | + return { |
| 276 | + "name": canonical_name, |
| 277 | + "class": component_class.__name__, |
| 278 | + "module": component_class.__module__, |
| 279 | + "description": component_class.__doc__ or "No description available", |
| 280 | + } |
0 commit comments