From e8af8bc7fe80a40df1759b0a79eb0c4909f0b755 Mon Sep 17 00:00:00 2001 From: Timothy Willard <9395586+TimothyWillard@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:17:47 -0400 Subject: [PATCH] `CliCommand.run` optionally return exit code Updated the `CliCommand.run` method's return signature to `ExitCode | None` where `ExitCode` is a new `IntEnum` added to `flepimop2.typing`. This creates consistent exit codes across CLI commands. Closes #17. --- CHANGELOG.md | 2 +- src/flepimop2/_cli/_build_command.py | 4 +- src/flepimop2/_cli/_cli_command.py | 22 ++++++----- src/flepimop2/_cli/_process_command.py | 7 +++- src/flepimop2/_cli/_simulate_command.py | 13 ++++--- src/flepimop2/_cli/_skeleton_command.py | 10 +++-- src/flepimop2/typing.py | 18 ++++++++- tests/_cli/test_cli_command_class.py | 52 +++++++++++++++++++++++++ 8 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 tests/_cli/test_cli_command_class.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b666d6..87b2f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- ... +- Updated CLI commands to return consistent exit codes based on the kind of failure. See [#17](https://github.com/ACCIDDA/flepimop2/issues/17). ### Deprecated diff --git a/src/flepimop2/_cli/_build_command.py b/src/flepimop2/_cli/_build_command.py index d9b157d..1bf8153 100644 --- a/src/flepimop2/_cli/_build_command.py +++ b/src/flepimop2/_cli/_build_command.py @@ -17,14 +17,14 @@ __all__ = [] - from flepimop2._cli._cli_command import CliCommand +from flepimop2.typing import ExitCode class BuildCommand(CliCommand): """Compile and build a model defined in a configuration file.""" - def run(self, *, config: str, dry_run: bool) -> None: # type: ignore[override] + def run(self, *, config: str, dry_run: bool) -> ExitCode: # type: ignore[override] """ Execute the build. diff --git a/src/flepimop2/_cli/_cli_command.py b/src/flepimop2/_cli/_cli_command.py index 5953be8..8e22db5 100644 --- a/src/flepimop2/_cli/_cli_command.py +++ b/src/flepimop2/_cli/_cli_command.py @@ -20,11 +20,13 @@ import inspect import logging import re +import sys from abc import ABC, abstractmethod from pathlib import Path from typing import Any from flepimop2._cli._logging import get_script_logger +from flepimop2.typing import ExitCode _COMMAND_NAME_REGEX = re.compile(r"(? None: The 'verbosity' option is consumed here for logger setup. If 'verbosity' was auto-appended (not in _literal_options), it's removed from kwargs - before passing to run(). + before passing to run(). The return value from run() is passed to + `sys.exit()`. Args: **kwargs: Command-specific arguments passed from Click options/arguments. @@ -63,10 +66,10 @@ def __call__(self, **kwargs: Any) -> None: self.debug("%s = %s", key.ljust(longest_key, " "), self.format(value)) if "verbosity" in self._literal_options(): kwargs |= {"verbosity": verbosity} - self.run(**kwargs) + sys.exit(self.run(**kwargs)) @abstractmethod - def run(self, **kwargs: Any) -> None: + def run(self, **kwargs: Any) -> ExitCode: """ Execute the command. @@ -112,17 +115,18 @@ def options(cls) -> list[str]: List of option names to request from `COMMON_OPTIONS`. Examples: + >>> from flepimop2.typing import ExitCode >>> class MyCommand(CliCommand): - ... def run(self, *, config: Path, dry_run: bool) -> None: - ... pass + ... def run(self, *, config: Path, dry_run: bool) -> ExitCode: + ... return ExitCode.OKAY >>> MyCommand.options() ['config', 'dry_run', 'verbosity'] >>> class MyCommandWithVerbosity(CliCommand): ... def run( ... self, *, config: Path, verbosity: int, dry_run: bool - ... ) -> None: - ... pass + ... ) -> ExitCode: + ... return ExitCode.OKAY >>> MyCommandWithVerbosity.options() ['config', 'verbosity', 'dry_run'] """ @@ -202,7 +206,7 @@ def critical(self, *args: Any, **kwargs: Any) -> None: self.logger.critical(*args, **kwargs) @staticmethod - def format(value: Any) -> Any: # noqa: ANN401 + def format(value: object) -> str: """ Format a value for logging output. @@ -230,4 +234,4 @@ def format(value: Any) -> Any: # noqa: ANN401 return str(value.absolute()) if isinstance(value, int | float): return f"{value:,}" - return value + return str(value) diff --git a/src/flepimop2/_cli/_process_command.py b/src/flepimop2/_cli/_process_command.py index c13e820..00090c9 100644 --- a/src/flepimop2/_cli/_process_command.py +++ b/src/flepimop2/_cli/_process_command.py @@ -23,6 +23,7 @@ from flepimop2._utils._click import _get_config_target from flepimop2.configuration import ConfigurationModel from flepimop2.process.abc import build as build_process +from flepimop2.typing import ExitCode class ProcessCommand(CliCommand): @@ -38,7 +39,7 @@ def run( # type: ignore[override] config: Path, dry_run: bool, target: str | None = None, - ) -> None: + ) -> ExitCode: """ Execute the processing step. @@ -46,6 +47,9 @@ def run( # type: ignore[override] config: Path to the configuration file. dry_run: Whether dry run mode is enabled. target: Optional target process config to use. + + Returns: + An exit code indicating success or failure. """ configmodel = ConfigurationModel.from_yaml(config) processconfig = configmodel.process @@ -57,3 +61,4 @@ def run( # type: ignore[override] process_instance = build_process(processtarget) process_instance.execute(dry_run=dry_run) + return ExitCode.OKAY diff --git a/src/flepimop2/_cli/_simulate_command.py b/src/flepimop2/_cli/_simulate_command.py index e5cff65..09a27fb 100644 --- a/src/flepimop2/_cli/_simulate_command.py +++ b/src/flepimop2/_cli/_simulate_command.py @@ -27,6 +27,7 @@ from flepimop2.parameter.abc import build as build_parameter from flepimop2.scenario.abc import build as build_scenario from flepimop2.simulator import Simulator +from flepimop2.typing import ExitCode class SimulateCommand(CliCommand): @@ -43,7 +44,7 @@ def run( # type: ignore[override] config: Path, dry_run: bool, target: str | None = None, - ) -> None: + ) -> ExitCode: """ Execute the simulation. @@ -52,8 +53,8 @@ def run( # type: ignore[override] dry_run: Whether dry run mode is enabled. target: Optional target simulate config to use. - Raises: - ValueError: If the simulator is missing a simulation configuration. + Returns: + An exit code indicating success or failure. """ config_model = ConfigurationModel.from_yaml(config) @@ -61,7 +62,8 @@ def run( # type: ignore[override] if simulator.simulate_config is None: msg = "simulate_config must be set before running the simulator." - raise ValueError(msg) + self.error(msg) + return ExitCode.CONFIGURATION ic_map: dict[str, str] | None = simulator.system.option( "initial_state", @@ -109,7 +111,7 @@ def run( # type: ignore[override] self.info(f" T: {simulator.simulate_config.times}") if dry_run: - return + return ExitCode.OKAY if scenario_name := simulator.simulate_config.scenario: # extract scenario parameters from the configuration @@ -123,3 +125,4 @@ def run( # type: ignore[override] ) else: simulator.run(initial_state, params) + return ExitCode.OKAY diff --git a/src/flepimop2/_cli/_skeleton_command.py b/src/flepimop2/_cli/_skeleton_command.py index dc10c43..8bea621 100644 --- a/src/flepimop2/_cli/_skeleton_command.py +++ b/src/flepimop2/_cli/_skeleton_command.py @@ -21,6 +21,7 @@ from pathlib import Path from flepimop2._cli._cli_command import CliCommand +from flepimop2.typing import ExitCode class SkeletonCommand(CliCommand): @@ -49,7 +50,7 @@ def run( # type: ignore[override] *, path: Path | None, dry_run: bool, - ) -> None: + ) -> ExitCode: """ Create a project skeleton. @@ -57,6 +58,8 @@ def run( # type: ignore[override] path: Path to the new project. dry_run: Whether to perform a dry run. + Returns: + An exit code indicating success or failure. """ path = path or Path.cwd() if not path.exists(): @@ -65,16 +68,17 @@ def run( # type: ignore[override] parent_dir = parent_dir.parent if os.access(parent_dir, os.W_OK) is False: self.error(f"Cannot write to path: {path}") - return + return ExitCode.GENERAL if dry_run: self.info(f"Would create skeleton project at: {path}") - return + return ExitCode.OKAY path.mkdir(parents=True, exist_ok=True) template_dir = Path(__file__).parent.parent / "templates" / "skeleton" self._copy_template_tree(template_dir, path) self.info(f"Skeleton project created at: {path}") self.info(f"Directory structure:\n{self._generate_tree(path)}") + return ExitCode.OKAY @staticmethod def _copy_template_tree(source: Path, destination: Path) -> None: diff --git a/src/flepimop2/typing.py b/src/flepimop2/typing.py index 39dc4c8..3224719 100644 --- a/src/flepimop2/typing.py +++ b/src/flepimop2/typing.py @@ -30,6 +30,7 @@ __all__ = [ "EngineProtocol", + "ExitCode", "Float64NDArray", "IdentifierString", "RaiseOnMissing", @@ -39,7 +40,7 @@ ] from collections.abc import Callable -from enum import StrEnum +from enum import IntEnum, StrEnum from keyword import iskeyword from typing import ( Annotated, @@ -61,6 +62,21 @@ """Alias for a NumPy ndarray with float64 data type.""" +class ExitCode(IntEnum): + """ + Standard process exit codes used by CLI commands. + + Attributes: + OKAY: Exit code 0, indicating successful execution. + GENERAL: Exit code 1, indicating a general error. + CONFIGURATION: Exit code 3, indicating a configuration related error. + """ + + OKAY = 0 + GENERAL = 1 + CONFIGURATION = 3 + + def _identifier_string(value: str) -> str: """ Validate that a string is a valid identifier string. diff --git a/tests/_cli/test_cli_command_class.py b/tests/_cli/test_cli_command_class.py new file mode 100644 index 0000000..079e8f2 --- /dev/null +++ b/tests/_cli/test_cli_command_class.py @@ -0,0 +1,52 @@ +# flepimop2: The FLExible Pipeline for Interchangeable MOdel Processing +# Copyright (C) 2026 Carl Pearson, Joshua Macdonald, Timothy Willard +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Tests for `flepimop2._cli._cli_command`.""" + +from unittest.mock import Mock + +import pytest + +from flepimop2._cli._cli_command import CliCommand +from flepimop2.typing import ExitCode + + +@pytest.mark.parametrize( + "return_exit_code", + [ExitCode.OKAY, ExitCode.GENERAL, ExitCode.CONFIGURATION], +) +def test_run_returns_exit_code( + monkeypatch: pytest.MonkeyPatch, + return_exit_code: ExitCode, +) -> None: + """Test that run return values are passed through to process exit codes.""" + + class MockExitCodeCommand(CliCommand): + """Mock command that returns a configurable exit code.""" + + def run(self) -> ExitCode: # type: ignore[override] + """Execute the command. + + Returns: + The configured exit code. + """ + return return_exit_code + + exit_mock = Mock() + monkeypatch.setattr("flepimop2._cli._cli_command.sys.exit", exit_mock) + + MockExitCodeCommand()() + + exit_mock.assert_called_once_with(return_exit_code)