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(-)
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | ``guardrails.default.json`` algorithm config, and a typed params object |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are |
| 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. | 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. |
| 8 | ``config.py`` is the loader/schema: the dataclass field set is the schema and | 8 | ``config.py`` is the loader/schema: the pydantic model field set is the schema |
| 9 | every field default is kept identical to ``guardrails.default.json`` (guarded by | 9 | and every field default is kept identical to ``guardrails.default.json`` |
| 10 | a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. | 10 | (guarded by a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. |
| 11 | |||
| 12 | To add a config key: add a field on :class:`DetectorConfig` and the matching | ||
| 13 | key/value on ``guardrails.default.json``. Nothing else. | ||
| 11 | """ | 14 | """ |
| 12 | 15 | ||
| 13 | from __future__ import annotations | 16 | from __future__ import annotations |
| 14 | 17 | ||
| 15 | import copy | ||
| 16 | import json | 18 | import json |
| 17 | import logging | 19 | import logging |
| 18 | from dataclasses import dataclass, fields | ||
| 19 | from pathlib import Path | 20 | from pathlib import Path |
| 20 | from typing import Any | 21 | from typing import Any |
| 21 | 22 | ||
| 22 | from iolabs.common.config_loader import ConfigError, default_config_path, validate_allowed_keys | 23 | from iolabs.common import config_loader |
| 23 | 24 | ||
| 24 | logger = logging.getLogger(__name__) | 25 | logger = logging.getLogger(__name__) |
| 25 | 26 | ||
| 27 | _PACKAGE_NAME = "guardrails" | ||
| 28 | _DEFAULT_CONFIG_NAME = "guardrails.default.json" | ||
| 29 | |||
| 26 | 30 | ||
| 27 | @dataclass(frozen=True) | 31 | class DetectorConfig(config_loader.ConfigModel): |
| 28 | class DetectorConfig: | ||
| 29 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" | 32 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" |
| 30 | 33 | ||
| 31 | # Ground model | 34 | # Ground model |
| 32 | ground_cell_m: float = 0.75 | 35 | ground_cell_m: float = 0.75 |
| 165 | exclusion_dbscan_mem_limit_gb: float = 6.0 | 168 | exclusion_dbscan_mem_limit_gb: float = 6.0 |
| 166 | exclusion_dbscan_timeout_s: float = 120.0 | 169 | exclusion_dbscan_timeout_s: float = 120.0 |
| 167 | 170 | ||
| 168 | 171 | ||
| 169 | class DetectorConfigError(ConfigError): | 172 | class 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 | |||
| 176 | def _field_types() -> dict[str, type]: | ||
| 177 | return {f.name: f.type for f in fields(DetectorConfig)} | ||
| 178 | 174 | ||
| 179 | 175 | ||
| 180 | def _default_config_path() -> Path: | 176 | def _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) |
| 184 | 180 | ||
| 185 | 181 | ||
| 186 | def load_default_config_dict() -> dict[str, Any]: | 182 | def 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: |
| 190 | 186 | return json.load(handle) | |
| 191 | 187 | return config_loader.load_packaged_json(__package__, _DEFAULT_CONFIG_NAME) | |
| 192 | def _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 | ||
| 206 | 188 | ||
| 207 | 189 | ||
| 208 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 190 | def 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) | ||
| 219 | 198 | ||
| 220 | 199 | ||
| 221 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: | 200 | def 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. |
| 223 | 202 | ||
| 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] = value | 207 | 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 config | 219 | return config |
| 233 | 220 | ||
| 234 | 221 | ||
| 235 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 222 | def 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 |
| 1 | from dataclasses import asdict | 1 | import pydantic |
| 2 | |||
| 3 | import pytest | 2 | import pytest |
| 4 | 3 | ||
| 5 | from guardrails.config import ( | 4 | from 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 | ) | ||
| 13 | 5 | ||
| 14 | 6 | ||
| 15 | def test_default_json_matches_dataclass_defaults() -> None: | 7 | def 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, key | 13 | assert json_config[key] == value, key |
| 22 | 14 | ||
| 23 | 15 | ||
| 24 | def test_load_config_without_overrides_equals_defaults() -> None: | 16 | def test_load_config_without_overrides_equals_defaults() -> None: |
| 25 | assert load_config() == DetectorConfig() | 17 | assert config_module.load_config() == config_module.DetectorConfig() |
| 26 | 18 | ||
| 27 | 19 | ||
| 28 | def test_parse_set_overrides_json_decodes_values() -> None: | 20 | def 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, |
| 36 | } | 28 | } |
| 37 | 29 | ||
| 38 | 30 | ||
| 39 | def test_load_config_applies_overrides_with_type_coercion() -> None: | 31 | def 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 True | 35 | assert config.decimation_enabled is True |
| 42 | assert config.merge_face_max_faces == 3 | 36 | assert config.merge_face_max_faces == 3 |
| 43 | 37 | ||
| 44 | 38 | ||
| 45 | def test_unknown_key_rejected() -> None: | 39 | def 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 | |||
| 46 | def 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 | |||
| 51 | def test_config_is_frozen() -> None: | ||
| 52 | config = config_module.DetectorConfig() | ||
| 53 | with pytest.raises(pydantic.ValidationError): | ||
| 54 | config.merge_gap_m = 1.0 | ||
| 48 | 55 | ||
| 49 | 56 | ||
| 50 | def test_invalid_override_string_rejected() -> None: | 57 | def 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"]) |
| 1 | [project] | 1 | [project] |
| 2 | name = "guardrails" | 2 | name = "guardrails" |
| 3 | version = "0.2.0" | 3 | version = "0.2.1" |
| 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" | 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" |
| 5 | readme = "README.md" | 5 | readme = "README.md" |
| 6 | requires-python = ">=3.11" | 6 | requires-python = ">=3.11" |
| 7 | dependencies = [ | 7 | dependencies = [ |
| 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", |
| 23 | 23 | ||
| 24 | Following the other iolabs point-cloud packages | 24 | Following the other iolabs point-cloud packages |
| 25 | (`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an | 25 | (`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an |
| 26 | algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is | 26 | algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is |
| 27 | the loader/schema: the frozen `DetectorConfig` dataclass is the typed params | 27 | the loader/schema: the frozen pydantic `DetectorConfig` model (derived from |
| 28 | object and its field set is the schema. Every dataclass default is kept | 28 | `iolabs.common.config_loader.ConfigModel`) is the typed params object and its |
| 29 | identical to the JSON (asserted by `tests/test_config.py`). | 29 | field 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. | ||
| 30 | 32 | ||
| 31 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are | 33 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are |
| 32 | JSON-decoded), never repo-local JSON files: | 34 | JSON-decoded), never repo-local JSON files: |
| 33 | 35 |
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | ``guardrails.default.json`` algorithm config, and a typed params object |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are |
| 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. | 7 | applied through repeatable ``--set PATH=VALUE`` flags, never repo-local JSON. |
| 8 | ``config.py`` is the loader/schema: the dataclass field set is the schema and | 8 | ``config.py`` is the loader/schema: the pydantic model field set is the schema |
| 9 | every field default is kept identical to ``guardrails.default.json`` (guarded by | 9 | and every field default is kept identical to ``guardrails.default.json`` |
| 10 | a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. | 10 | (guarded by a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. |
| 11 | |||
| 12 | To add a config key: add a field on :class:`DetectorConfig` and the matching | ||
| 13 | key/value on ``guardrails.default.json``. Nothing else. | ||
| 11 | """ | 14 | """ |
| 12 | 15 | ||
| 13 | from __future__ import annotations | 16 | from __future__ import annotations |
| 14 | 17 | ||
| 15 | import copy | ||
| 16 | import json | 18 | import json |
| 17 | import logging | 19 | import logging |
| 18 | from dataclasses import dataclass, fields | ||
| 19 | from pathlib import Path | 20 | from pathlib import Path |
| 20 | from typing import Any | 21 | from typing import Any |
| 21 | 22 | ||
| 22 | from iolabs.common.config_loader import ConfigError, default_config_path, validate_allowed_keys | 23 | from iolabs.common import config_loader |
| 23 | 24 | ||
| 24 | logger = logging.getLogger(__name__) | 25 | logger = logging.getLogger(__name__) |
| 25 | 26 | ||
| 27 | _PACKAGE_NAME = "guardrails" | ||
| 28 | _DEFAULT_CONFIG_NAME = "guardrails.default.json" | ||
| 29 | |||
| 26 | 30 | ||
| 27 | @dataclass(frozen=True) | 31 | class DetectorConfig(config_loader.ConfigModel): |
| 28 | class DetectorConfig: | ||
| 29 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" | 32 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" |
| 30 | 33 | ||
| 31 | # Ground model | 34 | # Ground model |
| 32 | ground_cell_m: float = 0.75 | 35 | ground_cell_m: float = 0.75 |
| 165 | exclusion_dbscan_mem_limit_gb: float = 6.0 | 168 | exclusion_dbscan_mem_limit_gb: float = 6.0 |
| 166 | exclusion_dbscan_timeout_s: float = 120.0 | 169 | exclusion_dbscan_timeout_s: float = 120.0 |
| 167 | 170 | ||
| 168 | 171 | ||
| 169 | class DetectorConfigError(ConfigError): | 172 | class 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 | |||
| 176 | def _field_types() -> dict[str, type]: | ||
| 177 | return {f.name: f.type for f in fields(DetectorConfig)} | ||
| 178 | 174 | ||
| 179 | 175 | ||
| 180 | def _default_config_path() -> Path: | 176 | def _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) |
| 184 | 180 | ||
| 185 | 181 | ||
| 186 | def load_default_config_dict() -> dict[str, Any]: | 182 | def 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: |
| 190 | 186 | return json.load(handle) | |
| 191 | 187 | return config_loader.load_packaged_json(__package__, _DEFAULT_CONFIG_NAME) | |
| 192 | def _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 | ||
| 206 | 188 | ||
| 207 | 189 | ||
| 208 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 190 | def 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) | ||
| 219 | 198 | ||
| 220 | 199 | ||
| 221 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: | 200 | def 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. |
| 223 | 202 | ||
| 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] = value | 207 | 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 config | 219 | return config |
| 233 | 220 | ||
| 234 | 221 | ||
| 235 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 222 | def 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 |
| 1 | [project] | 1 | [project] |
| 2 | name = "guardrails" | 2 | name = "guardrails" |
| 3 | version = "0.2.0" | 3 | version = "0.2.1" |
| 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" | 4 | description = "Classical geometric guardrail detection in MLS LiDAR point clouds" |
| 5 | readme = "README.md" | 5 | readme = "README.md" |
| 6 | requires-python = ">=3.11" | 6 | requires-python = ">=3.11" |
| 7 | dependencies = [ | 7 | dependencies = [ |
| 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", |
| 1 | from dataclasses import asdict | 1 | import pydantic |
| 2 | |||
| 3 | import pytest | 2 | import pytest |
| 4 | 3 | ||
| 5 | from guardrails.config import ( | 4 | from 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 | ) | ||
| 13 | 5 | ||
| 14 | 6 | ||
| 15 | def test_default_json_matches_dataclass_defaults() -> None: | 7 | def 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, key | 13 | assert json_config[key] == value, key |
| 22 | 14 | ||
| 23 | 15 | ||
| 24 | def test_load_config_without_overrides_equals_defaults() -> None: | 16 | def test_load_config_without_overrides_equals_defaults() -> None: |
| 25 | assert load_config() == DetectorConfig() | 17 | assert config_module.load_config() == config_module.DetectorConfig() |
| 26 | 18 | ||
| 27 | 19 | ||
| 28 | def test_parse_set_overrides_json_decodes_values() -> None: | 20 | def 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, |
| 36 | } | 28 | } |
| 37 | 29 | ||
| 38 | 30 | ||
| 39 | def test_load_config_applies_overrides_with_type_coercion() -> None: | 31 | def 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 True | 35 | assert config.decimation_enabled is True |
| 42 | assert config.merge_face_max_faces == 3 | 36 | assert config.merge_face_max_faces == 3 |
| 43 | 37 | ||
| 44 | 38 | ||
| 45 | def test_unknown_key_rejected() -> None: | 39 | def 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 | |||
| 46 | def 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 | |||
| 51 | def test_config_is_frozen() -> None: | ||
| 52 | config = config_module.DetectorConfig() | ||
| 53 | with pytest.raises(pydantic.ValidationError): | ||
| 54 | config.merge_gap_m = 1.0 | ||
| 48 | 55 | ||
| 49 | 56 | ||
| 50 | def test_invalid_override_string_rejected() -> None: | 57 | def 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"]) |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.