diff --git a/doc/source/api.rst b/doc/source/api.rst
index 0821512b..4ee8244f 100644
--- a/doc/source/api.rst
+++ b/doc/source/api.rst
@@ -48,6 +48,15 @@ configuration is built, so they raise ``FileNotFoundError`` whether or not the
input is validated; with the validation off the other missing inputs surface
only when the run reaches them.
+Pass ``allow_blocking_problems=True`` to either loader to run past those
+problems, the counterpart of ``rubem run --allow-blocking-problems``: the
+checks still run, every problem is kept in ``model.configuration.problems``,
+the blocking ones are logged as errors followed by one error line stating that
+the simulation continues despite them, and the model loads and runs instead of
+raising. The failures that are not problems (a document that does not match
+the schema, a missing file) raise as before. Pass ``validate_input=False``
+instead to skip the checks altogether.
+
An already loaded ``ModelConfiguration`` may be passed to ``Model.from_config``
as well, and is used as it is, neither validated again nor re-anchored.
@@ -106,7 +115,8 @@ and the raster memory of a run is not reclaimed. Two consequences follow for
``Model.run_isolated`` avoids both: it runs the simulation in a subprocess
started with the ``spawn`` method, used for that one run and shut down before
the call returns. The configuration crosses as its document, its base
-directory and the validation flag, and is rebuilt on the other side; the result
+directory and the validation flags (``validate_input`` and
+``allow_blocking_problems``), and is rebuilt on the other side; the result
crosses as plain data. A ``ConfigurationError`` raised while the subprocess
rebuilds the configuration reaches the caller as the same exception, with its
problems; any other exception of the run propagates as itself, and a subprocess
diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst
index 0fc3b984..aa562d4b 100644
--- a/doc/source/changelog.rst
+++ b/doc/source/changelog.rst
@@ -13,6 +13,17 @@ The format follows `Keep a Changelog `__.
Added
`````
+- Added ``rubem run --allow-blocking-problems``, which runs the model even when the input
+ validation finds blocking problems: the same checks run, every problem is still reported
+ (the non-blocking ones as warnings, the blocking ones as errors, followed by one error line
+ stating that the simulation continues despite them) and the run goes on instead of stopping
+ with exit code 1. The option cannot be combined with ``-s``, which skips the checks it
+ reports (usage error, exit code 2), and the deprecated ``rubem -c `` spelling does
+ not take it. ``ModelConfiguration(..., allow_blocking_problems=False)`` is the library
+ counterpart and keeps the full list in ``ModelConfiguration.problems``; ``Model.from_file``
+ and ``Model.from_config`` of ``rubem.api`` take the same keyword and an isolated run
+ rebuilds the configuration with it
+ (`#352 `__).
- Added ``rubem.api``, the public Python surface: ``Model.from_file`` and
``Model.from_config`` load a configuration file or document, ``Model.run()``
runs the simulation in the current process and ``Model.run_isolated()`` in a
diff --git a/doc/source/userguide.rst b/doc/source/userguide.rst
index ce1845cd..30737d5e 100644
--- a/doc/source/userguide.rst
+++ b/doc/source/userguide.rst
@@ -1209,6 +1209,9 @@ Use ``-h`` or ``--help`` to get a brief description of each command and its argu
[required]
-s, --skip-inputs-validation Disable input files validation before running
the model.
+ --allow-blocking-problems Run the model even if the input validation
+ finds blocking problems; they are logged as
+ errors instead of stopping the run.
-h, --help Show this message and exit.
Use ``-V`` or ``--version`` to get the version of the RUBEM.
@@ -1266,3 +1269,37 @@ Use ``-c`` or ``--configfile`` to set the path of the RUBEM configuration file.
.## Timestep 22 of 24
.## Timestep 23 of 24
.## Timestep 24 of 24
+
+Use ``-s`` or ``--skip-inputs-validation`` to skip the validation of the
+content of the input files: the checks do not run at all, so the run neither
+stops on a blocking problem nor reports one. Use ``--allow-blocking-problems``
+instead to keep the checks and their report, but not the stop: every problem is
+still logged, the non-blocking ones as warnings and the blocking ones as
+errors, followed by one error line stating that the simulation continues
+despite them, and the run goes on. Exploring a dataset, or reproducing a
+published run whose inputs fail a check, are the cases the option is meant
+for. The two options cannot be combined, since ``-s`` skips the very checks
+the other one reports: doing so is a usage error (exit code 2). A run forced
+this way exits with 0 when it completes and with 1 when it fails, as it does
+when a structural problem (a missing member of a raster series, a member on
+another grid) is reached by the simulation. Failures that are not validation
+problems still stop the run before it starts: a configuration file that does
+not match the schema, a lookup table file that is not there, no raster format
+enabled, or a DEM, clone and georeference that do not share their geometry.
+
+.. code-block:: console
+
+ $ rubem run -c project-config.json --allow-blocking-problems
+ Loading configuration and validating inputs...
+ 2026-09-22T10:27:30-0300.119 [WRN] rubem.configuration.model_configuration: Configuration problems found: 1
+ 2026-09-22T10:27:30-0300.119 [ERR] rubem.configuration.model_configuration: Configuration problem: Rainy days lookup table does not cover every month.: Missing months: [12]. The simulation cannot run with this table. /path/to/rainydays.txt
+ 2026-09-22T10:27:30-0300.119 [ERR] rubem.configuration.model_configuration: Simulation continues despite 1 blocking problem(s).
+ Simulation started...
+ .## Timestep 1 of 24
+ .## Timestep 2 of 24
+
+ -- Omitted for brevity --
+
+ .## Timestep 24 of 24
+ Simulation finished successfully!
+ Elapsed time: 12 seconds
diff --git a/rubem/api.py b/rubem/api.py
index a26112d8..40d6d99a 100644
--- a/rubem/api.py
+++ b/rubem/api.py
@@ -114,11 +114,23 @@ class Model:
and their content when it rebuilds the configuration. It has no effect
on ``configuration``, which is already loaded; pass the value the
configuration was loaded with.
+ :param allow_blocking_problems: Whether an isolated run keeps going when the
+ configuration it rebuilds carries blocking problems, as
+ :meth:`from_file` and :meth:`from_config` do with the same keyword. It
+ has no effect on ``configuration`` either; pass the value the
+ configuration was loaded with.
"""
- def __init__(self, configuration: "ModelConfiguration", *, validate_input: bool = True) -> None:
+ def __init__(
+ self,
+ configuration: "ModelConfiguration",
+ *,
+ validate_input: bool = True,
+ allow_blocking_problems: bool = False,
+ ) -> None:
self._configuration = configuration
self._validate_input = bool(validate_input)
+ self._allow_blocking_problems = bool(allow_blocking_problems)
@classmethod
def from_file(
@@ -127,6 +139,7 @@ def from_file(
*,
validate_input: bool = True,
base_dir: PathInput | None = None,
+ allow_blocking_problems: bool = False,
) -> "Model":
"""Load a configuration file and return the model it configures.
@@ -134,18 +147,30 @@ def from_file(
:param validate_input: Whether to validate the input files and their content.
:param base_dir: Directory the relative paths of the configuration are
anchored on. Defaults to the directory of the file.
+ :param allow_blocking_problems: Whether to load, and run, a configuration
+ whose inputs carry blocking problems. The checks still run and every
+ problem is kept in ``configuration.problems``; the blocking ones are
+ logged as errors instead of raising :class:`ConfigurationError`. An
+ isolated run rebuilds the configuration the same way.
:raises ImportError: If PCRaster or GDAL are not installed.
:raises FileNotFoundError: If ``path``, or a raster or table it names,
is not there.
:raises json.JSONDecodeError: If the file is not JSON.
:raises pydantic.ValidationError: If the document does not match the schema.
- :raises ConfigurationError: If the inputs carry blocking problems.
+ :raises ConfigurationError: If the inputs carry blocking problems, unless
+ ``allow_blocking_problems`` is set.
"""
_require_runtime_deps()
from .configuration.model_configuration import ModelConfiguration
- configuration = ModelConfiguration(path, validate_input, base_dir)
- return cls(configuration, validate_input=validate_input)
+ configuration = ModelConfiguration(
+ path, validate_input, base_dir, allow_blocking_problems=allow_blocking_problems
+ )
+ return cls(
+ configuration,
+ validate_input=validate_input,
+ allow_blocking_problems=allow_blocking_problems,
+ )
@classmethod
def from_config(
@@ -154,6 +179,7 @@ def from_config(
*,
validate_input: bool = True,
base_dir: PathInput | None = None,
+ allow_blocking_problems: bool = False,
) -> "Model":
"""Return the model a configuration document, or a loaded configuration, configures.
@@ -168,19 +194,26 @@ def from_config(
:param base_dir: Directory the relative paths of the document are
anchored on. A dictionary has no anchor unless this is passed.
Ignored when ``config`` is already loaded.
+ :param allow_blocking_problems: Whether to load, and run, a document
+ whose inputs carry blocking problems, as in :meth:`from_file`. For an
+ already loaded configuration the flag only says whether an isolated
+ run keeps going when it rebuilds the configuration.
:raises ImportError: If PCRaster or GDAL are not installed.
:raises FileNotFoundError: If a raster or table the document names is
not there.
:raises pydantic.ValidationError: If the document does not match the schema.
- :raises ConfigurationError: If the inputs carry blocking problems.
+ :raises ConfigurationError: If the inputs carry blocking problems, unless
+ ``allow_blocking_problems`` is set.
"""
_require_runtime_deps()
from .configuration.model_configuration import ModelConfiguration
- if isinstance(config, ModelConfiguration):
- return cls(config, validate_input=validate_input)
+ if not isinstance(config, ModelConfiguration):
+ config = ModelConfiguration(
+ config, validate_input, base_dir, allow_blocking_problems=allow_blocking_problems
+ )
return cls(
- ModelConfiguration(config, validate_input, base_dir), validate_input=validate_input
+ config, validate_input=validate_input, allow_blocking_problems=allow_blocking_problems
)
@property
@@ -214,8 +247,8 @@ def run_isolated(self) -> RunResult:
"""Run the simulation in a fresh spawned subprocess.
The configuration crosses as its document (the copy taken when it was
- loaded) plus its base directory and the validation flag, and is rebuilt
- on the other side; the result crosses as plain data. The subprocess is
+ loaded) plus its base directory and the validation flags, and is
+ rebuilt on the other side; the result crosses as plain data. The subprocess is
started for this call only and the executor is shut down before
returning, so the PCRaster state of the run leaves nothing behind. Every
call therefore pays a full interpreter start-up.
@@ -233,8 +266,9 @@ def run_isolated(self) -> RunResult:
A configuration problem found while the subprocess rebuilds the
configuration reaches the caller as the same
- :class:`ConfigurationError`, with its problems; any other exception of
- the run propagates as itself.
+ :class:`ConfigurationError`, with its problems, unless the model was
+ built with ``allow_blocking_problems``; any other exception of the run
+ propagates as itself.
The log records of the simulation are emitted in the subprocess, which
starts from the default logging configuration: the handlers of the
@@ -257,6 +291,7 @@ def run_isolated(self) -> RunResult:
self._configuration.config,
self._configuration.base_dir,
self._validate_input,
+ self._allow_blocking_problems,
)
document = future.result()
except BrokenProcessPool as error:
@@ -274,7 +309,9 @@ def run_isolated(self) -> RunResult:
return _result_from_json(document)
-def _run_document(document: dict, base_dir: str | None, validate_input: bool) -> dict[str, Any]:
+def _run_document(
+ document: dict, base_dir: str | None, validate_input: bool, allow_blocking_problems: bool
+) -> dict[str, Any]:
"""Rebuild a configuration, run it, and return the result as plain data.
The entry point of the subprocess of :meth:`Model.run_isolated`. It lives at
@@ -284,12 +321,15 @@ def _run_document(document: dict, base_dir: str | None, validate_input: bool) ->
:param document: The configuration document, legacy or format 1.0.
:param base_dir: Directory the relative paths of the document are anchored on.
:param validate_input: Whether to validate the input files and their content.
+ :param allow_blocking_problems: Whether to run past blocking problems.
:return: The :class:`RunResult` of the run, as a JSON-able dictionary.
"""
from .configuration.model_configuration import ModelConfiguration
from .core import DynamicFrameworkWrapper
- configuration = ModelConfiguration(document, validate_input, base_dir)
+ configuration = ModelConfiguration(
+ document, validate_input, base_dir, allow_blocking_problems=allow_blocking_problems
+ )
started = time.perf_counter()
DynamicFrameworkWrapper(configuration).run()
elapsed = time.perf_counter() - started
diff --git a/rubem/cli.py b/rubem/cli.py
index fb9eb5fe..dcf83d15 100644
--- a/rubem/cli.py
+++ b/rubem/cli.py
@@ -1,9 +1,9 @@
"""The ``rubem`` command line.
-``rubem run -c [-s]`` runs a simulation; ``rubem config schema``
-prints the JSON Schema of the configuration file. The former ``rubem -c
-`` spelling still works for one minor release and emits a
-``DeprecationWarning``.
+``rubem run -c [-s | --allow-blocking-problems]`` runs a
+simulation; ``rubem config schema`` prints the JSON Schema of the configuration
+file. The former ``rubem -c `` spelling still works for one minor
+release and emits a ``DeprecationWarning``.
"""
import json
@@ -122,8 +122,25 @@ def run(
help="Disable input files validation before running the model.",
),
] = False,
+ allow_blocking_problems: Annotated[
+ bool,
+ typer.Option(
+ "--allow-blocking-problems",
+ help=(
+ "Run the model even if the input validation finds blocking problems; "
+ "they are logged as errors instead of stopping the run."
+ ),
+ ),
+ ] = False,
) -> None:
"""Run a simulation from a configuration file."""
+ if skip_inputs_validation and allow_blocking_problems:
+ # ``-s`` skips the very checks this option exists to report.
+ raise typer.BadParameter(
+ "cannot be combined with '-s' / '--skip-inputs-validation': the problems "
+ "it allows are found by the validation that option skips.",
+ param_hint="'--allow-blocking-problems'",
+ )
require_runtime_deps()
validate_input = not skip_inputs_validation
@@ -140,7 +157,9 @@ def run(
# embedded run stays silent unless its host configures logging.
validating = " and validating inputs" if validate_input else ""
print(f"Loading configuration{validating}...", flush=True)
- model_config = ModelConfiguration(configfile, validate_input)
+ model_config = ModelConfiguration(
+ configfile, validate_input, allow_blocking_problems=allow_blocking_problems
+ )
model = DynamicFrameworkWrapper.load(model_config)
except (ConfigurationError, ValidationError, ValueError) as e:
# A configuration the user can fix: no traceback, the message says what.
diff --git a/rubem/configuration/model_configuration.py b/rubem/configuration/model_configuration.py
index ec19e824..ba04b284 100644
--- a/rubem/configuration/model_configuration.py
+++ b/rubem/configuration/model_configuration.py
@@ -52,12 +52,18 @@ class ModelConfiguration:
:type validate_input: bool, optional
:param base_dir: Directory the relative paths of the configuration are anchored on. Defaults to
the directory of the JSON file, or to ``None`` (paths kept as given) for a dictionary.
+ :param allow_blocking_problems: Whether to keep going when the inputs carry blocking
+ problems: the checks still run and every problem is kept in :attr:`problems`, the
+ blocking ones are logged as errors and the configuration loads instead of raising
+ :class:`~rubem.configuration._problems.ConfigurationError`. Defaults to ``False``.
+ :type allow_blocking_problems: bool, optional
:raises FileNotFoundError: If the specified config file is not found.
:raises ValueError: If the config file type is not supported, or a setting is missing or invalid
(``pydantic.ValidationError`` is a ``ValueError``).
:raises json.JSONDecodeError: If the JSON file is not valid.
- :raises ConfigurationError: If the inputs carry blocking problems.
+ :raises ConfigurationError: If the inputs carry blocking problems, unless
+ ``allow_blocking_problems`` is set.
"""
def __init__(
@@ -65,6 +71,7 @@ def __init__(
config_input: dict | PathInput,
validate_input: bool = True,
base_dir: PathInput | None = None,
+ allow_blocking_problems: bool = False,
):
self.logger = logging.getLogger(__name__)
self.problems = []
@@ -128,7 +135,7 @@ def __init__(
self.calibration_parameters.w_3,
)
)
- self.__check_inconsistencies()
+ self.__check_inconsistencies(allow_blocking_problems)
def __parse(self, data: dict, duplicates: list[str]) -> None:
"""Validate the document as format 1.0 (``version`` present) or legacy."""
@@ -461,16 +468,23 @@ def load(
config_input: dict | PathInput,
validate_input: bool = True,
base_dir: PathInput | None = None,
+ allow_blocking_problems: bool = False,
) -> "ModelConfiguration":
"""Load a legacy configuration from a dictionary or a JSON file.
Relative paths are anchored on the directory of the JSON file, or on
``base_dir`` when given (a dictionary has no anchor unless ``base_dir``
- is passed).
+ is passed). ``allow_blocking_problems`` is passed through to the
+ constructor.
"""
- return cls(config_input, validate_input=validate_input, base_dir=base_dir)
+ return cls(
+ config_input,
+ validate_input=validate_input,
+ base_dir=base_dir,
+ allow_blocking_problems=allow_blocking_problems,
+ )
- def __check_inconsistencies(self):
+ def __check_inconsistencies(self, allow_blocking_problems: bool):
if self.output_variables.any_enabled() and not self.output_variables.file_formats:
raise ValueError(
"No raster file format is enabled: set RASTER_FILE_FORMAT.map_raster_series "
@@ -546,9 +560,20 @@ def __check_inconsistencies(self):
if self.problems:
self.logger.warning("Configuration problems found: %d", len(self.problems))
for problem in self.problems:
- self.logger.warning("Configuration problem: %s", problem)
- if any(problem.blocking for problem in self.problems):
+ # A blocking problem the caller chose to run past is an error the
+ # log must keep; by default it is listed again by the exception.
+ level = (
+ logging.ERROR
+ if allow_blocking_problems and problem.blocking
+ else logging.WARNING
+ )
+ self.logger.log(level, "Configuration problem: %s", problem)
+ blocking = sum(1 for problem in self.problems if problem.blocking)
+ if not blocking:
+ return
+ if not allow_blocking_problems:
raise ConfigurationError(self.problems)
+ self.logger.error("Simulation continues despite %d blocking problem(s).", blocking)
def __read_json(self, file_path: PathInput, duplicates: list[str]):
self.logger.debug("Reading JSON file: %s", file_path)
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
index 3c5136d2..0de04a88 100644
--- a/tests/integration/test_cli.py
+++ b/tests/integration/test_cli.py
@@ -157,6 +157,9 @@ def _run_and_compare(self, temp_dir, *cli_flags):
run_cli("run", *cli_flags, "-c", config_path)
+ self._compare_with_goldens(temp_dir)
+
+ def _compare_with_goldens(self, temp_dir):
for raster_file in RASTER_GOLDENS + TIFF_GOLDENS:
candidate = os.path.join(temp_dir, raster_file)
assert os.path.exists(candidate), f"missing output {raster_file}"
@@ -209,6 +212,53 @@ def test_cli_app_skip_input_data_validation(self):
with tempfile.TemporaryDirectory() as temp_dir:
self._run_and_compare(temp_dir, "-s")
+ @pytest.mark.integration
+ def test_cli_app_refuses_allowing_blocking_problems_with_skipped_validation(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ config_path = os.path.join(temp_dir, "config.json")
+ with open(file=config_path, mode="w", encoding="utf8") as f:
+ f.write(json.dumps(base_model_config(temp_dir)))
+
+ result = run_cli_capture("run", "-s", "--allow-blocking-problems", "-c", config_path)
+
+ assert result.returncode == 2
+ assert "Invalid value for '--allow-blocking-problems'" in result.stderr
+ assert "Loading configuration" not in result.stdout
+
+ @pytest.mark.slow
+ @pytest.mark.integration
+ def test_cli_app_allows_blocking_problems(self):
+ """A forced run over a table that fails a check the run never reads.
+
+ The rainy days table lacks December, which the January and February
+ run does not use, so the outputs still match the goldens.
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ config = base_model_config(temp_dir)
+ rainy_days = os.path.join(temp_dir, "rainydays.txt")
+ # The line endings of the fixture are kept as they are on every platform.
+ with open(config["TABLES"]["rainydays"], encoding="utf8", newline="") as source:
+ rows = [line for line in source if not line.startswith("12")]
+ with open(rainy_days, "w", encoding="utf8", newline="") as f:
+ f.writelines(rows)
+ config["TABLES"]["rainydays"] = rainy_days
+ config_path = os.path.join(temp_dir, "config.json")
+ with open(file=config_path, mode="w", encoding="utf8") as f:
+ f.write(json.dumps(config))
+
+ refused = run_cli_capture("run", "-c", config_path)
+ assert refused.returncode == 1
+ assert "The configuration has 1 blocking problem(s)" in refused.stderr
+
+ result = run_cli_capture("run", "--allow-blocking-problems", "-c", config_path)
+
+ assert result.returncode == 0, result.stderr
+ assert "Simulation finished successfully!" in result.stdout
+ assert "[ERR]" in result.stderr
+ assert "Rainy days lookup table does not cover every month" in result.stderr
+ assert "Simulation continues despite 1 blocking problem(s)." in result.stderr
+ self._compare_with_goldens(temp_dir)
+
@pytest.mark.slow
@pytest.mark.integration
def test_cli_app_runs_from_any_working_directory(self):
diff --git a/tests/unit/api/_child.py b/tests/unit/api/_child.py
index 72f40be1..5fb6553c 100644
--- a/tests/unit/api/_child.py
+++ b/tests/unit/api/_child.py
@@ -18,7 +18,7 @@
MARKER_FILENAME = "child.json"
-def record_and_run(document, base_dir, validate_input):
+def record_and_run(document, base_dir, validate_input, allow_blocking_problems):
"""Record who runs the simulation, then delegate to the real entry point.
Installed over ``rubem.api._run_document`` in the parent; the child
@@ -28,13 +28,14 @@ def record_and_run(document, base_dir, validate_input):
:param document: The configuration document.
:param base_dir: Directory the relative paths of the document are anchored on.
:param validate_input: Whether to validate the input files and their content.
+ :param allow_blocking_problems: Whether to run past blocking problems.
:return: The result of the run, as the plain data the boundary carries.
"""
preloaded = sorted(name for name in ("pcraster", "osgeo") if name in sys.modules)
from rubem import api
- result = api._run_document(document, base_dir, validate_input)
+ result = api._run_document(document, base_dir, validate_input, allow_blocking_problems)
marker = Path(document["DIRECTORIES"]["output"]) / MARKER_FILENAME
marker.write_text(json.dumps({"pid": os.getpid(), "preloaded": preloaded}), encoding="utf8")
return result
@@ -51,7 +52,7 @@ def simulate(config):
return os.getpid(), Model.from_config(config).run()
-def die(document, base_dir, validate_input):
+def die(document, base_dir, validate_input, allow_blocking_problems):
"""Kill the subprocess the way a crashing native library does.
``os._exit`` leaves no exception for the executor to send back, which is
diff --git a/tests/unit/api/test_model.py b/tests/unit/api/test_model.py
index 4a10a1cc..ae4ea390 100644
--- a/tests/unit/api/test_model.py
+++ b/tests/unit/api/test_model.py
@@ -1,6 +1,7 @@
"""The public Python API: loading a configuration, running it, and what it reports."""
import json
+import logging
import math
import multiprocessing
import os
@@ -226,6 +227,35 @@ def test_runs_on_different_grids_follow_one_another_in_one_process(self, tmp_pat
assert small_after_large == small_alone
assert large_after_small == large_alone
+ @pytest.mark.unit
+ def test_allowing_blocking_problems_loads_reports_and_runs(self, tmp_path, caplog):
+ config = write_synthetic_dataset(str(tmp_path))
+ break_kp(config)
+ with pytest.raises(ConfigurationError):
+ Model.from_config(config)
+
+ with caplog.at_level(logging.ERROR, logger="rubem.configuration"):
+ model = Model.from_config(config, allow_blocking_problems=True)
+ result = model.run()
+
+ assert any(p.blocking and "not positive" in str(p) for p in model.configuration.problems)
+ assert "Simulation continues despite 1 blocking problem(s)." in caplog.text
+ assert not missing_files(result)
+
+ @pytest.mark.unit
+ def test_a_file_is_loaded_with_the_same_leniency(self, tmp_path):
+ config = write_synthetic_dataset(str(tmp_path))
+ break_kp(config)
+ path = tmp_path / "config.json"
+ path.write_text(json.dumps(config), encoding="utf8")
+ with pytest.raises(ConfigurationError):
+ Model.from_file(path)
+
+ model = Model.from_file(path, allow_blocking_problems=True)
+
+ assert any(problem.blocking for problem in model.configuration.problems)
+ assert not missing_files(model.run())
+
@pytest.mark.unit
def test_a_run_writes_nothing_to_stdout(self, tmp_path, capsys):
"""The library only logs; a front end is what prints."""
@@ -389,6 +419,30 @@ def test_skipping_the_validation_crosses_the_boundary_too(self, tmp_path):
assert not missing_files(result)
+ @pytest.mark.unit
+ def test_allowing_blocking_problems_crosses_the_boundary_too(self, tmp_path):
+ """The subprocess rebuilds the configuration with the same leniency."""
+ config = write_synthetic_dataset(str(tmp_path))
+ break_kp(config)
+ model = Model.from_config(config, allow_blocking_problems=True)
+
+ result = model.run_isolated()
+
+ assert not missing_files(result)
+
+ @pytest.mark.unit
+ def test_a_loaded_configuration_carries_the_leniency_the_caller_states(self, tmp_path):
+ """A configuration loaded elsewhere is not checked again; the flag rules the rebuild."""
+ config = write_synthetic_dataset(str(tmp_path))
+ break_kp(config)
+ loaded = ModelConfiguration(config, allow_blocking_problems=True)
+
+ with pytest.raises(ConfigurationError):
+ Model.from_config(loaded).run_isolated()
+ result = Model.from_config(loaded, allow_blocking_problems=True).run_isolated()
+
+ assert not missing_files(result)
+
@pytest.mark.unit
def test_a_dead_subprocess_is_reported_against_run_isolated(self, tmp_path, mocker):
model = Model.from_config(write_synthetic_dataset(str(tmp_path)), validate_input=False)
@@ -647,15 +701,18 @@ def test_the_exported_names_are_the_documented_ones(self):
@pytest.mark.unit
def test_the_signatures_are_the_documented_ones(self):
assert str(signature(Model.__init__)) == (
- "(self, configuration: 'ModelConfiguration', *, validate_input: bool = True) -> None"
+ "(self, configuration: 'ModelConfiguration', *, validate_input: bool = True,"
+ " allow_blocking_problems: bool = False) -> None"
)
assert str(signature(Model.from_file)) == (
"(path: str | os.PathLike[str] | bytes, *, validate_input: bool = True,"
- " base_dir: str | os.PathLike[str] | bytes | None = None) -> 'Model'"
+ " base_dir: str | os.PathLike[str] | bytes | None = None,"
+ " allow_blocking_problems: bool = False) -> 'Model'"
)
assert str(signature(Model.from_config)) == (
"(config: 'dict | ModelConfiguration', *, validate_input: bool = True,"
- " base_dir: str | os.PathLike[str] | bytes | None = None) -> 'Model'"
+ " base_dir: str | os.PathLike[str] | bytes | None = None,"
+ " allow_blocking_problems: bool = False) -> 'Model'"
)
assert str(signature(Model.run)) == "(self) -> rubem.api.RunResult"
assert str(signature(Model.run_isolated)) == "(self) -> rubem.api.RunResult"
diff --git a/tests/unit/configuration/test_validation_tiers.py b/tests/unit/configuration/test_validation_tiers.py
index e66d1faa..0d73a509 100644
--- a/tests/unit/configuration/test_validation_tiers.py
+++ b/tests/unit/configuration/test_validation_tiers.py
@@ -1,5 +1,7 @@
"""End-to-end behaviour of the content validation through the loader."""
+import json
+import logging
import os
from pathlib import Path
@@ -273,3 +275,61 @@ def test_a_bad_table_blocks(self, config, text, description):
ModelConfiguration(config)
assert any(description in r for r in blocking_reasons(error.value))
+
+
+class TestAllowBlockingProblems:
+ """``allow_blocking_problems`` keeps the checks and their report, but not the exception."""
+
+ @pytest.mark.unit
+ def test_blocking_problems_are_kept_and_logged_as_errors(self, config, caplog):
+ with open(config["TABLES"]["t_sat"], "w", encoding="utf8") as f:
+ f.write("1 0\n")
+ os.remove(os.path.join(config["DIRECTORIES"]["prec"], series_name("prec", 2)))
+ os.remove(os.path.join(config["DIRECTORIES"]["ndvi"], series_name("ndvi", 2)))
+
+ with caplog.at_level(logging.WARNING, logger="rubem.configuration.model_configuration"):
+ loaded = ModelConfiguration(config, allow_blocking_problems=True)
+
+ blocking = [str(p) for p in loaded.problems if p.blocking]
+ assert any("non-positive" in reason for reason in blocking)
+ assert any("precipitation raster series is incomplete" in reason for reason in blocking)
+ assert any(
+ "ndvi raster series has gaps" in str(p) and not p.blocking for p in loaded.problems
+ )
+
+ errors = [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR]
+ warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
+ assert any("Tsat" in message and "non-positive" in message for message in errors)
+ assert any("precipitation raster series is incomplete" in message for message in errors)
+ assert errors[-1] == f"Simulation continues despite {len(blocking)} blocking problem(s)."
+ assert any("ndvi raster series has gaps" in message for message in warnings)
+ assert not any("ndvi raster series has gaps" in message for message in errors)
+
+ @pytest.mark.unit
+ def test_the_default_still_raises(self, config):
+ with open(config["TABLES"]["t_sat"], "w", encoding="utf8") as f:
+ f.write("1 0\n")
+
+ with pytest.raises(ConfigurationError):
+ ModelConfiguration(config, allow_blocking_problems=False)
+
+ @pytest.mark.unit
+ def test_nothing_is_reported_without_blocking_problems(self, config, caplog):
+ with caplog.at_level(logging.WARNING, logger="rubem.configuration.model_configuration"):
+ loaded = ModelConfiguration(config, allow_blocking_problems=True)
+
+ assert not any(problem.blocking for problem in loaded.problems)
+ assert not any(r.levelno == logging.ERROR for r in caplog.records)
+
+ @pytest.mark.unit
+ def test_load_passes_the_keyword_through(self, tmp_path, config):
+ with open(config["TABLES"]["t_sat"], "w", encoding="utf8") as f:
+ f.write("1 0\n")
+ path = tmp_path / "config.json"
+ path.write_text(json.dumps(config), encoding="utf8")
+
+ with pytest.raises(ConfigurationError):
+ ModelConfiguration.load(path)
+ loaded = ModelConfiguration.load(path, allow_blocking_problems=True)
+
+ assert any(problem.blocking for problem in loaded.problems)
diff --git a/tests/unit/test_cli_run.py b/tests/unit/test_cli_run.py
index 57203017..4dca6e61 100644
--- a/tests/unit/test_cli_run.py
+++ b/tests/unit/test_cli_run.py
@@ -8,7 +8,7 @@
from rubem import __release__
from rubem.cli import main
from rubem.configuration.app_settings import AppSettings
-from tests.helpers.synthetic import write_synthetic_dataset
+from tests.helpers.synthetic import series_name, write_synthetic_dataset
class SettingsOverride:
@@ -33,13 +33,26 @@ def get_setting(self, key):
return self.packaged.get_setting(key)
+@pytest.fixture(name="config")
+def config_fixture(tmp_path):
+ return write_synthetic_dataset(str(tmp_path))
+
+
@pytest.fixture(name="config_path")
-def config_path_fixture(tmp_path):
+def config_path_fixture(tmp_path, config):
path = tmp_path / "config.json"
- path.write_text(json.dumps(write_synthetic_dataset(str(tmp_path))), encoding="utf8")
+ path.write_text(json.dumps(config), encoding="utf8")
return path
+def drop_december_from_rainy_days(config):
+ """Leave the rainy days table without December: a blocking problem the
+ two-step run (January and February) never reads."""
+ with open(config["TABLES"]["rainydays"], "w", encoding="utf8") as f:
+ for month in range(1, 12):
+ f.write(f"{month}\t{10 + month % 3}\n")
+
+
@pytest.fixture(name="english_humanize")
def english_humanize_fixture():
"""Leave ``humanize`` in its default language whatever a test activated."""
@@ -185,6 +198,132 @@ def interrupt(self):
assert "Elapsed time:" in captured.out
+class TestCliAllowBlockingProblems:
+ """``--allow-blocking-problems`` reports the blocking problems and runs anyway."""
+
+ @pytest.mark.unit
+ def test_a_blocking_problem_stops_the_run_by_default(
+ self, config, config_path, capsys, restore_logging
+ ):
+ drop_december_from_rainy_days(config)
+
+ with pytest.raises(SystemExit) as error:
+ main(["run", "-c", str(config_path)])
+
+ captured = capsys.readouterr()
+ assert error.value.code == 1
+ assert "Invalid configuration: The configuration has 1 blocking problem(s):" in captured.err
+ assert "Rainy days lookup table does not cover every month" in captured.err
+ assert "Simulation started..." not in captured.out
+
+ @pytest.mark.unit
+ def test_the_option_logs_the_blocking_problems_as_errors_and_runs(
+ self, tmp_path, config, config_path, capsys, restore_logging
+ ):
+ drop_december_from_rainy_days(config)
+
+ main(["run", "-c", str(config_path), "--allow-blocking-problems"])
+
+ captured = capsys.readouterr()
+ assert "Loading configuration and validating inputs..." in captured.out
+ assert "Simulation started..." in captured.out
+ assert "Simulation finished successfully!" in captured.out
+ assert (tmp_path / "out" / "tss_itp.csv").is_file()
+ assert (
+ "[ERR] rubem.configuration.model_configuration: Configuration problem: "
+ "Rainy days lookup table does not cover every month.: Missing months: [12]."
+ ) in captured.err
+ assert (
+ "[ERR] rubem.configuration.model_configuration: "
+ "Simulation continues despite 1 blocking problem(s)."
+ ) in captured.err
+ assert "Invalid configuration" not in captured.err
+
+ @pytest.mark.unit
+ def test_the_option_stays_silent_without_blocking_problems(
+ self, config_path, capsys, restore_logging
+ ):
+ main(["run", "-c", str(config_path), "--allow-blocking-problems"])
+
+ captured = capsys.readouterr()
+ assert "Simulation finished successfully!" in captured.out
+ assert "[ERR]" not in captured.err
+ assert "continues despite" not in captured.err
+
+ @pytest.mark.unit
+ def test_a_forced_run_that_fails_exits_with_one(
+ self, config, config_path, capsys, restore_logging
+ ):
+ """A structural problem forced past validation fails inside the run."""
+ import os
+
+ os.remove(os.path.join(config["DIRECTORIES"]["prec"], series_name("prec", 2)))
+
+ with pytest.raises(SystemExit) as error:
+ main(["run", "-c", str(config_path), "--allow-blocking-problems"])
+
+ captured = capsys.readouterr()
+ assert error.value.code == 1
+ assert "precipitation raster series is incomplete" in captured.err
+ assert "Simulation continues despite 1 blocking problem(s)." in captured.err
+ assert "Simulation started..." in captured.out
+ assert "Simulation finished successfully!" not in captured.out
+ assert "RUBEM unexpectedly quit." in captured.err
+
+ @pytest.mark.unit
+ def test_a_configuration_that_does_not_parse_is_still_fatal(
+ self, tmp_path, capsys, restore_logging
+ ):
+ config_path = tmp_path / "config.json"
+ config_path.write_text(json.dumps({}), encoding="utf8")
+
+ with pytest.raises(SystemExit) as error:
+ main(["run", "-c", str(config_path), "--allow-blocking-problems"])
+
+ captured = capsys.readouterr()
+ assert error.value.code == 1
+ assert "Invalid configuration:" in captured.err
+ assert "Simulation started..." not in captured.out
+
+ @pytest.mark.unit
+ @pytest.mark.parametrize("skip", ["-s", "--skip-inputs-validation"])
+ def test_the_option_cannot_be_combined_with_skipping_the_validation(
+ self, config_path, capsys, restore_logging, skip
+ ):
+ with pytest.raises(SystemExit) as error:
+ main(["run", "-c", str(config_path), skip, "--allow-blocking-problems"])
+
+ captured = capsys.readouterr()
+ assert error.value.code == 2
+ assert "Usage: rubem run [OPTIONS]" in captured.err
+ assert (
+ "Invalid value for '--allow-blocking-problems': cannot be combined with "
+ "'-s' / '--skip-inputs-validation'"
+ ) in captured.err
+ assert "Loading configuration" not in captured.out
+
+ @pytest.mark.unit
+ def test_the_legacy_spelling_does_not_take_the_option(
+ self, config_path, capsys, restore_logging
+ ):
+ """``rubem -c --allow-blocking-problems`` is not mapped to ``run``."""
+ with pytest.raises(SystemExit) as error:
+ main(["-c", str(config_path), "--allow-blocking-problems"])
+
+ captured = capsys.readouterr()
+ assert error.value.code == 2
+ assert "No such option" in captured.err
+ assert "Loading configuration" not in captured.out
+
+ @pytest.mark.unit
+ def test_the_help_describes_the_option(self, capsys, restore_logging):
+ with pytest.raises(SystemExit) as error:
+ main(["run", "-h"])
+
+ assert error.value.code == 0
+ assert "--allow-blocking-problems" in capsys.readouterr().out
+
+
class TestCliArguments:
@pytest.mark.unit
@pytest.mark.parametrize("flag", ["--version", "-V"])