Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:59:40+02:00
Commit #46 ยท 4 snippets
.../_config.py | 47 ++++++++++++++++++++++ tests/test_config.py | 44 ++++++++++++++++++++ 2 files changed, 91 insertions(+)
| 18 | from __future__ import annotations | 18 | from __future__ import annotations |
| 19 | 19 | ||
| 20 | import json | 20 | import json |
| 21 | import logging | 21 | import logging |
| 22 | from collections.abc import Mapping | ||
| 22 | from pathlib import Path | 23 | from pathlib import Path |
| 23 | from typing import Annotated, Any, Literal | 24 | from typing import Annotated, Any, Literal |
| 24 | 25 | ||
| 25 | import pydantic | 26 | import pydantic |
| 99 | "Single-Side Central Axis": (120, 170, 210, 255), | 100 | "Single-Side Central Axis": (120, 170, 210, 255), |
| 100 | "default": (255, 255, 255, 255), | 101 | "default": (255, 255, 255, 255), |
| 101 | } | 102 | } |
| 102 | 103 | ||
| 104 | @pydantic.field_validator("collage_parts", mode="before") | ||
| 105 | @classmethod | ||
| 106 | def _coerce_collage_parts(cls, value: Any) -> Any: | ||
| 107 | """Accept ``"auto"`` or a positive int, and nothing else. | ||
| 108 | |||
| 109 | The declared union is validated member-wise by pydantic, whose lax mode | ||
| 110 | would read ``True`` as ``1`` and would report one error per member; this | ||
| 111 | applies the fleet int matrix (which rejects ``bool``) and reports a | ||
| 112 | single message naming the field. | ||
| 113 | """ | ||
| 114 | if isinstance(value, str) and value == "auto": | ||
| 115 | return value | ||
| 116 | try: | ||
| 117 | parts = config_loader.coerce_config_value( | ||
| 118 | "collage_parts", value, int, error_cls=ValueError | ||
| 119 | ) | ||
| 120 | except ValueError as exc: | ||
| 121 | raise ValueError( | ||
| 122 | f"Invalid value for 'collage_parts': {value!r}. " | ||
| 123 | "Expected 'auto' or an integer >= 1." | ||
| 124 | ) from exc | ||
| 125 | if parts < 1: | ||
| 126 | raise ValueError( | ||
| 127 | f"Invalid value for 'collage_parts': {value!r}. " | ||
| 128 | "Expected 'auto' or an integer >= 1." | ||
| 129 | ) | ||
| 130 | return parts | ||
| 131 | |||
| 132 | @pydantic.field_validator("colors", mode="before") | ||
| 133 | @classmethod | ||
| 134 | def _coerce_colors(cls, value: Any) -> Any: | ||
| 135 | """Apply the fleet RGBA matrix to every entry of the open colour map. | ||
| 136 | |||
| 137 | Values of a ``dict``-typed field are not run through the fleet coercion | ||
| 138 | by `ConfigModel`, so without this a ``true`` channel would land as ``1`` | ||
| 139 | and a short colour would be reported as a missing key. | ||
| 140 | """ | ||
| 141 | if not isinstance(value, Mapping): | ||
| 142 | return value | ||
| 143 | return { | ||
| 144 | key: config_loader.coerce_config_value( | ||
| 145 | f"colors.{key}", item, Rgba, error_cls=ValueError | ||
| 146 | ) | ||
| 147 | for key, item in value.items() | ||
| 148 | } | ||
| 149 | |||
| 103 | @pydantic.model_validator(mode="after") | 150 | @pydantic.model_validator(mode="after") |
| 104 | def _check_default_raster_sets(self) -> XmlTopdownOverlayConfig: | 151 | def _check_default_raster_sets(self) -> XmlTopdownOverlayConfig: |
| 105 | """Reject default raster-set names that no raster set defines.""" | 152 | """Reject default raster-set names that no raster set defines.""" |
| 106 | unknown = sorted(set(self.default_raster_sets) - set(self.raster_sets)) | 153 | unknown = sorted(set(self.default_raster_sets) - set(self.raster_sets)) |
| 199 | path = tmp_path / "overlay.json" | 199 | path = tmp_path / "overlay.json" |
| 200 | path.write_text("{oops", encoding="utf-8") | 200 | path.write_text("{oops", encoding="utf-8") |
| 201 | with pytest.raises(_config.XmlTopdownOverlayConfigError, match="Invalid JSON"): | 201 | with pytest.raises(_config.XmlTopdownOverlayConfigError, match="Invalid JSON"): |
| 202 | _config.load_xml_topdown_overlay_config(path) | 202 | _config.load_xml_topdown_overlay_config(path) |
| 203 | |||
| 204 | |||
| 205 | @pytest.mark.parametrize( | ||
| 206 | "overrides", | ||
| 207 | [ | ||
| 208 | {"collage_parts": True}, | ||
| 209 | {"collage_parts": "nope"}, | ||
| 210 | {"collage_parts": 2.5}, | ||
| 211 | {"colors": {"default": [True, 0, 0, 255]}}, | ||
| 212 | {"colors": {"default": "red"}}, | ||
| 213 | {"flagged_color": [True, 0, 0, 255]}, | ||
| 214 | ], | ||
| 215 | ) | ||
| 216 | def test_bools_and_junk_never_slip_through_unions_or_color_maps( | ||
| 217 | tmp_path: Path, overrides: dict[str, object] | ||
| 218 | ) -> None: | ||
| 219 | path = _write_overrides(tmp_path, overrides) | ||
| 220 | with pytest.raises(_config.XmlTopdownOverlayConfigError): | ||
| 221 | _config.load_xml_topdown_overlay_config(path) | ||
| 222 | |||
| 223 | |||
| 224 | def test_collage_parts_error_names_the_field_once(tmp_path: Path) -> None: | ||
| 225 | path = _write_overrides(tmp_path, {"collage_parts": 0}) | ||
| 226 | with pytest.raises(_config.XmlTopdownOverlayConfigError) as excinfo: | ||
| 227 | _config.load_xml_topdown_overlay_config(path) | ||
| 228 | message = str(excinfo.value) | ||
| 229 | assert message == ( | ||
| 230 | "Invalid value for 'collage_parts': 0. Expected 'auto' or an integer >= 1." | ||
| 231 | ) | ||
| 232 | |||
| 233 | |||
| 234 | def test_short_color_reports_a_tuple_length_error(tmp_path: Path) -> None: | ||
| 235 | path = _write_overrides(tmp_path, {"colors": {"default": [255, 255, 255]}}) | ||
| 236 | with pytest.raises( | ||
| 237 | _config.XmlTopdownOverlayConfigError, match=r"expected 4 item\(s\), got 3" | ||
| 238 | ) as excinfo: | ||
| 239 | _config.load_xml_topdown_overlay_config(path) | ||
| 240 | assert "colors.default" in str(excinfo.value) | ||
| 241 | |||
| 242 | |||
| 243 | def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None: | ||
| 244 | path = _write_overrides(tmp_path, {"colors": {"default": ["10", "20", "30", "40"]}}) | ||
| 245 | config = _config.load_xml_topdown_overlay_config(path) | ||
| 246 | assert config["colors"]["default"] == (10, 20, 30, 40) |
| 199 | path = tmp_path / "overlay.json" | 199 | path = tmp_path / "overlay.json" |
| 200 | path.write_text("{oops", encoding="utf-8") | 200 | path.write_text("{oops", encoding="utf-8") |
| 201 | with pytest.raises(_config.XmlTopdownOverlayConfigError, match="Invalid JSON"): | 201 | with pytest.raises(_config.XmlTopdownOverlayConfigError, match="Invalid JSON"): |
| 202 | _config.load_xml_topdown_overlay_config(path) | 202 | _config.load_xml_topdown_overlay_config(path) |
| 203 | |||
| 204 | |||
| 205 | @pytest.mark.parametrize( | ||
| 206 | "overrides", | ||
| 207 | [ | ||
| 208 | {"collage_parts": True}, | ||
| 209 | {"collage_parts": "nope"}, | ||
| 210 | {"collage_parts": 2.5}, | ||
| 211 | {"colors": {"default": [True, 0, 0, 255]}}, | ||
| 212 | {"colors": {"default": "red"}}, | ||
| 213 | {"flagged_color": [True, 0, 0, 255]}, | ||
| 214 | ], | ||
| 215 | ) | ||
| 216 | def test_bools_and_junk_never_slip_through_unions_or_color_maps( | ||
| 217 | tmp_path: Path, overrides: dict[str, object] | ||
| 218 | ) -> None: | ||
| 219 | path = _write_overrides(tmp_path, overrides) | ||
| 220 | with pytest.raises(_config.XmlTopdownOverlayConfigError): | ||
| 221 | _config.load_xml_topdown_overlay_config(path) | ||
| 222 | |||
| 223 | |||
| 224 | def test_collage_parts_error_names_the_field_once(tmp_path: Path) -> None: | ||
| 225 | path = _write_overrides(tmp_path, {"collage_parts": 0}) | ||
| 226 | with pytest.raises(_config.XmlTopdownOverlayConfigError) as excinfo: | ||
| 227 | _config.load_xml_topdown_overlay_config(path) | ||
| 228 | message = str(excinfo.value) | ||
| 229 | assert message == ( | ||
| 230 | "Invalid value for 'collage_parts': 0. Expected 'auto' or an integer >= 1." | ||
| 231 | ) | ||
| 232 | |||
| 233 | |||
| 234 | def test_short_color_reports_a_tuple_length_error(tmp_path: Path) -> None: | ||
| 235 | path = _write_overrides(tmp_path, {"colors": {"default": [255, 255, 255]}}) | ||
| 236 | with pytest.raises( | ||
| 237 | _config.XmlTopdownOverlayConfigError, match=r"expected 4 item\(s\), got 3" | ||
| 238 | ) as excinfo: | ||
| 239 | _config.load_xml_topdown_overlay_config(path) | ||
| 240 | assert "colors.default" in str(excinfo.value) | ||
| 241 | |||
| 242 | |||
| 243 | def test_color_map_accepts_set_style_strings(tmp_path: Path) -> None: | ||
| 244 | path = _write_overrides(tmp_path, {"colors": {"default": ["10", "20", "30", "40"]}}) | ||
| 245 | config = _config.load_xml_topdown_overlay_config(path) | ||
| 246 | assert config["colors"]["default"] == (10, 20, 30, 40) |
collage_partsrejected with one clean error; colour-map values run through the RGBA coercion matrix.