From 4fa7069c705ef30264a94b0d192e95dbe1d4a341 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 1 Apr 2026 14:32:17 -0500 Subject: [PATCH 1/3] min API for external invocation --- nodescraper/cli/__init__.py | 18 +++++- nodescraper/cli/cli.py | 30 +++++---- nodescraper/cli/embed.py | 53 +++++++++++++++ nodescraper/cli/invocation.py | 118 ++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 nodescraper/cli/embed.py create mode 100644 nodescraper/cli/invocation.py diff --git a/nodescraper/cli/__init__.py b/nodescraper/cli/__init__.py index 12ed1099..f5e396b2 100644 --- a/nodescraper/cli/__init__.py +++ b/nodescraper/cli/__init__.py @@ -2,7 +2,7 @@ # # MIT License # -# Copyright (c) 2025 Advanced Micro Devices, Inc. +# Copyright (C) 2026 Advanced Micro Devices, Inc. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -25,5 +25,19 @@ ############################################################################### from .cli import main as cli_entry +from .embed import run_main_return_code +from .invocation import ( + PluginRunInvocation, + get_plugin_run_invocation, + plugin_run_invocation_scope, + run_plugin_queue_with_invocation, +) -__all__ = ["cli_entry"] +__all__ = [ + "cli_entry", + "run_main_return_code", + "PluginRunInvocation", + "get_plugin_run_invocation", + "plugin_run_invocation_scope", + "run_plugin_queue_with_invocation", +] diff --git a/nodescraper/cli/cli.py b/nodescraper/cli/cli.py index f4e2fe86..d9044cbe 100644 --- a/nodescraper/cli/cli.py +++ b/nodescraper/cli/cli.py @@ -49,6 +49,7 @@ process_args, ) from nodescraper.cli.inputargtypes import ModelArgHandler, json_arg, log_path_arg +from nodescraper.cli.invocation import run_plugin_queue_with_invocation from nodescraper.configregistry import ConfigRegistry from nodescraper.connection.redfish import ( RedfishConnection, @@ -359,11 +360,17 @@ def setup_logger(log_level: str = "INFO", log_path: Optional[str] = None) -> log return logger -def main(arg_input: Optional[list[str]] = None): +def main( + arg_input: Optional[list[str]] = None, + *, + host_cli_args: Optional[argparse.Namespace] = None, +): """Main entry point for the CLI Args: arg_input (Optional[list[str]], optional): list of args to parse. Defaults to None. + host_cli_args: Optional namespace from an embedding host (e.g. detect-errors) for code that + calls get_plugin_run_invocation during the plugin queue. """ if arg_input is None: arg_input = sys.argv[1:] @@ -524,17 +531,18 @@ def main(arg_input: Optional[list[str]] = None): except Exception as e: parser.error(str(e)) - plugin_executor = PluginExecutor( - logger=logger, - plugin_configs=plugin_config_inst_list, - connections=parsed_args.connection_config, - system_info=system_info, - log_path=log_path, - plugin_registry=plugin_reg, - ) - try: - results = plugin_executor.run_queue() + results = run_plugin_queue_with_invocation( + plugin_reg=plugin_reg, + parsed_args=parsed_args, + plugin_config_inst_list=plugin_config_inst_list, + system_info=system_info, + log_path=log_path, + logger=logger, + timestamp=timestamp, + sname=sname, + host_cli_args=host_cli_args, + ) dump_results_to_csv(results, sname, log_path, timestamp, logger) diff --git a/nodescraper/cli/embed.py b/nodescraper/cli/embed.py new file mode 100644 index 00000000..aa5ad082 --- /dev/null +++ b/nodescraper/cli/embed.py @@ -0,0 +1,53 @@ +############################################################################### +# +# MIT License +# +# Copyright (C) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +"""In-process CLI entry without adding new argparse flags.""" + +from __future__ import annotations + +import argparse +from typing import Optional + +__all__ = ["run_main_return_code"] + + +def run_main_return_code( + arg_input: list[str], + *, + host_cli_args: Optional[argparse.Namespace] = None, +) -> int: + """Runs the nodescraper main entrypoint and maps SystemExit to an integer return code.""" + from nodescraper.cli.cli import main + + try: + main(arg_input, host_cli_args=host_cli_args) + except SystemExit as exc: + code = exc.code + if code is None: + return 0 + if isinstance(code, int): + return code + return 1 + return 0 diff --git a/nodescraper/cli/invocation.py b/nodescraper/cli/invocation.py new file mode 100644 index 00000000..024f1882 --- /dev/null +++ b/nodescraper/cli/invocation.py @@ -0,0 +1,118 @@ +############################################################################### +# +# MIT License +# +# Copyright (C) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +"""Plugin run invocation context for embedded hosts (e.g. error-scraper OOB).""" + +from __future__ import annotations + +import argparse +import logging +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Iterator, Optional + +from nodescraper.models import PluginConfig, SystemInfo +from nodescraper.models.pluginresult import PluginResult +from nodescraper.pluginexecutor import PluginExecutor +from nodescraper.pluginregistry import PluginRegistry + +_plugin_run_invocation_ctx: ContextVar[Optional["PluginRunInvocation"]] = ContextVar( + "nodescraper_plugin_run_invocation", default=None +) + + +def get_plugin_run_invocation() -> Optional[PluginRunInvocation]: + """Return the active invocation while run_plugin_queue_with_invocation is running, if any.""" + return _plugin_run_invocation_ctx.get() + + +@contextmanager +def plugin_run_invocation_scope(inv: PluginRunInvocation) -> Iterator[None]: + """Bind *inv* for nested code (connection managers, plugins) for the scope of the context.""" + token = _plugin_run_invocation_ctx.set(inv) + try: + yield + finally: + _plugin_run_invocation_ctx.reset(token) + + +@dataclass +class PluginRunInvocation: + """Recorded inputs for one plugin run; optional host_cli_args for embedded hosts.""" + + plugin_reg: PluginRegistry + parsed_args: argparse.Namespace + plugin_config_inst_list: list[PluginConfig] + system_info: SystemInfo + log_path: Optional[str] + logger: logging.Logger + timestamp: str + sname: str + host_cli_args: Optional[argparse.Namespace] = None + + +def run_plugin_queue_with_invocation( + *, + plugin_reg: PluginRegistry, + parsed_args: argparse.Namespace, + plugin_config_inst_list: list[PluginConfig], + system_info: SystemInfo, + log_path: Optional[str], + logger: logging.Logger, + timestamp: str, + sname: str, + host_cli_args: Optional[argparse.Namespace] = None, +) -> list[PluginResult]: + """Constructs the plugin executor, binds invocation context, and runs the plugin queue.""" + inv = PluginRunInvocation( + plugin_reg=plugin_reg, + parsed_args=parsed_args, + plugin_config_inst_list=plugin_config_inst_list, + system_info=system_info, + log_path=log_path, + logger=logger, + timestamp=timestamp, + sname=sname, + host_cli_args=host_cli_args, + ) + plugin_executor = PluginExecutor( + logger=logger, + plugin_configs=plugin_config_inst_list, + connections=parsed_args.connection_config, + system_info=system_info, + log_path=log_path, + plugin_registry=plugin_reg, + ) + with plugin_run_invocation_scope(inv): + return plugin_executor.run_queue() + + +__all__ = [ + "PluginRunInvocation", + "get_plugin_run_invocation", + "plugin_run_invocation_scope", + "run_plugin_queue_with_invocation", +] From 0731d597753acabd262f4d0d800a00e8042740fe Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 1 Apr 2026 15:40:24 -0500 Subject: [PATCH 2/3] entry point for connection --- nodescraper/pluginregistry.py | 55 +++++++++++++++ .../test_connection_manager_entrypoints.py | 67 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 test/unit/framework/test_connection_manager_entrypoints.py diff --git a/nodescraper/pluginregistry.py b/nodescraper/pluginregistry.py index 997fd67b..559d96f6 100644 --- a/nodescraper/pluginregistry.py +++ b/nodescraper/pluginregistry.py @@ -47,6 +47,7 @@ def __init__( plugin_pkg: Optional[list[types.ModuleType]] = None, load_internal_plugins: bool = True, load_entry_point_plugins: bool = True, + load_entry_point_connection_managers: bool = True, ) -> None: """Initialize the PluginRegistry with optional plugin packages. @@ -54,6 +55,8 @@ def __init__( plugin_pkg (Optional[list[types.ModuleType]], optional): The module to search for plugins in. Defaults to None. load_internal_plugins (bool, optional): Whether internal plugin should be loaded. Defaults to True. load_entry_point_plugins (bool, optional): Whether to load plugins from entry points. Defaults to True. + load_entry_point_connection_managers (bool, optional): Whether to load connection managers from the + ``nodescraper.connection_managers`` entry-point group. Defaults to True. """ if load_internal_plugins: self.plugin_pkg = [internal_plugins, internal_connections, internal_collators] @@ -73,6 +76,13 @@ def __init__( PluginResultCollator, self.plugin_pkg ) + if load_entry_point_connection_managers: + for ( + name, + mgr_cls, + ) in PluginRegistry.load_connection_managers_from_entry_points().items(): + self.connection_managers[name] = mgr_cls + if load_entry_point_plugins: entry_point_plugins = self.load_plugins_from_entry_points() self.plugins.update(entry_point_plugins) @@ -112,6 +122,51 @@ def _recurse_pkg(pkg: types.ModuleType, base_class: type) -> None: _recurse_pkg(pkg, base_class) return registry + @staticmethod + def load_connection_managers_from_entry_points() -> dict[str, type]: + """Load ConnectionManager subclasses from ``nodescraper.connection_managers`` entry points. + + The class ``__name__`` is always a lookup key. If the distribution entry-point name + differs, it is registered as an alias (for ``--connection-config`` JSON keys). + + Returns: + dict[str, type]: Map of lookup key to connection manager class. + """ + managers: dict[str, type] = {} + + try: + try: + eps = importlib.metadata.entry_points( # type: ignore[call-arg] + group="nodescraper.connection_managers" + ) + except TypeError: + all_eps = importlib.metadata.entry_points() # type: ignore[assignment] + eps = all_eps.get("nodescraper.connection_managers", []) # type: ignore[assignment, attr-defined, arg-type] + + for entry_point in eps: + try: + loaded = entry_point.load() # type: ignore[attr-defined] + if not ( + inspect.isclass(loaded) + and issubclass(loaded, ConnectionManager) + and not inspect.isabstract(loaded) + ): + continue + if hasattr(loaded, "is_valid") and not loaded.is_valid(): + continue + cls = loaded + managers[cls.__name__] = cls + ep_name = getattr(entry_point, "name", None) + if ep_name and ep_name != cls.__name__: + managers[ep_name] = cls + except Exception: + pass + + except Exception: + pass + + return managers + @staticmethod def load_plugins_from_entry_points() -> dict[str, type]: """Load plugins registered via entry points. diff --git a/test/unit/framework/test_connection_manager_entrypoints.py b/test/unit/framework/test_connection_manager_entrypoints.py new file mode 100644 index 00000000..16721196 --- /dev/null +++ b/test/unit/framework/test_connection_manager_entrypoints.py @@ -0,0 +1,67 @@ +############################################################################### +# +# MIT License +# +# Copyright (C) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from unittest.mock import MagicMock, patch + +from nodescraper.connection.inband.inbandmanager import InBandConnectionManager +from nodescraper.pluginregistry import PluginRegistry + + +def _entry_points_side_effect_cm_only(mock_ep, *args, **kwargs): + group = kwargs.get("group") + if group == "nodescraper.connection_managers": + return [mock_ep] + return [] + + +def test_load_connection_managers_from_entry_points_registers_class_and_alias(): + mock_ep = MagicMock() + mock_ep.name = "AliasInBand" + mock_ep.load.return_value = InBandConnectionManager + + with patch("nodescraper.pluginregistry.importlib.metadata.entry_points") as mock_eps: + mock_eps.side_effect = lambda *a, **k: _entry_points_side_effect_cm_only(mock_ep, *a, **k) + found = PluginRegistry.load_connection_managers_from_entry_points() + + assert found["InBandConnectionManager"] is InBandConnectionManager + assert found["AliasInBand"] is InBandConnectionManager + + +def test_plugin_registry_merges_entry_point_connection_managers(): + mock_ep = MagicMock() + mock_ep.name = "AliasInBand" + mock_ep.load.return_value = InBandConnectionManager + + with patch("nodescraper.pluginregistry.importlib.metadata.entry_points") as mock_eps: + mock_eps.side_effect = lambda *a, **k: _entry_points_side_effect_cm_only(mock_ep, *a, **k) + reg = PluginRegistry(load_entry_point_connection_managers=True) + + assert reg.connection_managers["InBandConnectionManager"] is InBandConnectionManager + assert reg.connection_managers["AliasInBand"] is InBandConnectionManager + + +def test_plugin_registry_can_disable_entry_point_connection_managers(): + reg = PluginRegistry(load_entry_point_connection_managers=False) + assert "InBandConnectionManager" in reg.connection_managers From b964f546e97a68389d7404a18febeaa285620aa1 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 1 Apr 2026 16:04:52 -0500 Subject: [PATCH 3/3] dynamically load connection --- nodescraper/cli/invocation.py | 1 - nodescraper/pluginexecutor.py | 33 +++++++++++++-------- test/unit/framework/test_plugin_executor.py | 17 +++++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/nodescraper/cli/invocation.py b/nodescraper/cli/invocation.py index 024f1882..12cd3a94 100644 --- a/nodescraper/cli/invocation.py +++ b/nodescraper/cli/invocation.py @@ -23,7 +23,6 @@ # SOFTWARE. # ############################################################################### -"""Plugin run invocation context for embedded hosts (e.g. error-scraper OOB).""" from __future__ import annotations diff --git a/nodescraper/pluginexecutor.py b/nodescraper/pluginexecutor.py index a8da102b..1782bb50 100644 --- a/nodescraper/pluginexecutor.py +++ b/nodescraper/pluginexecutor.py @@ -26,6 +26,7 @@ from __future__ import annotations import copy +import inspect import logging from collections import deque from typing import Optional, Type, Union @@ -160,30 +161,38 @@ def run_queue(self) -> list[PluginResult]: connection_manager_class: Type[ConnectionManager] = plugin_class.CONNECTION_TYPE if ( connection_manager_class.__name__ - not in self.plugin_registry.connection_managers + in self.plugin_registry.connection_managers ): + mgr_impl = self.plugin_registry.connection_managers[ + connection_manager_class.__name__ + ] + elif ( + inspect.isclass(connection_manager_class) + and issubclass(connection_manager_class, ConnectionManager) + and not inspect.isabstract(connection_manager_class) + ): + # External packages set CONNECTION_TYPE on the plugin; + # use it when not listed under nodescraper.connection_managers entry points. + mgr_impl = connection_manager_class + else: self.logger.error( "Unable to find registered connection manager class for %s that is required by", connection_manager_class.__name__, ) continue - if connection_manager_class not in self.connection_library: + if mgr_impl not in self.connection_library: self.logger.info( "Initializing connection manager for %s with default args", - connection_manager_class.__name__, + mgr_impl.__name__, ) - self.connection_library[connection_manager_class] = ( - connection_manager_class( - system_info=self.system_info, - logger=self.logger, - task_result_hooks=self.connection_result_hooks, - ) + self.connection_library[mgr_impl] = mgr_impl( + system_info=self.system_info, + logger=self.logger, + task_result_hooks=self.connection_result_hooks, ) - init_payload["connection_manager"] = self.connection_library[ - connection_manager_class - ] + init_payload["connection_manager"] = self.connection_library[mgr_impl] try: plugin_inst = plugin_class(**init_payload) diff --git a/test/unit/framework/test_plugin_executor.py b/test/unit/framework/test_plugin_executor.py index a5121398..7ed75b93 100644 --- a/test/unit/framework/test_plugin_executor.py +++ b/test/unit/framework/test_plugin_executor.py @@ -164,3 +164,20 @@ def test_apply_global_args_to_plugin(): "foo": "analyzed", "regex_match": False, } + + +def test_connection_manager_from_plugin_when_not_in_registry(): + """CONNECTION_TYPE may come from an external package without a registry entry.""" + registry = PluginRegistry() + registry.plugins = {"TestPluginB": TestPluginB} + registry.connection_managers = {} + + executor = PluginExecutor( + plugin_configs=[PluginConfig(plugins={"TestPluginB": {}})], + plugin_registry=registry, + ) + results = executor.run_queue() + + assert len(results) == 1 + assert results[0].source == "testB" + assert results[0].status == ExecutionStatus.OK