From d7aef5ee3e585576e9fa0153c6cc9cb9517928fa Mon Sep 17 00:00:00 2001 From: Gabriel Soares <70075435+soaressgabriel@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:29:22 -0300 Subject: [PATCH 1/4] Add the --allow-blocking-problems option to rubem run By default every check of the input validation runs, the problems are logged as warnings and a blocking one raises ConfigurationError, so the run stops with exit code 1; with -s the content checks do not run at all. Neither lets a user run past a blocking problem and still see it. ModelConfiguration takes allow_blocking_problems=False. With it the same checks run, every problem stays in ModelConfiguration.problems, the blocking ones are logged at ERR, a final ERR line states that the simulation continues despite N blocking problem(s), and the configuration loads instead of raising. ModelConfiguration.load passes it through. rubem run --allow-blocking-problems (no short form) hands the keyword through. It cannot be combined with -s, which skips the very checks the option exists to report: that is a usage error with exit code 2, raised before anything else runs. Failures that are not problems (schema validation, missing lookup table files, no raster format enabled, the DEM/clone/georeference mismatch) stay fatal, and a forced run that fails exits 1. The deprecated rubem -c spelling does not take the option. Tests: the loader keeps and reports the problems at both levels, the command line stops by default, runs with the option, refuses the combination with -s, refuses the option on the legacy spelling, and a forced run over a rainy days table without December (never read by the January and February run) reproduces the goldens. --- rubem/cli.py | 29 +++- rubem/configuration/model_configuration.py | 39 ++++- tests/integration/test_cli.py | 50 ++++++ .../configuration/test_validation_tiers.py | 60 ++++++++ tests/unit/test_cli_run.py | 145 +++++++++++++++++- 5 files changed, 308 insertions(+), 15 deletions(-) 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 2cda9ddb..146af32f 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 = [] @@ -123,7 +130,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.""" @@ -452,16 +459,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 " @@ -507,9 +521,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/configuration/test_validation_tiers.py b/tests/unit/configuration/test_validation_tiers.py index 5c695ae3..0a2b1aca 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 import numpy as np @@ -186,3 +188,61 @@ def test_skipping_validation_skips_the_content_checks(self, config): loaded = ModelConfiguration(config, validate_input=False) assert not any(problem.blocking for problem in loaded.problems) + + +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"]) From 3cedf2aef246d6df28345b9ad2cb9d7a1fee2e82 Mon Sep 17 00:00:00 2001 From: Gabriel Soares <70075435+soaressgabriel@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:35:09 -0300 Subject: [PATCH 2/4] Expose allow_blocking_problems on the Python API Model.from_file and Model.from_config take the keyword and hand it to ModelConfiguration on the same path as validate_input; Model keeps it next to the validation flag so that an isolated run rebuilds the configuration in the subprocess with the same leniency instead of raising ConfigurationError there. A configuration loaded elsewhere is not checked again: for it the keyword only rules the rebuild. The public signatures pinned by the tests gain the keyword; the child entry points of the isolated-run tests take the extra argument. --- rubem/api.py | 68 ++++++++++++++++++++++++++++-------- tests/unit/api/_child.py | 7 ++-- tests/unit/api/test_model.py | 63 +++++++++++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 20 deletions(-) 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/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" From 75d9408f1637afbc0ee0594ff5e7176fea0c0d36 Mon Sep 17 00:00:00 2001 From: Gabriel Soares <70075435+soaressgabriel@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:35:09 -0300 Subject: [PATCH 3/4] Document the --allow-blocking-problems option The user guide shows the option in the rubem run -h block, contrasts it with -s (checks skipped, nothing reported) and states the exit codes, the usage error of the combination, the failures that stay fatal and what a forced run prints. The Python API page describes the keyword of the loaders and that an isolated run carries it across the process boundary. --- doc/source/api.rst | 12 +++++++++++- doc/source/userguide.rst | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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/userguide.rst b/doc/source/userguide.rst index 58a58b09..677c54f7 100644 --- a/doc/source/userguide.rst +++ b/doc/source/userguide.rst @@ -1190,6 +1190,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. @@ -1247,3 +1250,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 From 3491ee8985545f17abba2c652e39f4677bdbffd7 Mon Sep 17 00:00:00 2001 From: Gabriel Soares <70075435+soaressgabriel@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:35:09 -0300 Subject: [PATCH 4/4] Record the --allow-blocking-problems option in the changelog --- doc/source/changelog.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index f3a56ff2..d41fafd6 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