From ea8032707028ac9b61f48613b621df5a7d58f046 Mon Sep 17 00:00:00 2001 From: Patrick Leiverkus Date: Sun, 26 Jul 2026 15:08:04 +0200 Subject: [PATCH 1/2] fix: pin ruff and select the lint rule set explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI went red on an unchanged tree. The `dev` extra requested `ruff>=0.5`, so CI installed 0.16.0, which enabled RUF100 (unused-noqa) by default and reported 65 errors in files nobody had touched since June. Pin `ruff==0.16.0` and declare `select` explicitly, so neither the linter version nor the rule set can move without a commit saying so. Two rules are added on top of ruff's defaults: - RUF100, because it is what caught this and dead directives should not accumulate silently. - PLC0415 (import-outside-top-level). Lazy imports are load-bearing here: the package must import without the optional geo/model extras, and `structura --help` must not pull in the heavy stacks. Leaving the rule off made a deliberate lazy import indistinguishable from an accidental one. With it on, all 37 carry an explicit `# noqa: PLC0415`; the eight previously unmarked sites in cli.py and tests/conftest.py are annotated. Also drop the 35 `# noqa: E402` directives in tests/. They never suppressed anything — ruff exempts imports following `pytest.importorskip()`, so E402 does not fire at those sites even when the rule is selected and `--ignore-noqa` is passed. Verified against the pinned version: ruff clean, mypy clean on 26 files, 41 passed / 2 skipped. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 16 +++++++++++++++- src/structura/cli.py | 3 ++- tests/conftest.py | 14 +++++++------- tests/test_cellpose.py | 4 ++-- tests/test_classical.py | 4 ++-- tests/test_common.py | 8 ++++---- tests/test_edge_tracing.py | 4 ++-- tests/test_file_sink.py | 8 ++++---- tests/test_geo.py | 6 +++--- tests/test_metrics.py | 6 +++--- tests/test_pipeline.py | 6 +++--- tests/test_relief.py | 4 ++-- tests/test_sam.py | 4 ++-- tests/test_skeleton.py | 6 +++--- tests/test_wall_tracing.py | 10 +++++----- 16 files changed, 78 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ea217..dbd0532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Ruff is now pinned exactly (`ruff==0.16.0`) and the lint rule set is selected + explicitly.** Ruff 0.16.0 enabled `RUF100` (unused-`noqa`) by default, which + turned CI red on an unchanged tree — the `dev` extra requested `ruff>=0.5`, so + CI silently picked up the new rule set. Linter upgrades are now a deliberate, + reviewable commit rather than a side effect of the next CI run. +- **`PLC0415` (import-outside-top-level) is now enabled.** Lazy imports are an + architectural requirement here — the package must import without the optional + `geo` / model extras, and `structura --help` must not pull in the heavy stacks. + With the rule active, each of the 37 lazy imports carries an explicit + `# noqa: PLC0415`, so a deliberate lazy import is distinguishable from an + accidental one. Eight previously unmarked sites (`cli.py`, `tests/conftest.py`) + were annotated. + +### Removed +- The 35 `# noqa: E402` directives in `tests/`. They never suppressed anything: + ruff exempts imports that follow `pytest.importorskip()`, so E402 does not fire + at those sites even when the rule is explicitly selected. + ## [0.4.1] - 2026-06-18 ### Changed diff --git a/pyproject.toml b/pyproject.toml index cb0765a..0b0db97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,10 @@ cellpose = ["cellpose>=4"] # Cellpose v4 (Cellpose-SAM / cpsam) db = ["psycopg[binary]>=3.1", "SQLAlchemy>=2.0", "GeoAlchemy2>=0.14"] # Django-API sink api = ["httpx>=0.27"] -dev = ["pytest>=8", "ruff>=0.5", "mypy>=1.10"] +# ruff is pinned exactly: it is a linter, and its rule set changes between +# releases (0.16.0 enabled RUF100 by default, which turned CI red on an +# unchanged tree). Bumping it should be a visible, deliberate commit. +dev = ["pytest>=8", "ruff==0.16.0", "mypy>=1.10"] [project.scripts] structura = "structura.cli:main" @@ -44,6 +47,17 @@ packages = ["src/structura"] line-length = 100 target-version = "py311" +[tool.ruff.lint] +# Ruff's defaults (E4, E7, E9, F) plus two rules this project depends on: +# PLC0415 — lazy imports are a deliberate architectural choice (the package +# must import without the optional geo / model extras, and `--help` must not +# pull in the heavy stacks). Enabling the rule means each one is marked +# explicitly instead of being indistinguishable from an accidental import. +# RUF100 — flag `noqa` directives that have stopped suppressing anything. +# Selecting explicitly also pins the *rule set*, so a future ruff release cannot +# silently widen it; the version itself is pinned in the `dev` extra. +select = ["E4", "E7", "E9", "F", "PLC0415", "RUF100"] + [tool.mypy] python_version = "3.11" ignore_missing_imports = true diff --git a/src/structura/cli.py b/src/structura/cli.py index c5e68c2..20b4d96 100644 --- a/src/structura/cli.py +++ b/src/structura/cli.py @@ -34,7 +34,8 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "run": - from .pipeline import run as run_pipeline # lazy: avoids heavy imports for --help + # lazy: avoids heavy imports for --help + from .pipeline import run as run_pipeline # noqa: PLC0415 features = run_pipeline(settings, write=not args.dry_run) print(f"Produced {len(features)} feature(s); sink={settings.sink}") diff --git a/tests/conftest.py b/tests/conftest.py index efa2108..4210010 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,9 +19,9 @@ def synthetic_ortho(tmp_path: Path) -> Path: ``discover_inputs`` tags it as an orthophoto. """ pytest.importorskip("rasterio") - import numpy as np - import rasterio - from rasterio.transform import from_origin + import numpy as np # noqa: PLC0415 + import rasterio # noqa: PLC0415 + from rasterio.transform import from_origin # noqa: PLC0415 height = width = 100 array = np.full((3, height, width), 10, dtype="uint8") # dark background @@ -47,8 +47,8 @@ def synthetic_ortho(tmp_path: Path) -> Path: def _write_dem(path: Path, dem) -> Path: """Write a single-band float32 DEM GeoTIFF at 5 cm pixels in TEST_CRS.""" - import rasterio - from rasterio.transform import from_origin + import rasterio # noqa: PLC0415 + from rasterio.transform import from_origin # noqa: PLC0415 height, width = dem.shape transform = from_origin(500000, 4500000, 0.05, 0.05) # 5 cm pixels @@ -71,7 +71,7 @@ def _write_dem(path: Path, dem) -> Path: def synthetic_dem(tmp_path: Path) -> Path: """A flat DEM with one raised horizontal ridge (a wall). Filename tags it DEM.""" pytest.importorskip("rasterio") - import numpy as np + import numpy as np # noqa: PLC0415 dem = np.full((100, 100), 100.0, dtype="float32") dem[48:52, :] = 100.20 # a 20 cm ridge across the whole width @@ -82,7 +82,7 @@ def synthetic_dem(tmp_path: Path) -> Path: def synthetic_dem_step(tmp_path: Path) -> Path: """A flat DEM with a raised terrace half (a slope edge). Filename tags it DEM.""" pytest.importorskip("rasterio") - import numpy as np + import numpy as np # noqa: PLC0415 dem = np.full((100, 100), 100.0, dtype="float32") dem[:, 50:] = 100.50 # a 50 cm step → high slope along the boundary diff --git a/tests/test_cellpose.py b/tests/test_cellpose.py index 69cfc6a..53c1df6 100644 --- a/tests/test_cellpose.py +++ b/tests/test_cellpose.py @@ -11,8 +11,8 @@ pytest.importorskip("cellpose") pytest.importorskip("rasterio") -from structura.models import FeatureType, Track # noqa: E402 -from structura.segmentation.cellpose import CellposeSegmenter # noqa: E402 +from structura.models import FeatureType, Track +from structura.segmentation.cellpose import CellposeSegmenter def test_cellpose_segment_runs(synthetic_ortho: Path) -> None: diff --git a/tests/test_classical.py b/tests/test_classical.py index d17cfe9..73884e0 100644 --- a/tests/test_classical.py +++ b/tests/test_classical.py @@ -8,8 +8,8 @@ pytest.importorskip("skimage") pytest.importorskip("shapely") -from structura.models import FeatureType, Track # noqa: E402 -from structura.segmentation.classical import ClassicalSegmenter # noqa: E402 +from structura.models import FeatureType, Track +from structura.segmentation.classical import ClassicalSegmenter def test_classical_segments_blobs(synthetic_ortho: Path) -> None: diff --git a/tests/test_common.py b/tests/test_common.py index 8f1caa0..47976e0 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -5,11 +5,11 @@ pytest.importorskip("rasterio") pytest.importorskip("shapely") -import numpy as np # noqa: E402 -from rasterio.transform import from_origin # noqa: E402 +import numpy as np +from rasterio.transform import from_origin -from structura.models import FeatureType, Track # noqa: E402 -from structura.segmentation._common import label_mask_to_features # noqa: E402 +from structura.models import FeatureType, Track +from structura.segmentation._common import label_mask_to_features def test_label_mask_to_features() -> None: diff --git a/tests/test_edge_tracing.py b/tests/test_edge_tracing.py index 22bd4b8..4db42c4 100644 --- a/tests/test_edge_tracing.py +++ b/tests/test_edge_tracing.py @@ -8,8 +8,8 @@ pytest.importorskip("skimage") pytest.importorskip("shapely") -from structura.dem.edge_tracing import EdgeTracer # noqa: E402 -from structura.models import FeatureType, Track # noqa: E402 +from structura.dem.edge_tracing import EdgeTracer +from structura.models import FeatureType, Track def test_edge_tracer_finds_step(synthetic_dem_step: Path) -> None: diff --git a/tests/test_file_sink.py b/tests/test_file_sink.py index 4f1756a..3eda2fa 100644 --- a/tests/test_file_sink.py +++ b/tests/test_file_sink.py @@ -8,11 +8,11 @@ pytest.importorskip("geopandas") pytest.importorskip("shapely") -import geopandas as gpd # noqa: E402 -from shapely.geometry import box # noqa: E402 +import geopandas as gpd +from shapely.geometry import box -from structura.db.file import FileSink # noqa: E402 -from structura.models import Feature, FeatureType, Track # noqa: E402 +from structura.db.file import FileSink +from structura.models import Feature, FeatureType, Track def _features() -> list[Feature]: diff --git a/tests/test_geo.py b/tests/test_geo.py index 40225bb..e4f5b70 100644 --- a/tests/test_geo.py +++ b/tests/test_geo.py @@ -5,10 +5,10 @@ pytest.importorskip("rasterio") pytest.importorskip("shapely") -import numpy as np # noqa: E402 -from rasterio.transform import from_origin # noqa: E402 +import numpy as np +from rasterio.transform import from_origin -from structura import geo # noqa: E402 +from structura import geo # 1 m pixels anchored at a known origin. TRANSFORM = from_origin(1000, 2000, 1.0, 1.0) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 30cf9be..8d6a42a 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -4,10 +4,10 @@ pytest.importorskip("shapely") -from shapely.affinity import rotate, scale # noqa: E402 -from shapely.geometry import box # noqa: E402 +from shapely.affinity import rotate, scale +from shapely.geometry import box -from structura import metrics # noqa: E402 +from structura import metrics def test_iou_identical_and_partial() -> None: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index c231e4e..acbf933 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -8,10 +8,10 @@ pytest.importorskip("skimage") pytest.importorskip("geopandas") -import geopandas as gpd # noqa: E402 +import geopandas as gpd -from structura import pipeline # noqa: E402 -from structura.config import Settings # noqa: E402 +from structura import pipeline +from structura.config import Settings def _settings(input_dir: Path, output_path: Path) -> Settings: diff --git a/tests/test_relief.py b/tests/test_relief.py index aa8f3ff..5bbb787 100644 --- a/tests/test_relief.py +++ b/tests/test_relief.py @@ -4,9 +4,9 @@ pytest.importorskip("skimage") -import numpy as np # noqa: E402 +import numpy as np -from structura.dem import relief # noqa: E402 +from structura.dem import relief def _ridge_dem() -> np.ndarray: diff --git a/tests/test_sam.py b/tests/test_sam.py index b3d9511..f94b30a 100644 --- a/tests/test_sam.py +++ b/tests/test_sam.py @@ -12,8 +12,8 @@ pytest.importorskip("samgeo") pytest.importorskip("rasterio") -from structura.models import FeatureType, Track # noqa: E402 -from structura.segmentation.sam import SamSegmenter # noqa: E402 +from structura.models import FeatureType, Track +from structura.segmentation.sam import SamSegmenter def test_sam_segment_runs(synthetic_ortho: Path) -> None: diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py index 6a0fcb4..d9fb79c 100644 --- a/tests/test_skeleton.py +++ b/tests/test_skeleton.py @@ -5,10 +5,10 @@ pytest.importorskip("skimage") pytest.importorskip("shapely") -import numpy as np # noqa: E402 -from rasterio.transform import from_origin # noqa: E402 +import numpy as np +from rasterio.transform import from_origin -from structura import geo # noqa: E402 +from structura import geo TRANSFORM = from_origin(1000, 2000, 1.0, 1.0) # 1 m pixels diff --git a/tests/test_wall_tracing.py b/tests/test_wall_tracing.py index 10c7815..369d368 100644 --- a/tests/test_wall_tracing.py +++ b/tests/test_wall_tracing.py @@ -8,12 +8,12 @@ pytest.importorskip("skimage") pytest.importorskip("shapely") -import numpy as np # noqa: E402 -from rasterio.transform import from_origin # noqa: E402 +import numpy as np +from rasterio.transform import from_origin -from structura.dem._common import relief_response_to_features # noqa: E402 -from structura.dem.wall_tracing import WallTracer # noqa: E402 -from structura.models import FeatureType, Track # noqa: E402 +from structura.dem._common import relief_response_to_features +from structura.dem.wall_tracing import WallTracer +from structura.models import FeatureType, Track def test_wall_tracer_finds_ridge(synthetic_dem: Path) -> None: From a007bff67231225832ea9870c6f13dca0204e4c5 Mon Sep 17 00:00:00 2001 From: Patrick Leiverkus Date: Sun, 26 Jul 2026 15:12:37 +0200 Subject: [PATCH 2/2] fix: let mypy analyse each matrix Python as itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the lint step green, mypy ran for the first time in five weeks and the 3.12 job failed: numpy/__init__.pyi:737: error: Type statement is only supported in Python 3.12 and greater [syntax] `[tool.mypy] python_version` was hard-coded to "3.11" while the matrix runs 3.11 and 3.12. numpy 2.5 requires Python >= 3.12 and ships PEP 695 `type` statements, so the 3.12 job resolved numpy 2.5 and then parsed it against a 3.11 grammar; 3.11 resolves to numpy 2.4 and was unaffected, which is why the failure looked version-specific. Drop the pin so each job analyses its own interpreter — which is the point of having a version matrix. Verified in clean venvs on both: ruff clean, mypy clean on 26 files, 41 passed / 2 skipped. This is a second, independent instance of the same class of problem as the ruff drift: an unpinned dependency changing under an unchanged tree. It was masked because ruff runs before mypy and failed first. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 10 +++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd0532..7163fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 accidental one. Eight previously unmarked sites (`cli.py`, `tests/conftest.py`) were annotated. +- **Dropped mypy's hard-coded `python_version = "3.11"`.** CI type-checks under + both 3.11 and 3.12, and each job should analyse its own interpreter. The pin + made the 3.12 job parse its own dependencies against an older grammar: numpy + 2.5 (3.12-only — 3.11 resolves to 2.4) ships PEP 695 `type` statements, which + mypy rejected as a syntax error. This surfaced only once the lint step stopped + failing first and mypy actually ran. + ### Removed - The 35 `# noqa: E402` directives in `tests/`. They never suppressed anything: ruff exempts imports that follow `pytest.importorskip()`, so E402 does not fire diff --git a/pyproject.toml b/pyproject.toml index 0b0db97..8bf0493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,12 +59,16 @@ target-version = "py311" select = ["E4", "E7", "E9", "F", "PLC0415", "RUF100"] [tool.mypy] -python_version = "3.11" +# Deliberately no `python_version` pin: CI type-checks under both 3.11 and 3.12, +# and each job should analyse its own interpreter. Hard-coding 3.11 made the 3.12 +# job parse its own dependencies against an older grammar — numpy 2.5 (3.12-only; +# 3.11 resolves to 2.4) ships PEP 695 `type` statements, which mypy then rejected +# as a syntax error. That is a toolchain artefact, not a defect in this code. ignore_missing_imports = true # Don't follow into the geospatial / imaging stacks — they are untyped for our -# purposes and some (e.g. tifffile) use newer syntax that mypy's 3.11 target -# can't parse. Treat them as Any. +# purposes and some (e.g. tifffile) use syntax newer than the interpreter being +# analysed. Treat them as Any. [[tool.mypy.overrides]] module = [ "rasterio.*",