Skip to content
Merged
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
29 changes: 14 additions & 15 deletions nodescraper/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
from nodescraper.connection.redfish.redfish_params import RedfishConnectionParams
from nodescraper.constants import DEFAULT_LOGGER
from nodescraper.enums import ExecutionStatus, SystemInteractionLevel, SystemLocation
from nodescraper.models import PluginConfig, SystemInfo
from nodescraper.models import SystemInfo
from nodescraper.pluginexecutor import PluginExecutor
from nodescraper.pluginregistry import PluginRegistry

Expand All @@ -74,17 +74,16 @@ def _parse_plugin_configs_csv(value: str) -> list[str]:
return [p.strip() for p in value.split(",") if p.strip()]


def _config_registry_with_all_plugins(plugin_reg: PluginRegistry) -> ConfigRegistry:
"""Synthetic ``AllPlugins`` config used for CLI help and :func:`build_global_argument_parser`."""
config_reg = ConfigRegistry()
config_reg.configs["AllPlugins"] = PluginConfig(
name="AllPlugins",
desc="Run all registered plugins with default arguments",
global_args={},
plugins={name: {} for name in plugin_reg.plugins},
result_collators={},
)
return config_reg
def _default_config_registry(_plugin_reg: PluginRegistry) -> ConfigRegistry:
"""Build the config registry from bundled JSON and plugin-config entry points.

Args:
_plugin_reg (PluginRegistry): Unused; retained for call-site compatibility.

Returns:
ConfigRegistry: Registry containing bundled and entry-point plugin configs.
"""
return ConfigRegistry()


def _add_cli_root_globals(
Expand Down Expand Up @@ -203,7 +202,7 @@ def _add_cli_root_globals(
def build_global_argument_parser(*, add_help: bool = True) -> argparse.ArgumentParser:
"""Globals only (no subcommands), for host CLIs."""
plugin_reg = PluginRegistry()
config_reg = _config_registry_with_all_plugins(plugin_reg)
config_reg = _default_config_registry(plugin_reg)
parser = argparse.ArgumentParser(
description="node scraper CLI (global options only)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
Expand Down Expand Up @@ -406,7 +405,7 @@ def get_cli_top_level_subcommands() -> tuple[str, ...]:
Tuple of ``subcmd`` subparser names; call ``cache_clear()`` if registries change in-process.
"""
plugin_reg = PluginRegistry()
config_reg = _config_registry_with_all_plugins(plugin_reg)
config_reg = _default_config_registry(plugin_reg)
parser, _plugin_subparser_map = build_parser(plugin_reg, config_reg)
return _top_level_subcommand_names(parser)

Expand Down Expand Up @@ -474,7 +473,7 @@ def main(
arg_input = sys.argv[1:]

plugin_reg = PluginRegistry()
config_reg = _config_registry_with_all_plugins(plugin_reg)
config_reg = _default_config_registry(plugin_reg)
parser, plugin_subparser_map = build_parser(plugin_reg, config_reg)

try:
Expand Down
4 changes: 2 additions & 2 deletions nodescraper/cli/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ def parse_describe(
if not parsed_args.name:
out: list[str] = []
if parsed_args.type == "config":
out.append("Available built-in configs:")
for name in config_reg.configs:
out.append("Available configs:")
for name in sorted(config_reg.configs):
out.append(f" {name}")
elif parsed_args.type == "plugin":
out.append("Available plugins:")
Expand Down
127 changes: 122 additions & 5 deletions nodescraper/configregistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,55 @@
# SOFTWARE.
#
###############################################################################
from __future__ import annotations

import importlib.metadata
import inspect
import json
import os
from pathlib import Path
from typing import Optional
from typing import Any, Optional

from pydantic import ValidationError

from nodescraper.models import PluginConfig

PLUGIN_CONFIG_ENTRY_POINT_GROUP = "nodescraper.plugin_configs"


class PluginConfigEntryPointError(RuntimeError):
"""Raised when a ``nodescraper.plugin_configs`` entry point cannot be loaded."""


class ConfigRegistry:
"""Class to load json plugin configs into models"""

INTERNAL_SEARCH_PATH = os.path.join(os.path.dirname(__file__), "configs")

def __init__(self, config_path: Optional[str] = None) -> None:
self.configs = {}
def __init__(
self,
config_path: Optional[str] = None,
load_entry_point_configs: bool = True,
) -> None:
"""Initialize the config registry.

Args:
config_path (Optional[str], optional): Path in which to search for JSON config files.
Defaults to None.
load_entry_point_configs (bool, optional): Whether to load ``nodescraper.plugin_configs``
entry points. Defaults to True.
"""
self.configs: dict[str, PluginConfig] = {}
self.load_configs(config_path)
if load_entry_point_configs:
self.configs.update(self.load_plugin_configs_from_entry_points())

def load_configs(self, config_path: Optional[str] = None):
"""load plugin config json files into pydantic models
"""Load plugin config JSON files into pydantic models.

Args:
config_path (Optional[str], optional): Path in which to search for config files. Defaults to None.
config_path (Optional[str], optional): Path in which to search for config files.
Defaults to None.
"""
if not config_path:
config_path = self.INTERNAL_SEARCH_PATH
Expand All @@ -64,3 +89,95 @@ def load_configs(self, config_path: Optional[str] = None):
self.configs[config_file.name] = config_model
except (ValidationError, json.JSONDecodeError):
pass

@staticmethod
def _entry_points_for_group(group: str):
"""Return setuptools entry points for the given group name.

Args:
group (str): Entry point group to query.

Returns:
Iterable: Entry points registered under ``group``.
"""
try:
return importlib.metadata.entry_points(group=group) # type: ignore[call-arg]
except TypeError:
all_eps = importlib.metadata.entry_points() # type: ignore[assignment]
return all_eps.get(group, []) # type: ignore[assignment, attr-defined, arg-type]

@staticmethod
def _resolve_entry_point_config(loaded: Any) -> PluginConfig | dict[str, Any] | None:
"""Resolve a loaded entry point object into a plugin config.

Args:
loaded (Any): Object returned by an entry point ``load()`` call.

Returns:
Optional[PluginConfig | dict[str, Any]]: Plugin config, or None if ``loaded`` is unsupported.
"""
if isinstance(loaded, PluginConfig):
return loaded
if isinstance(loaded, dict):
return loaded
if inspect.isclass(loaded) and hasattr(loaded, "plugin_config"):
config_data = loaded.plugin_config()
elif callable(loaded):
config_data = loaded()
else:
return None

if isinstance(config_data, (PluginConfig, dict)):
return config_data
return None

@classmethod
def load_plugin_configs_from_entry_points(cls) -> dict[str, PluginConfig]:
"""Load plugin configs registered under ``nodescraper.plugin_configs`` entry points.

Returns:
dict[str, PluginConfig]: Map of config name to loaded :class:`~nodescraper.models.PluginConfig`.

Raises:
PluginConfigEntryPointError: If an entry point target is missing, invalid, or unsupported.
"""
configs: dict[str, PluginConfig] = {}

for entry_point in cls._entry_points_for_group(PLUGIN_CONFIG_ENTRY_POINT_GROUP):
entry_point_name = getattr(entry_point, "name", None)
entry_point_label = entry_point_name or "<unknown>"
try:
loaded = entry_point.load() # type: ignore[attr-defined]
config_data = cls._resolve_entry_point_config(loaded)
if config_data is None:
raise PluginConfigEntryPointError(
f"Failed to load plugin config entry point {entry_point_label!r}: "
f"unsupported target {loaded!r}"
)

config_model = (
config_data
if isinstance(config_data, PluginConfig)
else PluginConfig(**config_data)
)
config_key = entry_point_name or config_model.name
if config_key:
configs[config_key] = config_model
except ModuleNotFoundError as exc:
raise PluginConfigEntryPointError(
f"Failed to load plugin config entry point {entry_point_label!r}: "
f"module not found ({exc}). Check the entry point target in pyproject.toml."
) from exc
except ValidationError as exc:
raise PluginConfigEntryPointError(
f"Failed to load plugin config entry point {entry_point_label!r}: "
"invalid plugin config"
) from exc
except PluginConfigEntryPointError:
raise
except Exception as exc:
raise PluginConfigEntryPointError(
f"Failed to load plugin config entry point {entry_point_label!r}: {exc}"
) from exc

return configs
19 changes: 0 additions & 19 deletions nodescraper/configs/node_status.json

This file was deleted.

32 changes: 31 additions & 1 deletion nodescraper/models/pluginconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
# SOFTWARE.
#
###############################################################################
from typing import Optional
from __future__ import annotations

from typing import Any, Optional

from pydantic import BaseModel, Field

Expand All @@ -36,3 +38,31 @@ class PluginConfig(BaseModel):
result_collators: dict[str, dict] = Field(default_factory=dict)
name: Optional[str] = None
desc: Optional[str] = None

@classmethod
def coerce(cls, config: PluginConfig | dict[str, Any]) -> PluginConfig:
"""Return a ``PluginConfig`` instance from a model or mapping."""
if isinstance(config, cls):
return config
return cls.model_validate(config)

@classmethod
def merge(cls, *configs: PluginConfig | dict[str, Any]) -> PluginConfig:
"""Merge recipe plugin configs.

Plugin entries from later configs overwrite earlier ones with the same name.
``name``, ``desc``, ``global_args``, and ``result_collators`` come from the first
config.
"""
normalized = [cls.coerce(config) for config in configs]
merged_plugins: dict[str, dict[str, Any]] = {}
for config in normalized:
merged_plugins.update(config.plugins)
first = normalized[0] if normalized else cls()
return cls(
name=first.name,
desc=first.desc,
global_args=first.global_args,
plugins=merged_plugins,
result_collators=first.result_collators,
)
51 changes: 51 additions & 0 deletions nodescraper/pluginrecipe/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2025 Advanced Micro Devices, Inc.
#
###############################################################################
from nodescraper.models import PluginConfig

from .all_plugins import AllPlugins
from .discovery import (
load_plugin_class,
plugin_has_analyzer,
plugin_has_collector,
plugin_names_matching,
plugins_with_analyzer,
plugins_with_collector,
registered_plugin_names,
)
from .node_status import NodeStatus
from .pluginrecipe import (
ANALYZE_ONLY,
COLLECT_AND_ANALYZE,
COLLECT_ONLY,
AnalyzerOnlyPluginRecipe,
CollectorOnlyPluginRecipe,
PluginRecipe,
PluginRunFlags,
merge_plugin_configs,
)

__all__ = [
"ANALYZE_ONLY",
"COLLECT_AND_ANALYZE",
"COLLECT_ONLY",
"AllPlugins",
"AnalyzerOnlyPluginRecipe",
"CollectorOnlyPluginRecipe",
"NodeStatus",
"PluginConfig",
"PluginRecipe",
"PluginRunFlags",
"load_plugin_class",
"merge_plugin_configs",
"plugin_has_analyzer",
"plugin_has_collector",
"plugin_names_matching",
"plugins_with_analyzer",
"plugins_with_collector",
"registered_plugin_names",
]
24 changes: 24 additions & 0 deletions nodescraper/pluginrecipe/all_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2025 Advanced Micro Devices, Inc.
#
###############################################################################
from __future__ import annotations

from .discovery import registered_plugin_names
from .pluginrecipe import PluginRecipe


class AllPlugins(PluginRecipe):
"""Run all registered plugins with default arguments."""

@classmethod
def plugin_names(cls) -> tuple[str, ...]:
"""Return every plugin registered at runtime.

Returns:
tuple[str, ...]: Sorted names of all plugins in the plugin registry.
"""
return registered_plugin_names()
Loading
Loading