Back to report index

guardrails-seg3d 6f7ed4a: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:34:36+02:00

Commit #61 ยท 11 snippets

 README.md            |  8 +++--
 guardrails/config.py | 95 ++++++++++++++++++++--------------------------------
 pyproject.toml       |  5 +--
 tests/test_config.py | 47 +++++++++++++++-----------
 4 files changed, 71 insertions(+), 84 deletions(-)
Importance #1: guardrails/config.py @@ -4,29 +4,32 @@
4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a
5``guardrails.default.json`` algorithm config, and a typed params object5``guardrails.default.json`` algorithm config, and a typed params object
6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are
7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.
8``config.py`` is the loader/schema: the dataclass field set is the schema and8``config.py`` is the loader/schema: the pydantic model field set is the schema
9every field default is kept identical to ``guardrails.default.json`` (guarded by9and every field default is kept identical to ``guardrails.default.json``
10a unit test), so ``DetectorConfig()`` and ``load_config()`` agree.10(guarded by a unit test), so ``DetectorConfig()`` and ``load_config()`` agree.
11
12To add a config key: add a field on :class:`DetectorConfig` and the matching
13key/value on ``guardrails.default.json``. Nothing else.
11"""14"""
1215
13from __future__ import annotations16from __future__ import annotations
1417
15import copy
16import json18import json
17import logging19import logging
18from dataclasses import dataclass, fields
19from pathlib import Path20from pathlib import Path
20from typing import Any21from typing import Any
2122
22from iolabs.common.config_loader import ConfigError, default_config_path, validate_allowed_keys23from iolabs.common import config_loader
2324
24logger = logging.getLogger(__name__)25logger = logging.getLogger(__name__)
2526
27_PACKAGE_NAME = "guardrails"
28_DEFAULT_CONFIG_NAME = "guardrails.default.json"
29
2630
27@dataclass(frozen=True)31class DetectorConfig(config_loader.ConfigModel):
28class DetectorConfig:
29 """Spatial and geometric thresholds, in metres unless stated otherwise."""32 """Spatial and geometric thresholds, in metres unless stated otherwise."""
3033
31 # Ground model34 # Ground model
32 ground_cell_m: float = 0.7535 ground_cell_m: float = 0.75
Importance #2: guardrails/config.py @@ -165,86 +168,60 @@
165 exclusion_dbscan_mem_limit_gb: float = 6.0168 exclusion_dbscan_mem_limit_gb: float = 6.0
166 exclusion_dbscan_timeout_s: float = 120.0169 exclusion_dbscan_timeout_s: float = 120.0
167170
168171
169class DetectorConfigError(ConfigError):172class DetectorConfigError(config_loader.ConfigError):
170 """Raised when the guardrails config contains unsupported keys."""173 """Raised when the guardrails config holds unknown keys or invalid values."""
171
172
173_DEFAULT_CONFIG_NAME = "guardrails.default.json"
174
175
176def _field_types() -> dict[str, type]:
177 return {f.name: f.type for f in fields(DetectorConfig)}
178174
179175
180def _default_config_path() -> Path:176def _default_config_path() -> Path:
181 if __package__ in {None, ""}:177 if __package__ in {None, ""}:
182 return Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME)178 return Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME)
183 return default_config_path(__package__, _DEFAULT_CONFIG_NAME)179 return config_loader.default_config_path(__package__, _DEFAULT_CONFIG_NAME)
184180
185181
186def load_default_config_dict() -> dict[str, Any]:182def load_default_config_dict() -> dict[str, Any]:
187 """Return the package-owned default config as a plain dict."""183 """Return the package-owned default config as a plain dict."""
188 with _default_config_path().open("r", encoding="utf-8") as handle:184 if __package__ in {None, ""}:
189 return json.load(handle)185 with _default_config_path().open("r", encoding="utf-8") as handle:
190186 return json.load(handle)
191187 return config_loader.load_packaged_json(__package__, _DEFAULT_CONFIG_NAME)
192def _coerce(name: str, value: Any) -> Any:
193 """Coerce a raw JSON/CLI value to the dataclass field's declared type."""
194 declared = {f.name: f.type for f in fields(DetectorConfig)}[name]
195 if declared in (bool, "bool"):
196 if isinstance(value, bool):
197 return value
198 if isinstance(value, str):
199 return value.strip().lower() in {"1", "true", "yes", "on"}
200 return bool(value)
201 if declared in (int, "int"):
202 return int(value)
203 if declared in (float, "float"):
204 return float(value)
205 return value
206188
207189
208def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:190def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:
209 """Build a validated :class:`DetectorConfig` from a raw mapping."""191 """Build a validated :class:`DetectorConfig` from a raw mapping."""
210 allowed = frozenset(f.name for f in fields(DetectorConfig))192 return config_loader.validate_config(
211 validate_allowed_keys(193 DetectorConfig,
212 raw,194 raw,
213 allowed,
214 context="guardrails config",195 context="guardrails config",
215 error_cls=DetectorConfigError,196 error_cls=DetectorConfigError,
216 )197 )
217 values = {name: _coerce(name, value) for name, value in raw.items()}
218 return DetectorConfig(**values)
219198
220199
221def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:200def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:
222 """Load the default config and apply flat ``PATH=VALUE`` overrides.201 """Load the default config and apply flat ``PATH=VALUE`` overrides.
223202
224 Overrides come from the CLI ``--set`` flag (already parsed into a dict).203 Overrides come from the CLI ``--set`` flag (already parsed into a dict).
225 """204 """
226 merged = copy.deepcopy(load_default_config_dict())205 config_path = None
227 for key, value in (overrides or {}).items():206 if __package__ in {None, ""}:
228 merged[key] = value207 config_path = Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME)
229 config = config_from_dict(merged)208 config = config_loader.load_config(
209 DetectorConfig,
210 package=__package__ or _PACKAGE_NAME,
211 filename=_DEFAULT_CONFIG_NAME,
212 overrides=overrides,
213 config_path=config_path,
214 context="guardrails config",
215 error_cls=DetectorConfigError,
216 )
230 if overrides:217 if overrides:
231 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))218 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
232 return config219 return config
233220
234221
235def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:222def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:
236 """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value."""223 """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value."""
237 parsed: dict[str, Any] = {}224 return config_loader.parse_set_overrides(
238 for override in raw_overrides or []:225 raw_overrides,
239 if "=" not in override:226 error_cls=DetectorConfigError,
240 raise DetectorConfigError(227 )
241 f"Invalid --set override '{override}'. Expected KEY=VALUE."
242 )
243 key, raw_value = override.split("=", 1)
244 key = key.strip()
245 try:
246 value: Any = json.loads(raw_value)
247 except json.JSONDecodeError:
248 value = raw_value
249 parsed[key] = value
250 return parsed
Importance #3: tests/test_config.py @@ -1,33 +1,25 @@
1from dataclasses import asdict1import pydantic
2
3import pytest2import pytest
43
5from guardrails.config import (4from guardrails import config as config_module
6 DetectorConfig,
7 DetectorConfigError,
8 config_from_dict,
9 load_config,
10 load_default_config_dict,
11 parse_set_overrides,
12)
135
146
15def test_default_json_matches_dataclass_defaults() -> None:7def test_default_json_matches_model_defaults() -> None:
16 """guardrails.default.json is the schema source of truth; keep it in sync."""8 """guardrails.default.json is the schema source of truth; keep it in sync."""
17 defaults = asdict(DetectorConfig())9 defaults = config_module.DetectorConfig().model_dump()
18 json_config = load_default_config_dict()10 json_config = config_module.load_default_config_dict()
19 assert set(json_config) == set(defaults)11 assert set(json_config) == set(defaults)
20 for key, value in defaults.items():12 for key, value in defaults.items():
21 assert json_config[key] == value, key13 assert json_config[key] == value, key
2214
2315
24def test_load_config_without_overrides_equals_defaults() -> None:16def test_load_config_without_overrides_equals_defaults() -> None:
25 assert load_config() == DetectorConfig()17 assert config_module.load_config() == config_module.DetectorConfig()
2618
2719
28def test_parse_set_overrides_json_decodes_values() -> None:20def test_parse_set_overrides_json_decodes_values() -> None:
29 parsed = parse_set_overrides(21 parsed = config_module.parse_set_overrides(
30 ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"]22 ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"]
31 )23 )
32 assert parsed == {24 assert parsed == {
33 "merge_face_max_spacing_m": 1.5,25 "merge_face_max_spacing_m": 1.5,
Importance #4: tests/test_config.py @@ -36,17 +28,32 @@
36 }28 }
3729
3830
39def test_load_config_applies_overrides_with_type_coercion() -> None:31def test_load_config_applies_overrides_with_type_coercion() -> None:
40 config = load_config({"decimation_enabled": "true", "merge_face_max_faces": 3})32 config = config_module.load_config(
33 {"decimation_enabled": "true", "merge_face_max_faces": 3}
34 )
41 assert config.decimation_enabled is True35 assert config.decimation_enabled is True
42 assert config.merge_face_max_faces == 336 assert config.merge_face_max_faces == 3
4337
4438
45def test_unknown_key_rejected() -> None:39def test_unknown_key_rejected() -> None:
46 with pytest.raises(DetectorConfigError):40 with pytest.raises(config_module.DetectorConfigError):
47 config_from_dict({**load_default_config_dict(), "not_a_key": 1})41 config_module.config_from_dict(
42 {**config_module.load_default_config_dict(), "not_a_key": 1}
43 )
44
45
46def test_invalid_value_rejected() -> None:
47 with pytest.raises(config_module.DetectorConfigError, match="merge_face_max_faces"):
48 config_module.load_config({"merge_face_max_faces": "abc"})
49
50
51def test_config_is_frozen() -> None:
52 config = config_module.DetectorConfig()
53 with pytest.raises(pydantic.ValidationError):
54 config.merge_gap_m = 1.0
4855
4956
50def test_invalid_override_string_rejected() -> None:57def test_invalid_override_string_rejected() -> None:
51 with pytest.raises(DetectorConfigError):58 with pytest.raises(config_module.DetectorConfigError):
52 parse_set_overrides(["missing_equals_sign"])59 config_module.parse_set_overrides(["missing_equals_sign"])
Importance #5: pyproject.toml @@ -1,16 +1,17 @@
1[project]1[project]
2name = "guardrails"2name = "guardrails"
3version = "0.2.0"3version = "0.2.1"
4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11"6requires-python = ">=3.11"
7dependencies = [7dependencies = [
8 "numpy>=2.0",8 "numpy>=2.0",
9 "pillow>=10.0",9 "pillow>=10.0",
10 "scikit-learn>=1.5",10 "scikit-learn>=1.5",
11 "scipy>=1.13",11 "scipy>=1.13",
12 "iolabs-common>=0.4.0",12 "pydantic>=2.7",
13 "iolabs-common>=0.8.0",
13 "iolabs-geometry-geometry>=0.8.0",14 "iolabs-geometry-geometry>=0.8.0",
14 "iolabs-geometry-raster>=0.1.0",15 "iolabs-geometry-raster>=0.1.0",
15 "iolabs-point-cloud-modelling-export",16 "iolabs-point-cloud-modelling-export",
16 "iolabs-image-analyzer-rasterizer==0.3.3",17 "iolabs-image-analyzer-rasterizer==0.3.3",
Importance #6: README.md @@ -23,11 +23,13 @@
2323
24Following the other iolabs point-cloud packages24Following the other iolabs point-cloud packages
25(`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an25(`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an
26algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is26algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is
27the loader/schema: the frozen `DetectorConfig` dataclass is the typed params27the loader/schema: the frozen pydantic `DetectorConfig` model (derived from
28object and its field set is the schema. Every dataclass default is kept28`iolabs.common.config_loader.ConfigModel`) is the typed params object and its
29identical to the JSON (asserted by `tests/test_config.py`).29field set is the schema. Every model default is kept identical to the JSON
30(asserted by `tests/test_config.py`). To add a config key, add a field on
31`DetectorConfig` and the matching default in the JSON; nothing else.
3032
31Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are33Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are
32JSON-decoded), never repo-local JSON files:34JSON-decoded), never repo-local JSON files:
3335
Importance #7: guardrails/config.py @@ -4,29 +4,32 @@
4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a4(``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a
5``guardrails.default.json`` algorithm config, and a typed params object5``guardrails.default.json`` algorithm config, and a typed params object
6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are6(:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are
7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.7applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON.
8``config.py`` is the loader/schema: the dataclass field set is the schema and8``config.py`` is the loader/schema: the pydantic model field set is the schema
9every field default is kept identical to ``guardrails.default.json`` (guarded by9and every field default is kept identical to ``guardrails.default.json``
10a unit test), so ``DetectorConfig()`` and ``load_config()`` agree.10(guarded by a unit test), so ``DetectorConfig()`` and ``load_config()`` agree.
11
12To add a config key: add a field on :class:`DetectorConfig` and the matching
13key/value on ``guardrails.default.json``. Nothing else.
11"""14"""
1215
13from __future__ import annotations16from __future__ import annotations
1417
15import copy
16import json18import json
17import logging19import logging
18from dataclasses import dataclass, fields
19from pathlib import Path20from pathlib import Path
20from typing import Any21from typing import Any
2122
22from iolabs.common.config_loader import ConfigError, default_config_path, validate_allowed_keys23from iolabs.common import config_loader
2324
24logger = logging.getLogger(__name__)25logger = logging.getLogger(__name__)
2526
27_PACKAGE_NAME = "guardrails"
28_DEFAULT_CONFIG_NAME = "guardrails.default.json"
29
2630
27@dataclass(frozen=True)31class DetectorConfig(config_loader.ConfigModel):
28class DetectorConfig:
29 """Spatial and geometric thresholds, in metres unless stated otherwise."""32 """Spatial and geometric thresholds, in metres unless stated otherwise."""
3033
31 # Ground model34 # Ground model
32 ground_cell_m: float = 0.7535 ground_cell_m: float = 0.75
Importance #8: guardrails/config.py @@ -165,86 +168,60 @@
165 exclusion_dbscan_mem_limit_gb: float = 6.0168 exclusion_dbscan_mem_limit_gb: float = 6.0
166 exclusion_dbscan_timeout_s: float = 120.0169 exclusion_dbscan_timeout_s: float = 120.0
167170
168171
169class DetectorConfigError(ConfigError):172class DetectorConfigError(config_loader.ConfigError):
170 """Raised when the guardrails config contains unsupported keys."""173 """Raised when the guardrails config holds unknown keys or invalid values."""
171
172
173_DEFAULT_CONFIG_NAME = "guardrails.default.json"
174
175
176def _field_types() -> dict[str, type]:
177 return {f.name: f.type for f in fields(DetectorConfig)}
178174
179175
180def _default_config_path() -> Path:176def _default_config_path() -> Path:
181 if __package__ in {None, ""}:177 if __package__ in {None, ""}:
182 return Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME)178 return Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME)
183 return default_config_path(__package__, _DEFAULT_CONFIG_NAME)179 return config_loader.default_config_path(__package__, _DEFAULT_CONFIG_NAME)
184180
185181
186def load_default_config_dict() -> dict[str, Any]:182def load_default_config_dict() -> dict[str, Any]:
187 """Return the package-owned default config as a plain dict."""183 """Return the package-owned default config as a plain dict."""
188 with _default_config_path().open("r", encoding="utf-8") as handle:184 if __package__ in {None, ""}:
189 return json.load(handle)185 with _default_config_path().open("r", encoding="utf-8") as handle:
190186 return json.load(handle)
191187 return config_loader.load_packaged_json(__package__, _DEFAULT_CONFIG_NAME)
192def _coerce(name: str, value: Any) -> Any:
193 """Coerce a raw JSON/CLI value to the dataclass field's declared type."""
194 declared = {f.name: f.type for f in fields(DetectorConfig)}[name]
195 if declared in (bool, "bool"):
196 if isinstance(value, bool):
197 return value
198 if isinstance(value, str):
199 return value.strip().lower() in {"1", "true", "yes", "on"}
200 return bool(value)
201 if declared in (int, "int"):
202 return int(value)
203 if declared in (float, "float"):
204 return float(value)
205 return value
206188
207189
208def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:190def config_from_dict(raw: dict[str, Any]) -> DetectorConfig:
209 """Build a validated :class:`DetectorConfig` from a raw mapping."""191 """Build a validated :class:`DetectorConfig` from a raw mapping."""
210 allowed = frozenset(f.name for f in fields(DetectorConfig))192 return config_loader.validate_config(
211 validate_allowed_keys(193 DetectorConfig,
212 raw,194 raw,
213 allowed,
214 context="guardrails config",195 context="guardrails config",
215 error_cls=DetectorConfigError,196 error_cls=DetectorConfigError,
216 )197 )
217 values = {name: _coerce(name, value) for name, value in raw.items()}
218 return DetectorConfig(**values)
219198
220199
221def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:200def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig:
222 """Load the default config and apply flat ``PATH=VALUE`` overrides.201 """Load the default config and apply flat ``PATH=VALUE`` overrides.
223202
224 Overrides come from the CLI ``--set`` flag (already parsed into a dict).203 Overrides come from the CLI ``--set`` flag (already parsed into a dict).
225 """204 """
226 merged = copy.deepcopy(load_default_config_dict())205 config_path = None
227 for key, value in (overrides or {}).items():206 if __package__ in {None, ""}:
228 merged[key] = value207 config_path = Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME)
229 config = config_from_dict(merged)208 config = config_loader.load_config(
209 DetectorConfig,
210 package=__package__ or _PACKAGE_NAME,
211 filename=_DEFAULT_CONFIG_NAME,
212 overrides=overrides,
213 config_path=config_path,
214 context="guardrails config",
215 error_cls=DetectorConfigError,
216 )
230 if overrides:217 if overrides:
231 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))218 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
232 return config219 return config
233220
234221
235def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:222def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:
236 """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value."""223 """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value."""
237 parsed: dict[str, Any] = {}224 return config_loader.parse_set_overrides(
238 for override in raw_overrides or []:225 raw_overrides,
239 if "=" not in override:226 error_cls=DetectorConfigError,
240 raise DetectorConfigError(227 )
241 f"Invalid --set override '{override}'. Expected KEY=VALUE."
242 )
243 key, raw_value = override.split("=", 1)
244 key = key.strip()
245 try:
246 value: Any = json.loads(raw_value)
247 except json.JSONDecodeError:
248 value = raw_value
249 parsed[key] = value
250 return parsed
Importance #9: pyproject.toml @@ -1,16 +1,17 @@
1[project]1[project]
2name = "guardrails"2name = "guardrails"
3version = "0.2.0"3version = "0.2.1"
4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"4description = "Classical geometric guardrail detection in MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11"6requires-python = ">=3.11"
7dependencies = [7dependencies = [
8 "numpy>=2.0",8 "numpy>=2.0",
9 "pillow>=10.0",9 "pillow>=10.0",
10 "scikit-learn>=1.5",10 "scikit-learn>=1.5",
11 "scipy>=1.13",11 "scipy>=1.13",
12 "iolabs-common>=0.4.0",12 "pydantic>=2.7",
13 "iolabs-common>=0.8.0",
13 "iolabs-geometry-geometry>=0.8.0",14 "iolabs-geometry-geometry>=0.8.0",
14 "iolabs-geometry-raster>=0.1.0",15 "iolabs-geometry-raster>=0.1.0",
15 "iolabs-point-cloud-modelling-export",16 "iolabs-point-cloud-modelling-export",
16 "iolabs-image-analyzer-rasterizer==0.3.3",17 "iolabs-image-analyzer-rasterizer==0.3.3",
Importance #10: tests/test_config.py @@ -1,33 +1,25 @@
1from dataclasses import asdict1import pydantic
2
3import pytest2import pytest
43
5from guardrails.config import (4from guardrails import config as config_module
6 DetectorConfig,
7 DetectorConfigError,
8 config_from_dict,
9 load_config,
10 load_default_config_dict,
11 parse_set_overrides,
12)
135
146
15def test_default_json_matches_dataclass_defaults() -> None:7def test_default_json_matches_model_defaults() -> None:
16 """guardrails.default.json is the schema source of truth; keep it in sync."""8 """guardrails.default.json is the schema source of truth; keep it in sync."""
17 defaults = asdict(DetectorConfig())9 defaults = config_module.DetectorConfig().model_dump()
18 json_config = load_default_config_dict()10 json_config = config_module.load_default_config_dict()
19 assert set(json_config) == set(defaults)11 assert set(json_config) == set(defaults)
20 for key, value in defaults.items():12 for key, value in defaults.items():
21 assert json_config[key] == value, key13 assert json_config[key] == value, key
2214
2315
24def test_load_config_without_overrides_equals_defaults() -> None:16def test_load_config_without_overrides_equals_defaults() -> None:
25 assert load_config() == DetectorConfig()17 assert config_module.load_config() == config_module.DetectorConfig()
2618
2719
28def test_parse_set_overrides_json_decodes_values() -> None:20def test_parse_set_overrides_json_decodes_values() -> None:
29 parsed = parse_set_overrides(21 parsed = config_module.parse_set_overrides(
30 ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"]22 ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"]
31 )23 )
32 assert parsed == {24 assert parsed == {
33 "merge_face_max_spacing_m": 1.5,25 "merge_face_max_spacing_m": 1.5,
Importance #11: tests/test_config.py @@ -36,17 +28,32 @@
36 }28 }
3729
3830
39def test_load_config_applies_overrides_with_type_coercion() -> None:31def test_load_config_applies_overrides_with_type_coercion() -> None:
40 config = load_config({"decimation_enabled": "true", "merge_face_max_faces": 3})32 config = config_module.load_config(
33 {"decimation_enabled": "true", "merge_face_max_faces": 3}
34 )
41 assert config.decimation_enabled is True35 assert config.decimation_enabled is True
42 assert config.merge_face_max_faces == 336 assert config.merge_face_max_faces == 3
4337
4438
45def test_unknown_key_rejected() -> None:39def test_unknown_key_rejected() -> None:
46 with pytest.raises(DetectorConfigError):40 with pytest.raises(config_module.DetectorConfigError):
47 config_from_dict({**load_default_config_dict(), "not_a_key": 1})41 config_module.config_from_dict(
42 {**config_module.load_default_config_dict(), "not_a_key": 1}
43 )
44
45
46def test_invalid_value_rejected() -> None:
47 with pytest.raises(config_module.DetectorConfigError, match="merge_face_max_faces"):
48 config_module.load_config({"merge_face_max_faces": "abc"})
49
50
51def test_config_is_frozen() -> None:
52 config = config_module.DetectorConfig()
53 with pytest.raises(pydantic.ValidationError):
54 config.merge_gap_m = 1.0
4855
4956
50def test_invalid_override_string_rejected() -> None:57def test_invalid_override_string_rejected() -> None:
51 with pytest.raises(DetectorConfigError):58 with pytest.raises(config_module.DetectorConfigError):
52 parse_set_overrides(["missing_equals_sign"])59 config_module.parse_set_overrides(["missing_equals_sign"])