From 1bfbb877c8f3c4119cc4f8fd5fa891b24969dc02 Mon Sep 17 00:00:00 2001 From: Laurent Courty Date: Sun, 21 Jun 2026 23:14:29 -0600 Subject: [PATCH 1/6] add tests for ConfigReader --- tests/grass/test_config.py | 206 +++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/grass/test_config.py diff --git a/tests/grass/test_config.py b/tests/grass/test_config.py new file mode 100644 index 0000000..a5ec21c --- /dev/null +++ b/tests/grass/test_config.py @@ -0,0 +1,206 @@ +"""Test the reading and parsing of the config file.""" + +from configparser import ConfigParser +from datetime import datetime, timedelta +import logging + +import pytest + +from itzi.configreader import ConfigReader +from itzi.const import InfiltrationModelType, TemporalType +from itzi.itzi_error import ItziFatal + + +def write_config_file(tmp_path, config_dict: dict[str, dict[str, str]]) -> str: + """Write a config dictionary to a temporary INI file.""" + parser = ConfigParser() + parser.read_dict(config_dict) + config_file = tmp_path / "config.ini" + with config_file.open("w") as file_obj: + parser.write(file_obj) + return str(config_file) + + +def make_config_dict( + *, + time: dict[str, str] | None = None, + input_maps: dict[str, str] | None = None, + output: dict[str, str] | None = None, + options: dict[str, str] | None = None, + drainage: dict[str, str] | None = None, + statistics: dict[str, str] | None = None, + grass: dict[str, str] | None = None, +) -> dict[str, dict[str, str]]: + """Create a minimal valid config dictionary for ConfigReader tests.""" + + config_dict = { + "time": time or {"duration": "00:01:00", "record_step": "00:00:30"}, + "input": input_maps or {"dem": "z", "friction": "n"}, + "output": output or {"prefix": "out", "values": "water_depth"}, + } + + if options: + config_dict["options"] = options + if drainage: + config_dict["drainage"] = drainage + if statistics: + config_dict["statistics"] = statistics + if grass: + config_dict["grass"] = grass + return config_dict + + +def test_reader_normalizes_deprecated_aliases(tmp_path, caplog): + config_file = write_config_file( + tmp_path, + make_config_dict( + input_maps={ + "dem": "z", + "friction": "n", + "start_h": "legacy_depth", + "drainage_capacity": "legacy_losses", + }, + output={"prefix": "legacy", "values": "h, drainage_cap"}, + ), + ) + + with caplog.at_level(logging.WARNING, logger="itzi"): + sim_config = ConfigReader(config_file).get_sim_params() + + assert sim_config.input_map_names["water_depth"] == "legacy_depth" + assert sim_config.input_map_names["losses"] == "legacy_losses" + assert sim_config.output_map_names["water_depth"] == "legacy_water_depth" + assert sim_config.output_map_names["mean_losses"] == "legacy_mean_losses" + + warning_messages = [record.message for record in caplog.records] + assert any("Input 'start_h' is deprecated" in message for message in warning_messages) + assert any( + "Input 'drainage_capacity' is deprecated" in message for message in warning_messages + ) + assert any("Output 'h' is deprecated" in message for message in warning_messages) + assert any("Output 'drainage_cap' is deprecated" in message for message in warning_messages) + + +@pytest.mark.parametrize( + ("time_section", "expected_temporal_type", "expected_start", "expected_end"), + [ + ( + {"duration": "01:00:00", "record_step": "00:10:00"}, + TemporalType.RELATIVE, + datetime.min, + datetime.min + timedelta(hours=1), + ), + ( + { + "start_time": "2025-01-02 03:04", + "duration": "01:30:00", + "record_step": "00:10:00", + }, + TemporalType.ABSOLUTE, + datetime(2025, 1, 2, 3, 4), + datetime(2025, 1, 2, 4, 34), + ), + ( + { + "start_time": "2025-01-02 03:04", + "end_time": "2025-01-02 04:34", + "record_step": "00:10:00", + }, + TemporalType.ABSOLUTE, + datetime(2025, 1, 2, 3, 4), + datetime(2025, 1, 2, 4, 34), + ), + ], +) +def test_reader_accepts_supported_time_combinations( + tmp_path, + time_section, + expected_temporal_type, + expected_start, + expected_end, +): + config_file = write_config_file(tmp_path, make_config_dict(time=time_section)) + + sim_config = ConfigReader(config_file).get_sim_params() + + assert sim_config.temporal_type == expected_temporal_type + assert sim_config.start_time == expected_start + assert sim_config.end_time == expected_end + assert sim_config.record_step == timedelta(minutes=10) + + +@pytest.mark.parametrize( + "time_section", + [ + { + "end_time": "2025-01-02 04:34", + "duration": "01:30:00", + "record_step": "00:10:00", + }, + {"start_time": "2025-01-02 03:04", "record_step": "00:10:00"}, + { + "start_time": "2025-01-02 03:04", + "end_time": "2025-01-02 04:34", + "duration": "01:30:00", + "record_step": "00:10:00", + }, + ], +) +def test_reader_rejects_invalid_time_combinations(tmp_path, time_section): + config_file = write_config_file(tmp_path, make_config_dict(time=time_section)) + + with pytest.raises(ItziFatal, match="accepted combinations"): + ConfigReader(config_file) + + +def test_reader_rejects_mutually_exclusive_initial_conditions(tmp_path): + config_file = write_config_file( + tmp_path, + make_config_dict( + input_maps={ + "dem": "z", + "friction": "n", + "water_depth": "start_h", + "water_surface_elevation": "start_wse", + } + ), + ) + + with pytest.raises(ItziFatal, match="mutually exclusive"): + ConfigReader(config_file) + + +def test_reader_infers_green_ampt_model_from_complete_parameter_set(tmp_path): + config_file = write_config_file( + tmp_path, + make_config_dict( + input_maps={ + "dem": "z", + "friction": "n", + "effective_porosity": "porosity", + "capillary_pressure": "pressure", + "hydraulic_conductivity": "conductivity", + } + ), + ) + + sim_config = ConfigReader(config_file).get_sim_params() + + assert sim_config.infiltration_model == InfiltrationModelType.GREEN_AMPT + + +def test_reader_requires_all_green_ampt_maps(tmp_path): + config_file = write_config_file( + tmp_path, + make_config_dict( + input_maps={ + "dem": "z", + "friction": "n", + "effective_porosity": "porosity", + "capillary_pressure": "pressure", + } + ), + ) + + with pytest.raises(ItziFatal, match="mutualy inclusive"): + ConfigReader(config_file) From aace37c1a6cd285f4e2baa71bd4ed06c44be7894 Mon Sep 17 00:00:00 2001 From: Laurent Courty Date: Sun, 21 Jun 2026 23:55:38 -0600 Subject: [PATCH 2/6] example.ini: change superficial to surface --- src/itzi/data/example.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/itzi/data/example.ini b/src/itzi/data/example.ini index d662af9..7349f0b 100644 --- a/src/itzi/data/example.ini +++ b/src/itzi/data/example.ini @@ -62,7 +62,7 @@ cfl = 0.7 theta = 0.9 # Rain routing velocity in m/s vrouting = 0.1 -# Maximum superficial flow time-step in seconds +# Maximum surface flow time-step in seconds dtmax = 5 # Infiltration time-step in seconds dtinf = 60 From 26d38bf42e54b4f87029a336ef7120860414a117 Mon Sep 17 00:00:00 2001 From: Laurent Courty Date: Sun, 21 Jun 2026 23:56:48 -0600 Subject: [PATCH 3/6] add raw input data model, move deprecated mapping tto modul level --- src/itzi/configreader.py | 119 ++++++++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 20 deletions(-) diff --git a/src/itzi/configreader.py b/src/itzi/configreader.py index 2c8985b..7381977 100644 --- a/src/itzi/configreader.py +++ b/src/itzi/configreader.py @@ -1,5 +1,5 @@ """ -Copyright (C) 2015-2025 Laurent Courty +Copyright (C) 2015-2026 Laurent Courty This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License @@ -17,12 +17,108 @@ from configparser import ConfigParser from datetime import datetime, timedelta +from pydantic import BaseModel + import itzi.messenger as msgr from itzi.const import DefaultValues, TemporalType, InfiltrationModelType from itzi.array_definitions import ARRAY_DEFINITIONS, ArrayCategory from itzi.data_containers import SurfaceFlowParameters, SimulationConfig, GrassParams +DEPRECATED_INPUT_ALIASES: list[tuple[str, str]] = [ + # (old, new) + ("drainage_capacity", "losses"), + ("effective_pororosity", "effective_porosity"), + ("start_h", "water_depth"), +] + +DEPRECATED_OUTPUT_ALIASES: list[tuple[str, str]] = [ + # (old,new) + ("drainage_cap", "mean_losses"), + ("h", "water_depth"), + ("wse", "water_surface_elevation"), + ("boundaries", "mean_boundary_flow"), + ("verror", "volume_error"), + ("inflow", "mean_inflow"), + ("infiltration", "mean_infiltration"), + ("rainfall", "mean_rainfall"), + ("losses", "mean_losses"), + ("drainage_stats", "mean_drainage_flow"), +] + + +class RawTimeConfig(BaseModel): + """Stores raw time options from the config file.""" + + start_time: datetime | None + end_time: datetime | None + duration: timedelta | None + record_step: timedelta + + +class RawInputConfig(BaseModel): + """Stores raw input options from the config file.""" + + dem: str + friction: str + water_depth: str + water_surface_elevation: str + + rain: str + inflow: str + bctype: str + bcval: str + + infiltration: str + effective_porosity: str + capillary_pressure: str + hydraulic_conductivity: str + soil_water_content: str + + losses: str + + +class RawOutputConfig(BaseModel): + """Stores raw output options from the config file.""" + + prefix: str + values: list[str] + + +class RawOptionsConfig(BaseModel): + """Stores raw options data from the config file.""" + + hmin: float = DefaultValues.HFMIN + slmax: float = DefaultValues.MAX_SLOPE + cfl: float = DefaultValues.CFL + theta: float = DefaultValues.THETA + vrouting: float = DefaultValues.VROUTING + dtmax: float = DefaultValues.DTMAX + dtinf: float = DefaultValues.DTINF + max_error: float = DefaultValues.MAX_ERROR + + +class RawDrainageConfig(BaseModel): + """Stores raw drainage options from the config file.""" + + swmm_inp: str + output: str + orifice_coeff: float = DefaultValues.ORIFICE_COEFF + free_weir_coeff: float = DefaultValues.FREE_WEIR_COEFF + submerged_weir_coeff: float = DefaultValues.SUBMERGED_WEIR_COEFF + + +class RawGrassConfig(BaseModel): + """Stores raw grass options from the config file.""" + + grass_bin: str + grassdata: str + location: str + mapset: str + region: str + mask: str + + class ConfigReader: """Parse the configuration file and check validity of given options""" @@ -117,12 +213,7 @@ def read_param_file(self): if params.has_option("grass", k): self._grass_params[k] = params.get("grass", k) # check for deprecated input names - input_deprecated_list = [ # (old, new) - ("drainage_capacity", "losses"), - ("effective_pororosity", "effective_porosity"), - ("start_h", "water_depth"), - ] - for old_input_name, new_input_name in input_deprecated_list: + for old_input_name, new_input_name in DEPRECATED_INPUT_ALIASES: if params.has_option("input", old_input_name): msgr.warning( f"Input '{old_input_name}' is deprecated. Use '{new_input_name}' instead." @@ -153,19 +244,7 @@ def read_param_file(self): out_values = params.get("output", "values").split(",") self.out_values = [e.strip() for e in out_values] # check for deprecated values - output_deprecated_list = [ # (old,new) - ("drainage_cap", "mean_losses"), - ("h", "water_depth"), - ("wse", "water_surface_elevation"), - ("boundaries", "mean_boundary_flow"), - ("verror", "volume_error"), - ("inflow", "mean_inflow"), - ("infiltration", "mean_infiltration"), - ("rainfall", "mean_rainfall"), - ("losses", "mean_losses"), - ("drainage_stats", "mean_drainage_flow"), - ] - for old_output_name, new_output_name in output_deprecated_list: + for old_output_name, new_output_name in DEPRECATED_OUTPUT_ALIASES: if old_output_name in self.out_values and new_output_name not in self.out_values: msgr.warning( f"Output '{old_output_name}' is deprecated. " From 03711e2fa38da99f1f5986b7e0280fa0e9b7b1cb Mon Sep 17 00:00:00 2001 From: Laurent Courty Date: Mon, 22 Jun 2026 01:31:30 -0600 Subject: [PATCH 4/6] refactor configreader --- src/itzi/configreader.py | 748 +++++++++--------- .../test_configreader.py} | 0 2 files changed, 352 insertions(+), 396 deletions(-) rename tests/{grass/test_config.py => core/test_configreader.py} (100%) diff --git a/src/itzi/configreader.py b/src/itzi/configreader.py index 7381977..f013a50 100644 --- a/src/itzi/configreader.py +++ b/src/itzi/configreader.py @@ -12,18 +12,18 @@ GNU General Public License for more details. """ -from __future__ import division -from __future__ import absolute_import +from __future__ import annotations + from configparser import ConfigParser from datetime import datetime, timedelta +from typing import Any, Callable, NoReturn -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, ValidationError import itzi.messenger as msgr -from itzi.const import DefaultValues, TemporalType, InfiltrationModelType from itzi.array_definitions import ARRAY_DEFINITIONS, ArrayCategory -from itzi.data_containers import SurfaceFlowParameters, SimulationConfig, GrassParams - +from itzi.const import InfiltrationModelType, TemporalType +from itzi.data_containers import GrassParams, SimulationConfig, SurfaceFlowParameters DEPRECATED_INPUT_ALIASES: list[tuple[str, str]] = [ # (old, new) @@ -46,435 +46,391 @@ ("drainage_stats", "mean_drainage_flow"), ] +TIME_DATE_FORMAT = "%Y-%m-%d %H:%M" +RELATIVE_TIME_ERROR = "{}: invalid format (should be HH:MM:SS)" +ABSOLUTE_TIME_ERROR = "{}: invalid format (should be yyyy-mm-dd HH:MM)" +TIME_COMBINATION_ERROR = ( + "accepted combinations: duration alone, start_time and duration, start_time and end_time" +) + +TIME_OPTION_KEYS = ("start_time", "end_time", "duration", "record_step") +GREEN_AMPT_KEYS = ( + "effective_porosity", + "capillary_pressure", + "hydraulic_conductivity", +) +GRASS_MANDATORY_KEYS = ("grassdata", "location", "mapset") +GRASS_OPTION_KEYS = (*GRASS_MANDATORY_KEYS, "region", "mask", "grass_bin") +INPUT_MAP_KEYS = tuple( + arr_def.key for arr_def in ARRAY_DEFINITIONS if ArrayCategory.INPUT in arr_def.category +) +OUTPUT_MAP_KEYS = tuple( + arr_def.key for arr_def in ARRAY_DEFINITIONS if ArrayCategory.OUTPUT in arr_def.category +) +SURFACE_FLOW_OPTION_KEYS = tuple(SurfaceFlowParameters.model_fields) +SIMULATION_OPTION_KEYS = ("dtinf",) +DRAINAGE_STRING_KEYS = ("swmm_inp", "output") +DRAINAGE_FLOAT_KEYS = ("orifice_coeff", "free_weir_coeff", "submerged_weir_coeff") + + +def _read_parser(filename: str) -> ConfigParser: + """Read the INI file or fail fast if it is missing.""" + params = ConfigParser(allow_no_value=True) + if not params.read(filename): + msgr.fatal(f"File <{filename}> not found") + return params + + +def _read_optional_value( + params: ConfigParser, + section: str, + option: str, + reader: Callable[[str, str], Any], +) -> Any | None: + """Read one config value when the option exists.""" + if not params.has_option(section, option): + return None + return reader(section, option) + + +def _read_string_options( + params: ConfigParser, section: str, option_names: tuple[str, ...] +) -> dict[str, str]: + """Collect the string-valued options present in a section.""" + values: dict[str, str] = {} + for option_name in option_names: + value = _read_optional_value(params, section, option_name, params.get) + if value is not None: + values[option_name] = value + return values + + +def _read_float_options( + params: ConfigParser, section: str, option_names: tuple[str, ...] +) -> dict[str, float]: + """Collect the float-valued options present in a section.""" + values: dict[str, float] = {} + for option_name in option_names: + value = _read_optional_value(params, section, option_name, params.getfloat) + if value is not None: + values[option_name] = value + return values + + +def _fatal_validation_error(error: ValidationError) -> NoReturn: + """Convert a Pydantic validation error into an Itzi fatal error.""" + messages = [] + for item in error.errors(include_url=False): + location = ".".join(str(part) for part in item["loc"]) + if location: + messages.append(f"{location}: {item['msg']}") + else: + messages.append(str(item["msg"])) + msgr.fatal("; ".join(messages)) + raise AssertionError("unreachable") + + +def _warn_about_deprecated_alias(alias_kind: str, old_name: str, new_name: str) -> None: + """Emit a warning for a deprecated configuration alias.""" + msgr.warning(f"{alias_kind} '{old_name}' is deprecated. Use '{new_name}' instead.") + + +def _read_time_values(params: ConfigParser) -> dict[str, str | None]: + """Read raw time values from the config file.""" + time_values: dict[str, str | None] = dict.fromkeys(TIME_OPTION_KEYS) + time_values.update(_read_string_options(params, "time", TIME_OPTION_KEYS)) + return time_values -class RawTimeConfig(BaseModel): - """Stores raw time options from the config file.""" - start_time: datetime | None - end_time: datetime | None - duration: timedelta | None - record_step: timedelta +def _read_input_map_names(params: ConfigParser) -> dict[str, str | None]: + """Read input map names and normalize deprecated aliases.""" + map_names: dict[str, str | None] = dict.fromkeys(INPUT_MAP_KEYS) + for old_input_name, new_input_name in DEPRECATED_INPUT_ALIASES: + if params.has_option("input", old_input_name): + _warn_about_deprecated_alias("Input", old_input_name, new_input_name) + map_names[new_input_name] = params.get("input", old_input_name) -class RawInputConfig(BaseModel): - """Stores raw input options from the config file.""" + for input_name in INPUT_MAP_KEYS: + if params.has_option("input", input_name): + map_names[input_name] = params.get("input", input_name) - dem: str - friction: str - water_depth: str - water_surface_elevation: str + return map_names - rain: str - inflow: str - bctype: str - bcval: str - infiltration: str - effective_porosity: str - capillary_pressure: str - hydraulic_conductivity: str - soil_water_content: str +def _normalize_output_values(raw_values: str | None) -> list[str]: + """Normalize requested output names and deprecated aliases.""" + if raw_values is None: + return [] - losses: str + output_values = [value.strip() for value in raw_values.split(",") if value.strip()] + for old_output_name, new_output_name in DEPRECATED_OUTPUT_ALIASES: + if old_output_name in output_values and new_output_name not in output_values: + _warn_about_deprecated_alias("Output", old_output_name, new_output_name) + output_values.append(new_output_name) + return output_values -class RawOutputConfig(BaseModel): - """Stores raw output options from the config file.""" +def _generate_output_map_names(prefix: str, output_values: list[str]) -> dict[str, str | None]: + """Build the output map dictionary from the selected outputs.""" + output_map_names: dict[str, str | None] = dict.fromkeys(OUTPUT_MAP_KEYS) + for value in output_values: + if value in output_map_names: + output_map_names[value] = f"{prefix}_{value}" + return output_map_names - prefix: str - values: list[str] +def _read_output_config(params: ConfigParser) -> tuple[str, list[str], dict[str, str | None]]: + """Read output settings and derive output map names.""" + prefix = _read_optional_value(params, "output", "prefix", params.get) + if prefix is None: + prefix = f"itzi_results_{datetime.now().strftime('%Y%m%dT%H%M%S')}" + output_values = _normalize_output_values( + _read_optional_value(params, "output", "values", params.get) + ) + output_map_names = _generate_output_map_names(prefix, output_values) + return prefix, output_values, output_map_names -class RawOptionsConfig(BaseModel): - """Stores raw options data from the config file.""" - hmin: float = DefaultValues.HFMIN - slmax: float = DefaultValues.MAX_SLOPE - cfl: float = DefaultValues.CFL - theta: float = DefaultValues.THETA - vrouting: float = DefaultValues.VROUTING - dtmax: float = DefaultValues.DTMAX - dtinf: float = DefaultValues.DTINF - max_error: float = DefaultValues.MAX_ERROR +def _read_surface_flow_parameters(params: ConfigParser) -> SurfaceFlowParameters: + """Build validated surface flow parameters from config options.""" + surface_flow_values = _read_float_options(params, "options", SURFACE_FLOW_OPTION_KEYS) + try: + return SurfaceFlowParameters(**surface_flow_values) + except ValidationError as error: + _fatal_validation_error(error) + + +def _read_simulation_option_values(params: ConfigParser) -> dict[str, float]: + """Read simulation options that live outside the surface flow model.""" + return _read_float_options(params, "options", SIMULATION_OPTION_KEYS) + + +def _read_simulation_drainage_values(params: ConfigParser) -> dict[str, str | float]: + """Read drainage settings using SimulationConfig field names.""" + drainage_values: dict[str, str | float] = {} + + for option_name in DRAINAGE_STRING_KEYS: + value = _read_optional_value(params, "drainage", option_name, params.get) + if value is None: + continue + if option_name == "output": + drainage_values["drainage_output"] = value + else: + drainage_values[option_name] = value + drainage_values.update(_read_float_options(params, "drainage", DRAINAGE_FLOAT_KEYS)) + return drainage_values -class RawDrainageConfig(BaseModel): - """Stores raw drainage options from the config file.""" - swmm_inp: str - output: str - orifice_coeff: float = DefaultValues.ORIFICE_COEFF - free_weir_coeff: float = DefaultValues.FREE_WEIR_COEFF - submerged_weir_coeff: float = DefaultValues.SUBMERGED_WEIR_COEFF +def _read_grass_params(params: ConfigParser) -> GrassParams: + """Build GRASS session parameters from the config file.""" + return GrassParams(**_read_string_options(params, "grass", GRASS_OPTION_KEYS)) -class RawGrassConfig(BaseModel): - """Stores raw grass options from the config file.""" +def _build_simulation_config(**kwargs: Any) -> SimulationConfig: + """Build a validated simulation config from normalized values.""" + try: + return SimulationConfig(**kwargs) + except ValidationError as error: + _fatal_validation_error(error) - grass_bin: str - grassdata: str - location: str - mapset: str - region: str - mask: str + +def _legacy_drainage_params(sim_config: SimulationConfig) -> dict[str, str | float | None]: + """Expose drainage settings in the legacy dictionary shape.""" + return { + "swmm_inp": sim_config.swmm_inp, + "output": sim_config.drainage_output, + "orifice_coeff": sim_config.orifice_coeff, + "free_weir_coeff": sim_config.free_weir_coeff, + "submerged_weir_coeff": sim_config.submerged_weir_coeff, + } + + +class SimulationTimes(BaseModel): + """Parsed and validated simulation time settings.""" + + model_config = ConfigDict(frozen=True) + + start: datetime + end: datetime + duration: timedelta + record_step: timedelta | None + temporal_type: TemporalType + + @classmethod + def from_raw_values(cls, raw_values: dict[str, str | None]) -> SimulationTimes: + """Parse and validate the configured simulation times.""" + temporal_type = cls._resolve_temporal_type(raw_values) + duration = cls._parse_timedelta(raw_values["duration"]) + record_step = cls._parse_timedelta(raw_values["record_step"]) + start = cls._parse_datetime(raw_values["start_time"]) + end = cls._parse_datetime(raw_values["end_time"]) + + if start is None: + start = datetime.min + if end is None: + end = start + duration + if start >= end: + msgr.fatal("Simulation duration must be positive") + if duration is None: + duration = end - start + + return cls( + start=start, + end=end, + duration=duration, + record_step=record_step, + temporal_type=temporal_type, + ) + + @staticmethod + def _resolve_temporal_type(raw_values: dict[str, str | None]) -> TemporalType: + """Infer whether the simulation uses relative or absolute time.""" + has_duration_only = ( + raw_values["duration"] is not None + and raw_values["start_time"] is None + and raw_values["end_time"] is None + ) + has_start_and_duration = ( + raw_values["start_time"] is not None + and raw_values["duration"] is not None + and raw_values["end_time"] is None + ) + has_start_and_end = ( + raw_values["start_time"] is not None + and raw_values["end_time"] is not None + and raw_values["duration"] is None + ) + if not (has_duration_only or has_start_and_duration or has_start_and_end): + msgr.fatal(TIME_COMBINATION_ERROR) + if has_duration_only: + return TemporalType.RELATIVE + return TemporalType.ABSOLUTE + + @staticmethod + def _parse_timedelta(value: str | None) -> timedelta | None: + """Parse a `HH:MM:SS` string into a `timedelta`.""" + if value is None: + return None + try: + hours_str, minutes_str, seconds_str = value.split(":") + hours = int(hours_str) + minutes = int(minutes_str) + seconds = int(seconds_str) + except (TypeError, ValueError): + msgr.fatal(RELATIVE_TIME_ERROR.format(value)) + + if hours < 0 or not 0 <= minutes <= 59 or not 0 <= seconds <= 59: + msgr.fatal(RELATIVE_TIME_ERROR.format(value)) + return timedelta(hours=hours, minutes=minutes, seconds=seconds) + + @staticmethod + def _parse_datetime(value: str | None) -> datetime | None: + """Parse an absolute simulation timestamp.""" + if value is None: + return None + try: + return datetime.strptime(value, TIME_DATE_FORMAT) + except ValueError: + msgr.fatal(ABSOLUTE_TIME_ERROR.format(value)) class ConfigReader: - """Parse the configuration file and check validity of given options""" + """Parse an INI file and expose validated simulation parameters.""" - def __init__(self, filename): + def __init__(self, filename: str | None) -> None: + """Read, normalize, and validate a simulation config file.""" if filename is None: msgr.fatal("Not a valid configuration file") + self.config_file = filename - # default values to be passed to simulation - self.__set_defaults() - # read entry values - self.set_entry_values() - - def __set_defaults(self): - """Set dictionaries of default values to be passed to simulation""" - k_raw_input_times = ["start_time", "end_time", "duration", "record_step"] - self.ga_list = [ - "effective_porosity", - "capillary_pressure", - "hydraulic_conductivity", - ] - k_input_map_names = [ - arr_def.key for arr_def in ARRAY_DEFINITIONS if ArrayCategory.INPUT in arr_def.category - ] - k_output_map_names = [ - arr_def.key - for arr_def in ARRAY_DEFINITIONS - if ArrayCategory.OUTPUT in arr_def.category - ] - self.drainage_params = { - "swmm_inp": None, - "output": None, - "orifice_coeff": DefaultValues.ORIFICE_COEFF, - "free_weir_coeff": DefaultValues.FREE_WEIR_COEFF, - "submerged_weir_coeff": DefaultValues.SUBMERGED_WEIR_COEFF, + self.ga_list = list(GREEN_AMPT_KEYS) + self.grass_mandatory = list(GRASS_MANDATORY_KEYS) + + params = _read_parser(filename) + self.raw_input_times = _read_time_values(params) + self.input_map_names = _read_input_map_names(params) + self.out_prefix, self.out_values, self.output_map_names = _read_output_config(params) + self.sim_times = SimulationTimes.from_raw_values(self.raw_input_times) + self._check_general_input(self.input_map_names) + infiltration_model = self._resolve_infiltration_model(self.input_map_names) + + self.grass_params = _read_grass_params(params) + self._check_grass_params(self.grass_params) + + surface_flow_parameters = _read_surface_flow_parameters(params) + assert self.sim_times.record_step is not None + + simulation_kwargs: dict[str, Any] = { + "start_time": self.sim_times.start, + "end_time": self.sim_times.end, + "record_step": self.sim_times.record_step, + "temporal_type": self.sim_times.temporal_type, + "input_map_names": self.input_map_names, + "output_map_names": self.output_map_names, + "surface_flow_parameters": surface_flow_parameters, + "infiltration_model": infiltration_model, } + + stats_file = _read_optional_value(params, "statistics", "stats_file", params.get) + if stats_file is not None: + simulation_kwargs["stats_file"] = stats_file + + simulation_kwargs.update(_read_simulation_option_values(params)) + simulation_kwargs.update(_read_simulation_drainage_values(params)) + + self.sim_config = _build_simulation_config(**simulation_kwargs) + self.stats_file = self.sim_config.stats_file + self.drainage_params = _legacy_drainage_params(self.sim_config) + self._grass_params = self.grass_params.model_dump() self.sim_param = { - "hmin": DefaultValues.HFMIN, - "cfl": DefaultValues.CFL, - "theta": DefaultValues.THETA, - "g": DefaultValues.G, - "vrouting": DefaultValues.VROUTING, - "dtmax": DefaultValues.DTMAX, - "slope_threshold": DefaultValues.SLOPE_THRESHOLD, - "max_slope": DefaultValues.MAX_SLOPE, - "dtinf": DefaultValues.DTINF, - "max_error": DefaultValues.MAX_ERROR, - "inf_model": InfiltrationModelType.NULL, + **self.sim_config.surface_flow_parameters.model_dump(), + "dtinf": self.sim_config.dtinf, + "inf_model": self.sim_config.infiltration_model, } - self.grass_mandatory = ["grassdata", "location", "mapset"] - k_grass_params = self.grass_mandatory + ["region", "mask", "grass_bin"] - self.raw_input_times = dict.fromkeys(k_raw_input_times) - self.output_map_names = dict.fromkeys(k_output_map_names) - self.input_map_names = dict.fromkeys(k_input_map_names) - self._grass_params = dict.fromkeys(k_grass_params) - self.out_prefix = f"itzi_results_{datetime.now().strftime('%Y%m%dT%H%M%S')}" - self.stats_file = None - return self - - def set_entry_values(self): - """Read and check entry values""" - # read file and populate dictionaries - self.read_param_file() - # process inputs times - self.sim_times = SimulationTimes(self.raw_input_times) - # check if mandatory parameters are present - self.check_general_input() - # check coherence of infiltrations entries - self.check_inf_maps() - # check the sanity of simulation parameters - self.check_sim_params() - # check the sanity of GRASS parameters - self.check_grass_params() - return self - - def read_param_file(self): - """Read the parameter file and populate the relevant dictionaries""" - self.out_values = [] - # read the file - params = ConfigParser(allow_no_value=True) - f = params.read(self.config_file) - if not f: - msgr.fatal("File <{}> not found".format(self.config_file)) - # populate dictionaries using loops instead of using update() method - # in order to not add invalid key - for k in self.raw_input_times: - if params.has_option("time", k): - self.raw_input_times[k] = params.get("time", k) - for k in self.sim_param: - if params.has_option("options", k): - self.sim_param[k] = params.getfloat("options", k) - for k in self._grass_params: - if params.has_option("grass", k): - self._grass_params[k] = params.get("grass", k) - # check for deprecated input names - for old_input_name, new_input_name in DEPRECATED_INPUT_ALIASES: - if params.has_option("input", old_input_name): - msgr.warning( - f"Input '{old_input_name}' is deprecated. Use '{new_input_name}' instead." - ) - self.input_map_names[new_input_name] = params.get("input", old_input_name) - - # search for valid inputs - for k in self.input_map_names: - if params.has_option("input", k): - self.input_map_names[k] = params.get("input", k) - - # drainage parameters - for k in self.drainage_params: - if params.has_option("drainage", k): - if k in ["swmm_inp", "output"]: - self.drainage_params[k] = params.get("drainage", k) - else: - self.drainage_params[k] = params.getfloat("drainage", k) - # statistic file - if params.has_option("statistics", "stats_file"): - self.stats_file = params.get("statistics", "stats_file") - else: - self.stats_file = None - # output maps - if params.has_option("output", "prefix"): - self.out_prefix = params.get("output", "prefix") - if params.has_option("output", "values"): - out_values = params.get("output", "values").split(",") - self.out_values = [e.strip() for e in out_values] - # check for deprecated values - for old_output_name, new_output_name in DEPRECATED_OUTPUT_ALIASES: - if old_output_name in self.out_values and new_output_name not in self.out_values: - msgr.warning( - f"Output '{old_output_name}' is deprecated. " - f"Use '{new_output_name}' instead." - ) - self.out_values.append(new_output_name) - self.generate_output_name() - return self - - def generate_output_name(self): - """Generate the name of the strds""" - for v in self.out_values: - if v in self.output_map_names: - self.output_map_names[v] = "{}_{}".format(self.out_prefix, v) - return self - - def check_sim_params(self): - """Check if the simulations parameters are positives and valid""" - for k, v in self.sim_param.items(): - if k == "theta": - if not 0 <= v <= 1: - msgr.fatal("{} value must be between 0 and 1".format(k)) - elif k == "inf_model": - continue - else: - if not v > 0: - msgr.fatal("{} value must be positive".format(k)) - - def check_grass_params(self): - """Check if all grass params are presents if one is given""" - grass_any = any(self._grass_params[i] for i in self.grass_mandatory) - grass_all = all(self._grass_params[i] for i in self.grass_mandatory) + + def _check_grass_params(self, grass_params: GrassParams) -> None: + """Ensure mandatory GRASS settings are provided together.""" + grass_values = grass_params.model_dump() + grass_any = any(grass_values[key] for key in self.grass_mandatory) + grass_all = all(grass_values[key] for key in self.grass_mandatory) if grass_any and not grass_all: - msgr.fatal("{} are mutualy inclusive".format(self.grass_mandatory)) - return self - - def check_inf_maps(self): - """check coherence of input infiltration maps - set infiltration model type - """ - inf_k = "infiltration" - # if at least one Green-Ampt parameters is present - ga_any = any(self.input_map_names[i] for i in self.ga_list) - # if all Green-Ampt parameters are present - ga_all = all(self.input_map_names[i] for i in self.ga_list) - # verify parameters - if not self.input_map_names[inf_k] and not ga_any: - self.sim_param["inf_model"] = InfiltrationModelType.NULL - elif self.input_map_names[inf_k] and not ga_any: - self.sim_param["inf_model"] = InfiltrationModelType.CONSTANT - elif self.input_map_names[inf_k] and ga_any: + msgr.fatal(f"{self.grass_mandatory} are mutualy inclusive") + + def _resolve_infiltration_model( + self, input_map_names: dict[str, str | None] + ) -> InfiltrationModelType: + """Infer the infiltration model from the configured input maps.""" + infiltration_input = input_map_names["infiltration"] + ga_any = any(input_map_names[key] for key in self.ga_list) + ga_all = all(input_map_names[key] for key in self.ga_list) + + if not infiltration_input and not ga_any: + return InfiltrationModelType.NULL + if infiltration_input and not ga_any: + return InfiltrationModelType.CONSTANT + if infiltration_input and ga_any: msgr.fatal("Infiltration model incompatible with user-defined rate") - # check if all maps for Green-Ampt are presents - elif ga_any and not ga_all: - msgr.fatal("{} are mutualy inclusive".format(self.ga_list)) - elif ga_all and not self.input_map_names[inf_k]: - self.sim_param["inf_model"] = InfiltrationModelType.GREEN_AMPT - return self - - def check_general_input(self): - """check if mandatory parameters are present. - And if mutually exclusive parameters are not set together.""" + if ga_any and not ga_all: + msgr.fatal(f"{self.ga_list} are mutualy inclusive") + return InfiltrationModelType.GREEN_AMPT + + def _check_general_input(self, input_map_names: dict[str, str | None]) -> None: + """Validate mandatory and mutually exclusive input maps.""" if not all( - [ - self.input_map_names["dem"], - self.input_map_names["friction"], - self.sim_times.record_step, - ] + [input_map_names["dem"], input_map_names["friction"], self.sim_times.record_step] ): msgr.fatal("inputs , and are mandatory") - if self.input_map_names["water_depth"] and self.input_map_names["water_surface_elevation"]: + if input_map_names["water_depth"] and input_map_names["water_surface_elevation"]: msgr.fatal( "inputs and are mutually exclusive." ) def get_sim_params(self) -> SimulationConfig: - """Return a SimulationConfig object containing all simulation parameters""" - surface_params = SurfaceFlowParameters( - hmin=self.sim_param["hmin"], - cfl=self.sim_param["cfl"], - theta=self.sim_param["theta"], - g=self.sim_param["g"], - vrouting=self.sim_param["vrouting"], - dtmax=self.sim_param["dtmax"], - max_slope=self.sim_param["max_slope"], - slope_threshold=self.sim_param["slope_threshold"], - max_error=self.sim_param["max_error"], - ) - sim_config = SimulationConfig( - start_time=self.sim_times.start, - end_time=self.sim_times.end, - record_step=self.sim_times.record_step, - temporal_type=self.sim_times.temporal_type, - input_map_names=self.input_map_names, - output_map_names=self.output_map_names, - stats_file=self.stats_file, - surface_flow_parameters=surface_params, - dtinf=self.sim_param["dtinf"], - infiltration_model=self.sim_param["inf_model"], - swmm_inp=self.drainage_params["swmm_inp"], - drainage_output=self.drainage_params["output"], - orifice_coeff=self.drainage_params["orifice_coeff"], - free_weir_coeff=self.drainage_params["free_weir_coeff"], - submerged_weir_coeff=self.drainage_params["submerged_weir_coeff"], - ) - return sim_config + """Return validated simulation parameters.""" + return self.sim_config def get_grass_params(self) -> GrassParams: - """Return a GrassParams object""" - return GrassParams( - grassdata=self._grass_params["grassdata"], - location=self._grass_params["location"], - mapset=self._grass_params["mapset"], - region=self._grass_params["region"], - mask=self._grass_params["mask"], - grass_bin=self._grass_params["grass_bin"], - ) - - -class SimulationTimes: - """Store the information about simulation start & end time and duration""" - - def __init__(self, raw_input_times): - self.read_simulation_times(raw_input_times) - - def read_simulation_times(self, raw_input_times): - """Read a given dictionary of input times. - Check the coherence of the input and store it in the object - """ - self.raw_duration = raw_input_times["duration"] - self.raw_start = raw_input_times["start_time"] - self.raw_end = raw_input_times["end_time"] - self.raw_record_step = raw_input_times["record_step"] - - self.date_format = "%Y-%m-%d %H:%M" - self.rel_err_msg = "{}: invalid format (should be HH:MM:SS)" - self.abs_err_msg = "{}: invalid format (should be yyyy-mm-dd HH:MM)" - - # check if the given times are coherent - self.check_combination() - - # transform duration and record_step to timedelta object - self.duration = self.read_timedelta(self.raw_duration) - self.record_step = self.read_timedelta(self.raw_record_step) - - # transform start and end to datetime object - self.start = self.read_datetime(self.raw_start) - self.end = self.read_datetime(self.raw_end) - - # check coherence of the properties - self.check_coherence() - - # make sure everything went fine - assert isinstance(self.end, datetime) - assert isinstance(self.start, datetime) - assert isinstance(self.duration, timedelta) - assert self.end >= self.start - assert self.duration == (self.end - self.start) - - return self - - def str_to_timedelta(self, inp_str): - """Takes a string in the form HH:MM:SS - and return a timedelta object - """ - data = inp_str.split(":") - hours = int(data[0]) - minutes = int(data[1]) - seconds = int(data[2]) - if hours < 0: - raise ValueError - if not 0 <= minutes <= 59 or not 0 <= seconds <= 59: - raise ValueError - obj_dt = timedelta(hours=hours, minutes=minutes, seconds=seconds) - return obj_dt - - def check_combination(self): - """Verifies if the given input times combination is valid. - Sets temporal type. - """ - comb_err_msg = ("accepted combinations: {d} alone, {s} and {d}, {s} and {e}").format( - d="duration", s="start_time", e="end_time" - ) - b_dur = self.raw_duration and not self.raw_start and not self.raw_end - b_start_dur = self.raw_start and self.raw_duration and not self.raw_end - b_start_end = self.raw_start and self.raw_end and not self.raw_duration - if not (b_dur or b_start_dur or b_start_end): - msgr.fatal(comb_err_msg) - # if only duration is given, temporal type is relative - if b_dur: - self.temporal_type = TemporalType.RELATIVE - else: - self.temporal_type = TemporalType.ABSOLUTE - return self - - def read_timedelta(self, string): - """Try to transform string in timedelta object. - If it fail, return an error message - If string is None, return None - """ - if string: - try: - return self.str_to_timedelta(string) - except ValueError: - msgr.fatal(self.rel_err_msg.format(string)) - else: - return None - - def read_datetime(self, string): - """Try to transform string in datetime object. - If it fail, return an error message - If string is None, return None - """ - if string: - try: - return datetime.strptime(string, self.date_format) - except ValueError: - msgr.fatal(self.abs_err_msg.format(string)) - else: - return None - - def check_coherence(self): - """Sets end or duration if not given - Verifies if end is superior to starts - """ - if self.start is None: - self.start = datetime.min - if self.end is None: - self.end = self.start + self.duration - if self.start >= self.end: - msgr.fatal("Simulation duration must be positive") - if self.duration is None: - self.duration = self.end - self.start + """Return validated GRASS GIS session parameters.""" + return self.grass_params diff --git a/tests/grass/test_config.py b/tests/core/test_configreader.py similarity index 100% rename from tests/grass/test_config.py rename to tests/core/test_configreader.py From 0d851f6be14b3d623714ff10af6fffe80098b5ca Mon Sep 17 00:00:00 2001 From: Laurent Courty Date: Mon, 22 Jun 2026 10:02:42 -0600 Subject: [PATCH 5/6] fix typing errors --- src/itzi/configreader.py | 5 +++-- src/itzi/messenger.py | 11 ++++++----- uv.lock | 42 ++++++++++++++++++++-------------------- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/itzi/configreader.py b/src/itzi/configreader.py index f013a50..fd40ed3 100644 --- a/src/itzi/configreader.py +++ b/src/itzi/configreader.py @@ -270,11 +270,12 @@ def from_raw_values(cls, raw_values: dict[str, str | None]) -> SimulationTimes: if start is None: start = datetime.min if end is None: - end = start + duration + assert duration is not None + end: datetime = start + duration if start >= end: msgr.fatal("Simulation duration must be positive") if duration is None: - duration = end - start + duration: timedelta = end - start return cls( start=start, diff --git a/src/itzi/messenger.py b/src/itzi/messenger.py index e2125fe..8521789 100644 --- a/src/itzi/messenger.py +++ b/src/itzi/messenger.py @@ -15,6 +15,7 @@ import sys import logging import os +from typing import NoReturn from datetime import timedelta, datetime from itzi.itzi_error import ItziFatal @@ -74,7 +75,7 @@ def set_verbosity(self, verbosity_level): ): handler.setLevel(level) - def fatal(self, msg): + def fatal(self, msg) -> NoReturn: """Log fatal error and raise or exit""" self.logger.error(f"ERROR: {msg}") if raise_on_error: @@ -82,16 +83,16 @@ def fatal(self, msg): else: sys.exit(f"ERROR: {msg}") - def warning(self, msg): + def warning(self, msg) -> None: self.logger.warning(f"WARNING: {msg}") - def message(self, msg): + def message(self, msg) -> None: self.logger.info(msg) - def verbose(self, msg): + def verbose(self, msg) -> None: self.logger.log(self.VERBOSE_LEVEL, msg) - def debug(self, msg): + def debug(self, msg) -> None: self.logger.debug(msg) diff --git a/uv.lock b/uv.lock index 158d075..7953593 100644 --- a/uv.lock +++ b/uv.lock @@ -1784,27 +1784,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.1a22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/87/eab73cdc990d1141b60237379975efc0e913bfa0d19083daab0f497444a6/ty-0.0.1a22.tar.gz", hash = "sha256:b20ec5362830a1e9e05654c15e88607fdbb45325ec130a9a364c6dd412ecbf55", size = 4312182, upload-time = "2025-10-10T13:07:15.88Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/30/83e2dbfbc70de8a1932b19daf05ce803d7d76cdc6251de1519a49cf1c27d/ty-0.0.1a22-py3-none-linux_armv6l.whl", hash = "sha256:6efba0c777881d2d072fa7375a64ad20357e825eff2a0b6ff9ec80399a04253b", size = 8581795, upload-time = "2025-10-10T13:06:44.396Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8c/5193534fc4a3569f517408828d077b26d6280fe8c2dd0bdc63db4403dcdb/ty-0.0.1a22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2ada020eebe1b44403affdf45cd5c8d3fb8312c3e80469d795690093c0921f55", size = 8682602, upload-time = "2025-10-10T13:06:46.44Z" }, - { url = "https://files.pythonhosted.org/packages/22/4a/7ba53493bf37b61d3e0dfe6df910e6bc74c40d16c3effd84e15c0863d34e/ty-0.0.1a22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ed4f11f1a5824ea10d3e46b1990d092c3f341b1d492c357d23bed2ac347fd253", size = 8278839, upload-time = "2025-10-10T13:06:48.688Z" }, - { url = "https://files.pythonhosted.org/packages/52/0a/d9862c41b9615de56d2158bfbb5177dbf5a65e94922d3dd13855f48cb91b/ty-0.0.1a22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56f48d8f94292909d596dbeb56ff7f9f070bd316aa628b45c02ca2b2f5797f31", size = 8421483, upload-time = "2025-10-10T13:06:50.75Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cb/3ebe0e45b80724d4c2f849fdf304179727fd06df7fee7cd12fe6c3efe49d/ty-0.0.1a22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:733e9ac22885b6574de26bdbae439c960a06acc825a938d3780c9d498bb65339", size = 8419225, upload-time = "2025-10-10T13:06:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b5/da65f3f8ad31d881ca9987a3f6f26069a0cc649c9354adb7453ca62116bb/ty-0.0.1a22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5135d662484e56809c77b3343614005585caadaa5c1cf643ed6a09303497652b", size = 9352336, upload-time = "2025-10-10T13:06:54.476Z" }, - { url = "https://files.pythonhosted.org/packages/a3/24/9c46f2eb16734ab0fcf3291486b1c5c528a1569f94541dc1f19f97dd2a5b/ty-0.0.1a22-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:87f297f99a98154d33a3f21991979418c65d8bf480f6a1bad1e54d46d2dc7df7", size = 9857840, upload-time = "2025-10-10T13:06:56.514Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ae/930c94bbbe5c049eae5355a197c39522844f55c7ab7fccd0ba061f618541/ty-0.0.1a22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3310217eaa4dccf20b7336fcbeb072097addc6fde0c9d3f791dea437af0aa6dc", size = 9452611, upload-time = "2025-10-10T13:06:58.154Z" }, - { url = "https://files.pythonhosted.org/packages/a2/80/d8f594438465c352cf0ebd4072f5ca3be2871153a3cd279ed2f35ecd487c/ty-0.0.1a22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12b032e81012bf5228fd65f01b50e29eb409534b6aac28ee5c48ee3b7b860ddf", size = 9214875, upload-time = "2025-10-10T13:06:59.861Z" }, - { url = "https://files.pythonhosted.org/packages/fd/07/f852fb20ac27707de495c39a02aeb056e3368833b7e12888d43b1f61594d/ty-0.0.1a22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3ffda8149cab0000a21e7a078142073e27a1a9ac03b9a0837aa2f53d1fbebcb", size = 8906715, upload-time = "2025-10-10T13:07:01.926Z" }, - { url = "https://files.pythonhosted.org/packages/40/4d/0e0b85b4179891cc3067a6e717f5161921c07873a4f545963fdf1dd3619c/ty-0.0.1a22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:afa512e7dc78f0cf0b55f87394968ba59c46993c67bc0ef295962144fea85b12", size = 8350873, upload-time = "2025-10-10T13:07:03.999Z" }, - { url = "https://files.pythonhosted.org/packages/a1/1f/e70c63e12b4a0d97d4fd6f872dd199113666ad1b236e18838fa5e5d5502d/ty-0.0.1a22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:069cdbbea6025f7ebbb5e9043c8d0daf760358df46df8304ef5ca5bb3e320aef", size = 8442568, upload-time = "2025-10-10T13:07:05.745Z" }, - { url = "https://files.pythonhosted.org/packages/de/3b/55518906cb3598f2b99ff1e86c838d77d006cab70cdd2a0a625d02ccb52c/ty-0.0.1a22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:67d31d902e6fd67a4b3523604f635e71d2ec55acfb9118f984600584bfe0ff2a", size = 8896775, upload-time = "2025-10-10T13:07:08.02Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ea/60c654c27931bf84fa9cb463a4c4c49e8869c052fa607a6e930be717b619/ty-0.0.1a22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f9e154f262162e6f76b01f318e469ac6c22ffce22b010c396ed34e81d8369821", size = 9054544, upload-time = "2025-10-10T13:07:09.675Z" }, - { url = "https://files.pythonhosted.org/packages/6c/60/9a6d5530d6829ccf656e6ae0fb13d70a4e2514f4fb8910266ebd54286620/ty-0.0.1a22-py3-none-win32.whl", hash = "sha256:37525433ca7b02a8fca4b8fa9dcde818bf3a413b539b9dbc8f7b39d124eb7c49", size = 8165703, upload-time = "2025-10-10T13:07:11.378Z" }, - { url = "https://files.pythonhosted.org/packages/14/9c/ac08c832643850d4e18cbc959abc69cd51d531fe11bdb691098b3cf2f562/ty-0.0.1a22-py3-none-win_amd64.whl", hash = "sha256:75d21cdeba8bcef247af89518d7ce98079cac4a55c4160cb76682ea40a18b92c", size = 8828319, upload-time = "2025-10-10T13:07:12.815Z" }, - { url = "https://files.pythonhosted.org/packages/22/df/38068fc44e3cfb455aeb41d0ff1850a4d3c9988010466d4a8d19860b8b9a/ty-0.0.1a22-py3-none-win_arm64.whl", hash = "sha256:1c7f040fe311e9696917417434c2a0e58402235be842c508002c6a2eff1398b0", size = 8367136, upload-time = "2025-10-10T13:07:14.518Z" }, +version = "0.0.51" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ce/352fcdba5c72ea20e5d2e46e28809cdb617575b71209d971eff2ace8e6c4/ty-0.0.51.tar.gz", hash = "sha256:b90172d46365bb9d51a7011cbb5c60cc4f514f42c86635df6c092b717f85e1ac", size = 5953151, upload-time = "2026-06-19T01:48:58.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/8f/8fe7cab79a45320b2cdcd602f16d44c8108d2f418ff7ec316c6212f1f0cc/ty-0.0.51-py3-none-linux_armv6l.whl", hash = "sha256:947986bd82d324b3a5c58ce03f1dad160cdf36443d3e8f64b3484b861ba9bc64", size = 11884805, upload-time = "2026-06-19T01:48:20.184Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/56fdc39a3f44c0564fd157e1e59e1f9c3fc5ba57ae4472ded85c67c63d74/ty-0.0.51-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25a5b31e6f23fd5dc63ad29087ded09932409e4154e2fe07bbaed015035990bb", size = 11633593, upload-time = "2026-06-19T01:48:22.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/57/136e83f24fc04f5afdcabff42f40fa27eae5ac3f0e3f12627d072a55f679/ty-0.0.51-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2faed19a8f1505370de071c008df52a994fc03a204f3267c3a33a32ca26f854f", size = 11063076, upload-time = "2026-06-19T01:48:25.223Z" }, + { url = "https://files.pythonhosted.org/packages/32/f8/5d32f0df5692446440ab781b9b119aa3e0c0dbfa78c583fe9be8417d54fa/ty-0.0.51-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08adbe53fb8bc9e7f00e89bf1d3c875a02cda76d83f109d2e6ab1ff35a7bfa8c", size = 11579542, upload-time = "2026-06-19T01:48:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0c/4f54ef338e9623886809ecd508931b0cd5b3aba1e591586a2f6aeaa8bd11/ty-0.0.51-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc5e93695ab5dcbf1eef663aee60ec23a413547cc9cb06adcb0d842e9166bd0f", size = 11676189, upload-time = "2026-06-19T01:48:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/31729066f9b9d3596941edaf267894eefc0b30df4518f003dba5f7276258/ty-0.0.51-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd92913bc90d1705ef9391ff8c6822b61e2e827fa295eb30bf0dfabcf815645", size = 12188154, upload-time = "2026-06-19T01:48:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/2f/38/d4301aa12d2283c7130908baf1417a37dfe3e10f5669cb4ce2853c2540b4/ty-0.0.51-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:429a997394dac73870d71b87cc90efc54da3efaf319e72ca18aeef35a78aef90", size = 12780597, upload-time = "2026-06-19T01:48:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/c1/52/4b2e67e53f126d39abe201bd2299e467e27463a284e965ad195cbc217fa0/ty-0.0.51-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62d94f06e8c317e89b6884f2bde443040e596b88c7c79bd944c84c105b06257a", size = 12491115, upload-time = "2026-06-19T01:48:36.169Z" }, + { url = "https://files.pythonhosted.org/packages/74/50/aabfe55c132ebe72b4d639cbf772d931e11b0990d29c1f691922b6ccabc1/ty-0.0.51-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8f52952cff665bc52a36147e610c10f5699d30007d7a14ab7f345cff93476ff", size = 12230135, upload-time = "2026-06-19T01:48:38.445Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/9aa428052dbed91c50919cd080426a313cf20ce14c6bfe2b71345e548671/ty-0.0.51-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c1bd1355aee86af01e4e21b0bc16fc460fb05905761f0d8b8d70841de0feade8", size = 12468123, upload-time = "2026-06-19T01:48:40.47Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5a/f6ce69f2575259386c950c40e02578d0902760cb61f95045e9971182c24e/ty-0.0.51-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:79d1877e93460f936bc10ed1a31525702b7ce51075763ccba993be17f0b9e905", size = 11541672, upload-time = "2026-06-19T01:48:42.635Z" }, + { url = "https://files.pythonhosted.org/packages/35/3a/2af48924a683e959e95e5cc4dc88e5a8595206a0812b869032b95196f2b0/ty-0.0.51-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cc233a6235fb23e2a44b14731a10043e37ba2f30f2c361cf49ad3633c5b9da9c", size = 11694015, upload-time = "2026-06-19T01:48:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/12/899875d8a60b198c8121cb92ce18e18cc072d23ca2130fcdaa176383ef72/ty-0.0.51-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bc7459348a253247bbfb2669a021e614281b86bbea24c36112b8a6e1a2499a16", size = 11832856, upload-time = "2026-06-19T01:48:47.028Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a2/88f681d826d97cc96ef9f6cadd4935f775758944cee07340aa46113bce28/ty-0.0.51-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49a21237f6fd1de56beaff0a3e85fe022a09a3401e67e3abec41ce838a5d4d2e", size = 12333449, upload-time = "2026-06-19T01:48:49.091Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/535a4163b4452c6978c31fedfd7b5803cf3a2253e9455cde350f86638d6a/ty-0.0.51-py3-none-win32.whl", hash = "sha256:61b4b6a003c3ebe53a63a1125c9b6542aa01bc1b6c9a235d01ee328d000d61a9", size = 11177338, upload-time = "2026-06-19T01:48:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4d/2334fbb74291a20129fa7aaa8f789619ec9b6883b27f997b8baa27e4674f/ty-0.0.51-py3-none-win_amd64.whl", hash = "sha256:608d417cd1eaf79bcbd713d9830d5e3db9d57ec225c3af3e4ac9a9ff66b45d70", size = 12325675, upload-time = "2026-06-19T01:48:53.774Z" }, + { url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" }, ] [[package]] From 5b5e8f39d761a740cbcbff5618600d03fceccb84 Mon Sep 17 00:00:00 2001 From: Laurent Courty Date: Mon, 22 Jun 2026 14:36:28 -0600 Subject: [PATCH 6/6] update ty and ruff --- pyproject.toml | 6 +++--- uv.lock | 49 ++++++++++++++++++++++++------------------------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a5f1828..9424c13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "itzi" -version = "25.10" +version = "26.6" description = "A distributed dynamic flood model." authors = [ {name = "Laurent Courty", email = "lrntct@gmail.com"}, @@ -55,8 +55,8 @@ dev = [ "pytest-benchmark[histogram]==5.*", "scipy>=1.15.2", "pre-commit>=4.2.0", - "ruff>=0.12", - "ty>=0.0.1a16", + "ruff>=0.15", + "ty>=0.0.51", "pyinstrument==5.*", "rioxarray>=0.19.0", ] diff --git a/uv.lock b/uv.lock index 7953593..c428701 100644 --- a/uv.lock +++ b/uv.lock @@ -607,7 +607,7 @@ wheels = [ [[package]] name = "itzi" -version = "25.10" +version = "26.6" source = { editable = "." } dependencies = [ { name = "bmipy" }, @@ -668,9 +668,9 @@ dev = [ { name = "pytest-forked", specifier = "==1.*" }, { name = "requests", specifier = "==2.*" }, { name = "rioxarray", specifier = ">=0.19.0" }, - { name = "ruff", specifier = ">=0.12" }, + { name = "ruff", specifier = ">=0.15" }, { name = "scipy", specifier = ">=1.15.2" }, - { name = "ty", specifier = ">=0.0.1a16" }, + { name = "ty", specifier = ">=0.0.51" }, ] [[package]] @@ -1626,28 +1626,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/b9/9bd84453ed6dd04688de9b3f3a4146a1698e8faae2ceeccce4e14c67ae17/ruff-0.14.0.tar.gz", hash = "sha256:62ec8969b7510f77945df916de15da55311fade8d6050995ff7f680afe582c57", size = 5452071, upload-time = "2025-10-07T18:21:55.763Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/4e/79d463a5f80654e93fa653ebfb98e0becc3f0e7cf6219c9ddedf1e197072/ruff-0.14.0-py3-none-linux_armv6l.whl", hash = "sha256:58e15bffa7054299becf4bab8a1187062c6f8cafbe9f6e39e0d5aface455d6b3", size = 12494532, upload-time = "2025-10-07T18:21:00.373Z" }, - { url = "https://files.pythonhosted.org/packages/ee/40/e2392f445ed8e02aa6105d49db4bfff01957379064c30f4811c3bf38aece/ruff-0.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:838d1b065f4df676b7c9957992f2304e41ead7a50a568185efd404297d5701e8", size = 13160768, upload-time = "2025-10-07T18:21:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/75/da/2a656ea7c6b9bd14c7209918268dd40e1e6cea65f4bb9880eaaa43b055cd/ruff-0.14.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:703799d059ba50f745605b04638fa7e9682cc3da084b2092feee63500ff3d9b8", size = 12363376, upload-time = "2025-10-07T18:21:07.833Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/1ffef5a1875add82416ff388fcb7ea8b22a53be67a638487937aea81af27/ruff-0.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ba9a8925e90f861502f7d974cc60e18ca29c72bb0ee8bfeabb6ade35a3abde7", size = 12608055, upload-time = "2025-10-07T18:21:10.72Z" }, - { url = "https://files.pythonhosted.org/packages/4a/32/986725199d7cee510d9f1dfdf95bf1efc5fa9dd714d0d85c1fb1f6be3bc3/ruff-0.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e41f785498bd200ffc276eb9e1570c019c1d907b07cfb081092c8ad51975bbe7", size = 12318544, upload-time = "2025-10-07T18:21:13.741Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ed/4969cefd53315164c94eaf4da7cfba1f267dc275b0abdd593d11c90829a3/ruff-0.14.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30a58c087aef4584c193aebf2700f0fbcfc1e77b89c7385e3139956fa90434e2", size = 14001280, upload-time = "2025-10-07T18:21:16.411Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ad/96c1fc9f8854c37681c9613d825925c7f24ca1acfc62a4eb3896b50bacd2/ruff-0.14.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f8d07350bc7af0a5ce8812b7d5c1a7293cf02476752f23fdfc500d24b79b783c", size = 15027286, upload-time = "2025-10-07T18:21:19.577Z" }, - { url = "https://files.pythonhosted.org/packages/b3/00/1426978f97df4fe331074baf69615f579dc4e7c37bb4c6f57c2aad80c87f/ruff-0.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eec3bbbf3a7d5482b5c1f42d5fc972774d71d107d447919fca620b0be3e3b75e", size = 14451506, upload-time = "2025-10-07T18:21:22.779Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/9c1cea6e493c0cf0647674cca26b579ea9d2a213b74b5c195fbeb9678e15/ruff-0.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16b68e183a0e28e5c176d51004aaa40559e8f90065a10a559176713fcf435206", size = 13437384, upload-time = "2025-10-07T18:21:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/29/b4/4cd6a4331e999fc05d9d77729c95503f99eae3ba1160469f2b64866964e3/ruff-0.14.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb732d17db2e945cfcbbc52af0143eda1da36ca8ae25083dd4f66f1542fdf82e", size = 13447976, upload-time = "2025-10-07T18:21:28.83Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c0/ac42f546d07e4f49f62332576cb845d45c67cf5610d1851254e341d563b6/ruff-0.14.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c958f66ab884b7873e72df38dcabee03d556a8f2ee1b8538ee1c2bbd619883dd", size = 13682850, upload-time = "2025-10-07T18:21:31.842Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c4/4b0c9bcadd45b4c29fe1af9c5d1dc0ca87b4021665dfbe1c4688d407aa20/ruff-0.14.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7eb0499a2e01f6e0c285afc5bac43ab380cbfc17cd43a2e1dd10ec97d6f2c42d", size = 12449825, upload-time = "2025-10-07T18:21:35.074Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a8/e2e76288e6c16540fa820d148d83e55f15e994d852485f221b9524514730/ruff-0.14.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c63b2d99fafa05efca0ab198fd48fa6030d57e4423df3f18e03aa62518c565f", size = 12272599, upload-time = "2025-10-07T18:21:38.08Z" }, - { url = "https://files.pythonhosted.org/packages/18/14/e2815d8eff847391af632b22422b8207704222ff575dec8d044f9ab779b2/ruff-0.14.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:668fce701b7a222f3f5327f86909db2bbe99c30877c8001ff934c5413812ac02", size = 13193828, upload-time = "2025-10-07T18:21:41.216Z" }, - { url = "https://files.pythonhosted.org/packages/44/c6/61ccc2987cf0aecc588ff8f3212dea64840770e60d78f5606cd7dc34de32/ruff-0.14.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a86bf575e05cb68dcb34e4c7dfe1064d44d3f0c04bbc0491949092192b515296", size = 13628617, upload-time = "2025-10-07T18:21:44.04Z" }, - { url = "https://files.pythonhosted.org/packages/73/e6/03b882225a1b0627e75339b420883dc3c90707a8917d2284abef7a58d317/ruff-0.14.0-py3-none-win32.whl", hash = "sha256:7450a243d7125d1c032cb4b93d9625dea46c8c42b4f06c6b709baac168e10543", size = 12367872, upload-time = "2025-10-07T18:21:46.67Z" }, - { url = "https://files.pythonhosted.org/packages/41/77/56cf9cf01ea0bfcc662de72540812e5ba8e9563f33ef3d37ab2174892c47/ruff-0.14.0-py3-none-win_amd64.whl", hash = "sha256:ea95da28cd874c4d9c922b39381cbd69cb7e7b49c21b8152b014bd4f52acddc2", size = 13464628, upload-time = "2025-10-07T18:21:50.318Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2a/65880dfd0e13f7f13a775998f34703674a4554906167dce02daf7865b954/ruff-0.14.0-py3-none-win_arm64.whl", hash = "sha256:f42c9495f5c13ff841b1da4cb3c2a42075409592825dada7c5885c2c844ac730", size = 12565142, upload-time = "2025-10-07T18:21:53.577Z" }, +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, ] [[package]]