From ef8abd79d22a8f49bb7a29f4b6f3995e4c29c5e8 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 8 Apr 2026 09:22:50 -0500 Subject: [PATCH 1/3] --no-console-log --- nodescraper/cli/cli.py | 72 ++++++++++++++++------ nodescraper/cli/compare_runs.py | 10 ++- nodescraper/cli/helper.py | 63 +++++++++++++------ test/functional/test_cli_describe.py | 25 ++++++++ test/functional/test_cli_no_console_log.py | 50 +++++++++++++++ 5 files changed, 178 insertions(+), 42 deletions(-) create mode 100644 test/functional/test_cli_no_console_log.py diff --git a/nodescraper/cli/cli.py b/nodescraper/cli/cli.py index f4e2fe86..bd49e0c8 100644 --- a/nodescraper/cli/cli.py +++ b/nodescraper/cli/cli.py @@ -161,6 +161,14 @@ def build_parser( help="Change python log level", ) + parser.add_argument( + "--no-console-log", + action="store_true", + help="Write logs only to nodescraper.log under the run directory; do not print to stdout. " + "If no run log directory would be created (e.g. --log-path None), uses ./scraper_logs__/ " + "like the default layout.", + ) + parser.add_argument( "--gen-reference-config", dest="reference_config", @@ -316,26 +324,36 @@ def build_parser( parser_builder = DynamicParserBuilder(plugin_subparser, plugin_class) model_type_map = parser_builder.build_plugin_parser() except Exception as e: - print(f"Exception building arg parsers for {plugin_name}: {str(e)}") # noqa: T201 + logging.getLogger(DEFAULT_LOGGER).error( + "Exception building arg parsers for %s: %s", plugin_name, e, exc_info=True + ) continue plugin_subparser_map[plugin_name] = (plugin_subparser, model_type_map) return parser, plugin_subparser_map -def setup_logger(log_level: str = "INFO", log_path: Optional[str] = None) -> logging.Logger: +def setup_logger( + log_level: str = "INFO", + log_path: Optional[str] = None, + *, + console: bool = True, +) -> logging.Logger: """set up root logger when using the CLI Args: log_level (str): log level to use log_path (Optional[str]): optional path to filesystem log location + console (bool): if False, omit the stdout StreamHandler (file-only when log_path is set) Returns: logging.Logger: logger intstance """ - log_level = getattr(logging, log_level, "INFO") + log_level_no = getattr(logging, log_level, logging.INFO) - handlers = [logging.StreamHandler(stream=sys.stdout)] + handlers: list[logging.Handler] = [] + if console: + handlers.append(logging.StreamHandler(stream=sys.stdout)) if log_path: log_file_name = os.path.join(log_path, "nodescraper.log") @@ -343,15 +361,18 @@ def setup_logger(log_level: str = "INFO", log_path: Optional[str] = None) -> log logging.FileHandler(filename=log_file_name, mode="wt", encoding="utf-8"), ) + if not handlers: + handlers.append(logging.NullHandler()) + logging.basicConfig( force=True, - level=log_level, + level=log_level_no, format="%(asctime)25s %(levelname)10s %(name)25s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S %Z", handlers=handlers, encoding="utf-8", ) - logging.root.setLevel(logging.INFO) + logging.root.setLevel(log_level_no) logging.getLogger("paramiko").setLevel(logging.ERROR) logger = logging.getLogger(DEFAULT_LOGGER) @@ -391,11 +412,7 @@ def main(arg_input: Optional[list[str]] = None): sname = system_info.name.lower().replace("-", "_").replace(".", "_") timestamp = datetime.datetime.now().strftime("%Y_%m_%d-%I_%M_%S_%p") - if parsed_args.log_path and parsed_args.subcmd not in [ - "gen-plugin-config", - "describe", - "compare-runs", - ]: + if parsed_args.log_path: log_path = os.path.join( parsed_args.log_path, f"scraper_logs_{sname}_{timestamp}", @@ -404,7 +421,18 @@ def main(arg_input: Optional[list[str]] = None): else: log_path = None - logger = setup_logger(parsed_args.log_level, log_path) + if parsed_args.no_console_log and not log_path: + # Same layout as a normal run: /scraper_logs__/ + # Use "." when --log-path None (or unset); otherwise respect the given base directory. + base_dir = parsed_args.log_path if parsed_args.log_path else "." + log_path = os.path.join(base_dir, f"scraper_logs_{sname}_{timestamp}") + os.makedirs(log_path, exist_ok=True) + + logger = setup_logger( + parsed_args.log_level, + log_path, + console=not parsed_args.no_console_log, + ) if log_path: logger.info("Log path: %s", log_path) @@ -416,7 +444,12 @@ def main(arg_input: Optional[list[str]] = None): ) if parsed_args.subcmd == "summary": - generate_summary(parsed_args.search_path, parsed_args.output_path, logger) + generate_summary( + parsed_args.search_path, + parsed_args.output_path, + logger, + artifact_dir=log_path, + ) sys.exit(0) if parsed_args.subcmd == "describe": @@ -431,6 +464,7 @@ def main(arg_input: Optional[list[str]] = None): skip_plugins=getattr(parsed_args, "skip_plugins", None) or [], include_plugins=getattr(parsed_args, "include_plugins", None), truncate_message=not getattr(parsed_args, "dont_truncate", False), + artifact_dir=log_path, ) sys.exit(0) @@ -463,7 +497,7 @@ def main(arg_input: Optional[list[str]] = None): "Could not read OEMDiagnosticDataType@Redfish.AllowableValues from LogService" ) sys.exit(1) - print(json.dumps(allowable, indent=2)) # noqa: T201 + logger.info("%s", json.dumps(allowable, indent=2)) finally: conn.close() sys.exit(0) @@ -474,10 +508,8 @@ def main(arg_input: Optional[list[str]] = None): ref_config = generate_reference_config_from_logs( parsed_args.reference_config_from_logs, plugin_reg, logger ) - output_path = os.getcwd() - if parsed_args.output_path: - output_path = parsed_args.output_path - path = os.path.join(output_path, "reference_config.json") + out_dir = log_path if log_path else parsed_args.output_path + path = os.path.join(out_dir, "reference_config.json") try: with open(path, "w") as f: json.dump( @@ -490,7 +522,9 @@ def main(arg_input: Optional[list[str]] = None): logger.error(exp) sys.exit(0) - parse_gen_plugin_config(parsed_args, plugin_reg, config_reg, logger) + parse_gen_plugin_config( + parsed_args, plugin_reg, config_reg, logger, artifact_dir=log_path + ) parsed_plugin_args = {} for plugin, plugin_args in plugin_arg_map.items(): diff --git a/nodescraper/cli/compare_runs.py b/nodescraper/cli/compare_runs.py index fe7b8f76..acbb92ca 100644 --- a/nodescraper/cli/compare_runs.py +++ b/nodescraper/cli/compare_runs.py @@ -25,6 +25,7 @@ ############################################################################### import json import logging +import os import re import sys from pathlib import Path @@ -359,6 +360,7 @@ def run_compare_runs( include_plugins: Optional[Sequence[str]] = None, output_path: Optional[str] = None, truncate_message: bool = True, + artifact_dir: Optional[str] = None, ) -> None: """Compare datamodels from two run log directories and log results. @@ -369,8 +371,10 @@ def run_compare_runs( logger: Logger for output. skip_plugins: Optional list of plugin names to exclude from comparison. include_plugins: Optional list of plugin names to include; if set, only these are compared. - output_path: Optional path for full diff report; default is __diff.txt. + output_path: Optional path for full diff report; default is __diff.txt + in the current directory, or under artifact_dir when set. truncate_message: If True, truncate message text and show only first 3 errors; if False, show full text and all. + artifact_dir: When set and output_path is not, write the diff file inside this directory (e.g. CLI run log dir). """ p1 = Path(path1) p2 = Path(path2) @@ -482,11 +486,11 @@ def run_compare_runs( out_file = output_path if not out_file: - out_file = f"{Path(path1).name}_{Path(path2).name}_diff.txt" + basename = f"{Path(path1).name}_{Path(path2).name}_diff.txt" + out_file = os.path.join(artifact_dir, basename) if artifact_dir else basename full_report = _build_full_diff_report(path1, path2, data1, data2, all_plugins) Path(out_file).write_text(full_report, encoding="utf-8") logger.info("Full diff report written to: %s", out_file) table_summary = TableSummary(logger=logger) table_summary.collate_results(plugin_results=plugin_results, connection_results=[]) - print(f"Diff file written to {out_file}") # noqa: T201 diff --git a/nodescraper/cli/helper.py b/nodescraper/cli/helper.py index 41e30ede..1c82ed2c 100644 --- a/nodescraper/cli/helper.py +++ b/nodescraper/cli/helper.py @@ -31,7 +31,7 @@ import os import sys from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Sequence, Tuple from pydantic import BaseModel @@ -187,6 +187,13 @@ def build_config( return config +def log_cli_text_block(logger: logging.Logger, lines: Sequence[str]) -> None: + """Emit user-facing multi-line text through logging (respects handlers / --no-console-log).""" + text = "\n".join(lines).rstrip("\n") + if text: + logger.info("%s", text) + + def parse_describe( parsed_args: argparse.Namespace, plugin_reg: PluginRegistry, @@ -202,15 +209,18 @@ def parse_describe( logger (logging.Logger): logger instance """ if not parsed_args.name: + out: list[str] = [] if parsed_args.type == "config": - print("Available built-in configs:") # noqa: T201 + out.append("Available built-in configs:") for name in config_reg.configs: - print(f" {name}") # noqa: T201 + out.append(f" {name}") elif parsed_args.type == "plugin": - print("Available plugins:") # noqa: T201 + out.append("Available plugins:") for name in plugin_reg.plugins: - print(f" {name}") # noqa: T201 - print(f"\nUsage: describe {parsed_args.type} ") # noqa: T201 + out.append(f" {name}") + out.append("") + out.append(f"Usage: describe {parsed_args.type} ") + log_cli_text_block(logger, out) sys.exit(0) if parsed_args.type == "config": @@ -218,19 +228,25 @@ def parse_describe( logger.error("No config found for name: %s", parsed_args.name) sys.exit(1) config_model = config_reg.configs[parsed_args.name] - print(f"Config Name: {parsed_args.name}") # noqa: T201 - print(f"Description: {getattr(config_model, 'desc', '')}") # noqa: T201 - print("Plugins:") # noqa: T201 + out = [ + f"Config Name: {parsed_args.name}", + f"Description: {getattr(config_model, 'desc', '')}", + "Plugins:", + ] for plugin in getattr(config_model, "plugins", []): - print(f"\t{plugin}") # noqa: T201 + out.append(f"\t{plugin}") + log_cli_text_block(logger, out) elif parsed_args.type == "plugin": if parsed_args.name not in plugin_reg.plugins: logger.error("No plugin found for name: %s", parsed_args.name) sys.exit(1) plugin_class = plugin_reg.plugins[parsed_args.name] - print(f"Plugin Name: {parsed_args.name}") # noqa: T201 - print(f"Description: {getattr(plugin_class, '__doc__', '')}") # noqa: T201 + out = [ + f"Plugin Name: {parsed_args.name}", + f"Description: {getattr(plugin_class, '__doc__', '')}", + ] + log_cli_text_block(logger, out) sys.exit(0) @@ -240,6 +256,7 @@ def parse_gen_plugin_config( plugin_reg: PluginRegistry, config_reg: ConfigRegistry, logger: logging.Logger, + artifact_dir: Optional[str] = None, ): """parse 'gen_plugin_config' cmd line argument @@ -248,6 +265,7 @@ def parse_gen_plugin_config( plugin_reg (PluginRegistry): plugin registry instance config_reg (ConfigRegistry): config registry instance logger (logging.Logger): logger instance + artifact_dir (Optional[str]): if set, write the config under this directory (CLI run log dir) """ try: config = build_config( @@ -256,7 +274,8 @@ def parse_gen_plugin_config( config.name = parsed_args.config_name.split(".")[0] config.desc = "Auto generated config" - output_path = os.path.join(parsed_args.output_path, parsed_args.config_name) + out_dir = artifact_dir if artifact_dir else parsed_args.output_path + output_path = os.path.join(out_dir, parsed_args.config_name) with open(output_path, "w", encoding="utf-8") as out_file: out_file.write(config.model_dump_json(indent=2)) @@ -576,13 +595,19 @@ def dump_to_csv(all_rows: list, filename: str, fieldnames: list[str], logger: lo logger.info("Data written to csv file: %s", filename) -def generate_summary(search_path: str, output_path: Optional[str], logger: logging.Logger): +def generate_summary( + search_path: str, + output_path: Optional[str], + logger: logging.Logger, + artifact_dir: Optional[str] = None, +): """Concatenate csv files into 1 summary csv file Args: search_path (str): Path for previous runs - output_path (Optional[str]): Path for new summary csv file + output_path (Optional[str]): Directory for new summary.csv (ignored when artifact_dir is set) logger (logging.Logger): instance of logger + artifact_dir (Optional[str]): if set, write summary.csv under this directory (CLI run log dir) """ fieldnames = ["nodename", "plugin", "status", "timestamp", "message"] @@ -606,8 +631,6 @@ def generate_summary(search_path: str, output_path: Optional[str], logger: loggi logger.error("No data rows found in matched CSV files.") return - if not output_path: - output_path = os.getcwd() - - output_path = os.path.join(output_path, "summary.csv") - dump_to_csv(all_rows, output_path, fieldnames, logger) + base_dir = artifact_dir if artifact_dir else (output_path or os.getcwd()) + out_file = os.path.join(base_dir, "summary.csv") + dump_to_csv(all_rows, out_file, fieldnames, logger) diff --git a/test/functional/test_cli_describe.py b/test/functional/test_cli_describe.py index 52097a54..42df5596 100644 --- a/test/functional/test_cli_describe.py +++ b/test/functional/test_cli_describe.py @@ -25,6 +25,8 @@ ############################################################################### """Functional tests for CLI describe command.""" +from pathlib import Path + def test_describe_command_list_plugins(run_cli_command): """Test that describe command can list all plugins.""" @@ -53,3 +55,26 @@ def test_describe_invalid_plugin(run_cli_command): assert result.returncode != 0 output = (result.stdout + result.stderr).lower() assert "error" in output or "not found" in output or "invalid" in output + + +def test_describe_no_console_log_writes_nodescraper_log(run_cli_command, tmp_path): + """With --no-console-log, describe output is only in nodescraper.log under scraper_logs_*.""" + log_base = str(tmp_path / "logs") + result = run_cli_command( + [ + "--log-path", + log_base, + "--no-console-log", + "describe", + "plugin", + "BiosPlugin", + ], + check=False, + ) + assert result.returncode == 0 + run_dirs = list(Path(log_base).glob("scraper_logs_*")) + assert len(run_dirs) == 1 + log_file = run_dirs[0] / "nodescraper.log" + assert log_file.is_file() + text = log_file.read_text(encoding="utf-8").lower() + assert "bios" in text diff --git a/test/functional/test_cli_no_console_log.py b/test/functional/test_cli_no_console_log.py new file mode 100644 index 00000000..ed9fd8fa --- /dev/null +++ b/test/functional/test_cli_no_console_log.py @@ -0,0 +1,50 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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. +# +############################################################################### + +import subprocess +import sys + + +def test_no_console_log_with_log_path_none_still_parses(): + """--log-path None + --no-console-log defaults to ./scraper_logs_* (no argparse error).""" + result = subprocess.run( + [ + sys.executable, + "-m", + "nodescraper.cli.cli", + "--log-path", + "None", + "--no-console-log", + "run-plugins", + "OsPlugin", + "-h", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + combined = (result.stdout or "") + (result.stderr or "") + assert "no-console-log requires" not in combined.lower() From 0c60fbb6818f1a628e788137531620efd8808c16 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 8 Apr 2026 09:57:49 -0500 Subject: [PATCH 2/3] fix --- nodescraper/cli/cli.py | 2 -- nodescraper/models/taskresult.py | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/nodescraper/cli/cli.py b/nodescraper/cli/cli.py index bd49e0c8..f76393bf 100644 --- a/nodescraper/cli/cli.py +++ b/nodescraper/cli/cli.py @@ -422,8 +422,6 @@ def main(arg_input: Optional[list[str]] = None): log_path = None if parsed_args.no_console_log and not log_path: - # Same layout as a normal run: /scraper_logs__/ - # Use "." when --log-path None (or unset); otherwise respect the given base directory. base_dir = parsed_args.log_path if parsed_args.log_path else "." log_path = os.path.join(base_dir, f"scraper_logs_{sname}_{timestamp}") os.makedirs(log_path, exist_ok=True) diff --git a/nodescraper/models/taskresult.py b/nodescraper/models/taskresult.py index 3a4a2952..5615e464 100644 --- a/nodescraper/models/taskresult.py +++ b/nodescraper/models/taskresult.py @@ -209,6 +209,18 @@ def log_result(self, log_path: str) -> None: with open(event_log, "w", encoding="utf-8") as log_file: json.dump(all_events, log_file, indent=2) + @staticmethod + def _event_occurrence_count(event: Event) -> int: + """Occurrences represented by one event (RegexAnalyzer groups repeats in data['count']).""" + raw = event.data.get("count") + if raw is None: + return 1 + try: + n = int(raw) + except (TypeError, ValueError): + return 1 + return max(1, n) + def _get_event_summary(self) -> str: """Get summary string for events @@ -219,12 +231,13 @@ def _get_event_summary(self) -> str: warning_msg_counts: dict[str, int] = {} for event in self.events: + n = self._event_occurrence_count(event) if event.priority == EventPriority.WARNING: warning_msg_counts[event.description] = ( - warning_msg_counts.get(event.description, 0) + 1 + warning_msg_counts.get(event.description, 0) + n ) elif event.priority >= EventPriority.ERROR: - error_msg_counts[event.description] = error_msg_counts.get(event.description, 0) + 1 + error_msg_counts[event.description] = error_msg_counts.get(event.description, 0) + n summary_parts = [] From 20b6dc8357ea7ba6cf10320ec8f020289bb7a8b9 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 8 Apr 2026 10:10:02 -0500 Subject: [PATCH 3/3] extra utest to test every subcommand --- test/unit/cli/test_cli_no_console_stdout.py | 152 ++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 test/unit/cli/test_cli_no_console_stdout.py diff --git a/test/unit/cli/test_cli_no_console_stdout.py b/test/unit/cli/test_cli_no_console_stdout.py new file mode 100644 index 00000000..775bccdf --- /dev/null +++ b/test/unit/cli/test_cli_no_console_stdout.py @@ -0,0 +1,152 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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. +# +############################################################################### + +import io +import json +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + +import pytest + +from nodescraper.cli.cli import main + + +def _assert_main_leaves_stdout_empty(argv: list[str]) -> None: + out = io.StringIO() + with redirect_stdout(out): + with pytest.raises(SystemExit) as exc: + main(argv) + assert out.getvalue() == "", f"Unexpected stdout: {out.getvalue()!r}" + code = exc.value.code + if code is None: + code = 0 + assert code in (0, 1), f"Unexpected exit code: {exc.value.code!r}" + + +@pytest.fixture +def no_console_base(tmp_path): + log_base = tmp_path / "logs" + log_base.mkdir(parents=True, exist_ok=True) + return ["--log-path", str(log_base), "--no-console-log", "--log-level", "ERROR"] + + +def test_describe_no_stdout(no_console_base): + _assert_main_leaves_stdout_empty( + no_console_base + ["describe", "plugin", "BiosPlugin"], + ) + + +def test_summary_no_stdout(no_console_base, tmp_path): + search = tmp_path / "search_here" + search.mkdir() + _assert_main_leaves_stdout_empty( + no_console_base + ["summary", "--search-path", str(search)], + ) + + +def test_gen_plugin_config_no_stdout(no_console_base, tmp_path): + out_dir = tmp_path / "cfg_out" + out_dir.mkdir() + _assert_main_leaves_stdout_empty( + no_console_base + + [ + "gen-plugin-config", + "--plugins", + "BiosPlugin", + "--output-path", + str(out_dir), + "--config-name", + "out_config.json", + ], + ) + + +def test_compare_runs_no_stdout(no_console_base, tmp_path): + d1 = tmp_path / "run_a" + d2 = tmp_path / "run_b" + d1.mkdir() + d2.mkdir() + _assert_main_leaves_stdout_empty( + no_console_base + ["compare-runs", str(d1), str(d2)], + ) + + +def test_run_plugins_empty_config_no_stdout(no_console_base, tmp_path): + cfg = tmp_path / "empty_plugins.json" + cfg.write_text( + json.dumps( + { + "name": "empty", + "desc": "", + "plugins": {}, + "global_args": {}, + "result_collators": {}, + } + ), + encoding="utf-8", + ) + _assert_main_leaves_stdout_empty( + no_console_base + ["run-plugins", "--plugin-configs", str(cfg)], + ) + + +@patch("nodescraper.cli.cli.get_oem_diagnostic_allowable_values", return_value=["DiagTypeA"]) +@patch("nodescraper.cli.cli.RedfishConnection") +def test_show_redfish_oem_allowable_no_stdout( + mock_conn_cls, + _mock_get_allowable, + no_console_base, + tmp_path, +): + conn_path = tmp_path / "conn.json" + conn_path.write_text( + json.dumps( + { + "RedfishConnectionManager": { + "host": "127.0.0.1", + "username": "u", + "password": "p", + "verify_ssl": False, + } + } + ), + encoding="utf-8", + ) + mock_inst = MagicMock() + mock_conn_cls.return_value = mock_inst + + _assert_main_leaves_stdout_empty( + no_console_base + + [ + "--connection-config", + str(conn_path), + "show-redfish-oem-allowable", + "--log-service-path", + "redfish/v1/Systems/1/LogServices/Logs", + ], + ) + mock_inst._ensure_session.assert_called_once() + mock_inst.close.assert_called_once()