Skip to content

Commit cbde771

Browse files
Merge pull request #176 from amd/alex_invocation
Framework updates: PluginRunInvocation
2 parents 00b62f7 + b52db2f commit cbde771

11 files changed

Lines changed: 420 additions & 29 deletions

File tree

nodescraper/cli/__init__.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# MIT License
44
#
5-
# Copyright (c) 2025 Advanced Micro Devices, Inc.
5+
# Copyright (C) 2026 Advanced Micro Devices, Inc.
66
#
77
# Permission is hereby granted, free of charge, to any person obtaining a copy
88
# of this software and associated documentation files (the "Software"), to deal
@@ -25,5 +25,19 @@
2525
###############################################################################
2626

2727
from .cli import main as cli_entry
28+
from .embed import run_main_return_code
29+
from .invocation import (
30+
PluginRunInvocation,
31+
get_plugin_run_invocation,
32+
plugin_run_invocation_scope,
33+
run_plugin_queue_with_invocation,
34+
)
2835

29-
__all__ = ["cli_entry"]
36+
__all__ = [
37+
"cli_entry",
38+
"run_main_return_code",
39+
"PluginRunInvocation",
40+
"get_plugin_run_invocation",
41+
"plugin_run_invocation_scope",
42+
"run_plugin_queue_with_invocation",
43+
]

nodescraper/cli/cli.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
process_args,
5050
)
5151
from nodescraper.cli.inputargtypes import ModelArgHandler, json_arg, log_path_arg
52+
from nodescraper.cli.invocation import run_plugin_queue_with_invocation
5253
from nodescraper.configregistry import ConfigRegistry
5354
from nodescraper.connection.redfish import (
5455
RedfishConnection,
@@ -380,11 +381,17 @@ def setup_logger(
380381
return logger
381382

382383

383-
def main(arg_input: Optional[list[str]] = None):
384+
def main(
385+
arg_input: Optional[list[str]] = None,
386+
*,
387+
host_cli_args: Optional[argparse.Namespace] = None,
388+
):
384389
"""Main entry point for the CLI
385390
386391
Args:
387392
arg_input (Optional[list[str]], optional): list of args to parse. Defaults to None.
393+
host_cli_args: Optional namespace from an embedding host (e.g. detect-errors) for code that
394+
calls get_plugin_run_invocation during the plugin queue.
388395
"""
389396
if arg_input is None:
390397
arg_input = sys.argv[1:]
@@ -552,21 +559,23 @@ def main(arg_input: Optional[list[str]] = None):
552559
"skip_sudo"
553560
] = True
554561

555-
log_system_info(log_path, system_info, logger)
556562
except Exception as e:
557563
parser.error(str(e))
558564

559-
plugin_executor = PluginExecutor(
560-
logger=logger,
561-
plugin_configs=plugin_config_inst_list,
562-
connections=parsed_args.connection_config,
563-
system_info=system_info,
564-
log_path=log_path,
565-
plugin_registry=plugin_reg,
566-
)
567-
568565
try:
569-
results = plugin_executor.run_queue()
566+
results = run_plugin_queue_with_invocation(
567+
plugin_reg=plugin_reg,
568+
parsed_args=parsed_args,
569+
plugin_config_inst_list=plugin_config_inst_list,
570+
system_info=system_info,
571+
log_path=log_path,
572+
logger=logger,
573+
timestamp=timestamp,
574+
sname=sname,
575+
host_cli_args=host_cli_args,
576+
)
577+
578+
log_system_info(log_path, system_info, logger)
570579

571580
dump_results_to_csv(results, sname, log_path, timestamp, logger)
572581

nodescraper/cli/embed.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
###############################################################################
2+
#
3+
# MIT License
4+
#
5+
# Copyright (C) 2026 Advanced Micro Devices, Inc.
6+
#
7+
# Permission is hereby granted, free of charge, to any person obtaining a copy
8+
# of this software and associated documentation files (the "Software"), to deal
9+
# in the Software without restriction, including without limitation the rights
10+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
# copies of the Software, and to permit persons to whom the Software is
12+
# furnished to do so, subject to the following conditions:
13+
#
14+
# The above copyright notice and this permission notice shall be included in all
15+
# copies or substantial portions of the Software.
16+
#
17+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
# SOFTWARE.
24+
#
25+
###############################################################################
26+
"""In-process CLI entry without adding new argparse flags."""
27+
28+
from __future__ import annotations
29+
30+
import argparse
31+
from typing import Optional
32+
33+
__all__ = ["run_main_return_code"]
34+
35+
36+
def run_main_return_code(
37+
arg_input: list[str],
38+
*,
39+
host_cli_args: Optional[argparse.Namespace] = None,
40+
) -> int:
41+
"""Runs the nodescraper main entrypoint and maps SystemExit to an integer return code."""
42+
from nodescraper.cli.cli import main
43+
44+
try:
45+
main(arg_input, host_cli_args=host_cli_args)
46+
except SystemExit as exc:
47+
code = exc.code
48+
if code is None:
49+
return 0
50+
if isinstance(code, int):
51+
return code
52+
return 1
53+
return 0

nodescraper/cli/invocation.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
###############################################################################
2+
#
3+
# MIT License
4+
#
5+
# Copyright (C) 2026 Advanced Micro Devices, Inc.
6+
#
7+
# Permission is hereby granted, free of charge, to any person obtaining a copy
8+
# of this software and associated documentation files (the "Software"), to deal
9+
# in the Software without restriction, including without limitation the rights
10+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
# copies of the Software, and to permit persons to whom the Software is
12+
# furnished to do so, subject to the following conditions:
13+
#
14+
# The above copyright notice and this permission notice shall be included in all
15+
# copies or substantial portions of the Software.
16+
#
17+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
# SOFTWARE.
24+
#
25+
###############################################################################
26+
27+
from __future__ import annotations
28+
29+
import argparse
30+
import logging
31+
from contextlib import contextmanager
32+
from contextvars import ContextVar
33+
from dataclasses import dataclass
34+
from typing import Iterator, Optional
35+
36+
from nodescraper.models import PluginConfig, SystemInfo
37+
from nodescraper.models.pluginresult import PluginResult
38+
from nodescraper.pluginexecutor import PluginExecutor
39+
from nodescraper.pluginregistry import PluginRegistry
40+
41+
_plugin_run_invocation_ctx: ContextVar[Optional["PluginRunInvocation"]] = ContextVar(
42+
"nodescraper_plugin_run_invocation", default=None
43+
)
44+
45+
46+
def get_plugin_run_invocation() -> Optional[PluginRunInvocation]:
47+
"""Return the active invocation while run_plugin_queue_with_invocation is running, if any."""
48+
return _plugin_run_invocation_ctx.get()
49+
50+
51+
@contextmanager
52+
def plugin_run_invocation_scope(inv: PluginRunInvocation) -> Iterator[None]:
53+
"""Bind *inv* for nested code (connection managers, plugins) for the scope of the context."""
54+
token = _plugin_run_invocation_ctx.set(inv)
55+
try:
56+
yield
57+
finally:
58+
_plugin_run_invocation_ctx.reset(token)
59+
60+
61+
@dataclass
62+
class PluginRunInvocation:
63+
"""Recorded inputs for one plugin run; optional host_cli_args for embedded hosts."""
64+
65+
plugin_reg: PluginRegistry
66+
parsed_args: argparse.Namespace
67+
plugin_config_inst_list: list[PluginConfig]
68+
system_info: SystemInfo
69+
log_path: Optional[str]
70+
logger: logging.Logger
71+
timestamp: str
72+
sname: str
73+
host_cli_args: Optional[argparse.Namespace] = None
74+
75+
76+
def run_plugin_queue_with_invocation(
77+
*,
78+
plugin_reg: PluginRegistry,
79+
parsed_args: argparse.Namespace,
80+
plugin_config_inst_list: list[PluginConfig],
81+
system_info: SystemInfo,
82+
log_path: Optional[str],
83+
logger: logging.Logger,
84+
timestamp: str,
85+
sname: str,
86+
host_cli_args: Optional[argparse.Namespace] = None,
87+
) -> list[PluginResult]:
88+
"""Constructs the plugin executor, binds invocation context, and runs the plugin queue."""
89+
inv = PluginRunInvocation(
90+
plugin_reg=plugin_reg,
91+
parsed_args=parsed_args,
92+
plugin_config_inst_list=plugin_config_inst_list,
93+
system_info=system_info,
94+
log_path=log_path,
95+
logger=logger,
96+
timestamp=timestamp,
97+
sname=sname,
98+
host_cli_args=host_cli_args,
99+
)
100+
plugin_executor = PluginExecutor(
101+
logger=logger,
102+
plugin_configs=plugin_config_inst_list,
103+
connections=parsed_args.connection_config,
104+
system_info=system_info,
105+
log_path=log_path,
106+
plugin_registry=plugin_reg,
107+
)
108+
with plugin_run_invocation_scope(inv):
109+
return plugin_executor.run_queue()
110+
111+
112+
__all__ = [
113+
"PluginRunInvocation",
114+
"get_plugin_run_invocation",
115+
"plugin_run_invocation_scope",
116+
"run_plugin_queue_with_invocation",
117+
]

nodescraper/interfaces/datacollectortask.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import abc
2727
import inspect
2828
import logging
29+
from enum import Enum
2930
from functools import wraps
3031
from typing import Callable, ClassVar, Generic, Optional, Type, Union
3132

@@ -47,6 +48,21 @@
4748
from .taskresulthook import TaskResultHook
4849

4950

51+
def _supported_sku_name_set(supported: Optional[set[Union[str, Enum]]]) -> Optional[set[str]]:
52+
"""Map ``SUPPORTED_SKUS`` to string names for comparison with ``SystemInfo.sku``."""
53+
if not supported:
54+
return None
55+
names: set[str] = set()
56+
for item in supported:
57+
if isinstance(item, Enum):
58+
names.add(item.name)
59+
elif isinstance(item, str):
60+
names.add(item)
61+
else:
62+
names.add(str(item))
63+
return names
64+
65+
5066
def collect_decorator(
5167
func: Callable[..., tuple[TaskResult, Optional[TDataModel]]],
5268
) -> Callable[..., tuple[TaskResult, Optional[TDataModel]]]:
@@ -111,8 +127,8 @@ class DataCollector(Task, abc.ABC, Generic[TConnection, TDataModel, TCollectArg]
111127

112128
DATA_MODEL: Type[TDataModel]
113129

114-
# A set of supported SKUs for this data collector
115-
SUPPORTED_SKUS: ClassVar[Optional[set[str]]] = None
130+
# A set of supported SKUs for this data collector (strings or enum members; enum uses .name)
131+
SUPPORTED_SKUS: ClassVar[Optional[set[Union[str, Enum]]]] = None
116132

117133
# A set of supported Platforms for this data collector,
118134
SUPPORTED_PLATFORMS: ClassVar[Optional[set[str]]] = None
@@ -153,7 +169,12 @@ def __init__(
153169
self.system_interaction_level = system_interaction_level
154170
self.connection = connection
155171

156-
if self.SUPPORTED_SKUS and self.system_info.sku not in self.SUPPORTED_SKUS:
172+
allowed_skus = _supported_sku_name_set(self.SUPPORTED_SKUS)
173+
if (
174+
allowed_skus is not None
175+
and self.system_info.sku is not None
176+
and self.system_info.sku not in allowed_skus
177+
):
157178
raise SystemCompatibilityError(
158179
f"{self.system_info.sku} SKU is not supported for this collector"
159180
)

nodescraper/models/systeminfo.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ class SystemInfo(BaseModel):
3939
os_family: OSFamily = OSFamily.UNKNOWN
4040
sku: Optional[str] = None
4141
platform: Optional[str] = None
42+
gpu_count: Optional[int] = None
43+
cpu_count: Optional[int] = None
4244
metadata: Optional[dict] = Field(default_factory=dict)
4345
location: Optional[SystemLocation] = SystemLocation.LOCAL
4446
vendorid_ep: int = 0x1002

nodescraper/pluginexecutor.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from __future__ import annotations
2727

2828
import copy
29+
import inspect
2930
import logging
3031
from collections import deque
3132
from typing import Optional, Type, Union
@@ -160,30 +161,38 @@ def run_queue(self) -> list[PluginResult]:
160161
connection_manager_class: Type[ConnectionManager] = plugin_class.CONNECTION_TYPE
161162
if (
162163
connection_manager_class.__name__
163-
not in self.plugin_registry.connection_managers
164+
in self.plugin_registry.connection_managers
164165
):
166+
mgr_impl = self.plugin_registry.connection_managers[
167+
connection_manager_class.__name__
168+
]
169+
elif (
170+
inspect.isclass(connection_manager_class)
171+
and issubclass(connection_manager_class, ConnectionManager)
172+
and not inspect.isabstract(connection_manager_class)
173+
):
174+
# External packages set CONNECTION_TYPE on the plugin;
175+
# use it when not listed under nodescraper.connection_managers entry points.
176+
mgr_impl = connection_manager_class
177+
else:
165178
self.logger.error(
166179
"Unable to find registered connection manager class for %s that is required by",
167180
connection_manager_class.__name__,
168181
)
169182
continue
170183

171-
if connection_manager_class not in self.connection_library:
184+
if mgr_impl not in self.connection_library:
172185
self.logger.info(
173186
"Initializing connection manager for %s with default args",
174-
connection_manager_class.__name__,
187+
mgr_impl.__name__,
175188
)
176-
self.connection_library[connection_manager_class] = (
177-
connection_manager_class(
178-
system_info=self.system_info,
179-
logger=self.logger,
180-
task_result_hooks=self.connection_result_hooks,
181-
)
189+
self.connection_library[mgr_impl] = mgr_impl(
190+
system_info=self.system_info,
191+
logger=self.logger,
192+
task_result_hooks=self.connection_result_hooks,
182193
)
183194

184-
init_payload["connection_manager"] = self.connection_library[
185-
connection_manager_class
186-
]
195+
init_payload["connection_manager"] = self.connection_library[mgr_impl]
187196

188197
try:
189198
plugin_inst = plugin_class(**init_payload)

0 commit comments

Comments
 (0)