Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.*",
Expand Down
3 changes: 2 additions & 1 deletion src/structura/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
14 changes: 7 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cellpose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_classical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_edge_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions tests/test_file_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_relief.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_sam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions tests/test_wall_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down