Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions nodescraper/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
]
30 changes: 19 additions & 11 deletions nodescraper/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:]
Expand Down Expand Up @@ -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)

Expand Down
53 changes: 53 additions & 0 deletions nodescraper/cli/embed.py
Original file line number Diff line number Diff line change
@@ -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
117 changes: 117 additions & 0 deletions nodescraper/cli/invocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
###############################################################################
#
# 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 __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",
]
33 changes: 21 additions & 12 deletions nodescraper/pluginexecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from __future__ import annotations

import copy
import inspect
import logging
from collections import deque
from typing import Optional, Type, Union
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions nodescraper/pluginregistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,16 @@ 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.

Args:
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]
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading