diff --git a/pyproject.toml b/pyproject.toml index 0e28833..3782ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,11 @@ include = ["pywr_utils*"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--cov=pywr_utils --cov-report=term-missing --cov-report=xml" +markers = [ + "unit: a single function/class in isolation, no DB/network/subprocess", + "integration: real internal components wired together via the code's own Python API, no mocks", + "e2e: drives the installed CLI entrypoint end to end", +] [tool.coverage.report] # Ratchet, not an aspirational target: set at today's measured coverage (99.23%, after a full unit/integration/e2e pass on model_runner.py) minus a small buffer for environment variance, so coverage can't silently regress further without failing CI. Raise this number as coverage genuinely improves. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..926e6e1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import sys + +import pytest + +BLOCKED_MODULES = ("pandas", "pywr") + + +@pytest.fixture +def block_pandas_and_pywr(monkeypatch): + """Simulate a fresh install with neither pandas nor pywr present. + + Setting `sys.modules[name] = None` is the standard idiom for this: Python's import system raises ImportError for any name mapped to None in sys.modules, without needing to intercept `__import__` or actually uninstall the package. + """ + for name in BLOCKED_MODULES: + monkeypatch.setitem(sys.modules, name, None) + for name in ("pywr_utils.cli", "pywr_utils.model_runner"): + monkeypatch.delitem(sys.modules, name, raising=False) + yield + for name in ("pywr_utils.cli", "pywr_utils.model_runner"): + monkeypatch.delitem(sys.modules, name, raising=False) diff --git a/tests/e2e/test_cli_commands.py b/tests/e2e/test_cli_commands.py index b276596..7356262 100644 --- a/tests/e2e/test_cli_commands.py +++ b/tests/e2e/test_cli_commands.py @@ -15,6 +15,8 @@ from pywr_utils import model_runner from pywr_utils.cli import main +pytestmark = pytest.mark.e2e + @pytest.fixture def runner(): diff --git a/tests/test_cli_lazy_import.py b/tests/e2e/test_cli_lazy_import.py similarity index 58% rename from tests/test_cli_lazy_import.py rename to tests/e2e/test_cli_lazy_import.py index 8090335..6f67b20 100644 --- a/tests/test_cli_lazy_import.py +++ b/tests/e2e/test_cli_lazy_import.py @@ -1,34 +1,13 @@ -"""Regression tests for the model_runner lazy-import fix. +"""E2E tests for the model_runner lazy-import fix, driven through the Click entrypoint. -create-synthetic-model must not require pandas or pywr to be installed, since it only depends on model_creation (stdlib json/typing). Before the fix, cli.py imported model_runner (which needs both) at module load time, so every CLI invocation failed on a fresh install before pywr's own native build was ever reached. +create-synthetic-model must not require pandas or pywr to be installed, since it only depends on model_creation (stdlib json/typing). Before the fix, cli.py imported model_runner (which needs both) at module load time, so every CLI invocation failed on a fresh install before pywr's own native build was ever reached. See tests/unit/test_cli_lazy_import.py for the isolated import-only check. """ import importlib -import sys import pytest from click.testing import CliRunner -BLOCKED_MODULES = ("pandas", "pywr") - - -@pytest.fixture -def block_pandas_and_pywr(monkeypatch): - """Simulate a fresh install with neither pandas nor pywr present. - - Setting `sys.modules[name] = None` is the standard idiom for this: Python's import system raises ImportError for any name mapped to None in sys.modules, without needing to intercept `__import__` or actually uninstall the package. - """ - for name in BLOCKED_MODULES: - monkeypatch.setitem(sys.modules, name, None) - for name in ("pywr_utils.cli", "pywr_utils.model_runner"): - monkeypatch.delitem(sys.modules, name, raising=False) - yield - for name in ("pywr_utils.cli", "pywr_utils.model_runner"): - monkeypatch.delitem(sys.modules, name, raising=False) - - -def test_cli_module_imports_without_pandas_or_pywr(block_pandas_and_pywr): - module = importlib.import_module("pywr_utils.cli") - assert hasattr(module, "create_synthetic_model") +pytestmark = pytest.mark.e2e def test_create_synthetic_model_command_without_pandas_or_pywr(tmp_path, block_pandas_and_pywr): diff --git a/tests/integration/test_incremental_sweep_failures.py b/tests/integration/test_incremental_sweep_failures.py index 2aa59c8..9cf9dc0 100644 --- a/tests/integration/test_incremental_sweep_failures.py +++ b/tests/integration/test_incremental_sweep_failures.py @@ -10,6 +10,8 @@ from pywr_utils import model_runner from pywr_utils.model_runner import PywrFileRunner, run_incremental_sizes +pytestmark = pytest.mark.integration + SWEEP_ARGS = {"max_zones": 2, "zone_increment": 2, "transfer_increment": 2, "output_csv": "timings.csv"} diff --git a/tests/integration/test_model_runner_api.py b/tests/integration/test_model_runner_api.py index 14e3f59..d05f059 100644 --- a/tests/integration/test_model_runner_api.py +++ b/tests/integration/test_model_runner_api.py @@ -12,6 +12,8 @@ from pywr_utils.model_creation import SyntheticModelCreator from pywr_utils.model_runner import PywrFileRunner, run_file, run_incremental_sizes +pytestmark = pytest.mark.integration + DEFAULT_SOLVER = "glpk" ALTERNATIVE_SOLVER = "glpk-edge" diff --git a/tests/unit/test_cli_module_import_without_deps.py b/tests/unit/test_cli_module_import_without_deps.py new file mode 100644 index 0000000..9f96255 --- /dev/null +++ b/tests/unit/test_cli_module_import_without_deps.py @@ -0,0 +1,14 @@ +"""UNIT test for the model_runner lazy-import fix. + +create-synthetic-model must not require pandas or pywr to be installed, since it only depends on model_creation (stdlib json/typing). Before the fix, cli.py imported model_runner (which needs both) at module load time, so every CLI invocation failed on a fresh install before pywr's own native build was ever reached. This checks only the import itself, in isolation from the Click entrypoint — see tests/e2e/test_cli_lazy_import.py for the command-level regression coverage. +""" +import importlib + +import pytest + +pytestmark = pytest.mark.unit + + +def test_cli_module_imports_without_pandas_or_pywr(block_pandas_and_pywr): + module = importlib.import_module("pywr_utils.cli") + assert hasattr(module, "create_synthetic_model") diff --git a/tests/unit/test_model_creation.py b/tests/unit/test_model_creation.py index 559330c..a209f50 100644 --- a/tests/unit/test_model_creation.py +++ b/tests/unit/test_model_creation.py @@ -8,6 +8,8 @@ from pywr_utils.model_creation import SyntheticModelCreator, create_synthetic_model +pytestmark = pytest.mark.unit + # One input produces three nodes: an input, a link and a demand. Each transfer adds one further link node. NODES_PER_INPUT = 3 diff --git a/tests/unit/test_terminal_graph.py b/tests/unit/test_terminal_graph.py index 20e324f..006f8e9 100644 --- a/tests/unit/test_terminal_graph.py +++ b/tests/unit/test_terminal_graph.py @@ -7,6 +7,8 @@ from pywr_utils.model_runner import _display_terminal_graph, _draw_line +pytestmark = pytest.mark.unit + def timing_frame(zones, transfers, setup_times): return pd.DataFrame({"zones": zones, "transfers": transfers, "setup_time": setup_times})