From 7fd414a968c474007d5f6834fee1a501903438fa Mon Sep 17 00:00:00 2001 From: Laurent Courty Date: Sun, 26 Jul 2026 22:52:12 -0600 Subject: [PATCH] stop displaying traceback to cli users --- src/itzi/itzi.py | 11 ++++++--- src/itzi/messenger.py | 6 ++++- tests/cli/conftest.py | 17 ++++++++++++++ tests/cli/test_cli.py | 46 ++++++++++++++++++++++++++++++++++++- tests/cli/test_messenger.py | 12 ++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 tests/cli/conftest.py create mode 100644 tests/cli/test_messenger.py diff --git a/src/itzi/itzi.py b/src/itzi/itzi.py index cdcd2010..8cf6b7b2 100644 --- a/src/itzi/itzi.py +++ b/src/itzi/itzi.py @@ -51,7 +51,7 @@ from itzi.providers.grass_interface import GrassInterface -def main(argv=None): +def main(argv: list[str] | None = None) -> int | None: """argv: alternative CLI arguments, used for testing (default to sys.argv)""" args = build_parser().parse_args(argv) @@ -60,8 +60,11 @@ def main(argv=None): "version": itzi_version, } - # args.command is the name of the subcommand - command_mapper[args.command](args) + try: + # args.command is the name of the subcommand + command_mapper[args.command](args) + except msgr.FatalError: + return 1 class SimulationRunner: @@ -211,6 +214,8 @@ def sim_runner_worker(conf_file: str, hotstart_file: str | None): hotstart_file, ) sim_runner.run().finalize() + except msgr.FatalError: + return except Exception: msgr.warning("Error during execution: {}".format(traceback.format_exc())) diff --git a/src/itzi/messenger.py b/src/itzi/messenger.py index fd5f0957..9542833a 100644 --- a/src/itzi/messenger.py +++ b/src/itzi/messenger.py @@ -36,6 +36,10 @@ class VerbosityLevel: DEBUG = 4 +class FatalError(RuntimeError): + """An expected fatal error that has already been shown to the user.""" + + def verbosity(): """Return the current verbosity as integer""" try: @@ -99,7 +103,7 @@ def fatal(self, msg: str) -> NoReturn: """Log fatal error and raise or exit""" self.logger.error(f"ERROR: {msg}") if raise_on_error: - raise RuntimeError(msg) + raise FatalError(msg) else: sys.exit(f"ERROR: {msg}") diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 00000000..c5e4e5fe --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,17 @@ +from io import StringIO + +import pytest + +import itzi.messenger as msgr + + +@pytest.fixture +def itzi_stderr(monkeypatch): + console_handler = next( + handler + for handler in msgr._itzi_logger.logger.handlers + if getattr(handler, "_itzi_console_handler", False) + ) + stream = StringIO() + monkeypatch.setattr(console_handler, "stream", stream) + return stream diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 0b3e9215..ef2167c4 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -5,8 +5,15 @@ import pytest -from itzi.itzi import main, itzi_run, reconcile_hotstart_commands, VerbosityLevel +import itzi.messenger as msgr from itzi.cli_parser import build_parser +from itzi.itzi import ( + VerbosityLevel, + itzi_run, + main, + reconcile_hotstart_commands, + sim_runner_worker, +) def test_run_parser_accepts_multiple_config_files(): @@ -43,6 +50,43 @@ def test_prints_version(monkeypatch, capsys): assert capsys.readouterr().out.strip() == "22.2" +def test_main_returns_error_status_for_fatal_error(monkeypatch, itzi_stderr): + def fail(_): + msgr.fatal("expected failure") + + monkeypatch.setattr("itzi.itzi.itzi_run", fail) + + assert main(["run", "a.ini"]) == 1 + stderr = itzi_stderr.getvalue() + assert stderr.count("ERROR: expected failure") == 1 + assert "Traceback" not in stderr + + +def test_main_propagates_unexpected_error(monkeypatch): + def fail(_): + raise ValueError("unexpected") + + monkeypatch.setattr("itzi.itzi.itzi_run", fail) + + with pytest.raises(ValueError, match="unexpected"): + main(["run", "a.ini"]) + + +def test_worker_does_not_format_fatal_error_as_traceback(monkeypatch, itzi_stderr): + def fail(_): + msgr.fatal("expected worker failure") + + monkeypatch.setenv("ITZI_VERBOSE", str(VerbosityLevel.QUIET)) + monkeypatch.setattr("itzi.itzi.ConfigReader", fail) + + sim_runner_worker("a.ini", None) + + stderr = itzi_stderr.getvalue() + assert stderr.count("ERROR: expected worker failure") == 1 + assert "Traceback" not in stderr + assert "WARNING: Error during execution" not in stderr + + def test_reconcile_hotstart_commands_accepts_single_resume_for_single_config(): assert reconcile_hotstart_commands(["/tmp/a.ini"], [(None, "restart_a.zip")]) == [ ("/tmp/a.ini", "restart_a.zip"), diff --git a/tests/cli/test_messenger.py b/tests/cli/test_messenger.py new file mode 100644 index 00000000..a8b72379 --- /dev/null +++ b/tests/cli/test_messenger.py @@ -0,0 +1,12 @@ +import pytest + +import itzi.messenger as msgr + + +def test_fatal_raises_runtime_compatible_error(itzi_stderr): + with pytest.raises(msgr.FatalError, match="expected failure") as error: + msgr.fatal("expected failure") + + assert isinstance(error.value, RuntimeError) + assert str(error.value) == "expected failure" + assert itzi_stderr.getvalue().count("ERROR: expected failure") == 1