Back to report index

tablecloth 8c72b27: AI3D-379 Align config module with fleet pattern

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(-)
Importance #1: src/iolabs_point_cloud_tablecloth/config.py @@ -37,9 +45,9 @@
37class TableclothConfig(config_loader.ConfigModel):45class TableclothConfig(config_loader.ConfigModel):
38 """Filter and runtime thresholds for tablecloth ground classification."""46 """Filter and runtime thresholds for tablecloth ground classification."""
3947
40 # Mechanism: none | smrf_numpy | csf_cloth48 # Mechanism: none | smrf_numpy | csf_cloth
41 mechanism: Literal["none", "smrf_numpy", "csf_cloth"] = "smrf_numpy"49 mechanism: Mechanism = "smrf_numpy"
4250
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.2552 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 wall53 slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall
Importance #2: src/iolabs_point_cloud_tablecloth/config.py @@ -70,18 +78,17 @@
7078
7179
72def load_default_config_dict() -> dict[str, Any]:80def 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_NAME82 return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME)
75 return config_loader.load_packaged_json(package, _DEFAULT_CONFIG_NAME)
7683
7784
78def config_from_dict(raw: dict[str, Any]) -> TableclothConfig:85def 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.
8087
81 Unknown-key rejection and per-field value coercion come from88 Unknown-key rejection and per-field value coercion come from
82 :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is89 :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is
83 restricted to the declared ``Literal`` choices.90 restricted to the declared :data:`Mechanism` choices.
8491
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).
Importance #3: src/iolabs_point_cloud_tablecloth/config.py @@ -95,32 +102,68 @@
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 )
102109
103110
104def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig:111def 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.
106117
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 config144 return config
121145
122146
147def 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
123def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:166def 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.
125168
126 Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to169 Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to
Importance #4: src/iolabs_point_cloud_tablecloth/config.py @@ -4,11 +4,14 @@
4come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local4come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local
5JSON. Config plumbing (packaged load, deep merge, unknown-key rejection,5JSON. Config plumbing (packaged load, deep merge, unknown-key rejection,
6coercion) comes from :mod:`iolabs.common.config_loader`.6coercion) comes from :mod:`iolabs.common.config_loader`.
77
8To add a config key, add a field to :class:`TableclothConfig` and a matching8The schema is :class:`TableclothConfig` (a ``config_loader.ConfigModel``),
9entry in ``tablecloth.default.json`` nothing else. Packaged defaults must9mirroring ``tablecloth.default.json`` key for key.
10validate with zero overrides.10
11Adding 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
13entry points return the frozen :class:`TableclothConfig`.
1114
12Coercion is strict, by design: a value must be valid for the field's declared15Coercion is strict, by design: a value must be valid for the field's declared
13type or the load fails. ``--set cell_m=true`` (bool for a float field),16type or the load fails. ``--set cell_m=true`` (bool for a float field),
14``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set17``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set
Importance #5: src/iolabs_point_cloud_tablecloth/config.py @@ -19,16 +22,21 @@
1922
20from __future__ import annotations23from __future__ import annotations
2124
22import logging25import logging
23from typing import Any, Literal26from pathlib import Path
27from typing import Any, Literal, TypeAlias
2428
25from iolabs.common import config_loader29from iolabs.common import config_loader
2630
27logger = logging.getLogger(__name__)31logger = logging.getLogger(__name__)
2832
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.
38Mechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"]
3139
3240
33class TableclothConfigError(config_loader.ConfigError):41class 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."""
Importance #6: tests/test_config.py @@ -1,4 +1,7 @@
1import json
2from pathlib import Path
3
1import pytest4import pytest
2from iolabs.common import config_loader5from iolabs.common import config_loader
36
4from iolabs_point_cloud_tablecloth.config import (7from iolabs_point_cloud_tablecloth.config import (
Importance #7: tests/test_config.py @@ -7,19 +10,24 @@
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)
1216
1317
14def test_packaged_defaults_validate_as_config_model() -> None:18def 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()
2023
2124
25def test_error_class_is_config_error() -> None:
26 assert issubclass(TableclothConfigError, config_loader.ConfigError)
27 assert issubclass(TableclothConfigError, ValueError)
28
29
22def test_model_defaults_match_packaged_json() -> None:30def 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()
2533
Importance #8: tests/test_config.py @@ -40,13 +48,44 @@
40 assert config.overlay_enabled is True48 assert config.overlay_enabled is True
41 assert config.cell_m == 0.1549 assert config.cell_m == 0.15
4250
4351
44def test_unknown_key_rejected() -> None:52def 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})
4755
4856
57def 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
64def 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
71def 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
79def 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
49def test_invalid_override_string_rejected() -> None:88def 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"])
5291
Importance #9: README.md @@ -42,18 +42,21 @@
4242
43See [docs/plans/v1-implementation-plan.md](docs/plans/v1-implementation-plan.md)43See [docs/plans/v1-implementation-plan.md](docs/plans/v1-implementation-plan.md)
44for the locked decisions and acceptance gates.44for the locked decisions and acceptance gates.
4545
46## Configuration (iolabs convention)46## Configuration
4747
48Following the other iolabs point-cloud packages (guardrails, asphaltedge, …),48Defaults live in `src/iolabs_point_cloud_tablecloth/tablecloth.default.json`.
49the package owns an algorithm config49The 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 model51key: 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 the52and the same key with the same default to the JSON — nothing else.**
53schema. To add a config key, add a field to the model and a matching entry in53`load_default_config_dict`, `config_from_dict`, `load_config`,
54`tablecloth.default.json` — nothing else. Packaged defaults must validate with54`with_overrides` and `parse_set_overrides` are the entry points; all but
55zero 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`,
57never repo-local JSON. Packaged defaults must validate with zero overrides
58(asserted by `tests/test_config.py`).
5659
57Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are60Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are
58JSON-decoded), never repo-local JSON files. Each value is coerced strictly to61JSON-decoded), never repo-local JSON files. Each value is coerced strictly to
59its field's declared type: a bool for a float field, a non-integral value for62its field's declared type: a bool for a float field, a non-integral value for
Importance #10: src/iolabs_point_cloud_tablecloth/config.py @@ -4,11 +4,14 @@
4come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local4come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local
5JSON. Config plumbing (packaged load, deep merge, unknown-key rejection,5JSON. Config plumbing (packaged load, deep merge, unknown-key rejection,
6coercion) comes from :mod:`iolabs.common.config_loader`.6coercion) comes from :mod:`iolabs.common.config_loader`.
77
8To add a config key, add a field to :class:`TableclothConfig` and a matching8The schema is :class:`TableclothConfig` (a ``config_loader.ConfigModel``),
9entry in ``tablecloth.default.json`` nothing else. Packaged defaults must9mirroring ``tablecloth.default.json`` key for key.
10validate with zero overrides.10
11Adding 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
13entry points return the frozen :class:`TableclothConfig`.
1114
12Coercion is strict, by design: a value must be valid for the field's declared15Coercion is strict, by design: a value must be valid for the field's declared
13type or the load fails. ``--set cell_m=true`` (bool for a float field),16type or the load fails. ``--set cell_m=true`` (bool for a float field),
14``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set17``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set
Importance #11: src/iolabs_point_cloud_tablecloth/config.py @@ -19,16 +22,21 @@
1922
20from __future__ import annotations23from __future__ import annotations
2124
22import logging25import logging
23from typing import Any, Literal26from pathlib import Path
27from typing import Any, Literal, TypeAlias
2428
25from iolabs.common import config_loader29from iolabs.common import config_loader
2630
27logger = logging.getLogger(__name__)31logger = logging.getLogger(__name__)
2832
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.
38Mechanism: TypeAlias = Literal["none", "smrf_numpy", "csf_cloth"]
3139
3240
33class TableclothConfigError(config_loader.ConfigError):41class 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."""
Importance #12: src/iolabs_point_cloud_tablecloth/config.py @@ -37,9 +45,9 @@
37class TableclothConfig(config_loader.ConfigModel):45class TableclothConfig(config_loader.ConfigModel):
38 """Filter and runtime thresholds for tablecloth ground classification."""46 """Filter and runtime thresholds for tablecloth ground classification."""
3947
40 # Mechanism: none | smrf_numpy | csf_cloth48 # Mechanism: none | smrf_numpy | csf_cloth
41 mechanism: Literal["none", "smrf_numpy", "csf_cloth"] = "smrf_numpy"49 mechanism: Mechanism = "smrf_numpy"
4250
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.2552 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 wall53 slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall
Importance #13: src/iolabs_point_cloud_tablecloth/config.py @@ -70,18 +78,17 @@
7078
7179
72def load_default_config_dict() -> dict[str, Any]:80def 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_NAME82 return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME)
75 return config_loader.load_packaged_json(package, _DEFAULT_CONFIG_NAME)
7683
7784
78def config_from_dict(raw: dict[str, Any]) -> TableclothConfig:85def 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.
8087
81 Unknown-key rejection and per-field value coercion come from88 Unknown-key rejection and per-field value coercion come from
82 :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is89 :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is
83 restricted to the declared ``Literal`` choices.90 restricted to the declared :data:`Mechanism` choices.
8491
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).
Importance #14: src/iolabs_point_cloud_tablecloth/config.py @@ -95,32 +102,68 @@
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 )
102109
103110
104def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig:111def 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.
106117
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 config144 return config
121145
122146
147def 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
123def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:166def 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.
125168
126 Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to169 Thin binding of :func:`iolabs.common.config_loader.parse_set_overrides` to
Importance #15: tests/test_config.py @@ -1,4 +1,7 @@
1import json
2from pathlib import Path
3
1import pytest4import pytest
2from iolabs.common import config_loader5from iolabs.common import config_loader
36
4from iolabs_point_cloud_tablecloth.config import (7from iolabs_point_cloud_tablecloth.config import (
Importance #16: tests/test_config.py @@ -7,19 +10,24 @@
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)
1216
1317
14def test_packaged_defaults_validate_as_config_model() -> None:18def 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()
2023
2124
25def test_error_class_is_config_error() -> None:
26 assert issubclass(TableclothConfigError, config_loader.ConfigError)
27 assert issubclass(TableclothConfigError, ValueError)
28
29
22def test_model_defaults_match_packaged_json() -> None:30def 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()
2533
Importance #17: tests/test_config.py @@ -40,13 +48,44 @@
40 assert config.overlay_enabled is True48 assert config.overlay_enabled is True
41 assert config.cell_m == 0.1549 assert config.cell_m == 0.15
4250
4351
44def test_unknown_key_rejected() -> None:52def 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})
4755
4856
57def 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
64def 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
71def 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
79def 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
49def test_invalid_override_string_rejected() -> None:88def 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"])
5291