From cfc1b4e8e3b598c2707305b807e72c3ed42800e4 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:08:25 +0800 Subject: [PATCH 01/11] feat: add geospatial dataset adapters --- pyproject.toml | 18 ++++- src/geodebug/adapters/__init__.py | 0 src/geodebug/adapters/base.py | 36 +++++++++ src/geodebug/adapters/defaults.py | 16 ++++ src/geodebug/adapters/geojson.py | 124 +++++++++++++++++++++++++++++ src/geodebug/adapters/geopandas.py | 92 +++++++++++++++++++++ src/geodebug/adapters/parquet.py | 114 ++++++++++++++++++++++++++ src/geodebug/adapters/pyogrio.py | 122 ++++++++++++++++++++++++++++ src/geodebug/adapters/rasterio.py | 112 ++++++++++++++++++++++++++ src/geodebug/adapters/registry.py | 31 ++++++++ src/geodebug/facts/crs.py | 78 ++++++++++++++++++ src/geodebug/facts/keys.py | 20 +++++ 12 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 src/geodebug/adapters/__init__.py create mode 100644 src/geodebug/adapters/base.py create mode 100644 src/geodebug/adapters/defaults.py create mode 100644 src/geodebug/adapters/geojson.py create mode 100644 src/geodebug/adapters/geopandas.py create mode 100644 src/geodebug/adapters/parquet.py create mode 100644 src/geodebug/adapters/pyogrio.py create mode 100644 src/geodebug/adapters/rasterio.py create mode 100644 src/geodebug/adapters/registry.py create mode 100644 src/geodebug/facts/crs.py diff --git a/pyproject.toml b/pyproject.toml index 5ba3a91..6a95352 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,18 +4,30 @@ build-backend = "hatchling.build" [project] name = "geodebug" -version = "0.0.1.dev0" +version = "0.1.0.dev0" description = "Deterministic diagnostics for geospatial data and workflows." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GeoGeekLab" }] dependencies = [ "pydantic>=2.9,<3", + "pyproj>=3.6,<4", "rich>=13.9,<15", + "shapely>=2.0,<3", "typer>=0.15,<1", ] [project.optional-dependencies] +vector = ["pyogrio>=0.10,<1"] +raster = ["rasterio>=1.4,<2"] +parquet = ["pyarrow>=17,<30"] +geopandas = ["geopandas>=1.0,<2"] +all = [ + "geopandas>=1.0,<2", + "pyarrow>=17,<30", + "pyogrio>=0.10,<1", + "rasterio>=1.4,<2", +] dev = [ "jsonschema>=4.23,<5", "mypy>=1.13,<2", @@ -49,6 +61,10 @@ python_version = "3.12" strict = true packages = ["geodebug"] +[[tool.mypy.overrides]] +module = ["geopandas.*", "pyarrow.*", "pyogrio.*", "rasterio.*"] +ignore_missing_imports = true + [tool.coverage.run] source = ["geodebug"] branch = true diff --git a/src/geodebug/adapters/__init__.py b/src/geodebug/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geodebug/adapters/base.py b/src/geodebug/adapters/base.py new file mode 100644 index 0000000..36236c7 --- /dev/null +++ b/src/geodebug/adapters/base.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from geodebug.models.subjects import DatasetSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectOptions: + deep: bool = False + + +class AdapterError(RuntimeError): + pass + + +class AdapterDependencyError(AdapterError): + pass + + +class UnsupportedTargetError(AdapterError): + pass + + +class Adapter(Protocol): + name: str + + def supports(self, target: Any) -> int: ... + + def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: ... + + +def path_subject_id(path: Path) -> str: + return f"file:{path.as_posix()}" diff --git a/src/geodebug/adapters/defaults.py b/src/geodebug/adapters/defaults.py new file mode 100644 index 0000000..24ccfba --- /dev/null +++ b/src/geodebug/adapters/defaults.py @@ -0,0 +1,16 @@ +from geodebug.adapters.geojson import GeoJSONAdapter +from geodebug.adapters.geopandas import GeoPandasAdapter +from geodebug.adapters.parquet import GeoParquetAdapter +from geodebug.adapters.pyogrio import PyogrioAdapter +from geodebug.adapters.rasterio import RasterioAdapter +from geodebug.adapters.registry import AdapterRegistry + + +def build_default_adapter_registry() -> AdapterRegistry: + registry = AdapterRegistry() + registry.register(GeoPandasAdapter()) + registry.register(GeoJSONAdapter()) + registry.register(GeoParquetAdapter()) + registry.register(RasterioAdapter()) + registry.register(PyogrioAdapter()) + return registry diff --git a/src/geodebug/adapters/geojson.py b/src/geodebug/adapters/geojson.py new file mode 100644 index 0000000..c913ea1 --- /dev/null +++ b/src/geodebug/adapters/geojson.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from shapely.geometry import shape +from shapely.geometry.base import BaseGeometry + +from geodebug.adapters.base import AdapterError, InspectOptions, path_subject_id +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import ( + SPATIAL_BOUNDS, + VECTOR_EMPTY_GEOMETRY_COUNT, + VECTOR_GEOMETRY_COUNT, + VECTOR_GEOMETRY_TYPES, + VECTOR_INVALID_GEOMETRY_COUNT, +) +from geodebug.models.enums import SubjectKind +from geodebug.models.facts import FactProvenance, FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +class GeoJSONAdapter: + name = "geojson" + + def supports(self, target: Any) -> int: + if not isinstance(target, (str, Path)): + return 0 + suffix = Path(target).suffix.casefold() + return 100 if suffix in {".geojson", ".json"} else 0 + + def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: + path = Path(target) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise AdapterError(f"unable to read GeoJSON: {path}") from exc + + geometries = _geometries(payload) + provenance = FactProvenance(origin=self.name, method="geojson") + records = list(build_crs_facts("OGC:CRS84", origin=self.name, method="rfc7946")) + records.extend(_geometry_facts(geometries, provenance=provenance, deep=options.deep)) + bounds = _total_bounds(geometries) + if bounds is None: + records.append(FactRecord.unknown(SPATIAL_BOUNDS, provenance=provenance)) + else: + records.append(FactRecord.known(SPATIAL_BOUNDS, bounds, provenance=provenance)) + + return DatasetSnapshot( + subject=SubjectRef( + id=path_subject_id(path), + kind=SubjectKind.VECTOR, + uri=path.as_posix(), + label=path.name, + adapter=self.name, + ), + facts=FactStore(records), + ) + + +def _geometries(payload: Any) -> list[BaseGeometry]: + if not isinstance(payload, dict): + raise AdapterError("GeoJSON root must be an object") + + object_type = payload.get("type") + raw: list[Any] + if object_type == "FeatureCollection": + features = payload.get("features") + if not isinstance(features, list): + raise AdapterError("GeoJSON FeatureCollection must contain a features array") + raw = [feature.get("geometry") for feature in features if isinstance(feature, dict)] + elif object_type == "Feature": + raw = [payload.get("geometry")] + else: + raw = [payload] + + geometries: list[BaseGeometry] = [] + for item in raw: + if item is None: + continue + try: + geometries.append(shape(item)) + except (AttributeError, TypeError, ValueError) as exc: + raise AdapterError("GeoJSON contains an invalid geometry object") from exc + return geometries + + +def _geometry_facts( + geometries: list[BaseGeometry], + *, + provenance: FactProvenance, + deep: bool, +) -> tuple[FactRecord, ...]: + count = len(geometries) + empty_count = sum(geometry.is_empty for geometry in geometries) + geometry_types = tuple(sorted({geometry.geom_type for geometry in geometries})) + records = [ + FactRecord.known(VECTOR_GEOMETRY_COUNT, count, provenance=provenance), + FactRecord.known(VECTOR_EMPTY_GEOMETRY_COUNT, empty_count, provenance=provenance), + FactRecord.known(VECTOR_GEOMETRY_TYPES, geometry_types, provenance=provenance), + ] + if deep: + records.append( + FactRecord.known( + VECTOR_INVALID_GEOMETRY_COUNT, + sum(not geometry.is_valid for geometry in geometries), + provenance=provenance, + ) + ) + else: + records.append(FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance)) + return tuple(records) + + +def _total_bounds(geometries: list[BaseGeometry]) -> tuple[float, float, float, float] | None: + non_empty = [geometry for geometry in geometries if not geometry.is_empty] + if not non_empty: + return None + xmin = min(geometry.bounds[0] for geometry in non_empty) + ymin = min(geometry.bounds[1] for geometry in non_empty) + xmax = max(geometry.bounds[2] for geometry in non_empty) + ymax = max(geometry.bounds[3] for geometry in non_empty) + return (float(xmin), float(ymin), float(xmax), float(ymax)) diff --git a/src/geodebug/adapters/geopandas.py b/src/geodebug/adapters/geopandas.py new file mode 100644 index 0000000..68b7ab4 --- /dev/null +++ b/src/geodebug/adapters/geopandas.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Any + +from geodebug.adapters.base import AdapterDependencyError, InspectOptions +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import ( + SPATIAL_BOUNDS, + VECTOR_EMPTY_GEOMETRY_COUNT, + VECTOR_GEOMETRY_COUNT, + VECTOR_GEOMETRY_TYPES, + VECTOR_INVALID_GEOMETRY_COUNT, +) +from geodebug.models.enums import SubjectKind +from geodebug.models.facts import FactProvenance, FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +class GeoPandasAdapter: + name = "geopandas" + + def supports(self, target: Any) -> int: + try: + import geopandas as gpd + except ImportError: + return 0 + return 110 if isinstance(target, gpd.GeoDataFrame) else 0 + + def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: + del options + try: + import geopandas as gpd + except ImportError as exc: + raise AdapterDependencyError( + "GeoPandas support requires 'pip install geodebug[geopandas]'" + ) from exc + if not isinstance(target, gpd.GeoDataFrame): + raise TypeError("GeoPandasAdapter requires a GeoDataFrame") + + geometry = target.geometry + provenance = FactProvenance(origin=self.name, method="geodataframe") + records = list( + build_crs_facts( + target.crs.to_wkt() if target.crs is not None else None, + origin=self.name, + method="geodataframe.crs", + ) + ) + records.extend( + ( + FactRecord.known( + VECTOR_GEOMETRY_COUNT, + len(target), + provenance=provenance, + ), + FactRecord.known( + VECTOR_INVALID_GEOMETRY_COUNT, + int((~geometry.is_valid & ~geometry.isna()).sum()), + provenance=provenance, + ), + FactRecord.known( + VECTOR_EMPTY_GEOMETRY_COUNT, + int(geometry.is_empty.sum()), + provenance=provenance, + ), + FactRecord.known( + VECTOR_GEOMETRY_TYPES, + tuple(sorted(str(value) for value in geometry.geom_type.dropna().unique())), + provenance=provenance, + ), + ) + ) + if len(target) == 0 or geometry.dropna().empty: + records.append(FactRecord.unknown(SPATIAL_BOUNDS, provenance=provenance)) + else: + records.append( + FactRecord.known( + SPATIAL_BOUNDS, + tuple(float(value) for value in target.total_bounds), + provenance=provenance, + ) + ) + + return DatasetSnapshot( + subject=SubjectRef( + id=f"memory:geodataframe:{id(target):x}", + kind=SubjectKind.VECTOR, + label="GeoDataFrame", + adapter=self.name, + ), + facts=FactStore(records), + ) diff --git a/src/geodebug/adapters/parquet.py b/src/geodebug/adapters/parquet.py new file mode 100644 index 0000000..3bf32c2 --- /dev/null +++ b/src/geodebug/adapters/parquet.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from geodebug.adapters.base import ( + AdapterDependencyError, + AdapterError, + InspectOptions, + path_subject_id, +) +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import ( + SPATIAL_BOUNDS, + VECTOR_GEOMETRY_COUNT, + VECTOR_GEOMETRY_TYPES, + VECTOR_INVALID_GEOMETRY_COUNT, +) +from geodebug.models.enums import SubjectKind +from geodebug.models.facts import FactProvenance, FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +class GeoParquetAdapter: + name = "geoparquet" + + def supports(self, target: Any) -> int: + if not isinstance(target, (str, Path)): + return 0 + return 95 if Path(target).suffix.casefold() in {".parquet", ".geoparquet"} else 0 + + def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: + try: + import pyarrow.parquet as pq + except ImportError as exc: + raise AdapterDependencyError( + "GeoParquet support requires 'pip install geodebug[parquet]'" + ) from exc + + path = Path(target) + try: + parquet = pq.ParquetFile(path) + metadata = parquet.schema_arrow.metadata or {} + raw_geo = metadata.get(b"geo") + if raw_geo is None: + raise AdapterError("Parquet file does not contain GeoParquet metadata") + geo = json.loads(raw_geo.decode("utf-8")) + except AdapterError: + raise + except Exception as exc: + raise AdapterError(f"unable to inspect GeoParquet dataset: {path}") from exc + + primary = geo.get("primary_column") + columns = geo.get("columns", {}) + if not isinstance(primary, str) or not isinstance(columns, dict) or primary not in columns: + raise AdapterError("GeoParquet metadata does not define a valid primary geometry column") + column = columns[primary] + if not isinstance(column, dict): + raise AdapterError("GeoParquet primary geometry metadata is invalid") + + provenance = FactProvenance(origin=self.name, method="parquet.metadata") + if "crs" not in column: + crs_value: Any = "OGC:CRS84" + else: + crs_value = column.get("crs") + + records = list(build_crs_facts(crs_value, origin=self.name, method="geo.crs")) + records.append( + FactRecord.known( + VECTOR_GEOMETRY_COUNT, + parquet.metadata.num_rows, + provenance=provenance, + ) + ) + geometry_types = column.get("geometry_types") + if isinstance(geometry_types, list): + records.append( + FactRecord.known( + VECTOR_GEOMETRY_TYPES, + tuple(sorted(str(value) for value in geometry_types)), + provenance=provenance, + ) + ) + else: + records.append(FactRecord.unknown(VECTOR_GEOMETRY_TYPES, provenance=provenance)) + + bbox = column.get("bbox") + if isinstance(bbox, list) and len(bbox) >= 4: + records.append( + FactRecord.known( + SPATIAL_BOUNDS, + tuple(float(value) for value in bbox[:4]), + provenance=provenance, + ) + ) + else: + records.append(FactRecord.unknown(SPATIAL_BOUNDS, provenance=provenance)) + + if options.deep: + records.append(FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance)) + else: + records.append(FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance)) + + return DatasetSnapshot( + subject=SubjectRef( + id=path_subject_id(path), + kind=SubjectKind.VECTOR, + uri=path.as_posix(), + label=path.name, + adapter=self.name, + ), + facts=FactStore(records), + ) diff --git a/src/geodebug/adapters/pyogrio.py b/src/geodebug/adapters/pyogrio.py new file mode 100644 index 0000000..51920b2 --- /dev/null +++ b/src/geodebug/adapters/pyogrio.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from geodebug.adapters.base import ( + AdapterDependencyError, + AdapterError, + InspectOptions, + path_subject_id, +) +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import ( + SPATIAL_BOUNDS, + VECTOR_EMPTY_GEOMETRY_COUNT, + VECTOR_GEOMETRY_COUNT, + VECTOR_GEOMETRY_TYPES, + VECTOR_INVALID_GEOMETRY_COUNT, +) +from geodebug.models.enums import SubjectKind +from geodebug.models.facts import FactProvenance, FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +class PyogrioAdapter: + name = "pyogrio" + _suffixes = {".fgb", ".gpkg", ".shp"} + + def supports(self, target: Any) -> int: + if not isinstance(target, (str, Path)): + return 0 + return 80 if Path(target).suffix.casefold() in self._suffixes else 0 + + def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: + try: + import pyogrio + except ImportError as exc: + raise AdapterDependencyError( + "Pyogrio support requires 'pip install geodebug[vector]'" + ) from exc + + path = Path(target) + try: + info = pyogrio.read_info(path) + except Exception as exc: + raise AdapterError(f"unable to inspect vector dataset: {path}") from exc + + provenance = FactProvenance(origin=self.name, method="read_info") + records = list( + build_crs_facts(info.get("crs"), origin=self.name, method="read_info.crs") + ) + + feature_count = info.get("features") + if isinstance(feature_count, int) and feature_count >= 0: + records.append( + FactRecord.known(VECTOR_GEOMETRY_COUNT, feature_count, provenance=provenance) + ) + else: + records.append(FactRecord.unknown(VECTOR_GEOMETRY_COUNT, provenance=provenance)) + + geometry_type = info.get("geometry_type") + if geometry_type: + records.append( + FactRecord.known( + VECTOR_GEOMETRY_TYPES, + (str(geometry_type),), + provenance=provenance, + ) + ) + else: + records.append(FactRecord.unknown(VECTOR_GEOMETRY_TYPES, provenance=provenance)) + + total_bounds = info.get("total_bounds") + if total_bounds is not None and len(total_bounds) == 4: + records.append( + FactRecord.known( + SPATIAL_BOUNDS, + tuple(float(value) for value in total_bounds), + provenance=provenance, + ) + ) + else: + records.append(FactRecord.unknown(SPATIAL_BOUNDS, provenance=provenance)) + + if options.deep: + try: + frame = pyogrio.read_dataframe(path) + geometry = frame.geometry + records.extend( + ( + FactRecord.known( + VECTOR_INVALID_GEOMETRY_COUNT, + int((~geometry.is_valid & ~geometry.isna()).sum()), + provenance=FactProvenance(origin=self.name, method="read_dataframe"), + ), + FactRecord.known( + VECTOR_EMPTY_GEOMETRY_COUNT, + int(geometry.is_empty.sum()), + provenance=FactProvenance(origin=self.name, method="read_dataframe"), + ), + ) + ) + except Exception as exc: + raise AdapterError(f"unable to scan vector geometries: {path}") from exc + else: + records.extend( + ( + FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance), + FactRecord.unknown(VECTOR_EMPTY_GEOMETRY_COUNT, provenance=provenance), + ) + ) + + return DatasetSnapshot( + subject=SubjectRef( + id=path_subject_id(path), + kind=SubjectKind.VECTOR, + uri=path.as_posix(), + label=path.name, + adapter=self.name, + ), + facts=FactStore(records), + ) diff --git a/src/geodebug/adapters/rasterio.py b/src/geodebug/adapters/rasterio.py new file mode 100644 index 0000000..e24b4e1 --- /dev/null +++ b/src/geodebug/adapters/rasterio.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from geodebug.adapters.base import ( + AdapterDependencyError, + AdapterError, + InspectOptions, + path_subject_id, +) +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import ( + RASTER_BAND_COUNT, + RASTER_DTYPES, + RASTER_HEIGHT, + RASTER_NODATA, + RASTER_RESOLUTION, + RASTER_TRANSFORM, + RASTER_WIDTH, + SPATIAL_BOUNDS, +) +from geodebug.models.enums import SubjectKind +from geodebug.models.facts import FactProvenance, FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +class RasterioAdapter: + name = "rasterio" + _suffixes = {".img", ".tif", ".tiff", ".vrt"} + + def supports(self, target: Any) -> int: + if not isinstance(target, (str, Path)): + return 0 + return 90 if Path(target).suffix.casefold() in self._suffixes else 0 + + def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: + del options + try: + import rasterio + except ImportError as exc: + raise AdapterDependencyError( + "Raster support requires 'pip install geodebug[raster]'" + ) from exc + + path = Path(target) + try: + with rasterio.open(path) as dataset: + provenance = FactProvenance(origin=self.name, method="dataset.metadata") + records = list( + build_crs_facts( + dataset.crs.to_wkt() if dataset.crs is not None else None, + origin=self.name, + method="dataset.crs", + ) + ) + records.extend( + ( + FactRecord.known( + SPATIAL_BOUNDS, + tuple(float(value) for value in dataset.bounds), + provenance=provenance, + ), + FactRecord.known(RASTER_WIDTH, dataset.width, provenance=provenance), + FactRecord.known(RASTER_HEIGHT, dataset.height, provenance=provenance), + FactRecord.known( + RASTER_RESOLUTION, + (float(abs(dataset.res[0])), float(abs(dataset.res[1]))), + provenance=provenance, + ), + FactRecord.known( + RASTER_TRANSFORM, + ( + float(dataset.transform.a), + float(dataset.transform.b), + float(dataset.transform.c), + float(dataset.transform.d), + float(dataset.transform.e), + float(dataset.transform.f), + ), + provenance=provenance, + ), + FactRecord.known( + RASTER_BAND_COUNT, + dataset.count, + provenance=provenance, + ), + FactRecord.known( + RASTER_DTYPES, + tuple(str(value) for value in dataset.dtypes), + provenance=provenance, + ), + FactRecord.known( + RASTER_NODATA, + dataset.nodata, + provenance=provenance, + ), + ) + ) + except Exception as exc: + raise AdapterError(f"unable to inspect raster dataset: {path}") from exc + + return DatasetSnapshot( + subject=SubjectRef( + id=path_subject_id(path), + kind=SubjectKind.RASTER, + uri=path.as_posix(), + label=path.name, + adapter=self.name, + ), + facts=FactStore(records), + ) diff --git a/src/geodebug/adapters/registry.py b/src/geodebug/adapters/registry.py new file mode 100644 index 0000000..1e8ffb9 --- /dev/null +++ b/src/geodebug/adapters/registry.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any + +from geodebug.adapters.base import Adapter, InspectOptions, UnsupportedTargetError +from geodebug.models.subjects import DatasetSnapshot + + +class AdapterRegistry: + def __init__(self) -> None: + self._adapters: list[Adapter] = [] + + def register(self, adapter: Adapter) -> None: + self._adapters.append(adapter) + + def select(self, target: Any) -> Adapter: + candidates = [(adapter.supports(target), adapter) for adapter in self._adapters] + candidates = [(score, adapter) for score, adapter in candidates if score > 0] + if not candidates: + raise UnsupportedTargetError(f"no adapter supports target: {target!r}") + candidates.sort(key=lambda item: item[0], reverse=True) + return candidates[0][1] + + def inspect( + self, + target: Any, + *, + options: InspectOptions | None = None, + ) -> DatasetSnapshot: + adapter = self.select(target) + return adapter.inspect(target, options or InspectOptions()) diff --git a/src/geodebug/facts/crs.py b/src/geodebug/facts/crs.py new file mode 100644 index 0000000..080673e --- /dev/null +++ b/src/geodebug/facts/crs.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +from pyproj import CRS +from pyproj.exceptions import CRSError + +from geodebug.facts.keys import ( + CRS_AUTHORITY, + CRS_AXIS_UNITS, + CRS_CODE, + CRS_KIND, + CRS_PRESENT, + CRS_WKT, +) +from geodebug.models.facts import FactProvenance, FactRecord + + +def build_crs_facts( + value: Any, + *, + origin: str, + method: str, +) -> tuple[FactRecord, ...]: + provenance = FactProvenance(origin=origin, method=method) + if value is None: + return ( + FactRecord.known(CRS_PRESENT, False, provenance=provenance), + FactRecord.unknown(CRS_WKT, provenance=provenance), + FactRecord.unknown(CRS_KIND, provenance=provenance), + FactRecord.unknown(CRS_AXIS_UNITS, provenance=provenance), + ) + + try: + crs = CRS.from_user_input(value) + except (CRSError, TypeError, ValueError): + return ( + FactRecord.known(CRS_PRESENT, True, provenance=provenance), + FactRecord.unknown(CRS_WKT, provenance=provenance), + FactRecord.unknown(CRS_KIND, provenance=provenance), + FactRecord.unknown(CRS_AXIS_UNITS, provenance=provenance), + ) + + authority = crs.to_authority() + kind = ( + "geographic" + if crs.is_geographic + else "projected" + if crs.is_projected + else "geocentric" + if crs.is_geocentric + else "other" + ) + records = [ + FactRecord.known(CRS_PRESENT, True, provenance=provenance), + FactRecord.known(CRS_WKT, crs.to_wkt(), provenance=provenance), + FactRecord.known(CRS_KIND, kind, provenance=provenance), + FactRecord.known( + CRS_AXIS_UNITS, + tuple(axis.unit_name or "unknown" for axis in crs.axis_info), + provenance=provenance, + ), + ] + if authority is None: + records.extend( + ( + FactRecord.unknown(CRS_AUTHORITY, provenance=provenance), + FactRecord.unknown(CRS_CODE, provenance=provenance), + ) + ) + else: + records.extend( + ( + FactRecord.known(CRS_AUTHORITY, authority[0], provenance=provenance), + FactRecord.known(CRS_CODE, authority[1], provenance=provenance), + ) + ) + return tuple(records) diff --git a/src/geodebug/facts/keys.py b/src/geodebug/facts/keys.py index c1ed5c0..7d9372c 100644 --- a/src/geodebug/facts/keys.py +++ b/src/geodebug/facts/keys.py @@ -1,6 +1,26 @@ CRS_PRESENT = "crs.present" +CRS_WKT = "crs.wkt" +CRS_AUTHORITY = "crs.authority" +CRS_CODE = "crs.code" CRS_KIND = "crs.kind" CRS_AXIS_UNITS = "crs.axis_units" +SPATIAL_BOUNDS = "spatial.bounds" + VECTOR_GEOMETRY_COUNT = "vector.geometry.count" VECTOR_INVALID_GEOMETRY_COUNT = "vector.geometry.invalid_count" +VECTOR_EMPTY_GEOMETRY_COUNT = "vector.geometry.empty_count" +VECTOR_GEOMETRY_TYPES = "vector.geometry.types" + +RASTER_WIDTH = "raster.width" +RASTER_HEIGHT = "raster.height" +RASTER_RESOLUTION = "raster.resolution" +RASTER_TRANSFORM = "raster.transform" +RASTER_BAND_COUNT = "raster.band_count" +RASTER_DTYPES = "raster.dtypes" +RASTER_NODATA = "raster.nodata" + +RELATION_BOUNDS_OVERLAP = "relation.bounds.overlap" +RELATION_OVERLAP_RATIO = "relation.bounds.overlap_ratio" +RELATION_GRID_ALIGNED = "relation.grid.aligned" +RELATION_GRID_OFFSET_PIXELS = "relation.grid.offset_pixels" From 09fdd8722b5d31f97d5e8f0fee847752d2fca077 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:09:43 +0800 Subject: [PATCH 02/11] feat: add relational diagnostics --- src/geodebug/engine/defaults.py | 4 + src/geodebug/engine/evaluator.py | 120 +++++++------ src/geodebug/facts/relations.py | 210 +++++++++++++++++++++++ src/geodebug/models/context.py | 4 +- src/geodebug/rules/relations/__init__.py | 0 src/geodebug/rules/relations/geo402.py | 51 ++++++ src/geodebug/rules/relations/geo404.py | 51 ++++++ 7 files changed, 391 insertions(+), 49 deletions(-) create mode 100644 src/geodebug/facts/relations.py create mode 100644 src/geodebug/rules/relations/__init__.py create mode 100644 src/geodebug/rules/relations/geo402.py create mode 100644 src/geodebug/rules/relations/geo404.py diff --git a/src/geodebug/engine/defaults.py b/src/geodebug/engine/defaults.py index 26274bf..adfb446 100644 --- a/src/geodebug/engine/defaults.py +++ b/src/geodebug/engine/defaults.py @@ -1,6 +1,8 @@ from geodebug.engine.registry import RuleRegistry from geodebug.rules.crs.geo101 import MissingCRSRule from geodebug.rules.operations.geo501 import AngularCRSBufferRule +from geodebug.rules.relations.geo402 import NoSpatialOverlapRule +from geodebug.rules.relations.geo404 import RasterGridMisalignmentRule from geodebug.rules.vector.geo201 import InvalidGeometryRule @@ -8,5 +10,7 @@ def build_default_registry() -> RuleRegistry: registry = RuleRegistry() registry.register(MissingCRSRule()) registry.register(InvalidGeometryRule()) + registry.register(NoSpatialOverlapRule()) + registry.register(RasterGridMisalignmentRule()) registry.register(AngularCRSBufferRule()) return registry diff --git a/src/geodebug/engine/evaluator.py b/src/geodebug/engine/evaluator.py index 3452640..a5b7b04 100644 --- a/src/geodebug/engine/evaluator.py +++ b/src/geodebug/engine/evaluator.py @@ -1,8 +1,10 @@ +from collections.abc import Iterable + from geodebug.engine.fingerprint import diagnostic_fingerprint from geodebug.engine.policy import Policy from geodebug.engine.registry import RuleRegistry from geodebug.models.context import EvaluationContext -from geodebug.models.enums import RuleState, Severity +from geodebug.models.enums import RuleScope, RuleState, Severity from geodebug.models.report import ( DiagnosticModel, EvidenceModel, @@ -13,6 +15,7 @@ SummaryModel, ToolInfo, ) +from geodebug.rules.base import Rule from geodebug.version import __version__ @@ -27,57 +30,63 @@ def evaluate(self, context: EvaluationContext) -> Report: unknown_rules = 0 not_applicable_rules = 0 - subject_ids = tuple(subject.subject.id for subject in context.subjects) - operation_name = context.operation.name if context.operation is not None else None - for rule in self._registry.all(): - result = rule.evaluate(context) - if result.state is RuleState.PASS: - passed_rules += 1 - continue - if result.state is RuleState.UNKNOWN: - unknown_rules += 1 - continue - if result.state is RuleState.NOT_APPLICABLE: - not_applicable_rules += 1 - continue + for rule_context in _contexts_for_rule(rule, context): + result = rule.evaluate(rule_context) + if result.state is RuleState.PASS: + passed_rules += 1 + continue + if result.state is RuleState.UNKNOWN: + unknown_rules += 1 + continue + if result.state is RuleState.NOT_APPLICABLE: + not_applicable_rules += 1 + continue - severity = self._policy.severity_for(rule.spec) - fingerprint = diagnostic_fingerprint( - rule_id=rule.spec.id, - subject_ids=subject_ids, - operation_name=operation_name, - evidence=result.evidence, - ) - diagnostics.append( - DiagnosticModel( - fingerprint=fingerprint, + subject_ids = tuple( + subject.subject.id for subject in rule_context.subjects + ) + operation_name = ( + rule_context.operation.name + if rule_context.operation is not None + else None + ) + severity = self._policy.severity_for(rule.spec) + fingerprint = diagnostic_fingerprint( rule_id=rule.spec.id, - severity=severity, - certainty=rule.spec.certainty, - subject_ids=list(subject_ids), - message=result.message or rule.spec.name, - evidence=[ - EvidenceModel( - key=item.key, - value=item.value, - origin=item.origin, - certainty=item.certainty, - ) - for item in result.evidence - ], - implication=result.implication, - suggestion=( - SuggestedActionModel( - action=result.suggestion.action, - safety=result.suggestion.safety, - detail=result.suggestion.detail, - ) - if result.suggestion is not None - else None - ), + subject_ids=subject_ids, + operation_name=operation_name, + evidence=result.evidence, + ) + diagnostics.append( + DiagnosticModel( + fingerprint=fingerprint, + rule_id=rule.spec.id, + severity=severity, + certainty=rule.spec.certainty, + subject_ids=list(subject_ids), + message=result.message or rule.spec.name, + evidence=[ + EvidenceModel( + key=item.key, + value=item.value, + origin=item.origin, + certainty=item.certainty, + ) + for item in result.evidence + ], + implication=result.implication, + suggestion=( + SuggestedActionModel( + action=result.suggestion.action, + safety=result.suggestion.safety, + detail=result.suggestion.detail, + ) + if result.suggestion is not None + else None + ), + ) ) - ) diagnostics.sort( key=lambda item: (_severity_rank(item.severity), item.rule_id, item.fingerprint) @@ -107,6 +116,21 @@ def evaluate(self, context: EvaluationContext) -> Report: ) +def _contexts_for_rule( + rule: Rule, + context: EvaluationContext, +) -> Iterable[EvaluationContext]: + if rule.spec.scope is RuleScope.DATASET: + for subject in context.subjects: + yield EvaluationContext( + subjects=(subject,), + operation=context.operation, + facts=context.facts, + ) + return + yield context + + def _severity_rank(severity: Severity) -> int: return { Severity.ERROR: 0, diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py new file mode 100644 index 0000000..f6c2077 --- /dev/null +++ b/src/geodebug/facts/relations.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import math + +from pyproj import CRS, Transformer +from pyproj.exceptions import CRSError, ProjError + +from geodebug.facts.keys import ( + CRS_WKT, + RASTER_RESOLUTION, + RASTER_TRANSFORM, + RELATION_BOUNDS_OVERLAP, + RELATION_GRID_ALIGNED, + RELATION_GRID_OFFSET_PIXELS, + RELATION_OVERLAP_RATIO, + SPATIAL_BOUNDS, +) +from geodebug.models.enums import FactState, SubjectKind +from geodebug.models.facts import FactProvenance, FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot + + +def build_relation_facts(left: DatasetSnapshot, right: DatasetSnapshot) -> FactStore: + provenance = FactProvenance(origin="geodebug", method="relation") + records = list(_bounds_relation(left, right, provenance=provenance)) + records.extend(_grid_relation(left, right, provenance=provenance)) + return FactStore(records) + + +def _known(snapshot: DatasetSnapshot, key: str) -> object | None: + fact = snapshot.facts.get(key) + if fact is None or fact.state is not FactState.KNOWN: + return None + return fact.value + + +def _bounds_relation( + left: DatasetSnapshot, + right: DatasetSnapshot, + *, + provenance: FactProvenance, +) -> tuple[FactRecord, ...]: + left_bounds = _known(left, SPATIAL_BOUNDS) + right_bounds = _known(right, SPATIAL_BOUNDS) + left_wkt = _known(left, CRS_WKT) + right_wkt = _known(right, CRS_WKT) + if ( + not _is_bounds(left_bounds) + or not _is_bounds(right_bounds) + or not isinstance(left_wkt, str) + or not isinstance(right_wkt, str) + ): + return ( + FactRecord.unknown(RELATION_BOUNDS_OVERLAP, provenance=provenance), + FactRecord.unknown(RELATION_OVERLAP_RATIO, provenance=provenance), + ) + + transformed = _transform_bounds(right_bounds, right_wkt, left_wkt) + if transformed is None: + return ( + FactRecord.unknown(RELATION_BOUNDS_OVERLAP, provenance=provenance), + FactRecord.unknown(RELATION_OVERLAP_RATIO, provenance=provenance), + ) + + lx0, ly0, lx1, ly1 = left_bounds + rx0, ry0, rx1, ry1 = transformed + overlaps = max(lx0, rx0) <= min(lx1, rx1) and max(ly0, ry0) <= min(ly1, ry1) + + intersection_width = max(0.0, min(lx1, rx1) - max(lx0, rx0)) + intersection_height = max(0.0, min(ly1, ry1) - max(ly0, ry0)) + intersection_area = intersection_width * intersection_height + left_area = max(0.0, lx1 - lx0) * max(0.0, ly1 - ly0) + right_area = max(0.0, rx1 - rx0) * max(0.0, ry1 - ry0) + denominator = min(left_area, right_area) + + ratio = intersection_area / denominator if denominator > 0.0 else None + ratio_fact = ( + FactRecord.known(RELATION_OVERLAP_RATIO, float(ratio), provenance=provenance) + if ratio is not None + else FactRecord.unknown(RELATION_OVERLAP_RATIO, provenance=provenance) + ) + return ( + FactRecord.known(RELATION_BOUNDS_OVERLAP, overlaps, provenance=provenance), + ratio_fact, + ) + + +def _grid_relation( + left: DatasetSnapshot, + right: DatasetSnapshot, + *, + provenance: FactProvenance, +) -> tuple[FactRecord, ...]: + if left.subject.kind is not SubjectKind.RASTER or right.subject.kind is not SubjectKind.RASTER: + return ( + FactRecord( + key=RELATION_GRID_ALIGNED, + state=FactState.NOT_APPLICABLE, + provenance=provenance, + ), + FactRecord( + key=RELATION_GRID_OFFSET_PIXELS, + state=FactState.NOT_APPLICABLE, + provenance=provenance, + ), + ) + + left_wkt = _known(left, CRS_WKT) + right_wkt = _known(right, CRS_WKT) + left_resolution = _known(left, RASTER_RESOLUTION) + right_resolution = _known(right, RASTER_RESOLUTION) + left_transform = _known(left, RASTER_TRANSFORM) + right_transform = _known(right, RASTER_TRANSFORM) + if ( + not isinstance(left_wkt, str) + or not isinstance(right_wkt, str) + or not _is_pair(left_resolution) + or not _is_pair(right_resolution) + or not _is_transform(left_transform) + or not _is_transform(right_transform) + ): + return _unknown_grid(provenance) + + try: + if CRS.from_wkt(left_wkt) != CRS.from_wkt(right_wkt): + return _unknown_grid(provenance) + except CRSError: + return _unknown_grid(provenance) + + if not ( + math.isclose(left_resolution[0], right_resolution[0], rel_tol=1e-9, abs_tol=1e-12) + and math.isclose(left_resolution[1], right_resolution[1], rel_tol=1e-9, abs_tol=1e-12) + ): + return _unknown_grid(provenance) + + if any( + not math.isclose(value, 0.0, abs_tol=1e-12) + for value in (left_transform[1], left_transform[3], right_transform[1], right_transform[3]) + ): + return _unknown_grid(provenance) + + xres, yres = left_resolution + if xres <= 0.0 or yres <= 0.0: + return _unknown_grid(provenance) + + x_offset = (right_transform[2] - left_transform[2]) / xres + y_offset = (right_transform[5] - left_transform[5]) / yres + x_fraction = x_offset - round(x_offset) + y_fraction = y_offset - round(y_offset) + aligned = math.isclose(x_fraction, 0.0, abs_tol=1e-9) and math.isclose( + y_fraction, 0.0, abs_tol=1e-9 + ) + return ( + FactRecord.known(RELATION_GRID_ALIGNED, aligned, provenance=provenance), + FactRecord.known( + RELATION_GRID_OFFSET_PIXELS, + (float(x_fraction), float(y_fraction)), + provenance=provenance, + ), + ) + + +def _transform_bounds( + bounds: tuple[float, float, float, float], + source_wkt: str, + target_wkt: str, +) -> tuple[float, float, float, float] | None: + try: + source = CRS.from_wkt(source_wkt) + target = CRS.from_wkt(target_wkt) + if source == target: + return bounds + transformer = Transformer.from_crs(source, target, always_xy=True) + transformed = transformer.transform_bounds(*bounds, densify_pts=21) + except (CRSError, ProjError, ValueError): + return None + if not all(math.isfinite(value) for value in transformed): + return None + return tuple(float(value) for value in transformed) + + +def _unknown_grid(provenance: FactProvenance) -> tuple[FactRecord, ...]: + return ( + FactRecord.unknown(RELATION_GRID_ALIGNED, provenance=provenance), + FactRecord.unknown(RELATION_GRID_OFFSET_PIXELS, provenance=provenance), + ) + + +def _is_pair(value: object) -> bool: + return ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and all(isinstance(item, (int, float)) for item in value) + ) + + +def _is_transform(value: object) -> bool: + return ( + isinstance(value, (list, tuple)) + and len(value) == 6 + and all(isinstance(item, (int, float)) for item in value) + ) + + +def _is_bounds(value: object) -> bool: + return ( + isinstance(value, (list, tuple)) + and len(value) == 4 + and all(isinstance(item, (int, float)) and math.isfinite(item) for item in value) + ) diff --git a/src/geodebug/models/context.py b/src/geodebug/models/context.py index f455dc9..db0b8b3 100644 --- a/src/geodebug/models/context.py +++ b/src/geodebug/models/context.py @@ -1,5 +1,6 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from geodebug.models.facts import FactStore from geodebug.models.operations import OperationContext from geodebug.models.subjects import DatasetSnapshot @@ -8,6 +9,7 @@ class EvaluationContext: subjects: tuple[DatasetSnapshot, ...] operation: OperationContext | None = None + facts: FactStore = field(default_factory=FactStore) @property def primary(self) -> DatasetSnapshot | None: diff --git a/src/geodebug/rules/relations/__init__.py b/src/geodebug/rules/relations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geodebug/rules/relations/geo402.py b/src/geodebug/rules/relations/geo402.py new file mode 100644 index 0000000..22fbad6 --- /dev/null +++ b/src/geodebug/rules/relations/geo402.py @@ -0,0 +1,51 @@ +from geodebug.facts.keys import RELATION_BOUNDS_OVERLAP, RELATION_OVERLAP_RATIO +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import ( + Certainty, + CostClass, + FactState, + FixSafety, + RuleScope, + RuleState, + Severity, +) +from geodebug.models.evidence import Evidence +from geodebug.rules.base import RuleResult, RuleSpec + + +class NoSpatialOverlapRule: + spec = RuleSpec( + id="GEO402", + name="no-spatial-overlap", + category="relation.extent", + scope=RuleScope.RELATION, + default_severity=Severity.ERROR, + certainty=Certainty.DETERMINISTIC, + requires=(RELATION_BOUNDS_OVERLAP,), + cost=CostClass.METADATA, + fix_safety=FixSafety.NONE, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + if len(context.subjects) != 2: + return RuleResult.not_applicable() + + overlap = context.facts.get(RELATION_BOUNDS_OVERLAP) + if overlap is None or overlap.state is FactState.UNKNOWN: + return RuleResult.unknown("Dataset extent overlap could not be established.") + if overlap.state is FactState.NOT_APPLICABLE: + return RuleResult.not_applicable() + if bool(overlap.value): + return RuleResult.passed() + + evidence = [Evidence(key=RELATION_BOUNDS_OVERLAP, value=False)] + ratio = context.facts.get(RELATION_OVERLAP_RATIO) + if ratio is not None and ratio.state is FactState.KNOWN: + evidence.append(Evidence(key=RELATION_OVERLAP_RATIO, value=float(ratio.value))) + + return RuleResult( + state=RuleState.FAIL, + message="Dataset extents do not overlap.", + evidence=tuple(evidence), + implication="Spatial operations between these datasets cannot produce intersecting results.", + ) diff --git a/src/geodebug/rules/relations/geo404.py b/src/geodebug/rules/relations/geo404.py new file mode 100644 index 0000000..faab9fa --- /dev/null +++ b/src/geodebug/rules/relations/geo404.py @@ -0,0 +1,51 @@ +from geodebug.facts.keys import RELATION_GRID_ALIGNED, RELATION_GRID_OFFSET_PIXELS +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import ( + Certainty, + CostClass, + FactState, + FixSafety, + RuleScope, + RuleState, + Severity, +) +from geodebug.models.evidence import Evidence +from geodebug.rules.base import RuleResult, RuleSpec + + +class RasterGridMisalignmentRule: + spec = RuleSpec( + id="GEO404", + name="raster-grid-misalignment", + category="relation.grid", + scope=RuleScope.RELATION, + default_severity=Severity.WARNING, + certainty=Certainty.DETERMINISTIC, + requires=(RELATION_GRID_ALIGNED,), + cost=CostClass.METADATA, + fix_safety=FixSafety.REVIEW_REQUIRED, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + if len(context.subjects) != 2: + return RuleResult.not_applicable() + + aligned = context.facts.get(RELATION_GRID_ALIGNED) + if aligned is None or aligned.state is FactState.UNKNOWN: + return RuleResult.unknown("Raster grid alignment could not be established.") + if aligned.state is FactState.NOT_APPLICABLE: + return RuleResult.not_applicable() + if bool(aligned.value): + return RuleResult.passed() + + evidence = [Evidence(key=RELATION_GRID_ALIGNED, value=False)] + offset = context.facts.get(RELATION_GRID_OFFSET_PIXELS) + if offset is not None and offset.state is FactState.KNOWN: + evidence.append(Evidence(key=RELATION_GRID_OFFSET_PIXELS, value=offset.value)) + + return RuleResult( + state=RuleState.FAIL, + message="Raster grids are not aligned to the same pixel lattice.", + evidence=tuple(evidence), + implication="Cell-by-cell operations may require an explicit resampling step.", + ) From 571f35ccba714aa364bcf58fe1f1b5fdd5ebdfa6 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:11:09 +0800 Subject: [PATCH 03/11] feat: expose inspect check and compare workflows --- .github/workflows/ci.yml | 13 ++++ README.md | 86 ++++++++++++++++++++------- src/geodebug/__init__.py | 4 +- src/geodebug/api.py | 47 ++++++++++++++- src/geodebug/cli/app.py | 95 ++++++++++++++++++++++++++++++ src/geodebug/facts/relations.py | 78 ++++++++++++++---------- src/geodebug/reporters/__init__.py | 0 src/geodebug/reporters/terminal.py | 70 ++++++++++++++++++++++ src/geodebug/version.py | 2 +- 9 files changed, 339 insertions(+), 56 deletions(-) create mode 100644 src/geodebug/reporters/__init__.py create mode 100644 src/geodebug/reporters/terminal.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b56cb9..7fa5170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,19 @@ jobs: - name: Tests run: pytest --cov=geodebug --cov-report=term-missing + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install geospatial extras + run: python -m pip install -e '.[dev,all]' + - name: Integration tests + run: pytest tests/integration + smoke: strategy: matrix: diff --git a/README.md b/README.md index b353eb7..310b37e 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,73 @@ Deterministic diagnostics for geospatial data and workflows. **Find the geographic bug, not just the code bug.** -GeoDebug is an early-stage GeoGeekLab project for turning geospatial correctness rules into explicit, testable diagnostics. The core is intentionally independent of GIS file formats and language models: adapters produce normalized facts, rules evaluate spatial invariants, and reporters expose stable diagnostics. +GeoDebug turns geospatial correctness rules into explicit, testable diagnostics. Adapters +observe datasets, rules evaluate spatial invariants, and the canonical JSON report provides a +stable integration boundary for CLI, CI, and future agent tooling. -## Status +## Install -The repository is in kernel development. The current vertical slice includes the fact model, rule engine, canonical report schema, CLI rule inspection, and three sentinel rules: +Core GeoJSON support: + +```bash +pip install geodebug +``` + +Optional format support: + +```bash +pip install "geodebug[vector]" # GPKG, Shapefile, FlatGeobuf +pip install "geodebug[raster]" # GeoTIFF, COG, VRT +pip install "geodebug[parquet]" # GeoParquet +pip install "geodebug[geopandas]" # in-memory GeoDataFrame +pip install "geodebug[all]" +``` + +## Usage + +Inspect normalized facts without diagnostics: + +```bash +geodebug inspect roads.geojson +``` + +Run dataset checks: + +```bash +geodebug check roads.geojson +geodebug check roads.geojson --deep +``` + +Compare two datasets: + +```bash +geodebug compare dem-a.tif dem-b.tif +``` + +Emit the canonical JSON report: + +```bash +geodebug check roads.geojson --format json +``` + +The default path is metadata-first. Full vector geometry scans are opt-in with `--deep`. + +## Built-in rules + +The v0.1 usable core currently includes: - `GEO101` — missing CRS - `GEO201` — invalid geometry +- `GEO402` — no spatial overlap +- `GEO404` — raster grid misalignment - `GEO501` — metric buffer on a geographic CRS -File-format adapters and user-facing `check` / `compare` workflows are the next milestone. +Inspect rule contracts with: + +```bash +geodebug rules list +geodebug rules show GEO404 +``` ## Architecture @@ -22,31 +78,19 @@ File-format adapters and user-facing `check` / `compare` workflows are the next adapter -> facts -> rules -> diagnostics -> report ``` -Rules never read files directly and adapters never emit diagnostics. +Rules never read files directly and adapters never emit diagnostics. Unknown evidence remains +`UNKNOWN`; it is never silently treated as a pass. + +See [`docs/architecture.md`](docs/architecture.md) for the kernel contracts. ## Development ```bash python -m venv .venv source .venv/bin/activate -pip install -e '.[dev]' +pip install -e '.[dev,all]' ruff check . mypy src/geodebug pytest ``` - -Inspect the registered rules: - -```bash -geodebug rules list -geodebug rules show GEO501 -``` - -Print the canonical report JSON Schema: - -```bash -geodebug schema -``` - -See [`docs/architecture.md`](docs/architecture.md) for the kernel contracts. diff --git a/src/geodebug/__init__.py b/src/geodebug/__init__.py index caddf34..f640fdd 100644 --- a/src/geodebug/__init__.py +++ b/src/geodebug/__init__.py @@ -1,5 +1,5 @@ -from geodebug.api import evaluate +from geodebug.api import check, compare, evaluate, inspect from geodebug.models.report import Report from geodebug.version import __version__ -__all__ = ["Report", "__version__", "evaluate"] +__all__ = ["Report", "__version__", "check", "compare", "evaluate", "inspect"] diff --git a/src/geodebug/api.py b/src/geodebug/api.py index 98d8760..c3dae28 100644 --- a/src/geodebug/api.py +++ b/src/geodebug/api.py @@ -1,10 +1,55 @@ +from __future__ import annotations + +from typing import Any + +from geodebug.adapters.base import InspectOptions +from geodebug.adapters.defaults import build_default_adapter_registry from geodebug.engine.defaults import build_default_registry from geodebug.engine.evaluator import Evaluator from geodebug.engine.policy import Policy +from geodebug.facts.relations import build_relation_facts from geodebug.models.context import EvaluationContext from geodebug.models.report import Report +from geodebug.models.subjects import DatasetSnapshot + + +def inspect(target: Any, *, deep: bool = False) -> DatasetSnapshot: + """Inspect one geospatial target and return normalized facts.""" + return build_default_adapter_registry().inspect( + target, + options=InspectOptions(deep=deep), + ) + + +def check( + target: Any, + *, + deep: bool = False, + policy: Policy | None = None, +) -> Report: + """Run dataset diagnostics against one target.""" + snapshot = inspect(target, deep=deep) + return evaluate(EvaluationContext(subjects=(snapshot,)), policy=policy) + + +def compare( + left: Any, + right: Any, + *, + deep: bool = False, + policy: Policy | None = None, +) -> Report: + """Run dataset and relational diagnostics against two targets.""" + left_snapshot = inspect(left, deep=deep) + right_snapshot = inspect(right, deep=deep) + relation_facts = build_relation_facts(left_snapshot, right_snapshot) + context = EvaluationContext( + subjects=(left_snapshot, right_snapshot), + facts=relation_facts, + ) + return evaluate(context, policy=policy) def evaluate(context: EvaluationContext, *, policy: Policy | None = None) -> Report: - """Evaluate the built-in rule set against an already-normalized context.""" + """Evaluate built-in rules against an already-normalized context.""" return Evaluator(build_default_registry(), policy=policy).evaluate(context) diff --git a/src/geodebug/cli/app.py b/src/geodebug/cli/app.py index 2e6cd32..f047e46 100644 --- a/src/geodebug/cli/app.py +++ b/src/geodebug/cli/app.py @@ -1,13 +1,21 @@ from __future__ import annotations +from enum import StrEnum from importlib.resources import files +from pathlib import Path from typing import Annotated import typer from rich.console import Console from rich.table import Table +from geodebug.adapters.base import AdapterError +from geodebug.api import check as check_target +from geodebug.api import compare as compare_targets +from geodebug.api import inspect as inspect_target from geodebug.engine.defaults import build_default_registry +from geodebug.models.report import Report +from geodebug.reporters.terminal import print_report, print_snapshot from geodebug.version import __version__ app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False) @@ -16,6 +24,17 @@ console = Console() +class OutputFormat(StrEnum): + TERMINAL = "terminal" + JSON = "json" + + +class FailOn(StrEnum): + ERROR = "error" + WARNING = "warning" + NONE = "none" + + def _version_callback(value: bool) -> None: if value: typer.echo(__version__) @@ -37,6 +56,67 @@ def root( """Deterministic diagnostics for geospatial data and workflows.""" +@app.command("inspect") +def inspect_command( + target: Path, + deep: Annotated[bool, typer.Option(help="Allow full geometry scans.")] = False, +) -> None: + """Inspect normalized facts without running diagnostic rules.""" + try: + snapshot = inspect_target(target, deep=deep) + except AdapterError as exc: + console.print(str(exc), style="bold red", stderr=True) + raise typer.Exit(code=2) from None + print_snapshot(snapshot, console=console) + + +@app.command("check") +def check_command( + target: Path, + deep: Annotated[bool, typer.Option(help="Allow full geometry scans.")] = False, + output_format: Annotated[ + OutputFormat, + typer.Option("--format", help="Output format."), + ] = OutputFormat.TERMINAL, + fail_on: Annotated[ + FailOn, + typer.Option(help="Diagnostic severity that fails the command."), + ] = FailOn.ERROR, +) -> None: + """Run diagnostics against one dataset.""" + try: + report = check_target(target, deep=deep) + except AdapterError as exc: + console.print(str(exc), style="bold red", stderr=True) + raise typer.Exit(code=2) from None + _emit_report(report, output_format) + raise typer.Exit(code=_exit_code(report, fail_on)) + + +@app.command("compare") +def compare_command( + left: Path, + right: Path, + deep: Annotated[bool, typer.Option(help="Allow full geometry scans.")] = False, + output_format: Annotated[ + OutputFormat, + typer.Option("--format", help="Output format."), + ] = OutputFormat.TERMINAL, + fail_on: Annotated[ + FailOn, + typer.Option(help="Diagnostic severity that fails the command."), + ] = FailOn.ERROR, +) -> None: + """Run dataset and relational diagnostics against two datasets.""" + try: + report = compare_targets(left, right, deep=deep) + except AdapterError as exc: + console.print(str(exc), style="bold red", stderr=True) + raise typer.Exit(code=2) from None + _emit_report(report, output_format) + raise typer.Exit(code=_exit_code(report, fail_on)) + + @rules_app.command("list") def list_rules() -> None: """List built-in diagnostic rules.""" @@ -88,5 +168,20 @@ def schema() -> None: ) +def _emit_report(report: Report, output_format: OutputFormat) -> None: + if output_format is OutputFormat.JSON: + typer.echo(report.model_dump_json(indent=2)) + return + print_report(report, console=console) + + +def _exit_code(report: Report, fail_on: FailOn) -> int: + if fail_on is FailOn.NONE: + return 0 + if fail_on is FailOn.WARNING: + return 1 if report.summary.errors or report.summary.warnings else 0 + return 1 if report.summary.errors else 0 + + def main() -> None: app() diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py index f6c2077..dbd852e 100644 --- a/src/geodebug/facts/relations.py +++ b/src/geodebug/facts/relations.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from typing import TypeGuard from pyproj import CRS, Transformer from pyproj.exceptions import CRSError, ProjError @@ -20,6 +21,11 @@ from geodebug.models.subjects import DatasetSnapshot +Bounds = tuple[float, float, float, float] +Pair = tuple[float, float] +Transform6 = tuple[float, float, float, float, float, float] + + def build_relation_facts(left: DatasetSnapshot, right: DatasetSnapshot) -> FactStore: provenance = FactProvenance(origin="geodebug", method="relation") records = list(_bounds_relation(left, right, provenance=provenance)) @@ -40,13 +46,13 @@ def _bounds_relation( *, provenance: FactProvenance, ) -> tuple[FactRecord, ...]: - left_bounds = _known(left, SPATIAL_BOUNDS) - right_bounds = _known(right, SPATIAL_BOUNDS) + left_bounds = _as_bounds(_known(left, SPATIAL_BOUNDS)) + right_bounds = _as_bounds(_known(right, SPATIAL_BOUNDS)) left_wkt = _known(left, CRS_WKT) right_wkt = _known(right, CRS_WKT) if ( - not _is_bounds(left_bounds) - or not _is_bounds(right_bounds) + left_bounds is None + or right_bounds is None or not isinstance(left_wkt, str) or not isinstance(right_wkt, str) ): @@ -107,17 +113,17 @@ def _grid_relation( left_wkt = _known(left, CRS_WKT) right_wkt = _known(right, CRS_WKT) - left_resolution = _known(left, RASTER_RESOLUTION) - right_resolution = _known(right, RASTER_RESOLUTION) - left_transform = _known(left, RASTER_TRANSFORM) - right_transform = _known(right, RASTER_TRANSFORM) + left_resolution = _as_pair(_known(left, RASTER_RESOLUTION)) + right_resolution = _as_pair(_known(right, RASTER_RESOLUTION)) + left_transform = _as_transform(_known(left, RASTER_TRANSFORM)) + right_transform = _as_transform(_known(right, RASTER_TRANSFORM)) if ( not isinstance(left_wkt, str) or not isinstance(right_wkt, str) - or not _is_pair(left_resolution) - or not _is_pair(right_resolution) - or not _is_transform(left_transform) - or not _is_transform(right_transform) + or left_resolution is None + or right_resolution is None + or left_transform is None + or right_transform is None ): return _unknown_grid(provenance) @@ -160,11 +166,7 @@ def _grid_relation( ) -def _transform_bounds( - bounds: tuple[float, float, float, float], - source_wkt: str, - target_wkt: str, -) -> tuple[float, float, float, float] | None: +def _transform_bounds(bounds: Bounds, source_wkt: str, target_wkt: str) -> Bounds | None: try: source = CRS.from_wkt(source_wkt) target = CRS.from_wkt(target_wkt) @@ -176,7 +178,12 @@ def _transform_bounds( return None if not all(math.isfinite(value) for value in transformed): return None - return tuple(float(value) for value in transformed) + return ( + float(transformed[0]), + float(transformed[1]), + float(transformed[2]), + float(transformed[3]), + ) def _unknown_grid(provenance: FactProvenance) -> tuple[FactRecord, ...]: @@ -186,25 +193,34 @@ def _unknown_grid(provenance: FactProvenance) -> tuple[FactRecord, ...]: ) -def _is_pair(value: object) -> bool: +def _is_numeric_sequence(value: object, length: int) -> TypeGuard[list[float] | tuple[float, ...]]: return ( isinstance(value, (list, tuple)) - and len(value) == 2 - and all(isinstance(item, (int, float)) for item in value) + and len(value) == length + and all(isinstance(item, (int, float)) and math.isfinite(item) for item in value) ) -def _is_transform(value: object) -> bool: - return ( - isinstance(value, (list, tuple)) - and len(value) == 6 - and all(isinstance(item, (int, float)) for item in value) - ) +def _as_pair(value: object) -> Pair | None: + if not _is_numeric_sequence(value, 2): + return None + return (float(value[0]), float(value[1])) -def _is_bounds(value: object) -> bool: +def _as_transform(value: object) -> Transform6 | None: + if not _is_numeric_sequence(value, 6): + return None return ( - isinstance(value, (list, tuple)) - and len(value) == 4 - and all(isinstance(item, (int, float)) and math.isfinite(item) for item in value) + float(value[0]), + float(value[1]), + float(value[2]), + float(value[3]), + float(value[4]), + float(value[5]), ) + + +def _as_bounds(value: object) -> Bounds | None: + if not _is_numeric_sequence(value, 4): + return None + return (float(value[0]), float(value[1]), float(value[2]), float(value[3])) diff --git a/src/geodebug/reporters/__init__.py b/src/geodebug/reporters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geodebug/reporters/terminal.py b/src/geodebug/reporters/terminal.py new file mode 100644 index 0000000..e753681 --- /dev/null +++ b/src/geodebug/reporters/terminal.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + +from rich.console import Console +from rich.table import Table +from rich.text import Text + +from geodebug.models.facts import FactRecord +from geodebug.models.report import Report +from geodebug.models.subjects import DatasetSnapshot + + +def print_snapshot(snapshot: DatasetSnapshot, *, console: Console) -> None: + console.print(f"[bold]{snapshot.subject.label or snapshot.subject.id}[/bold]") + console.print( + f"{snapshot.subject.kind.value} · adapter={snapshot.subject.adapter or 'unknown'}" + ) + table = Table(show_header=True, header_style="bold") + table.add_column("Fact") + table.add_column("State") + table.add_column("Value") + table.add_column("Certainty") + for fact in snapshot.facts: + table.add_row( + fact.key, + fact.state.value, + Text(_format_value(fact)), + fact.certainty.value, + ) + console.print(table) + + +def print_report(report: Report, *, console: Console) -> None: + for subject in report.subjects: + console.print(f"[bold]{subject.label or subject.id}[/bold]") + + if not report.diagnostics: + console.print("[green]No diagnostics.[/green]") + else: + for diagnostic in report.diagnostics: + style = { + "error": "bold red", + "warning": "bold yellow", + "note": "bold cyan", + }[diagnostic.severity.value] + console.print( + f"[{style}]{diagnostic.severity.value.upper()}[/{style}] " + f"[bold]{diagnostic.rule_id}[/bold] {diagnostic.message}" + ) + for evidence in diagnostic.evidence: + console.print(f" {evidence.key}: {_format_json_value(evidence.value)}") + + summary = report.summary + console.print( + f"{summary.errors} error(s) · {summary.warnings} warning(s) · " + f"{summary.notes} note(s) · {summary.unknown_rules} unknown" + ) + + +def _format_value(fact: FactRecord) -> str: + if fact.state.value != "known": + return "—" + return _format_json_value(fact.value) + + +def _format_json_value(value: Any) -> str: + if isinstance(value, (list, tuple)): + return ", ".join(str(item) for item in value) + return str(value) diff --git a/src/geodebug/version.py b/src/geodebug/version.py index 15addcb..4b4a921 100644 --- a/src/geodebug/version.py +++ b/src/geodebug/version.py @@ -1 +1 @@ -__version__ = "0.0.1.dev0" +__version__ = "0.1.0.dev0" From b4dffad08e7c784e81a2eb1ecc3467b7b6a4bb73 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:12:06 +0800 Subject: [PATCH 04/11] test: cover v0.1 dataset workflows --- pyproject.toml | 2 +- src/geodebug/adapters/parquet.py | 8 +-- src/geodebug/cli/app.py | 7 +-- tests/integration/test_geojson_workflow.py | 58 +++++++++++++++++++++ tests/integration/test_optional_adapters.py | 40 ++++++++++++++ tests/integration/test_rasterio_adapter.py | 47 +++++++++++++++++ tests/unit/test_cli.py | 27 ++++++++-- tests/unit/test_evaluator_scope.py | 28 ++++++++++ tests/unit/test_relations.py | 44 ++++++++++++++++ 9 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 tests/integration/test_geojson_workflow.py create mode 100644 tests/integration/test_optional_adapters.py create mode 100644 tests/integration/test_rasterio_adapter.py create mode 100644 tests/unit/test_evaluator_scope.py create mode 100644 tests/unit/test_relations.py diff --git a/pyproject.toml b/pyproject.toml index 6a95352..202b1f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ ] [project.optional-dependencies] -vector = ["pyogrio>=0.10,<1"] +vector = ["geopandas>=1.0,<2", "pyogrio>=0.10,<1"] raster = ["rasterio>=1.4,<2"] parquet = ["pyarrow>=17,<30"] geopandas = ["geopandas>=1.0,<2"] diff --git a/src/geodebug/adapters/parquet.py b/src/geodebug/adapters/parquet.py index 3bf32c2..11f1d72 100644 --- a/src/geodebug/adapters/parquet.py +++ b/src/geodebug/adapters/parquet.py @@ -31,6 +31,7 @@ def supports(self, target: Any) -> int: return 95 if Path(target).suffix.casefold() in {".parquet", ".geoparquet"} else 0 def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: + del options try: import pyarrow.parquet as pq except ImportError as exc: @@ -97,10 +98,9 @@ def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: else: records.append(FactRecord.unknown(SPATIAL_BOUNDS, provenance=provenance)) - if options.deep: - records.append(FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance)) - else: - records.append(FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance)) + records.append( + FactRecord.unknown(VECTOR_INVALID_GEOMETRY_COUNT, provenance=provenance) + ) return DatasetSnapshot( subject=SubjectRef( diff --git a/src/geodebug/cli/app.py b/src/geodebug/cli/app.py index f047e46..ced1340 100644 --- a/src/geodebug/cli/app.py +++ b/src/geodebug/cli/app.py @@ -22,6 +22,7 @@ rules_app = typer.Typer(no_args_is_help=True) app.add_typer(rules_app, name="rules") console = Console() +error_console = Console(stderr=True) class OutputFormat(StrEnum): @@ -65,7 +66,7 @@ def inspect_command( try: snapshot = inspect_target(target, deep=deep) except AdapterError as exc: - console.print(str(exc), style="bold red", stderr=True) + error_console.print(str(exc), style="bold red") raise typer.Exit(code=2) from None print_snapshot(snapshot, console=console) @@ -87,7 +88,7 @@ def check_command( try: report = check_target(target, deep=deep) except AdapterError as exc: - console.print(str(exc), style="bold red", stderr=True) + error_console.print(str(exc), style="bold red") raise typer.Exit(code=2) from None _emit_report(report, output_format) raise typer.Exit(code=_exit_code(report, fail_on)) @@ -111,7 +112,7 @@ def compare_command( try: report = compare_targets(left, right, deep=deep) except AdapterError as exc: - console.print(str(exc), style="bold red", stderr=True) + error_console.print(str(exc), style="bold red") raise typer.Exit(code=2) from None _emit_report(report, output_format) raise typer.Exit(code=_exit_code(report, fail_on)) diff --git a/tests/integration/test_geojson_workflow.py b/tests/integration/test_geojson_workflow.py new file mode 100644 index 0000000..fab2a6b --- /dev/null +++ b/tests/integration/test_geojson_workflow.py @@ -0,0 +1,58 @@ +import json + +from geodebug import check, compare, inspect +from geodebug.facts.keys import CRS_KIND, VECTOR_INVALID_GEOMETRY_COUNT +from geodebug.models.enums import FactState, SubjectKind + + +def _write_geojson(path, coordinates) -> None: + payload = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [coordinates], + }, + } + ], + } + path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_geojson_inspect_normalizes_crs_and_geometry_facts(tmp_path) -> None: + path = tmp_path / "valid.geojson" + _write_geojson(path, [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) + + snapshot = inspect(path) + + assert snapshot.subject.kind is SubjectKind.VECTOR + assert snapshot.subject.adapter == "geojson" + assert snapshot.facts.require(CRS_KIND).value == "geographic" + assert snapshot.facts.require(VECTOR_INVALID_GEOMETRY_COUNT).state is FactState.UNKNOWN + + +def test_geojson_deep_check_detects_invalid_geometry(tmp_path) -> None: + path = tmp_path / "bowtie.geojson" + _write_geojson(path, [[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]) + + report = check(path, deep=True) + + assert [diagnostic.rule_id for diagnostic in report.diagnostics] == ["GEO201"] + + +def test_compare_detects_non_overlapping_extents(tmp_path) -> None: + left = tmp_path / "left.geojson" + right = tmp_path / "right.geojson" + _write_geojson(left, [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) + _write_geojson(right, [[10, 10], [11, 10], [11, 11], [10, 11], [10, 10]]) + + report = compare(left, right) + + assert [diagnostic.rule_id for diagnostic in report.diagnostics] == ["GEO402"] + assert report.diagnostics[0].subject_ids == [ + f"file:{left.as_posix()}", + f"file:{right.as_posix()}", + ] diff --git a/tests/integration/test_optional_adapters.py b/tests/integration/test_optional_adapters.py new file mode 100644 index 0000000..6717da0 --- /dev/null +++ b/tests/integration/test_optional_adapters.py @@ -0,0 +1,40 @@ +import pytest + +geopandas = pytest.importorskip("geopandas") +pytest.importorskip("pyarrow") +pytest.importorskip("pyogrio") +from shapely.geometry import Point + +from geodebug import inspect + + +def _frame(): + return geopandas.GeoDataFrame( + {"value": [1, 2]}, + geometry=[Point(0, 0), Point(1, 1)], + crs="EPSG:4326", + ) + + +def test_geopandas_in_memory_adapter() -> None: + snapshot = inspect(_frame()) + + assert snapshot.subject.adapter == "geopandas" + + +def test_pyogrio_file_adapter(tmp_path) -> None: + path = tmp_path / "points.gpkg" + _frame().to_file(path, driver="GPKG") + + snapshot = inspect(path) + + assert snapshot.subject.adapter == "pyogrio" + + +def test_geoparquet_adapter(tmp_path) -> None: + path = tmp_path / "points.parquet" + _frame().to_parquet(path) + + snapshot = inspect(path) + + assert snapshot.subject.adapter == "geoparquet" diff --git a/tests/integration/test_rasterio_adapter.py b/tests/integration/test_rasterio_adapter.py new file mode 100644 index 0000000..5d4bbf5 --- /dev/null +++ b/tests/integration/test_rasterio_adapter.py @@ -0,0 +1,47 @@ +import pytest + +rasterio = pytest.importorskip("rasterio") +from rasterio.transform import from_origin + +from geodebug import compare, inspect +from geodebug.facts.keys import RASTER_RESOLUTION + + +def _write_raster(path, *, west: float) -> None: + import numpy as np + + data = np.ones((1, 10, 10), dtype="uint8") + with rasterio.open( + path, + "w", + driver="GTiff", + width=10, + height=10, + count=1, + dtype="uint8", + crs="EPSG:32648", + transform=from_origin(west, 100.0, 10.0, 10.0), + ) as dataset: + dataset.write(data) + + +def test_rasterio_adapter_reads_grid_metadata(tmp_path) -> None: + path = tmp_path / "grid.tif" + _write_raster(path, west=0.0) + + snapshot = inspect(path) + + assert snapshot.subject.adapter == "rasterio" + assert snapshot.facts.require(RASTER_RESOLUTION).value == (10.0, 10.0) + + +def test_compare_detects_half_pixel_raster_misalignment(tmp_path) -> None: + left = tmp_path / "left.tif" + right = tmp_path / "right.tif" + _write_raster(left, west=0.0) + _write_raster(right, west=5.0) + + report = compare(left, right) + + ids = [diagnostic.rule_id for diagnostic in report.diagnostics] + assert "GEO404" in ids diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 5b0fe23..eb35e99 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -16,10 +16,29 @@ def test_schema_command_emits_canonical_schema() -> None: assert schema["properties"]["schema_version"]["const"] == "1.0.0" -def test_rules_list_contains_sentinel_rules() -> None: +def test_rules_list_contains_usable_core_rules() -> None: result = runner.invoke(app, ["rules", "list"]) assert result.exit_code == 0 - assert "GEO101" in result.stdout - assert "GEO201" in result.stdout - assert "GEO501" in result.stdout + for rule_id in ("GEO101", "GEO201", "GEO402", "GEO404", "GEO501"): + assert rule_id in result.stdout + + +def test_check_json_exit_code_is_zero_for_clean_geojson(tmp_path) -> None: + path = tmp_path / "point.geojson" + path.write_text( + json.dumps( + { + "type": "Feature", + "properties": {}, + "geometry": {"type": "Point", "coordinates": [0, 0]}, + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["check", str(path), "--format", "json"]) + + assert result.exit_code == 0 + report = json.loads(result.stdout) + assert report["diagnostics"] == [] diff --git a/tests/unit/test_evaluator_scope.py b/tests/unit/test_evaluator_scope.py new file mode 100644 index 0000000..db3dc78 --- /dev/null +++ b/tests/unit/test_evaluator_scope.py @@ -0,0 +1,28 @@ +from geodebug.engine.defaults import build_default_registry +from geodebug.engine.evaluator import Evaluator +from geodebug.facts.keys import CRS_PRESENT +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import SubjectKind +from geodebug.models.facts import FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +def _snapshot(subject_id: str, crs_present: bool) -> DatasetSnapshot: + return DatasetSnapshot( + subject=SubjectRef(id=subject_id, kind=SubjectKind.VECTOR, adapter="test"), + facts=FactStore([FactRecord.known(CRS_PRESENT, crs_present)]), + ) + + +def test_dataset_rules_are_evaluated_per_subject() -> None: + context = EvaluationContext( + subjects=( + _snapshot("left", False), + _snapshot("right", True), + ) + ) + + report = Evaluator(build_default_registry()).evaluate(context) + + diagnostic = next(item for item in report.diagnostics if item.rule_id == "GEO101") + assert diagnostic.subject_ids == ["left"] diff --git a/tests/unit/test_relations.py b/tests/unit/test_relations.py new file mode 100644 index 0000000..6b52ba8 --- /dev/null +++ b/tests/unit/test_relations.py @@ -0,0 +1,44 @@ +from pyproj import CRS + +from geodebug.facts.keys import ( + CRS_WKT, + RASTER_RESOLUTION, + RASTER_TRANSFORM, + RELATION_GRID_ALIGNED, + RELATION_GRID_OFFSET_PIXELS, + SPATIAL_BOUNDS, +) +from geodebug.facts.relations import build_relation_facts +from geodebug.models.enums import SubjectKind +from geodebug.models.facts import FactRecord, FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef + + +def _raster(subject_id: str, origin_x: float) -> DatasetSnapshot: + return DatasetSnapshot( + subject=SubjectRef(id=subject_id, kind=SubjectKind.RASTER, adapter="test"), + facts=FactStore( + [ + FactRecord.known(CRS_WKT, CRS.from_epsg(32648).to_wkt()), + FactRecord.known(SPATIAL_BOUNDS, (origin_x, 0.0, origin_x + 100.0, 100.0)), + FactRecord.known(RASTER_RESOLUTION, (10.0, 10.0)), + FactRecord.known( + RASTER_TRANSFORM, + (10.0, 0.0, origin_x, 0.0, -10.0, 100.0), + ), + ] + ), + ) + + +def test_relation_facts_detect_half_pixel_grid_offset() -> None: + facts = build_relation_facts(_raster("left", 0.0), _raster("right", 5.0)) + + assert facts.require(RELATION_GRID_ALIGNED).value is False + assert facts.require(RELATION_GRID_OFFSET_PIXELS).value == (0.5, 0.0) + + +def test_relation_facts_accept_integer_pixel_offset() -> None: + facts = build_relation_facts(_raster("left", 0.0), _raster("right", 20.0)) + + assert facts.require(RELATION_GRID_ALIGNED).value is True From ce19e8cf0595f2d4c5fc32da3be0bfa8fc2fea2e Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:13:38 +0800 Subject: [PATCH 05/11] style: satisfy v0.1 lint contracts --- src/geodebug/adapters/parquet.py | 2 +- src/geodebug/adapters/pyogrio.py | 2 +- src/geodebug/adapters/rasterio.py | 2 +- src/geodebug/facts/relations.py | 40 ++++++++++++--------- src/geodebug/rules/relations/geo402.py | 2 +- tests/integration/test_optional_adapters.py | 7 ++-- tests/integration/test_rasterio_adapter.py | 8 +++-- 7 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/geodebug/adapters/parquet.py b/src/geodebug/adapters/parquet.py index 11f1d72..be4259b 100644 --- a/src/geodebug/adapters/parquet.py +++ b/src/geodebug/adapters/parquet.py @@ -55,7 +55,7 @@ def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: primary = geo.get("primary_column") columns = geo.get("columns", {}) if not isinstance(primary, str) or not isinstance(columns, dict) or primary not in columns: - raise AdapterError("GeoParquet metadata does not define a valid primary geometry column") + raise AdapterError(\n "GeoParquet metadata does not define a valid primary geometry column"\n ) column = columns[primary] if not isinstance(column, dict): raise AdapterError("GeoParquet primary geometry metadata is invalid") diff --git a/src/geodebug/adapters/pyogrio.py b/src/geodebug/adapters/pyogrio.py index 51920b2..e49985f 100644 --- a/src/geodebug/adapters/pyogrio.py +++ b/src/geodebug/adapters/pyogrio.py @@ -24,7 +24,7 @@ class PyogrioAdapter: name = "pyogrio" - _suffixes = {".fgb", ".gpkg", ".shp"} + _suffixes = frozenset({".fgb", ".gpkg", ".shp"}) def supports(self, target: Any) -> int: if not isinstance(target, (str, Path)): diff --git a/src/geodebug/adapters/rasterio.py b/src/geodebug/adapters/rasterio.py index e24b4e1..9877f8e 100644 --- a/src/geodebug/adapters/rasterio.py +++ b/src/geodebug/adapters/rasterio.py @@ -27,7 +27,7 @@ class RasterioAdapter: name = "rasterio" - _suffixes = {".img", ".tif", ".tiff", ".vrt"} + _suffixes = frozenset({".img", ".tif", ".tiff", ".vrt"}) def supports(self, target: Any) -> int: if not isinstance(target, (str, Path)): diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py index dbd852e..1a8c8ba 100644 --- a/src/geodebug/facts/relations.py +++ b/src/geodebug/facts/relations.py @@ -193,34 +193,40 @@ def _unknown_grid(provenance: FactProvenance) -> tuple[FactRecord, ...]: ) -def _is_numeric_sequence(value: object, length: int) -> TypeGuard[list[float] | tuple[float, ...]]: - return ( - isinstance(value, (list, tuple)) - and len(value) == length - and all(isinstance(item, (int, float)) and math.isfinite(item) for item in value) - ) +def _numbers(value: object, length: int) -> tuple[float, ...] | None: + if not isinstance(value, (list, tuple)) or len(value) != length: + return None + numbers: list[float] = [] + for item in value: + if not isinstance(item, (int, float)) or not math.isfinite(item): + return None + numbers.append(float(item)) + return tuple(numbers) def _as_pair(value: object) -> Pair | None: - if not _is_numeric_sequence(value, 2): + numbers = _numbers(value, 2) + if numbers is None: return None - return (float(value[0]), float(value[1])) + return (numbers[0], numbers[1]) def _as_transform(value: object) -> Transform6 | None: - if not _is_numeric_sequence(value, 6): + numbers = _numbers(value, 6) + if numbers is None: return None return ( - float(value[0]), - float(value[1]), - float(value[2]), - float(value[3]), - float(value[4]), - float(value[5]), + numbers[0], + numbers[1], + numbers[2], + numbers[3], + numbers[4], + numbers[5], ) def _as_bounds(value: object) -> Bounds | None: - if not _is_numeric_sequence(value, 4): + numbers = _numbers(value, 4) + if numbers is None: return None - return (float(value[0]), float(value[1]), float(value[2]), float(value[3])) + return (numbers[0], numbers[1], numbers[2], numbers[3]) diff --git a/src/geodebug/rules/relations/geo402.py b/src/geodebug/rules/relations/geo402.py index 22fbad6..4abcf8c 100644 --- a/src/geodebug/rules/relations/geo402.py +++ b/src/geodebug/rules/relations/geo402.py @@ -47,5 +47,5 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: state=RuleState.FAIL, message="Dataset extents do not overlap.", evidence=tuple(evidence), - implication="Spatial operations between these datasets cannot produce intersecting results.", + implication=(\n "Spatial operations between these datasets cannot produce "\n "intersecting results."\n ), ) diff --git a/tests/integration/test_optional_adapters.py b/tests/integration/test_optional_adapters.py index 6717da0..b0a1b05 100644 --- a/tests/integration/test_optional_adapters.py +++ b/tests/integration/test_optional_adapters.py @@ -1,14 +1,11 @@ import pytest - -geopandas = pytest.importorskip("geopandas") -pytest.importorskip("pyarrow") -pytest.importorskip("pyogrio") from shapely.geometry import Point from geodebug import inspect def _frame(): + geopandas = pytest.importorskip("geopandas") return geopandas.GeoDataFrame( {"value": [1, 2]}, geometry=[Point(0, 0), Point(1, 1)], @@ -23,6 +20,7 @@ def test_geopandas_in_memory_adapter() -> None: def test_pyogrio_file_adapter(tmp_path) -> None: + pytest.importorskip("pyogrio") path = tmp_path / "points.gpkg" _frame().to_file(path, driver="GPKG") @@ -32,6 +30,7 @@ def test_pyogrio_file_adapter(tmp_path) -> None: def test_geoparquet_adapter(tmp_path) -> None: + pytest.importorskip("pyarrow") path = tmp_path / "points.parquet" _frame().to_parquet(path) diff --git a/tests/integration/test_rasterio_adapter.py b/tests/integration/test_rasterio_adapter.py index 5d4bbf5..745fb24 100644 --- a/tests/integration/test_rasterio_adapter.py +++ b/tests/integration/test_rasterio_adapter.py @@ -1,8 +1,5 @@ import pytest -rasterio = pytest.importorskip("rasterio") -from rasterio.transform import from_origin - from geodebug import compare, inspect from geodebug.facts.keys import RASTER_RESOLUTION @@ -10,6 +7,9 @@ def _write_raster(path, *, west: float) -> None: import numpy as np + rasterio = pytest.importorskip("rasterio") + from rasterio.transform import from_origin + data = np.ones((1, 10, 10), dtype="uint8") with rasterio.open( path, @@ -26,6 +26,7 @@ def _write_raster(path, *, west: float) -> None: def test_rasterio_adapter_reads_grid_metadata(tmp_path) -> None: + pytest.importorskip("rasterio") path = tmp_path / "grid.tif" _write_raster(path, west=0.0) @@ -36,6 +37,7 @@ def test_rasterio_adapter_reads_grid_metadata(tmp_path) -> None: def test_compare_detects_half_pixel_raster_misalignment(tmp_path) -> None: + pytest.importorskip("rasterio") left = tmp_path / "left.tif" right = tmp_path / "right.tif" _write_raster(left, west=0.0) From 4fc6801e00041678b01beb6886d2df9631063fa4 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:15:02 +0800 Subject: [PATCH 06/11] fix: correct v0.1 source formatting --- src/geodebug/adapters/parquet.py | 4 +++- src/geodebug/facts/relations.py | 1 - src/geodebug/rules/relations/geo402.py | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/geodebug/adapters/parquet.py b/src/geodebug/adapters/parquet.py index be4259b..fde86f7 100644 --- a/src/geodebug/adapters/parquet.py +++ b/src/geodebug/adapters/parquet.py @@ -55,7 +55,9 @@ def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: primary = geo.get("primary_column") columns = geo.get("columns", {}) if not isinstance(primary, str) or not isinstance(columns, dict) or primary not in columns: - raise AdapterError(\n "GeoParquet metadata does not define a valid primary geometry column"\n ) + raise AdapterError( + "GeoParquet metadata does not define a valid primary geometry column" + ) column = columns[primary] if not isinstance(column, dict): raise AdapterError("GeoParquet primary geometry metadata is invalid") diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py index 1a8c8ba..8315a6e 100644 --- a/src/geodebug/facts/relations.py +++ b/src/geodebug/facts/relations.py @@ -1,7 +1,6 @@ from __future__ import annotations import math -from typing import TypeGuard from pyproj import CRS, Transformer from pyproj.exceptions import CRSError, ProjError diff --git a/src/geodebug/rules/relations/geo402.py b/src/geodebug/rules/relations/geo402.py index 4abcf8c..a6fb865 100644 --- a/src/geodebug/rules/relations/geo402.py +++ b/src/geodebug/rules/relations/geo402.py @@ -47,5 +47,8 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: state=RuleState.FAIL, message="Dataset extents do not overlap.", evidence=tuple(evidence), - implication=(\n "Spatial operations between these datasets cannot produce "\n "intersecting results."\n ), + implication=( + "Spatial operations between these datasets cannot produce " + "intersecting results." + ), ) From cfda8d9dfc990e1ad2e15566473d43fc6e1af13c Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:16:54 +0800 Subject: [PATCH 07/11] style: simplify pyproj imports --- src/geodebug/facts/relations.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py index 8315a6e..eb01893 100644 --- a/src/geodebug/facts/relations.py +++ b/src/geodebug/facts/relations.py @@ -2,7 +2,7 @@ import math -from pyproj import CRS, Transformer +import pyproj from pyproj.exceptions import CRSError, ProjError from geodebug.facts.keys import ( @@ -127,7 +127,7 @@ def _grid_relation( return _unknown_grid(provenance) try: - if CRS.from_wkt(left_wkt) != CRS.from_wkt(right_wkt): + if pyproj.CRS.from_wkt(left_wkt) != pyproj.CRS.from_wkt(right_wkt): return _unknown_grid(provenance) except CRSError: return _unknown_grid(provenance) @@ -167,11 +167,11 @@ def _grid_relation( def _transform_bounds(bounds: Bounds, source_wkt: str, target_wkt: str) -> Bounds | None: try: - source = CRS.from_wkt(source_wkt) - target = CRS.from_wkt(target_wkt) + source = pyproj.CRS.from_wkt(source_wkt) + target = pyproj.CRS.from_wkt(target_wkt) if source == target: return bounds - transformer = Transformer.from_crs(source, target, always_xy=True) + transformer = pyproj.Transformer.from_crs(source, target, always_xy=True) transformed = transformer.transform_bounds(*bounds, densify_pts=21) except (CRSError, ProjError, ValueError): return None From ce79ba6cba8b2292150b49979c14cadf39d3257c Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:17:44 +0800 Subject: [PATCH 08/11] style: normalize relation imports --- src/geodebug/facts/relations.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py index eb01893..26560f6 100644 --- a/src/geodebug/facts/relations.py +++ b/src/geodebug/facts/relations.py @@ -3,7 +3,6 @@ import math import pyproj -from pyproj.exceptions import CRSError, ProjError from geodebug.facts.keys import ( CRS_WKT, @@ -129,7 +128,7 @@ def _grid_relation( try: if pyproj.CRS.from_wkt(left_wkt) != pyproj.CRS.from_wkt(right_wkt): return _unknown_grid(provenance) - except CRSError: + except pyproj.exceptions.CRSError: return _unknown_grid(provenance) if not ( @@ -173,7 +172,7 @@ def _transform_bounds(bounds: Bounds, source_wkt: str, target_wkt: str) -> Bound return bounds transformer = pyproj.Transformer.from_crs(source, target, always_xy=True) transformed = transformer.transform_bounds(*bounds, densify_pts=21) - except (CRSError, ProjError, ValueError): + except (pyproj.exceptions.CRSError, pyproj.exceptions.ProjError, ValueError): return None if not all(math.isfinite(value) for value in transformed): return None From 0b653d3294c4de3599beeedef5326397c5a1c78b Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:19:09 +0800 Subject: [PATCH 09/11] chore: inspect ruff import fix --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fa5170..4f2e53e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install run: python -m pip install -e '.[dev]' - name: Ruff - run: ruff check . + run: ruff check src/geodebug/facts/relations.py --fix --diff - name: Mypy run: mypy src/geodebug - name: Tests From f51150a79fa795db211b0d0828bc99e3ef1766ac Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:20:03 +0800 Subject: [PATCH 10/11] fix: apply ruff import formatting --- .github/workflows/ci.yml | 2 +- src/geodebug/facts/relations.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f2e53e..7fa5170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install run: python -m pip install -e '.[dev]' - name: Ruff - run: ruff check src/geodebug/facts/relations.py --fix --diff + run: ruff check . - name: Mypy run: mypy src/geodebug - name: Tests diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py index 26560f6..933c6fa 100644 --- a/src/geodebug/facts/relations.py +++ b/src/geodebug/facts/relations.py @@ -18,7 +18,6 @@ from geodebug.models.facts import FactProvenance, FactRecord, FactStore from geodebug.models.subjects import DatasetSnapshot - Bounds = tuple[float, float, float, float] Pair = tuple[float, float] Transform6 = tuple[float, float, float, float, float, float] From 0f7825fe9ff62131767a5eb77656384488747686 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:21:18 +0800 Subject: [PATCH 11/11] fix: tighten adapter typing boundaries --- pyproject.toml | 2 +- src/geodebug/facts/relations.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 202b1f3..a848247 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ strict = true packages = ["geodebug"] [[tool.mypy.overrides]] -module = ["geopandas.*", "pyarrow.*", "pyogrio.*", "rasterio.*"] +module = ["geopandas.*", "pyarrow.*", "pyogrio.*", "rasterio.*", "shapely.*"] ignore_missing_imports = true [tool.coverage.run] diff --git a/src/geodebug/facts/relations.py b/src/geodebug/facts/relations.py index 933c6fa..cb95a6c 100644 --- a/src/geodebug/facts/relations.py +++ b/src/geodebug/facts/relations.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from typing import cast import pyproj @@ -34,7 +35,7 @@ def _known(snapshot: DatasetSnapshot, key: str) -> object | None: fact = snapshot.facts.get(key) if fact is None or fact.state is not FactState.KNOWN: return None - return fact.value + return cast(object, fact.value) def _bounds_relation(