Skip to content

Commit 0589a6d

Browse files
Merge pull request #21 from amd/alex_summary
Summary csv file
2 parents 79ddee6 + 07eeb3f commit 0589a6d

File tree

4 files changed

+214
-7
lines changed

4 files changed

+214
-7
lines changed

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ usage: node-scraper [-h] [--sys-name STRING] [--sys-location {LOCAL,REMOTE}] [--
2121
[--sys-sku STRING] [--sys-platform STRING] [--plugin-configs [STRING ...]] [--system-config STRING]
2222
[--connection-config STRING] [--log-path STRING] [--log-level {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
2323
[--gen-reference-config]
24-
{run-plugins,describe,gen-plugin-config} ...
24+
{summary,run-plugins,describe,gen-plugin-config} ...
2525

2626
node scraper CLI
2727

2828
positional arguments:
29-
{run-plugins,describe,gen-plugin-config}
29+
{summary,run-plugins,describe,gen-plugin-config}
3030
Subcommands
31+
summary Generates summary csv file
3132
run-plugins Run a series of plugins
3233
describe Display details on a built-in config or plugin
3334
gen-plugin-config Generate a config for a plugin or list of plugins
@@ -38,7 +39,8 @@ options:
3839
--sys-location {LOCAL,REMOTE}
3940
Location of target system (default: LOCAL)
4041
--sys-interaction-level {PASSIVE,INTERACTIVE,DISRUPTIVE}
41-
Specify system interaction level, used to determine the type of actions that plugins can perform (default: INTERACTIVE)
42+
Specify system interaction level, used to determine the type of actions that plugins can perform (default:
43+
INTERACTIVE)
4244
--sys-sku STRING Manually specify SKU of system (default: None)
4345
--sys-platform STRING
4446
Specify system platform (default: None)
@@ -54,7 +56,6 @@ options:
5456
--gen-reference-config
5557
Generate reference config from system. Writes to ./reference_config.json. (default: False)
5658

57-
5859
```
5960
6061
### Subcommmands
@@ -167,6 +168,16 @@ This would produce the following config:
167168
}
168169
```
169170
171+
4. **'summary' sub command**
172+
The 'summary' subcommand can be used to combine results from multiple runs of node-scraper to a
173+
single summary.csv file. Sample run:
174+
```sh
175+
node-scraper summary --summary_path /<path_to_node-scraper_logs>
176+
```
177+
This will generate a new file '/<path_to_node-scraper_logs>/summary.csv' file. This file will
178+
contain the results from all 'nodescraper.csv' files from '/<path_to_node-scarper_logs>'.
179+
180+
170181
### Plugin Configs
171182
A plugin JSON config should follow the structure of the plugin config model defined here.
172183
The globals field is a dictionary of global key-value pairs; values in globals will be passed to

nodescraper/cli/cli.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@
3535
from nodescraper.cli.constants import DEFAULT_CONFIG, META_VAR_MAP
3636
from nodescraper.cli.dynamicparserbuilder import DynamicParserBuilder
3737
from nodescraper.cli.helper import (
38+
dump_results_to_csv,
3839
generate_reference_config,
3940
generate_reference_config_from_logs,
41+
generate_summary,
4042
get_plugin_configs,
4143
get_system_info,
4244
log_system_info,
@@ -154,6 +156,25 @@ def build_parser(
154156

155157
subparsers = parser.add_subparsers(dest="subcmd", help="Subcommands")
156158

159+
summary_parser = subparsers.add_parser(
160+
"summary",
161+
help="Generates summary csv file",
162+
)
163+
164+
summary_parser.add_argument(
165+
"--search-path",
166+
dest="search_path",
167+
type=log_path_arg,
168+
help="Path to node-scraper previously generated results.",
169+
)
170+
171+
summary_parser.add_argument(
172+
"--output-path",
173+
dest="output_path",
174+
type=log_path_arg,
175+
help="Specifies path for summary.csv.",
176+
)
177+
157178
run_plugin_parser = subparsers.add_parser(
158179
"run-plugins",
159180
help="Run a series of plugins",
@@ -249,7 +270,7 @@ def setup_logger(log_level: str = "INFO", log_path: str | None = None) -> loggin
249270
handlers = [logging.StreamHandler(stream=sys.stdout)]
250271

251272
if log_path:
252-
log_file_name = os.path.join(log_path, "errorscraper.log")
273+
log_file_name = os.path.join(log_path, "nodescraper.log")
253274
handlers.append(
254275
logging.FileHandler(filename=log_file_name, mode="wt", encoding="utf-8"),
255276
)
@@ -327,12 +348,13 @@ def main(arg_input: Optional[list[str]] = None):
327348

328349
parsed_args = parser.parse_args(top_level_args)
329350
system_info = get_system_info(parsed_args)
351+
sname = system_info.name.lower().replace("-", "_").replace(".", "_")
352+
timestamp = datetime.datetime.now().strftime("%Y_%m_%d-%I_%M_%S_%p")
330353

331354
if parsed_args.log_path and parsed_args.subcmd not in ["gen-plugin-config", "describe"]:
332-
sname = system_info.name.lower().replace("-", "_").replace(".", "_")
333355
log_path = os.path.join(
334356
parsed_args.log_path,
335-
f"scraper_logs_{sname}_{datetime.datetime.now().strftime('%Y_%m_%d-%I_%M_%S_%p')}",
357+
f"scraper_logs_{sname}_{timestamp}",
336358
)
337359
os.makedirs(log_path)
338360
else:
@@ -342,6 +364,10 @@ def main(arg_input: Optional[list[str]] = None):
342364
if log_path:
343365
logger.info("Log path: %s", log_path)
344366

367+
if parsed_args.subcmd == "summary":
368+
generate_summary(parsed_args.search_path, parsed_args.output_path, logger)
369+
sys.exit(0)
370+
345371
if parsed_args.subcmd == "describe":
346372
parse_describe(parsed_args, plugin_reg, config_reg, logger)
347373

@@ -407,6 +433,8 @@ def main(arg_input: Optional[list[str]] = None):
407433
try:
408434
results = plugin_executor.run_queue()
409435

436+
dump_results_to_csv(results, sname, log_path, timestamp, logger)
437+
410438
if parsed_args.reference_config:
411439
ref_config = generate_reference_config(results, plugin_reg, logger)
412440
path = os.path.join(os.getcwd(), "reference_config.json")

nodescraper/cli/helper.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
#
2525
###############################################################################
2626
import argparse
27+
import csv
28+
import glob
2729
import json
2830
import logging
2931
import os
@@ -424,3 +426,91 @@ def find_datamodel_and_result(base_path: str) -> list[Tuple[str, str]]:
424426
tuple_list.append((datamodel_path, result_path))
425427

426428
return tuple_list
429+
430+
431+
def dump_results_to_csv(
432+
results: list[PluginResult],
433+
nodename: str,
434+
log_path: str,
435+
timestamp: str,
436+
logger: logging.Logger,
437+
):
438+
"""dump node-scraper summary results to csv file
439+
440+
Args:
441+
results (list[PluginResult]): list of PluginResults
442+
nodename (str): node where results come from
443+
log_path (str): path to results
444+
timestamp (str): time when results were taken
445+
logger (logging.Logger): instance of logger
446+
"""
447+
fieldnames = ["nodename", "plugin", "status", "timestamp", "message"]
448+
filename = log_path + "/nodescraper.csv"
449+
all_rows = []
450+
for res in results:
451+
row = {
452+
"nodename": nodename,
453+
"plugin": res.source,
454+
"status": res.status.name,
455+
"timestamp": timestamp,
456+
"message": res.message,
457+
}
458+
all_rows.append(row)
459+
dump_to_csv(all_rows, filename, fieldnames, logger)
460+
461+
462+
def dump_to_csv(all_rows: list, filename: str, fieldnames: list[str], logger: logging.Logger):
463+
"""dump data to csv
464+
465+
Args:
466+
all_rows (list): rows to be written
467+
filename (str): name of file to write to
468+
fieldnames (list[str]): header for csv file
469+
logger (logging.Logger): isntance of logger
470+
"""
471+
try:
472+
with open(filename, "w", newline="") as f:
473+
writer = csv.DictWriter(f, fieldnames=fieldnames)
474+
writer.writeheader()
475+
for row in all_rows:
476+
writer.writerow(row)
477+
except Exception as exp:
478+
logger.error("Could not dump data to csv file: %s", exp)
479+
logger.info("Data written to csv file: %s", filename)
480+
481+
482+
def generate_summary(search_path: str, output_path: str | None, logger: logging.Logger):
483+
"""Concatenate csv files into 1 summary csv file
484+
485+
Args:
486+
search_path (str): Path for previous runs
487+
output_path (str | None): Path for new summary csv file
488+
logger (logging.Logger): instance of logger
489+
"""
490+
491+
fieldnames = ["nodename", "plugin", "status", "timestamp", "message"]
492+
all_rows = []
493+
494+
pattern = os.path.join(search_path, "**", "nodescraper.csv")
495+
matched_files = glob.glob(pattern, recursive=True)
496+
497+
if not matched_files:
498+
logger.error(f"No nodescraper.csv files found under {search_path}")
499+
return
500+
501+
for filepath in matched_files:
502+
logger.info(f"Reading: {filepath}")
503+
with open(filepath, newline="") as f:
504+
reader = csv.DictReader(f)
505+
for row in reader:
506+
all_rows.append(row)
507+
508+
if not all_rows:
509+
logger.error("No data rows found in matched CSV files.")
510+
return
511+
512+
if not output_path:
513+
output_path = os.getcwd()
514+
515+
output_path = os.path.join(output_path, "summary.csv")
516+
dump_to_csv(all_rows, output_path, fieldnames, logger)

test/unit/framework/test_cli_helper.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#
2525
###############################################################################
2626
import argparse
27+
import csv
2728
import json
2829
import logging
2930
import os
@@ -39,7 +40,10 @@
3940
from nodescraper.cli import cli
4041
from nodescraper.cli.helper import (
4142
build_config,
43+
dump_results_to_csv,
44+
dump_to_csv,
4245
find_datamodel_and_result,
46+
generate_summary,
4347
)
4448
from nodescraper.configregistry import ConfigRegistry
4549
from nodescraper.enums import ExecutionStatus, SystemInteractionLevel
@@ -181,3 +185,77 @@ def build_from_model(cls, datamodel):
181185
assert isinstance(cfg, PluginConfig)
182186
assert set(cfg.plugins) == {parent}
183187
assert cfg.plugins[parent]["analysis_args"] == {}
188+
189+
190+
def test_dump_to_csv(tmp_path):
191+
logger = logging.getLogger()
192+
data = [
193+
{
194+
"nodename": "node1",
195+
"plugin": "TestPlugin",
196+
"status": "OK",
197+
"timestamp": "2025_07_16-12_00_00_PM",
198+
"message": "Success",
199+
}
200+
]
201+
filename = tmp_path / "test.csv"
202+
fieldnames = list(data[0].keys())
203+
204+
dump_to_csv(data, str(filename), fieldnames, logger)
205+
206+
with open(filename, newline="") as f:
207+
reader = list(csv.DictReader(f))
208+
assert reader == data
209+
210+
211+
def test_dump_results_to_csv(tmp_path, caplog):
212+
logger = logging.getLogger()
213+
214+
result = PluginResult(
215+
source="TestPlugin", status=ExecutionStatus.OK, message="some message", result_data={}
216+
)
217+
218+
dump_results_to_csv([result], "node123", str(tmp_path), "2025_07_16-01_00_00_PM", logger)
219+
220+
out_file = tmp_path / "nodescraper.csv"
221+
assert out_file.exists()
222+
223+
with open(out_file, newline="") as f:
224+
reader = list(csv.DictReader(f))
225+
assert reader[0]["nodename"] == "node123"
226+
assert reader[0]["plugin"] == "TestPlugin"
227+
assert reader[0]["status"] == "OK"
228+
assert reader[0]["message"] == "some message"
229+
230+
231+
def test_generate_summary(tmp_path):
232+
logger = logging.getLogger()
233+
234+
subdir = tmp_path / "sub"
235+
subdir.mkdir()
236+
237+
errorscraper_path = subdir / "nodescraper.csv"
238+
with open(errorscraper_path, "w", newline="") as f:
239+
writer = csv.DictWriter(
240+
f, fieldnames=["nodename", "plugin", "status", "timestamp", "message"]
241+
)
242+
writer.writeheader()
243+
writer.writerow(
244+
{
245+
"nodename": "nodeX",
246+
"plugin": "PluginA",
247+
"status": "OK",
248+
"timestamp": "2025_07_16-01_00_00_PM",
249+
"message": "some message",
250+
}
251+
)
252+
253+
generate_summary(str(tmp_path), str(tmp_path), logger)
254+
255+
summary_path = tmp_path / "summary.csv"
256+
assert summary_path.exists()
257+
258+
with open(summary_path, newline="") as f:
259+
rows = list(csv.DictReader(f))
260+
assert len(rows) == 1
261+
assert rows[0]["plugin"] == "PluginA"

0 commit comments

Comments
 (0)