Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:04:35+02:00
Commit #38 · 6 snippets
src/iolabs_point_cloud_mask_clustering/_config.py | 28 ++++++++++++++++++++--- tests/test_config.py | 16 +++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-)
| 40 | 41 | ||
| 41 | background_class: int = 0 | 42 | background_class: int = 0 |
| 42 | solid_class: int = 1 | 43 | solid_class: int = 1 |
| 43 | dashed_class: int = 2 | 44 | dashed_class: int = 2 |
| 44 | connectivity: Literal[4, 8] = 8 | 45 | connectivity: int = 8 |
| 45 | vector_stroke_px: int = pydantic.Field(default=4, ge=1) | 46 | vector_stroke_px: int = pydantic.Field(default=4, ge=1) |
| 46 | 47 | ||
| 48 | @pydantic.field_validator("connectivity") | ||
| 49 | @classmethod | ||
| 50 | def _check_connectivity(cls, value: int) -> int: | ||
| 51 | """Reject a pixel connectivity other than 4 or 8.""" | ||
| 52 | if value not in (4, 8): | ||
| 53 | raise ValueError("mask.connectivity must be 4 or 8") | ||
| 54 | return value | ||
| 55 | |||
| 47 | 56 | ||
| 48 | class ClustersConfig(config_loader.ConfigModel): | 57 | class ClustersConfig(config_loader.ConfigModel): |
| 49 | """Sparse-cluster thresholds.""" | 58 | """Sparse-cluster thresholds.""" |
| 50 | 59 |
| 214 | 236 | ||
| 215 | Raises: | 237 | Raises: |
| 216 | MaskClusteringConfigError: An unknown key or an out-of-range value. | 238 | MaskClusteringConfigError: An unknown key or an out-of-range value. |
| 217 | """ | 239 | """ |
| 218 | return _load_model(overrides=raw).model_dump() | 240 | return _load_model(overrides=_as_mapping(raw)).model_dump() |
| 219 | 241 | ||
| 220 | 242 | ||
| 221 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: | 243 | def load_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 222 | """Load a configuration JSON, or the packaged defaults when *config_path* is None. | 244 | """Load a configuration JSON, or the packaged defaults when *config_path* is None. |
| 15 | ``mask_clustering.default.json`` — nothing else. | 15 | ``mask_clustering.default.json`` — nothing else. |
| 16 | """ | 16 | """ |
| 17 | 17 | ||
| 18 | import json | 18 | import json |
| 19 | from collections.abc import Mapping | ||
| 19 | from pathlib import Path | 20 | from pathlib import Path |
| 20 | from typing import Any, Literal | 21 | from typing import Any, Literal |
| 21 | 22 | ||
| 22 | import pydantic | 23 | import pydantic |
| 165 | 174 | ||
| 166 | Raises: | 175 | Raises: |
| 167 | MaskClusteringConfigError: The mapping is not a valid configuration. | 176 | MaskClusteringConfigError: The mapping is not a valid configuration. |
| 168 | """ | 177 | """ |
| 169 | return _load_model(overrides=config) | 178 | return _load_model(overrides=_as_mapping(config)) |
| 179 | |||
| 180 | |||
| 181 | def _as_mapping(config: Any) -> dict[str, Any]: | ||
| 182 | """Return *config* as a dict, rejecting values that are not mappings. | ||
| 183 | |||
| 184 | Raises: | ||
| 185 | MaskClusteringConfigError: *config* is not a mapping (``None`` included). | ||
| 186 | """ | ||
| 187 | if not isinstance(config, Mapping): | ||
| 188 | raise MaskClusteringConfigError( | ||
| 189 | f"config must be a mapping, got {type(config).__name__}" | ||
| 190 | ) | ||
| 191 | return dict(config) | ||
| 170 | 192 | ||
| 171 | 193 | ||
| 172 | def _load_model(overrides: dict[str, Any] | None = None) -> MaskClusteringConfig: | 194 | def _load_model(overrides: dict[str, Any] | None = None) -> MaskClusteringConfig: |
| 173 | """Merge *overrides* onto the packaged defaults and validate the result.""" | 195 | """Merge *overrides* onto the packaged defaults and validate the result.""" |
| 97 | "vector_stroke_px" | 97 | "vector_stroke_px" |
| 98 | ] == 6 | 98 | ] == 6 |
| 99 | with pytest.raises(MaskClusteringConfigError): | 99 | with pytest.raises(MaskClusteringConfigError): |
| 100 | build_config(overrides={"mask": {"vector_stroke_px": True}}) | 100 | build_config(overrides={"mask": {"vector_stroke_px": True}}) |
| 101 | |||
| 102 | |||
| 103 | @pytest.mark.parametrize("value", [8, 8.0, "8", 4]) | ||
| 104 | def test_connectivity_accepts_legacy_int_spellings(value: object) -> None: | ||
| 105 | """JSON/CLI spellings of an int reach ``mask.connectivity`` as an int.""" | ||
| 106 | config = build_config(overrides={"mask": {"connectivity": value}}) | ||
| 107 | assert config["mask"]["connectivity"] == int(value) # type: ignore[arg-type] | ||
| 108 | |||
| 109 | |||
| 110 | @pytest.mark.parametrize("config", [None, [], "", 0, False]) | ||
| 111 | def test_non_mapping_config_is_rejected(config: object) -> None: | ||
| 112 | """A non-mapping is an error, not a silent "use the defaults".""" | ||
| 113 | with pytest.raises(MaskClusteringConfigError): | ||
| 114 | _config.MaskClusteringConfig.coerce(config) # type: ignore[arg-type] | ||
| 115 | with pytest.raises(MaskClusteringConfigError): | ||
| 116 | _config.normalize_config(config) # type: ignore[arg-type] |
| 97 | "vector_stroke_px" | 97 | "vector_stroke_px" |
| 98 | ] == 6 | 98 | ] == 6 |
| 99 | with pytest.raises(MaskClusteringConfigError): | 99 | with pytest.raises(MaskClusteringConfigError): |
| 100 | build_config(overrides={"mask": {"vector_stroke_px": True}}) | 100 | build_config(overrides={"mask": {"vector_stroke_px": True}}) |
| 101 | |||
| 102 | |||
| 103 | @pytest.mark.parametrize("value", [8, 8.0, "8", 4]) | ||
| 104 | def test_connectivity_accepts_legacy_int_spellings(value: object) -> None: | ||
| 105 | """JSON/CLI spellings of an int reach ``mask.connectivity`` as an int.""" | ||
| 106 | config = build_config(overrides={"mask": {"connectivity": value}}) | ||
| 107 | assert config["mask"]["connectivity"] == int(value) # type: ignore[arg-type] | ||
| 108 | |||
| 109 | |||
| 110 | @pytest.mark.parametrize("config", [None, [], "", 0, False]) | ||
| 111 | def test_non_mapping_config_is_rejected(config: object) -> None: | ||
| 112 | """A non-mapping is an error, not a silent "use the defaults".""" | ||
| 113 | with pytest.raises(MaskClusteringConfigError): | ||
| 114 | _config.MaskClusteringConfig.coerce(config) # type: ignore[arg-type] | ||
| 115 | with pytest.raises(MaskClusteringConfigError): | ||
| 116 | _config.normalize_config(config) # type: ignore[arg-type] |
mask.connectivitycoerced as int; non-mapping config rejected.