Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:38:53+02:00
Commit #67 · 17 snippets
README.md | 23 +++++---- src/iolabs_point_cloud_tablecloth/config.py | 75 +++++++++++++++++++++++------ tests/test_config.py | 47 ++++++++++++++++-- 3 files changed, 115 insertions(+), 30 deletions(-)
| 37 | class TableclothConfig(config_loader.ConfigModel): | 45 | class TableclothConfig(config_loader.ConfigModel): |
| 38 | """Filter and runtime thresholds for tablecloth ground classification.""" | 46 | """Filter and runtime thresholds for tablecloth ground classification.""" |
| 39 | 47 | ||
| 40 | # Mechanism: none | smrf_numpy | csf_cloth | 48 | # Mechanism: none | smrf_numpy | csf_cloth |
| 41 | mechanism: Literal["none", "smrf_numpy", "csf_cloth"] = "smrf_numpy" | 49 | mechanism: Mechanism = "smrf_numpy" |
| 42 | 50 | ||
| 43 | # SMRF (lip-first defaults; see plan §3) | 51 | # SMRF (lip-first defaults; see plan §3) |
| 44 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 | 52 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 |
| 45 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall | 53 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall |
| 70 | 78 | ||
| 71 | 79 | ||
| 72 | def load_default_config_dict() -> dict[str, Any]: | 80 | def load_default_config_dict() -> dict[str, Any]: |
| 73 | """Return the package-owned default config as a plain dict.""" | 81 | """Return the package-owned default config as a plain dict.""" |
| 74 | package = __package__ or _PACKAGE_NAME | 82 | return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME) |
| 75 | return config_loader.load_packaged_json(package, _DEFAULT_CONFIG_NAME) | ||
| 76 | 83 | ||
| 77 | 84 | ||
| 78 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: | 85 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: |
| 79 | """Build a validated :class:`TableclothConfig` from a raw mapping. | 86 | """Build a validated :class:`TableclothConfig` from a raw mapping. |
| 80 | 87 | ||
| 81 | Unknown-key rejection and per-field value coercion come from | 88 | Unknown-key rejection and per-field value coercion come from |
| 82 | :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is | 89 | :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is |
| 83 | restricted to the declared ``Literal`` choices. | 90 | restricted to the declared :data:`Mechanism` choices. |
| 84 | 91 | ||
| 85 | Args: | 92 | Args: |
| 86 | raw: Merged config mapping (packaged defaults plus ``--set`` | 93 | raw: Merged config mapping (packaged defaults plus ``--set`` |
| 87 | overrides). | 94 | overrides). |
| 95 | """ | 102 | """ |
| 96 | return config_loader.validate_config( | 103 | return config_loader.validate_config( |
| 97 | TableclothConfig, | 104 | TableclothConfig, |
| 98 | raw, | 105 | raw, |
| 99 | context="tablecloth config", | 106 | context=_CONTEXT, |
| 100 | error_cls=TableclothConfigError, | 107 | error_cls=TableclothConfigError, |
| 101 | ) | 108 | ) |
| 102 | 109 | ||
| 103 | 110 | ||
| 104 | def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig: | 111 | def load_config( |
| 105 | """Load the default config and apply flat ``KEY=VALUE`` overrides. | 112 | overrides: dict[str, Any] | None = None, |
| 113 | *, | ||
| 114 | config_path: str | Path | None = None, | ||
| 115 | ) -> TableclothConfig: | ||
| 116 | """Load the config and apply flat ``KEY=VALUE`` overrides. | ||
| 106 | 117 | ||
| 107 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). | 118 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). |
| 119 | A *config_path* replaces the packaged defaults (shared-layer semantics). | ||
| 120 | |||
| 121 | Args: | ||
| 122 | overrides: Flat mapping of field name to value, or ``None``. | ||
| 123 | config_path: JSON file replacing ``tablecloth.default.json``. | ||
| 124 | |||
| 125 | Returns: | ||
| 126 | The validated config. | ||
| 127 | |||
| 128 | Raises: | ||
| 129 | TableclothConfigError: An unknown key or an invalid value. | ||
| 108 | """ | 130 | """ |
| 109 | package = __package__ or _PACKAGE_NAME | ||
| 110 | config = config_loader.load_config( | 131 | config = config_loader.load_config( |
| 111 | TableclothConfig, | 132 | TableclothConfig, |
| 112 | package=package, | 133 | package=_PACKAGE_NAME, |
| 113 | filename=_DEFAULT_CONFIG_NAME, | 134 | filename=_DEFAULT_FILENAME, |
| 114 | overrides=overrides, | 135 | overrides=overrides, |
| 115 | context="tablecloth config", | 136 | config_path=config_path, |
| 137 | context=_CONTEXT, | ||
| 116 | error_cls=TableclothConfigError, | 138 | error_cls=TableclothConfigError, |
| 117 | ) | 139 | ) |
| 140 | if config_path is not None: | ||
| 141 | logger.info("Config file applied: %s", config_path) | ||
| 118 | if overrides: | 142 | if overrides: |
| 119 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 143 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 120 | return config | 144 | return config |
| 121 | 145 | ||
| 122 | 146 | ||
| 147 | def with_overrides(config: TableclothConfig, updates: dict[str, Any]) -> TableclothConfig: | ||
| 148 | """Derive a new config from *config* with *updates* applied. | ||
| 149 | |||
| 150 | Re-validates the whole mapping, unlike ``model_copy(update=...)`` which | ||
| 151 | skips coercion and cross-field rules. | ||
| 152 | |||
| 153 | Args: | ||
| 154 | config: The config to derive from. | ||
| 155 | updates: Flat mapping of field name to replacement value. | ||
| 156 | |||
| 157 | Returns: | ||
| 158 | The derived, validated config. | ||
| 159 | |||
| 160 | Raises: | ||
| 161 | TableclothConfigError: An unknown key or an invalid value. | ||
| 162 | """ | ||
| 163 | return config_from_dict({**config.model_dump(), **updates}) | ||
| 164 | |||
| 165 | |||
| 123 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 166 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 124 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value. | 167 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value. |
| 125 | 168 | ||
| 126 | Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to | 169 | Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to |
| 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local | 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local |
| 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection, | 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection, |
| 6 | coercion) comes from :mod:`iolabs.common.config_loader`. | 6 | coercion) comes from :mod:`iolabs.common.config_loader`. |
| 7 | 7 | ||
| 8 | To add a config key, add a field to :class:`TableclothConfig` and a matching | 8 | The schema is :class:`TableclothConfig` (a ``config_loader.ConfigModel``), |
| 9 | entry in ``tablecloth.default.json`` — nothing else. Packaged defaults must | 9 | mirroring ``tablecloth.default.json`` key for key. |
| 10 | validate with zero overrides. | 10 | |
| 11 | Adding a config key means adding the field to the model and the same key to | ||
| 12 | ``tablecloth.default.json`` — nothing else. Unknown keys are rejected. The | ||
| 13 | entry points return the frozen :class:`TableclothConfig`. | ||
| 11 | 14 | ||
| 12 | Coercion is strict, by design: a value must be valid for the field's declared | 15 | Coercion is strict, by design: a value must be valid for the field's declared |
| 13 | type or the load fails. ``--set cell_m=true`` (bool for a float field), | 16 | type or the load fails. ``--set cell_m=true`` (bool for a float field), |
| 14 | ``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set | 17 | ``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set |
| 19 | 22 | ||
| 20 | from __future__ import annotations | 23 | from __future__ import annotations |
| 21 | 24 | ||
| 22 | import logging | 25 | import logging |
| 23 | from typing import Any, Literal | 26 | from pathlib import Path |
| 27 | from typing import Any, Literal, TypeAlias | ||
| 24 | 28 | ||
| 25 | from iolabs.common import config_loader | 29 | from iolabs.common import config_loader |
| 26 | 30 | ||
| 27 | logger = logging.getLogger(__name__) | 31 | logger = logging.getLogger(__name__) |
| 28 | 32 | ||
| 29 | _DEFAULT_CONFIG_NAME = "tablecloth.default.json" | ||
| 30 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" | 33 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" |
| 34 | _DEFAULT_FILENAME = "tablecloth.default.json" | ||
| 35 | _CONTEXT = "tablecloth config" | ||
| 36 | |||
| 37 | #: Ground-classification mechanisms; also the CLI ``--set mechanism=`` choices. | ||
| 38 | Mechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"] | ||
| 31 | 39 | ||
| 32 | 40 | ||
| 33 | class TableclothConfigError(config_loader.ConfigError): | 41 | class TableclothConfigError(config_loader.ConfigError): |
| 34 | """Raised when the tablecloth config contains unsupported keys or values.""" | 42 | """Raised when the tablecloth config contains unsupported keys or values.""" |
| 1 | import json | ||
| 2 | from pathlib import Path | ||
| 3 | |||
| 1 | import pytest | 4 | import pytest |
| 2 | from iolabs.common import config_loader | 5 | from iolabs.common import config_loader |
| 3 | 6 | ||
| 4 | from iolabs_point_cloud_tablecloth.config import ( | 7 | from iolabs_point_cloud_tablecloth.config import ( |
| 7 | config_from_dict, | 10 | config_from_dict, |
| 8 | load_config, | 11 | load_config, |
| 9 | load_default_config_dict, | 12 | load_default_config_dict, |
| 10 | parse_set_overrides, | 13 | parse_set_overrides, |
| 14 | with_overrides, | ||
| 11 | ) | 15 | ) |
| 12 | 16 | ||
| 13 | 17 | ||
| 14 | def test_packaged_defaults_validate_as_config_model() -> None: | 18 | def test_load_config_returns_packaged_defaults() -> None: |
| 15 | """Zero-override load must yield a ConfigModel whose dump matches the JSON.""" | 19 | """Zero-override load must yield a ConfigModel whose dump matches the JSON.""" |
| 16 | config = load_config() | 20 | config = load_config() |
| 17 | assert isinstance(config, config_loader.ConfigModel) | 21 | assert isinstance(config, config_loader.ConfigModel) |
| 18 | assert issubclass(TableclothConfigError, config_loader.ConfigError) | ||
| 19 | assert load_default_config_dict() == config.model_dump() | 22 | assert load_default_config_dict() == config.model_dump() |
| 20 | 23 | ||
| 21 | 24 | ||
| 25 | def test_error_class_is_config_error() -> None: | ||
| 26 | assert issubclass(TableclothConfigError, config_loader.ConfigError) | ||
| 27 | assert issubclass(TableclothConfigError, ValueError) | ||
| 28 | |||
| 29 | |||
| 22 | def test_model_defaults_match_packaged_json() -> None: | 30 | def test_model_defaults_match_packaged_json() -> None: |
| 23 | """Field defaults must stay identical to the packaged default JSON.""" | 31 | """Field defaults must stay identical to the packaged default JSON.""" |
| 24 | assert TableclothConfig().model_dump() == load_default_config_dict() | 32 | assert TableclothConfig().model_dump() == load_default_config_dict() |
| 25 | 33 |
| 40 | assert config.overlay_enabled is True | 48 | assert config.overlay_enabled is True |
| 41 | assert config.cell_m == 0.15 | 49 | assert config.cell_m == 0.15 |
| 42 | 50 | ||
| 43 | 51 | ||
| 44 | def test_unknown_key_rejected() -> None: | 52 | def test_unknown_top_level_key_is_rejected() -> None: |
| 45 | with pytest.raises(TableclothConfigError): | 53 | with pytest.raises(TableclothConfigError, match="not_a_key"): |
| 46 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) | 54 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) |
| 47 | 55 | ||
| 48 | 56 | ||
| 57 | def test_overrides_merge_onto_defaults() -> None: | ||
| 58 | """Overrides replace only their own keys; the rest stay at packaged values.""" | ||
| 59 | defaults = load_default_config_dict() | ||
| 60 | config = load_config({"cell_m": 0.15}) | ||
| 61 | assert config.model_dump() == {**defaults, "cell_m": 0.15} | ||
| 62 | |||
| 63 | |||
| 64 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 65 | path = tmp_path / "tablecloth.json" | ||
| 66 | path.write_text(json.dumps({**load_default_config_dict(), "cell_m": 0.11})) | ||
| 67 | config = load_config(config_path=path) | ||
| 68 | assert config.cell_m == 0.11 | ||
| 69 | |||
| 70 | |||
| 71 | def test_set_override_coercion_and_rejection() -> None: | ||
| 72 | config = load_config(parse_set_overrides(["csf_iterations=1e3", "overlay_enabled=on"])) | ||
| 73 | assert config.csf_iterations == 1000 | ||
| 74 | assert config.overlay_enabled is True | ||
| 75 | with pytest.raises(TableclothConfigError, match="Invalid boolean"): | ||
| 76 | load_config(parse_set_overrides(["overlay_enabled=flase"])) | ||
| 77 | |||
| 78 | |||
| 79 | def test_with_overrides_revalidates() -> None: | ||
| 80 | base = load_config() | ||
| 81 | derived = with_overrides(base, {"cell_m": "0.15"}) | ||
| 82 | assert derived.cell_m == 0.15 | ||
| 83 | assert base.cell_m == 0.20 | ||
| 84 | with pytest.raises(TableclothConfigError, match="Expected one of"): | ||
| 85 | with_overrides(base, {"mechanism": "pdal_smrf"}) | ||
| 86 | |||
| 87 | |||
| 49 | def test_invalid_override_string_rejected() -> None: | 88 | def test_invalid_override_string_rejected() -> None: |
| 50 | with pytest.raises(TableclothConfigError): | 89 | with pytest.raises(TableclothConfigError): |
| 51 | parse_set_overrides(["missing_equals_sign"]) | 90 | parse_set_overrides(["missing_equals_sign"]) |
| 52 | 91 |
| 42 | 42 | ||
| 43 | See [docs/plans/v1-implementation-plan.md](docs/plans/v1-implementation-plan.md) | 43 | See [docs/plans/v1-implementation-plan.md](docs/plans/v1-implementation-plan.md) |
| 44 | for the locked decisions and acceptance gates. | 44 | for the locked decisions and acceptance gates. |
| 45 | 45 | ||
| 46 | ## Configuration (iolabs convention) | 46 | ## Configuration |
| 47 | 47 | ||
| 48 | Following the other iolabs point-cloud packages (guardrails, asphaltedge, …), | 48 | Defaults live in `src/iolabs_point_cloud_tablecloth/tablecloth.default.json`. |
| 49 | the package owns an algorithm config | 49 | The schema is `TableclothConfig` in `iolabs_point_cloud_tablecloth.config` |
| 50 | `src/iolabs_point_cloud_tablecloth/tablecloth.default.json`. | 50 | (a `config_loader.ConfigModel`); unknown keys are rejected. **To add a config |
| 51 | `config.py` is the loader/schema: the frozen `TableclothConfig` pydantic model | 51 | key: add the field (with its type, default and any `Field` range) to the model |
| 52 | (`config_loader.ConfigModel`) is the typed params object and its fields are the | 52 | and the same key with the same default to the JSON — nothing else.** |
| 53 | schema. To add a config key, add a field to the model and a matching entry in | 53 | `load_default_config_dict`, `config_from_dict`, `load_config`, |
| 54 | `tablecloth.default.json` — nothing else. Packaged defaults must validate with | 54 | `with_overrides` and `parse_set_overrides` are the entry points; all but |
| 55 | zero overrides (asserted by `tests/test_config.py`). | 55 | `load_default_config_dict`/`parse_set_overrides` return the frozen |
| 56 | `TableclothConfig`. Runtime overrides come from repeatable `--set KEY=VALUE`, | ||
| 57 | never repo-local JSON. Packaged defaults must validate with zero overrides | ||
| 58 | (asserted by `tests/test_config.py`). | ||
| 56 | 59 | ||
| 57 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are | 60 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are |
| 58 | JSON-decoded), never repo-local JSON files. Each value is coerced strictly to | 61 | JSON-decoded), never repo-local JSON files. Each value is coerced strictly to |
| 59 | its field's declared type: a bool for a float field, a non-integral value for | 62 | its field's declared type: a bool for a float field, a non-integral value for |
| 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local | 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local |
| 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection, | 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection, |
| 6 | coercion) comes from :mod:`iolabs.common.config_loader`. | 6 | coercion) comes from :mod:`iolabs.common.config_loader`. |
| 7 | 7 | ||
| 8 | To add a config key, add a field to :class:`TableclothConfig` and a matching | 8 | The schema is :class:`TableclothConfig` (a ``config_loader.ConfigModel``), |
| 9 | entry in ``tablecloth.default.json`` — nothing else. Packaged defaults must | 9 | mirroring ``tablecloth.default.json`` key for key. |
| 10 | validate with zero overrides. | 10 | |
| 11 | Adding a config key means adding the field to the model and the same key to | ||
| 12 | ``tablecloth.default.json`` — nothing else. Unknown keys are rejected. The | ||
| 13 | entry points return the frozen :class:`TableclothConfig`. | ||
| 11 | 14 | ||
| 12 | Coercion is strict, by design: a value must be valid for the field's declared | 15 | Coercion is strict, by design: a value must be valid for the field's declared |
| 13 | type or the load fails. ``--set cell_m=true`` (bool for a float field), | 16 | type or the load fails. ``--set cell_m=true`` (bool for a float field), |
| 14 | ``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set | 17 | ``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set |
| 19 | 22 | ||
| 20 | from __future__ import annotations | 23 | from __future__ import annotations |
| 21 | 24 | ||
| 22 | import logging | 25 | import logging |
| 23 | from typing import Any, Literal | 26 | from pathlib import Path |
| 27 | from typing import Any, Literal, TypeAlias | ||
| 24 | 28 | ||
| 25 | from iolabs.common import config_loader | 29 | from iolabs.common import config_loader |
| 26 | 30 | ||
| 27 | logger = logging.getLogger(__name__) | 31 | logger = logging.getLogger(__name__) |
| 28 | 32 | ||
| 29 | _DEFAULT_CONFIG_NAME = "tablecloth.default.json" | ||
| 30 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" | 33 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" |
| 34 | _DEFAULT_FILENAME = "tablecloth.default.json" | ||
| 35 | _CONTEXT = "tablecloth config" | ||
| 36 | |||
| 37 | #: Ground-classification mechanisms; also the CLI ``--set mechanism=`` choices. | ||
| 38 | Mechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"] | ||
| 31 | 39 | ||
| 32 | 40 | ||
| 33 | class TableclothConfigError(config_loader.ConfigError): | 41 | class TableclothConfigError(config_loader.ConfigError): |
| 34 | """Raised when the tablecloth config contains unsupported keys or values.""" | 42 | """Raised when the tablecloth config contains unsupported keys or values.""" |
| 37 | class TableclothConfig(config_loader.ConfigModel): | 45 | class TableclothConfig(config_loader.ConfigModel): |
| 38 | """Filter and runtime thresholds for tablecloth ground classification.""" | 46 | """Filter and runtime thresholds for tablecloth ground classification.""" |
| 39 | 47 | ||
| 40 | # Mechanism: none | smrf_numpy | csf_cloth | 48 | # Mechanism: none | smrf_numpy | csf_cloth |
| 41 | mechanism: Literal["none", "smrf_numpy", "csf_cloth"] = "smrf_numpy" | 49 | mechanism: Mechanism = "smrf_numpy" |
| 42 | 50 | ||
| 43 | # SMRF (lip-first defaults; see plan §3) | 51 | # SMRF (lip-first defaults; see plan §3) |
| 44 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 | 52 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 |
| 45 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall | 53 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall |
| 70 | 78 | ||
| 71 | 79 | ||
| 72 | def load_default_config_dict() -> dict[str, Any]: | 80 | def load_default_config_dict() -> dict[str, Any]: |
| 73 | """Return the package-owned default config as a plain dict.""" | 81 | """Return the package-owned default config as a plain dict.""" |
| 74 | package = __package__ or _PACKAGE_NAME | 82 | return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME) |
| 75 | return config_loader.load_packaged_json(package, _DEFAULT_CONFIG_NAME) | ||
| 76 | 83 | ||
| 77 | 84 | ||
| 78 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: | 85 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: |
| 79 | """Build a validated :class:`TableclothConfig` from a raw mapping. | 86 | """Build a validated :class:`TableclothConfig` from a raw mapping. |
| 80 | 87 | ||
| 81 | Unknown-key rejection and per-field value coercion come from | 88 | Unknown-key rejection and per-field value coercion come from |
| 82 | :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is | 89 | :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is |
| 83 | restricted to the declared ``Literal`` choices. | 90 | restricted to the declared :data:`Mechanism` choices. |
| 84 | 91 | ||
| 85 | Args: | 92 | Args: |
| 86 | raw: Merged config mapping (packaged defaults plus ``--set`` | 93 | raw: Merged config mapping (packaged defaults plus ``--set`` |
| 87 | overrides). | 94 | overrides). |
| 95 | """ | 102 | """ |
| 96 | return config_loader.validate_config( | 103 | return config_loader.validate_config( |
| 97 | TableclothConfig, | 104 | TableclothConfig, |
| 98 | raw, | 105 | raw, |
| 99 | context="tablecloth config", | 106 | context=_CONTEXT, |
| 100 | error_cls=TableclothConfigError, | 107 | error_cls=TableclothConfigError, |
| 101 | ) | 108 | ) |
| 102 | 109 | ||
| 103 | 110 | ||
| 104 | def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig: | 111 | def load_config( |
| 105 | """Load the default config and apply flat ``KEY=VALUE`` overrides. | 112 | overrides: dict[str, Any] | None = None, |
| 113 | *, | ||
| 114 | config_path: str | Path | None = None, | ||
| 115 | ) -> TableclothConfig: | ||
| 116 | """Load the config and apply flat ``KEY=VALUE`` overrides. | ||
| 106 | 117 | ||
| 107 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). | 118 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). |
| 119 | A *config_path* replaces the packaged defaults (shared-layer semantics). | ||
| 120 | |||
| 121 | Args: | ||
| 122 | overrides: Flat mapping of field name to value, or ``None``. | ||
| 123 | config_path: JSON file replacing ``tablecloth.default.json``. | ||
| 124 | |||
| 125 | Returns: | ||
| 126 | The validated config. | ||
| 127 | |||
| 128 | Raises: | ||
| 129 | TableclothConfigError: An unknown key or an invalid value. | ||
| 108 | """ | 130 | """ |
| 109 | package = __package__ or _PACKAGE_NAME | ||
| 110 | config = config_loader.load_config( | 131 | config = config_loader.load_config( |
| 111 | TableclothConfig, | 132 | TableclothConfig, |
| 112 | package=package, | 133 | package=_PACKAGE_NAME, |
| 113 | filename=_DEFAULT_CONFIG_NAME, | 134 | filename=_DEFAULT_FILENAME, |
| 114 | overrides=overrides, | 135 | overrides=overrides, |
| 115 | context="tablecloth config", | 136 | config_path=config_path, |
| 137 | context=_CONTEXT, | ||
| 116 | error_cls=TableclothConfigError, | 138 | error_cls=TableclothConfigError, |
| 117 | ) | 139 | ) |
| 140 | if config_path is not None: | ||
| 141 | logger.info("Config file applied: %s", config_path) | ||
| 118 | if overrides: | 142 | if overrides: |
| 119 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 143 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 120 | return config | 144 | return config |
| 121 | 145 | ||
| 122 | 146 | ||
| 147 | def with_overrides(config: TableclothConfig, updates: dict[str, Any]) -> TableclothConfig: | ||
| 148 | """Derive a new config from *config* with *updates* applied. | ||
| 149 | |||
| 150 | Re-validates the whole mapping, unlike ``model_copy(update=...)`` which | ||
| 151 | skips coercion and cross-field rules. | ||
| 152 | |||
| 153 | Args: | ||
| 154 | config: The config to derive from. | ||
| 155 | updates: Flat mapping of field name to replacement value. | ||
| 156 | |||
| 157 | Returns: | ||
| 158 | The derived, validated config. | ||
| 159 | |||
| 160 | Raises: | ||
| 161 | TableclothConfigError: An unknown key or an invalid value. | ||
| 162 | """ | ||
| 163 | return config_from_dict({**config.model_dump(), **updates}) | ||
| 164 | |||
| 165 | |||
| 123 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 166 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 124 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value. | 167 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value. |
| 125 | 168 | ||
| 126 | Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to | 169 | Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to |
| 1 | import json | ||
| 2 | from pathlib import Path | ||
| 3 | |||
| 1 | import pytest | 4 | import pytest |
| 2 | from iolabs.common import config_loader | 5 | from iolabs.common import config_loader |
| 3 | 6 | ||
| 4 | from iolabs_point_cloud_tablecloth.config import ( | 7 | from iolabs_point_cloud_tablecloth.config import ( |
| 7 | config_from_dict, | 10 | config_from_dict, |
| 8 | load_config, | 11 | load_config, |
| 9 | load_default_config_dict, | 12 | load_default_config_dict, |
| 10 | parse_set_overrides, | 13 | parse_set_overrides, |
| 14 | with_overrides, | ||
| 11 | ) | 15 | ) |
| 12 | 16 | ||
| 13 | 17 | ||
| 14 | def test_packaged_defaults_validate_as_config_model() -> None: | 18 | def test_load_config_returns_packaged_defaults() -> None: |
| 15 | """Zero-override load must yield a ConfigModel whose dump matches the JSON.""" | 19 | """Zero-override load must yield a ConfigModel whose dump matches the JSON.""" |
| 16 | config = load_config() | 20 | config = load_config() |
| 17 | assert isinstance(config, config_loader.ConfigModel) | 21 | assert isinstance(config, config_loader.ConfigModel) |
| 18 | assert issubclass(TableclothConfigError, config_loader.ConfigError) | ||
| 19 | assert load_default_config_dict() == config.model_dump() | 22 | assert load_default_config_dict() == config.model_dump() |
| 20 | 23 | ||
| 21 | 24 | ||
| 25 | def test_error_class_is_config_error() -> None: | ||
| 26 | assert issubclass(TableclothConfigError, config_loader.ConfigError) | ||
| 27 | assert issubclass(TableclothConfigError, ValueError) | ||
| 28 | |||
| 29 | |||
| 22 | def test_model_defaults_match_packaged_json() -> None: | 30 | def test_model_defaults_match_packaged_json() -> None: |
| 23 | """Field defaults must stay identical to the packaged default JSON.""" | 31 | """Field defaults must stay identical to the packaged default JSON.""" |
| 24 | assert TableclothConfig().model_dump() == load_default_config_dict() | 32 | assert TableclothConfig().model_dump() == load_default_config_dict() |
| 25 | 33 |
| 40 | assert config.overlay_enabled is True | 48 | assert config.overlay_enabled is True |
| 41 | assert config.cell_m == 0.15 | 49 | assert config.cell_m == 0.15 |
| 42 | 50 | ||
| 43 | 51 | ||
| 44 | def test_unknown_key_rejected() -> None: | 52 | def test_unknown_top_level_key_is_rejected() -> None: |
| 45 | with pytest.raises(TableclothConfigError): | 53 | with pytest.raises(TableclothConfigError, match="not_a_key"): |
| 46 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) | 54 | config_from_dict({**load_default_config_dict(), "not_a_key": 1}) |
| 47 | 55 | ||
| 48 | 56 | ||
| 57 | def test_overrides_merge_onto_defaults() -> None: | ||
| 58 | """Overrides replace only their own keys; the rest stay at packaged values.""" | ||
| 59 | defaults = load_default_config_dict() | ||
| 60 | config = load_config({"cell_m": 0.15}) | ||
| 61 | assert config.model_dump() == {**defaults, "cell_m": 0.15} | ||
| 62 | |||
| 63 | |||
| 64 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 65 | path = tmp_path / "tablecloth.json" | ||
| 66 | path.write_text(json.dumps({**load_default_config_dict(), "cell_m": 0.11})) | ||
| 67 | config = load_config(config_path=path) | ||
| 68 | assert config.cell_m == 0.11 | ||
| 69 | |||
| 70 | |||
| 71 | def test_set_override_coercion_and_rejection() -> None: | ||
| 72 | config = load_config(parse_set_overrides(["csf_iterations=1e3", "overlay_enabled=on"])) | ||
| 73 | assert config.csf_iterations == 1000 | ||
| 74 | assert config.overlay_enabled is True | ||
| 75 | with pytest.raises(TableclothConfigError, match="Invalid boolean"): | ||
| 76 | load_config(parse_set_overrides(["overlay_enabled=flase"])) | ||
| 77 | |||
| 78 | |||
| 79 | def test_with_overrides_revalidates() -> None: | ||
| 80 | base = load_config() | ||
| 81 | derived = with_overrides(base, {"cell_m": "0.15"}) | ||
| 82 | assert derived.cell_m == 0.15 | ||
| 83 | assert base.cell_m == 0.20 | ||
| 84 | with pytest.raises(TableclothConfigError, match="Expected one of"): | ||
| 85 | with_overrides(base, {"mechanism": "pdal_smrf"}) | ||
| 86 | |||
| 87 | |||
| 49 | def test_invalid_override_string_rejected() -> None: | 88 | def test_invalid_override_string_rejected() -> None: |
| 50 | with pytest.raises(TableclothConfigError): | 89 | with pytest.raises(TableclothConfigError): |
| 51 | parse_set_overrides(["missing_equals_sign"]) | 90 | parse_set_overrides(["missing_equals_sign"]) |
| 52 | 91 |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.