Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:39:11+02:00
Commit #8 ยท 15 snippets
README.md | 9 ++++++--- src/pipeline/job_config.py | 41 ++++++++++++++++++++++++++++++----------- test/test_job_config.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 14 deletions(-)
| 52 | return {} if value is None else value | 63 | return {} if value is None else value |
| 53 | 64 | ||
| 54 | @pydantic.field_validator("spine_las_files", mode="after") | 65 | @pydantic.field_validator("spine_las_files", mode="after") |
| 55 | @classmethod | 66 | @classmethod |
| 56 | def _empty_spine_list_as_none(cls, value: list[str] | None) -> list[str] | None: | 67 | def _empty_spine_list_as_none( |
| 57 | """Preserve the legacy empty-list -> ``None`` behaviour.""" | 68 | cls, value: tuple[str, ...] | None |
| 58 | if not value: | 69 | ) -> tuple[str, ...] | None: |
| 59 | return None | 70 | """Map an empty list to ``None``. |
| 60 | return list(value) | 71 | |
| 72 | ``"spine_las_files": []`` is the checked-in way of saying "use every | ||
| 73 | spine", i.e. exactly what omitting the key means; collapsing it here | ||
| 74 | keeps :func:`merge_into_args` from passing an empty ``--spine-las-files`` | ||
| 75 | string to Step 3. | ||
| 76 | """ | ||
| 77 | return value or None | ||
| 61 | 78 | ||
| 62 | 79 | ||
| 63 | def load_job_config(path: Path) -> JobConfig: | 80 | def load_job_config(path: Path) -> JobConfig: |
| 64 | """Parse ``path`` into a :class:`JobConfig`. | 81 | """Parse ``path`` into a :class:`JobConfig`. |
| 10 | ``args.config_overrides`` **before** any CLI ``--set`` flags so that CLI | 10 | ``args.config_overrides`` **before** any CLI ``--set`` flags so that CLI |
| 11 | overrides win on key collision. | 11 | overrides win on key collision. |
| 12 | 12 | ||
| 13 | The schema is :class:`JobConfig` (a :class:`config_loader.ConfigModel`). Adding | 13 | The schema is :class:`JobConfig` (a :class:`config_loader.ConfigModel`). Adding |
| 14 | a key means adding a field here and using it in ``configs/jobs/*.json``; | 14 | a config key means adding the field to the model and using the same key in |
| 15 | unknown keys are rejected. | 15 | ``configs/jobs/*.json`` -- nothing else. Unknown keys are rejected. |
| 16 | |||
| 17 | Unlike the pipeline packages this module has no packaged ``*.default.json`` and | ||
| 18 | no ``--set`` entry points: a job config is always a user-supplied file, so | ||
| 19 | :func:`load_job_config` is the only loader. The ``set`` field keeps the name of | ||
| 20 | its public JSON key and therefore shadows the ``set`` builtin inside the class | ||
| 21 | body -- read it as ``cfg.set`` and never call ``set(...)`` in this module. | ||
| 16 | """ | 22 | """ |
| 17 | 23 | ||
| 18 | from __future__ import annotations | 24 | from __future__ import annotations |
| 19 | 25 | ||
| 20 | import argparse | 26 | import argparse |
| 21 | import json | 27 | import json |
| 22 | import logging | 28 | import logging |
| 23 | from pathlib import Path | 29 | from pathlib import Path |
| 24 | from typing import Any | 30 | from typing import Annotated, Any |
| 25 | 31 | ||
| 26 | import pydantic | 32 | import pydantic |
| 27 | from iolabs.common import config_loader | 33 | from iolabs.common import config_loader |
| 28 | 34 | ||
| 29 | logger = logging.getLogger(__name__) | 35 | logger = logging.getLogger(__name__) |
| 30 | 36 | ||
| 37 | _DEVICE_PATTERN = r"^(CPU|CUDA):\d+$" | ||
| 38 | |||
| 39 | Device = Annotated[str, pydantic.StringConstraints(pattern=_DEVICE_PATTERN)] | ||
| 40 | """Open3D device string as accepted by the pipeline wrappers (``CUDA:0``/``CPU:0``).""" | ||
| 41 | |||
| 31 | 42 | ||
| 32 | class JobConfigError(config_loader.ConfigError): | 43 | class JobConfigError(config_loader.ConfigError): |
| 33 | """Raised when a job config JSON is missing keys, has unknown keys, or is invalid.""" | 44 | """Raised when a job config JSON is missing keys, has unknown keys, or is invalid.""" |
| 34 | 45 |
| 39 | job_id: str | 50 | job_id: str |
| 40 | data_dir: Path | 51 | data_dir: Path |
| 41 | from_segment: int | None = None | 52 | from_segment: int | None = None |
| 42 | to_segment: int | None = None | 53 | to_segment: int | None = None |
| 43 | device: str | None = None | 54 | device: Device | None = None |
| 44 | random_seed: int | None = None | 55 | random_seed: int | None = None |
| 45 | spine_las_files: list[str] | None = None | 56 | spine_las_files: tuple[str, ...] | None = None |
| 46 | set: dict[str, Any] = pydantic.Field(default_factory=dict) | 57 | set: dict[str, Any] = pydantic.Field(default_factory=dict) |
| 47 | 58 | ||
| 48 | @pydantic.field_validator("set", mode="before") | 59 | @pydantic.field_validator("set", mode="before") |
| 49 | @classmethod | 60 | @classmethod |
| 109 | and getattr(args, "spine_las_files", None) is None | 126 | and getattr(args, "spine_las_files", None) is None |
| 110 | ): | 127 | ): |
| 111 | args.spine_las_files = ",".join(cfg.spine_las_files) | 128 | args.spine_las_files = ",".join(cfg.spine_las_files) |
| 112 | 129 | ||
| 113 | cfg_overrides = [f"{path}={_encode_override_value(value)}" for path, value in cfg.set.items()] | 130 | cfg_overrides = [ |
| 131 | f"{key}={_encode_override_value(value)}" for key, value in cfg.set.items() | ||
| 132 | ] | ||
| 114 | existing_overrides = list(getattr(args, "config_overrides", None) or []) | 133 | existing_overrides = list(getattr(args, "config_overrides", None) or []) |
| 115 | args.config_overrides = cfg_overrides + existing_overrides | 134 | args.config_overrides = cfg_overrides + existing_overrides |
| 116 | 135 | ||
| 117 | 136 |
| 3 | from pathlib import Path | 3 | from pathlib import Path |
| 4 | 4 | ||
| 5 | import pydantic | 5 | import pydantic |
| 6 | import pytest | 6 | import pytest |
| 7 | from iolabs.common import config_loader | ||
| 7 | 8 | ||
| 8 | from src.pipeline import job_config | 9 | from src.pipeline import job_config |
| 9 | 10 | ||
| 10 | _JOBS_DIR = Path(__file__).resolve().parents[1] / "configs" / "jobs" | 11 | _JOBS_DIR = Path(__file__).resolve().parents[1] / "configs" / "jobs" |
| 13 | def _write_json(path: Path, payload: dict) -> None: | 14 | def _write_json(path: Path, payload: dict) -> None: |
| 14 | path.write_text(json.dumps(payload)) | 15 | path.write_text(json.dumps(payload)) |
| 15 | 16 | ||
| 16 | 17 | ||
| 18 | def test_error_class_is_config_error() -> None: | ||
| 19 | assert issubclass(job_config.JobConfigError, config_loader.ConfigError) | ||
| 20 | assert issubclass(job_config.JobConfigError, ValueError) | ||
| 21 | |||
| 22 | |||
| 17 | def test_load_job_config_parses_full_schema(tmp_path: Path) -> None: | 23 | def test_load_job_config_parses_full_schema(tmp_path: Path) -> None: |
| 18 | cfg_path = tmp_path / "job.json" | 24 | cfg_path = tmp_path / "job.json" |
| 19 | _write_json( | 25 | _write_json( |
| 20 | cfg_path, | 26 | cfg_path, |
| 267 | with pytest.raises(job_config.JobConfigError, match="not valid JSON"): | 273 | with pytest.raises(job_config.JobConfigError, match="not valid JSON"): |
| 268 | job_config.load_job_config(cfg_path) | 274 | job_config.load_job_config(cfg_path) |
| 269 | 275 | ||
| 270 | 276 | ||
| 277 | def test_load_job_config_rejects_unknown_device(tmp_path: Path) -> None: | ||
| 278 | cfg_path = tmp_path / "job.json" | ||
| 279 | _write_json(cfg_path, {"job_id": "x", "data_dir": "d", "device": "gpu"}) | ||
| 280 | |||
| 281 | with pytest.raises(job_config.JobConfigError, match="device"): | ||
| 282 | job_config.load_job_config(cfg_path) | ||
| 283 | |||
| 284 | |||
| 285 | def test_spine_las_files_is_a_tuple(tmp_path: Path) -> None: | ||
| 286 | cfg_path = tmp_path / "job.json" | ||
| 287 | _write_json( | ||
| 288 | cfg_path, | ||
| 289 | {"job_id": "x", "data_dir": "d", "spine_las_files": ["a", "b"]}, | ||
| 290 | ) | ||
| 291 | |||
| 292 | cfg = job_config.load_job_config(cfg_path) | ||
| 293 | |||
| 294 | assert cfg.spine_las_files == ("a", "b") | ||
| 295 | |||
| 296 | |||
| 297 | def test_empty_spine_las_files_means_all_spines(tmp_path: Path) -> None: | ||
| 298 | cfg_path = tmp_path / "job.json" | ||
| 299 | _write_json(cfg_path, {"job_id": "x", "data_dir": "d", "spine_las_files": []}) | ||
| 300 | |||
| 301 | cfg = job_config.load_job_config(cfg_path) | ||
| 302 | |||
| 303 | assert cfg.spine_las_files is None | ||
| 304 | |||
| 305 | |||
| 271 | def test_job_config_is_frozen() -> None: | 306 | def test_job_config_is_frozen() -> None: |
| 272 | cfg = job_config.JobConfig(job_id="j", data_dir=Path("/x")) | 307 | cfg = job_config.JobConfig(job_id="j", data_dir=Path("/x")) |
| 273 | 308 | ||
| 274 | with pytest.raises(pydantic.ValidationError): | 309 | with pytest.raises(pydantic.ValidationError): |
| 114 | Step 5 `line_bitmap_inference.*`, Step 6 `mask_clustering.*`, Step 7 | 114 | Step 5 `line_bitmap_inference.*`, Step 6 `mask_clustering.*`, Step 7 |
| 115 | `iolabs-point-cloud-modelling-lines`. | 115 | `iolabs-point-cloud-modelling-lines`. |
| 116 | 116 | ||
| 117 | Job-config keys (`configs/jobs/*.json`) are declared on `JobConfig` in | 117 | Job-config keys (`configs/jobs/*.json`) are declared on `JobConfig` in |
| 118 | `src/pipeline/job_config.py`. Adding a key means adding a field to that | 118 | `src/pipeline/job_config.py` (a `config_loader.ConfigModel`); unknown keys are |
| 119 | `config_loader.ConfigModel` and using it in the job JSON; there is no separate | 119 | rejected. **To add a job-config key: add the field (with its type, default and |
| 120 | allow-list. | 120 | any `Field` range) to `JobConfig` and use the same key in the job JSON -- |
| 121 | nothing else.** `load_job_config()` returns the frozen `JobConfig`. Unlike the | ||
| 122 | pipeline packages there is no packaged `job.default.json` and no `--set` entry | ||
| 123 | point; `device` must match `CUDA:<n>`/`CPU:<n>`. | ||
| 121 | 124 | ||
| 122 | Use a dataset shortcut to keep commands short: | 125 | Use a dataset shortcut to keep commands short: |
| 123 | 126 | ||
| 124 | ```bash | 127 | ```bash |
| 10 | ``args.config_overrides`` **before** any CLI ``--set`` flags so that CLI | 10 | ``args.config_overrides`` **before** any CLI ``--set`` flags so that CLI |
| 11 | overrides win on key collision. | 11 | overrides win on key collision. |
| 12 | 12 | ||
| 13 | The schema is :class:`JobConfig` (a :class:`config_loader.ConfigModel`). Adding | 13 | The schema is :class:`JobConfig` (a :class:`config_loader.ConfigModel`). Adding |
| 14 | a key means adding a field here and using it in ``configs/jobs/*.json``; | 14 | a config key means adding the field to the model and using the same key in |
| 15 | unknown keys are rejected. | 15 | ``configs/jobs/*.json`` -- nothing else. Unknown keys are rejected. |
| 16 | |||
| 17 | Unlike the pipeline packages this module has no packaged ``*.default.json`` and | ||
| 18 | no ``--set`` entry points: a job config is always a user-supplied file, so | ||
| 19 | :func:`load_job_config` is the only loader. The ``set`` field keeps the name of | ||
| 20 | its public JSON key and therefore shadows the ``set`` builtin inside the class | ||
| 21 | body -- read it as ``cfg.set`` and never call ``set(...)`` in this module. | ||
| 16 | """ | 22 | """ |
| 17 | 23 | ||
| 18 | from __future__ import annotations | 24 | from __future__ import annotations |
| 19 | 25 | ||
| 20 | import argparse | 26 | import argparse |
| 21 | import json | 27 | import json |
| 22 | import logging | 28 | import logging |
| 23 | from pathlib import Path | 29 | from pathlib import Path |
| 24 | from typing import Any | 30 | from typing import Annotated, Any |
| 25 | 31 | ||
| 26 | import pydantic | 32 | import pydantic |
| 27 | from iolabs.common import config_loader | 33 | from iolabs.common import config_loader |
| 28 | 34 | ||
| 29 | logger = logging.getLogger(__name__) | 35 | logger = logging.getLogger(__name__) |
| 30 | 36 | ||
| 37 | _DEVICE_PATTERN = r"^(CPU|CUDA):\d+$" | ||
| 38 | |||
| 39 | Device = Annotated[str, pydantic.StringConstraints(pattern=_DEVICE_PATTERN)] | ||
| 40 | """Open3D device string as accepted by the pipeline wrappers (``CUDA:0``/``CPU:0``).""" | ||
| 41 | |||
| 31 | 42 | ||
| 32 | class JobConfigError(config_loader.ConfigError): | 43 | class JobConfigError(config_loader.ConfigError): |
| 33 | """Raised when a job config JSON is missing keys, has unknown keys, or is invalid.""" | 44 | """Raised when a job config JSON is missing keys, has unknown keys, or is invalid.""" |
| 34 | 45 |
| 39 | job_id: str | 50 | job_id: str |
| 40 | data_dir: Path | 51 | data_dir: Path |
| 41 | from_segment: int | None = None | 52 | from_segment: int | None = None |
| 42 | to_segment: int | None = None | 53 | to_segment: int | None = None |
| 43 | device: str | None = None | 54 | device: Device | None = None |
| 44 | random_seed: int | None = None | 55 | random_seed: int | None = None |
| 45 | spine_las_files: list[str] | None = None | 56 | spine_las_files: tuple[str, ...] | None = None |
| 46 | set: dict[str, Any] = pydantic.Field(default_factory=dict) | 57 | set: dict[str, Any] = pydantic.Field(default_factory=dict) |
| 47 | 58 | ||
| 48 | @pydantic.field_validator("set", mode="before") | 59 | @pydantic.field_validator("set", mode="before") |
| 49 | @classmethod | 60 | @classmethod |
| 52 | return {} if value is None else value | 63 | return {} if value is None else value |
| 53 | 64 | ||
| 54 | @pydantic.field_validator("spine_las_files", mode="after") | 65 | @pydantic.field_validator("spine_las_files", mode="after") |
| 55 | @classmethod | 66 | @classmethod |
| 56 | def _empty_spine_list_as_none(cls, value: list[str] | None) -> list[str] | None: | 67 | def _empty_spine_list_as_none( |
| 57 | """Preserve the legacy empty-list -> ``None`` behaviour.""" | 68 | cls, value: tuple[str, ...] | None |
| 58 | if not value: | 69 | ) -> tuple[str, ...] | None: |
| 59 | return None | 70 | """Map an empty list to ``None``. |
| 60 | return list(value) | 71 | |
| 72 | ``"spine_las_files": []`` is the checked-in way of saying "use every | ||
| 73 | spine", i.e. exactly what omitting the key means; collapsing it here | ||
| 74 | keeps :func:`merge_into_args` from passing an empty ``--spine-las-files`` | ||
| 75 | string to Step 3. | ||
| 76 | """ | ||
| 77 | return value or None | ||
| 61 | 78 | ||
| 62 | 79 | ||
| 63 | def load_job_config(path: Path) -> JobConfig: | 80 | def load_job_config(path: Path) -> JobConfig: |
| 64 | """Parse ``path`` into a :class:`JobConfig`. | 81 | """Parse ``path`` into a :class:`JobConfig`. |
| 109 | and getattr(args, "spine_las_files", None) is None | 126 | and getattr(args, "spine_las_files", None) is None |
| 110 | ): | 127 | ): |
| 111 | args.spine_las_files = ",".join(cfg.spine_las_files) | 128 | args.spine_las_files = ",".join(cfg.spine_las_files) |
| 112 | 129 | ||
| 113 | cfg_overrides = [f"{path}={_encode_override_value(value)}" for path, value in cfg.set.items()] | 130 | cfg_overrides = [ |
| 131 | f"{key}={_encode_override_value(value)}" for key, value in cfg.set.items() | ||
| 132 | ] | ||
| 114 | existing_overrides = list(getattr(args, "config_overrides", None) or []) | 133 | existing_overrides = list(getattr(args, "config_overrides", None) or []) |
| 115 | args.config_overrides = cfg_overrides + existing_overrides | 134 | args.config_overrides = cfg_overrides + existing_overrides |
| 116 | 135 | ||
| 117 | 136 |
| 3 | from pathlib import Path | 3 | from pathlib import Path |
| 4 | 4 | ||
| 5 | import pydantic | 5 | import pydantic |
| 6 | import pytest | 6 | import pytest |
| 7 | from iolabs.common import config_loader | ||
| 7 | 8 | ||
| 8 | from src.pipeline import job_config | 9 | from src.pipeline import job_config |
| 9 | 10 | ||
| 10 | _JOBS_DIR = Path(__file__).resolve().parents[1] / "configs" / "jobs" | 11 | _JOBS_DIR = Path(__file__).resolve().parents[1] / "configs" / "jobs" |
| 13 | def _write_json(path: Path, payload: dict) -> None: | 14 | def _write_json(path: Path, payload: dict) -> None: |
| 14 | path.write_text(json.dumps(payload)) | 15 | path.write_text(json.dumps(payload)) |
| 15 | 16 | ||
| 16 | 17 | ||
| 18 | def test_error_class_is_config_error() -> None: | ||
| 19 | assert issubclass(job_config.JobConfigError, config_loader.ConfigError) | ||
| 20 | assert issubclass(job_config.JobConfigError, ValueError) | ||
| 21 | |||
| 22 | |||
| 17 | def test_load_job_config_parses_full_schema(tmp_path: Path) -> None: | 23 | def test_load_job_config_parses_full_schema(tmp_path: Path) -> None: |
| 18 | cfg_path = tmp_path / "job.json" | 24 | cfg_path = tmp_path / "job.json" |
| 19 | _write_json( | 25 | _write_json( |
| 20 | cfg_path, | 26 | cfg_path, |
| 267 | with pytest.raises(job_config.JobConfigError, match="not valid JSON"): | 273 | with pytest.raises(job_config.JobConfigError, match="not valid JSON"): |
| 268 | job_config.load_job_config(cfg_path) | 274 | job_config.load_job_config(cfg_path) |
| 269 | 275 | ||
| 270 | 276 | ||
| 277 | def test_load_job_config_rejects_unknown_device(tmp_path: Path) -> None: | ||
| 278 | cfg_path = tmp_path / "job.json" | ||
| 279 | _write_json(cfg_path, {"job_id": "x", "data_dir": "d", "device": "gpu"}) | ||
| 280 | |||
| 281 | with pytest.raises(job_config.JobConfigError, match="device"): | ||
| 282 | job_config.load_job_config(cfg_path) | ||
| 283 | |||
| 284 | |||
| 285 | def test_spine_las_files_is_a_tuple(tmp_path: Path) -> None: | ||
| 286 | cfg_path = tmp_path / "job.json" | ||
| 287 | _write_json( | ||
| 288 | cfg_path, | ||
| 289 | {"job_id": "x", "data_dir": "d", "spine_las_files": ["a", "b"]}, | ||
| 290 | ) | ||
| 291 | |||
| 292 | cfg = job_config.load_job_config(cfg_path) | ||
| 293 | |||
| 294 | assert cfg.spine_las_files == ("a", "b") | ||
| 295 | |||
| 296 | |||
| 297 | def test_empty_spine_las_files_means_all_spines(tmp_path: Path) -> None: | ||
| 298 | cfg_path = tmp_path / "job.json" | ||
| 299 | _write_json(cfg_path, {"job_id": "x", "data_dir": "d", "spine_las_files": []}) | ||
| 300 | |||
| 301 | cfg = job_config.load_job_config(cfg_path) | ||
| 302 | |||
| 303 | assert cfg.spine_las_files is None | ||
| 304 | |||
| 305 | |||
| 271 | def test_job_config_is_frozen() -> None: | 306 | def test_job_config_is_frozen() -> None: |
| 272 | cfg = job_config.JobConfig(job_id="j", data_dir=Path("/x")) | 307 | cfg = job_config.JobConfig(job_id="j", data_dir=Path("/x")) |
| 273 | 308 | ||
| 274 | with pytest.raises(pydantic.ValidationError): | 309 | with pytest.raises(pydantic.ValidationError): |
job_config.py: docstring shape, error class naming, canonical test names.