Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:58:12+02:00
Commit #34 · 5 snippets
README.md | 2 ++ .../_config.py | 21 +++++++++++++++++++++ tests/test_clustering_gpu_config.py | 15 +++++++++++++++ 3 files changed, 38 insertions(+)
| 130 | ) | 130 | ) |
| 131 | postprocess: ClusterFinderGPUPostprocessConfig = ClusterFinderGPUPostprocessConfig() | 131 | postprocess: ClusterFinderGPUPostprocessConfig = ClusterFinderGPUPostprocessConfig() |
| 132 | file_naming: ClusterFinderGPUFileNamingConfig = ClusterFinderGPUFileNamingConfig() | 132 | file_naming: ClusterFinderGPUFileNamingConfig = ClusterFinderGPUFileNamingConfig() |
| 133 | 133 | ||
| 134 | @pydantic.model_validator(mode="before") | ||
| 135 | @classmethod | ||
| 136 | def _drop_null_sections(cls, value: Any) -> Any: | ||
| 137 | """Treat an explicitly ``null`` nested section as "use section defaults".""" | ||
| 138 | if not isinstance(value, dict): | ||
| 139 | return value | ||
| 140 | null_sections = [ | ||
| 141 | name | ||
| 142 | for name, field in cls.model_fields.items() | ||
| 143 | if value.get(name, ...) is None | ||
| 144 | and isinstance(field.annotation, type) | ||
| 145 | and issubclass(field.annotation, config_loader.ConfigModel) | ||
| 146 | ] | ||
| 147 | if not null_sections: | ||
| 148 | return value | ||
| 149 | data = dict(value) | ||
| 150 | for name in null_sections: | ||
| 151 | data.pop(name) | ||
| 152 | logger.debug("Using defaults for null %s section(s): %s", _CONTEXT, null_sections) | ||
| 153 | return data | ||
| 154 | |||
| 134 | 155 | ||
| 135 | def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 156 | def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 136 | """Validate *raw_config* against the model tree and return a plain dict.""" | 157 | """Validate *raw_config* against the model tree and return a plain dict.""" |
| 137 | return config_loader.validate_config( | 158 | return config_loader.validate_config( |
| 63 | ).read_text(encoding="utf-8") | 63 | ).read_text(encoding="utf-8") |
| 64 | ) | 64 | ) |
| 65 | 65 | ||
| 66 | assert _config.ClusterFinderGPUConfig().model_dump() == packaged | 66 | assert _config.ClusterFinderGPUConfig().model_dump() == packaged |
| 67 | |||
| 68 | |||
| 69 | def test_null_section_falls_back_to_section_defaults() -> None: | ||
| 70 | """An explicit ``null`` section means "use defaults", as before the pydantic move.""" | ||
| 71 | config = _config.normalize_cluster_finder_gpu_config( | ||
| 72 | {"voxelization": None, "clustering": None} | ||
| 73 | ) | ||
| 74 | |||
| 75 | assert config["voxelization"] == {"enabled": False, "voxel_size": 0.03} | ||
| 76 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 77 | |||
| 78 | |||
| 79 | def test_non_mapping_section_is_still_rejected() -> None: | ||
| 80 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="voxelization"): | ||
| 81 | _config.normalize_cluster_finder_gpu_config({"voxelization": 5}) |
| 35 | GPU Step 6 defaults live in `src/iolabs_point_cloud_filtering_clusters/clustering_gpu.default.json` and are mirrored by the pydantic model tree in `_config.py` (`ClusterFinderGPUConfig`, nested sections as nested models). | 35 | GPU Step 6 defaults live in `src/iolabs_point_cloud_filtering_clusters/clustering_gpu.default.json` and are mirrored by the pydantic model tree in `_config.py` (`ClusterFinderGPUConfig`, nested sections as nested models). |
| 36 | 36 | ||
| 37 | To add a config key: add a field to the matching `config_loader.ConfigModel` and the same key to the packaged JSON default. Nothing else. Unknown keys are rejected; overrides deep-merge onto the packaged defaults. | 37 | To add a config key: add a field to the matching `config_loader.ConfigModel` and the same key to the packaged JSON default. Nothing else. Unknown keys are rejected; overrides deep-merge onto the packaged defaults. |
| 38 | 38 | ||
| 39 | To add a whole section: declare a new `config_loader.ConfigModel` subclass, add it as a field on `ClusterFinderGPUConfig` with a default instance, and mirror the section in the packaged JSON. A section given as `null` falls back to that section's defaults; a section of any non-mapping type is rejected. | ||
| 40 | |||
| 39 | ## Develop locally (Nexus) | 41 | ## Develop locally (Nexus) |
| 40 | 42 | ||
| 41 | Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps — e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc — then: | 43 | Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps — e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc — then: |
| 42 | 44 |
| 130 | ) | 130 | ) |
| 131 | postprocess: ClusterFinderGPUPostprocessConfig = ClusterFinderGPUPostprocessConfig() | 131 | postprocess: ClusterFinderGPUPostprocessConfig = ClusterFinderGPUPostprocessConfig() |
| 132 | file_naming: ClusterFinderGPUFileNamingConfig = ClusterFinderGPUFileNamingConfig() | 132 | file_naming: ClusterFinderGPUFileNamingConfig = ClusterFinderGPUFileNamingConfig() |
| 133 | 133 | ||
| 134 | @pydantic.model_validator(mode="before") | ||
| 135 | @classmethod | ||
| 136 | def _drop_null_sections(cls, value: Any) -> Any: | ||
| 137 | """Treat an explicitly ``null`` nested section as "use section defaults".""" | ||
| 138 | if not isinstance(value, dict): | ||
| 139 | return value | ||
| 140 | null_sections = [ | ||
| 141 | name | ||
| 142 | for name, field in cls.model_fields.items() | ||
| 143 | if value.get(name, ...) is None | ||
| 144 | and isinstance(field.annotation, type) | ||
| 145 | and issubclass(field.annotation, config_loader.ConfigModel) | ||
| 146 | ] | ||
| 147 | if not null_sections: | ||
| 148 | return value | ||
| 149 | data = dict(value) | ||
| 150 | for name in null_sections: | ||
| 151 | data.pop(name) | ||
| 152 | logger.debug("Using defaults for null %s section(s): %s", _CONTEXT, null_sections) | ||
| 153 | return data | ||
| 154 | |||
| 134 | 155 | ||
| 135 | def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 156 | def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]: |
| 136 | """Validate *raw_config* against the model tree and return a plain dict.""" | 157 | """Validate *raw_config* against the model tree and return a plain dict.""" |
| 137 | return config_loader.validate_config( | 158 | return config_loader.validate_config( |
| 63 | ).read_text(encoding="utf-8") | 63 | ).read_text(encoding="utf-8") |
| 64 | ) | 64 | ) |
| 65 | 65 | ||
| 66 | assert _config.ClusterFinderGPUConfig().model_dump() == packaged | 66 | assert _config.ClusterFinderGPUConfig().model_dump() == packaged |
| 67 | |||
| 68 | |||
| 69 | def test_null_section_falls_back_to_section_defaults() -> None: | ||
| 70 | """An explicit ``null`` section means "use defaults", as before the pydantic move.""" | ||
| 71 | config = _config.normalize_cluster_finder_gpu_config( | ||
| 72 | {"voxelization": None, "clustering": None} | ||
| 73 | ) | ||
| 74 | |||
| 75 | assert config["voxelization"] == {"enabled": False, "voxel_size": 0.03} | ||
| 76 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 77 | |||
| 78 | |||
| 79 | def test_non_mapping_section_is_still_rejected() -> None: | ||
| 80 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="voxelization"): | ||
| 81 | _config.normalize_cluster_finder_gpu_config({"voxelization": 5}) |
nullconfig sections fall back to section defaults (pre-migration behaviour), re-validation instead ofmodel_copy(update=), doc/test parity with the packaged JSON.