Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/flepimop2/_cli/_build_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 13 additions & 9 deletions src/flepimop2/_cli/_cli_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!^)(?=[A-Z])")

Expand All @@ -50,7 +52,8 @@ def __call__(self, **kwargs: Any) -> 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.
Expand All @@ -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.

Expand Down Expand Up @@ -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']
"""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)
7 changes: 6 additions & 1 deletion src/flepimop2/_cli/_process_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -38,14 +39,17 @@ def run( # type: ignore[override]
config: Path,
dry_run: bool,
target: str | None = None,
) -> None:
) -> ExitCode:
"""
Execute the processing step.

Args:
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
Expand All @@ -57,3 +61,4 @@ def run( # type: ignore[override]

process_instance = build_process(processtarget)
process_instance.execute(dry_run=dry_run)
return ExitCode.OKAY
13 changes: 8 additions & 5 deletions src/flepimop2/_cli/_simulate_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -43,7 +44,7 @@ def run( # type: ignore[override]
config: Path,
dry_run: bool,
target: str | None = None,
) -> None:
) -> ExitCode:
"""
Execute the simulation.

Expand All @@ -52,16 +53,17 @@ 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)

simulator = Simulator.from_configuration_model(config_model, target=target)

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",
Expand Down Expand Up @@ -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
Expand All @@ -123,3 +125,4 @@ def run( # type: ignore[override]
)
else:
simulator.run(initial_state, params)
return ExitCode.OKAY
10 changes: 7 additions & 3 deletions src/flepimop2/_cli/_skeleton_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from pathlib import Path

from flepimop2._cli._cli_command import CliCommand
from flepimop2.typing import ExitCode


class SkeletonCommand(CliCommand):
Expand Down Expand Up @@ -49,14 +50,16 @@ def run( # type: ignore[override]
*,
path: Path | None,
dry_run: bool,
) -> None:
) -> ExitCode:
"""
Create a project skeleton.

Args:
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():
Expand All @@ -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:
Expand Down
18 changes: 17 additions & 1 deletion src/flepimop2/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

__all__ = [
"EngineProtocol",
"ExitCode",
"Float64NDArray",
"IdentifierString",
"RaiseOnMissing",
Expand All @@ -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,
Expand All @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions tests/_cli/test_cli_command_class.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
"""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)
Loading