Back to report index

Step 6 maskclustering b1cd342: AI3D-379 Review fixes: int coercion for mask.connectivity, reject non-mapping config

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(-)
Importance #1: src/iolabs_point_cloud_mask_clustering/_config.py @@ -40,11 +41,19 @@
4041
41 background_class: int = 042 background_class: int = 0
42 solid_class: int = 143 solid_class: int = 1
43 dashed_class: int = 244 dashed_class: int = 2
44 connectivity: Literal[4, 8] = 845 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)
4647
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
4756
48class ClustersConfig(config_loader.ConfigModel):57class ClustersConfig(config_loader.ConfigModel):
49 """Sparse-cluster thresholds."""58 """Sparse-cluster thresholds."""
5059
Importance #2: src/iolabs_point_cloud_mask_clustering/_config.py @@ -214,9 +236,9 @@
214236
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()
219241
220242
221def load_config(config_path: str | Path | None = None) -> dict[str, Any]:243def 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.
Importance #3: src/iolabs_point_cloud_mask_clustering/_config.py @@ -15,8 +15,9 @@
15``mask_clustering.default.json`` nothing else.15``mask_clustering.default.json`` nothing else.
16"""16"""
1717
18import json18import json
19from collections.abc import Mapping
19from pathlib import Path20from pathlib import Path
20from typing import Any, Literal21from typing import Any, Literal
2122
22import pydantic23import pydantic
Importance #4: src/iolabs_point_cloud_mask_clustering/_config.py @@ -165,9 +174,22 @@
165174
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
181def _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)
170192
171193
172def _load_model(overrides: dict[str, Any] | None = None) -> MaskClusteringConfig:194def _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."""
Importance #5: tests/test_config.py @@ -97,4 +97,20 @@
97 "vector_stroke_px"97 "vector_stroke_px"
98 ] == 698 ] == 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])
104def 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])
111def 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]
Importance #6: tests/test_config.py @@ -97,4 +97,20 @@
97 "vector_stroke_px"97 "vector_stroke_px"
98 ] == 698 ] == 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])
104def 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])
111def 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]