diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ea217..7163fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ 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. + +- **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 + 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..8bf0493 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,13 +47,28 @@ 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" +# 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.*", 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: