Skip to content

Commit 0193dee

Browse files
authored
refactor/factories (#522)
* add MerlinBaseFactory class * refactor MerlinBackendFactory to use MerlinBaseFactory * add tests for MerlinBaseFactory and fix backend tests * convert monitor factory to use new MerlinBaseFactory * remove comment and update MonitorFactory docstring * update MerlinStatusRendererFactory to use MerlinBaseFactory and fix TODOs related to Maestro * add tests for the status renderer factory * update CHANGELOG * run fix-style * fix issue with typehint that breaks in python 3.8 * mocked more items to try to fix broken tests on github * add warning log for _discover_builtin_modules * change from _get_component_error_class to _raise_component_error_class * run fix-style
1 parent 980277e commit 0193dee

File tree

23 files changed

+935
-258
lines changed

23 files changed

+935
-258
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
### Added
99
- Unit tests for the `spec/` folder
1010
- A page in the docs explaining the `feature_demo` example
11+
- New `MerlinBaseFactory` class to help enable future plugins for backends, monitors, status renderers, etc.
12+
13+
### Changed
14+
- Maestro version requirement is now at minimum 1.1.10 for status renderer changes
15+
- The `BackendFactory`, `MonitorFactory`, and `StatusRendererFactory` classes all now inherit from `MerlinBaseFactory`
1116

1217
## [1.13.0b2]
1318
### Added

merlin/abstracts/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
The `abstracts` package provides ABC classes that can be used throughout
9+
Merlin's codebase.
10+
11+
Modules:
12+
factory: Contains `MerlinBaseFactory`, used to manage pluggable components in Merlin.
13+
"""
14+
15+
from merlin.abstracts.factory import MerlinBaseFactory
16+
17+
18+
__all__ = ["MerlinBaseFactory"]

merlin/abstracts/factory.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
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

Comments
 (0)