From aee06524b8e0067117c7dfa43bb22045f4688fee Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:31:28 +0800 Subject: [PATCH 1/5] feat: add policy configuration and suppression --- src/geodebug/config/__init__.py | 0 src/geodebug/config/loader.py | 43 +++++++++++++++ src/geodebug/config/model.py | 91 ++++++++++++++++++++++++++++++++ src/geodebug/engine/evaluator.py | 4 ++ src/geodebug/engine/policy.py | 51 +++++++++++++++++- tests/unit/test_config.py | 70 ++++++++++++++++++++++++ tests/unit/test_policy.py | 71 +++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 src/geodebug/config/__init__.py create mode 100644 src/geodebug/config/loader.py create mode 100644 src/geodebug/config/model.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_policy.py diff --git a/src/geodebug/config/__init__.py b/src/geodebug/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geodebug/config/loader.py b/src/geodebug/config/loader.py new file mode 100644 index 0000000..3bec8ea --- /dev/null +++ b/src/geodebug/config/loader.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path + +from pydantic import ValidationError + +from geodebug.config.model import GeoDebugConfig + + +class ConfigError(ValueError): + pass + + +def find_config(start: Path | None = None) -> Path | None: + current = (start or Path.cwd()).resolve() + if current.is_file(): + current = current.parent + + for directory in (current, *current.parents): + candidate = directory / ".geodebug.toml" + if candidate.is_file(): + return candidate + return None + + +def load_config( + path: Path | None = None, + *, + start: Path | None = None, +) -> GeoDebugConfig: + config_path = path.resolve() if path is not None else find_config(start) + if config_path is None: + return GeoDebugConfig() + if not config_path.is_file(): + raise ConfigError(f"config file does not exist: {config_path}") + + try: + with config_path.open("rb") as handle: + payload = tomllib.load(handle) + return GeoDebugConfig.model_validate(payload) + except (OSError, tomllib.TOMLDecodeError, ValidationError) as exc: + raise ConfigError(f"invalid GeoDebug config: {config_path}: {exc}") from exc diff --git a/src/geodebug/config/model.py b/src/geodebug/config/model.py new file mode 100644 index 0000000..a683040 --- /dev/null +++ b/src/geodebug/config/model.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from datetime import date +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from geodebug.engine.policy import Policy, Suppression +from geodebug.models.enums import Severity + + +class ProfileName(StrEnum): + DEFAULT = "default" + STRICT = "strict" + EXPLORATORY = "exploratory" + + +class FailOnName(StrEnum): + ERROR = "error" + WARNING = "warning" + NONE = "none" + + +def _rule_id(value: str) -> str: + normalized = value.upper() + if len(normalized) != 6 or not normalized.startswith("GEO") or not normalized[3:].isdigit(): + raise ValueError("rule id must match GEO###") + return normalized + + +class RulesConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + disable: list[str] = Field(default_factory=list) + severity: dict[str, Severity] = Field(default_factory=dict) + + @field_validator("disable") + @classmethod + def validate_disabled_rules(cls, values: list[str]) -> list[str]: + return [_rule_id(value) for value in values] + + @field_validator("severity") + @classmethod + def validate_severity_rules(cls, values: dict[str, Severity]) -> dict[str, Severity]: + return {_rule_id(rule_id): severity for rule_id, severity in values.items()} + + +class SuppressionConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + rule: str + path: str = "*" + reason: str = Field(min_length=1) + expires: date | None = None + + @field_validator("rule") + @classmethod + def validate_rule(cls, value: str) -> str: + return _rule_id(value) + + +class GeoDebugConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + profile: ProfileName = ProfileName.DEFAULT + fail_on: FailOnName = FailOnName.ERROR + rules: RulesConfig = Field(default_factory=RulesConfig) + suppress: list[SuppressionConfig] = Field(default_factory=list) + + def to_policy( + self, + *, + profile: ProfileName | None = None, + today: date | None = None, + ) -> Policy: + current_date = today or date.today() + suppressions = tuple( + Suppression( + rule_id=item.rule, + path_pattern=item.path, + reason=item.reason, + ) + for item in self.suppress + if item.expires is None or item.expires >= current_date + ) + return Policy( + profile=(profile or self.profile).value, + severity_overrides=self.rules.severity, + disabled_rules=frozenset(self.rules.disable), + suppressions=suppressions, + ) diff --git a/src/geodebug/engine/evaluator.py b/src/geodebug/engine/evaluator.py index a5b7b04..3c450bc 100644 --- a/src/geodebug/engine/evaluator.py +++ b/src/geodebug/engine/evaluator.py @@ -31,7 +31,11 @@ def evaluate(self, context: EvaluationContext) -> Report: not_applicable_rules = 0 for rule in self._registry.all(): + if not self._policy.enables(rule.spec): + continue for rule_context in _contexts_for_rule(rule, context): + if self._policy.suppresses(rule.spec, rule_context): + continue result = rule.evaluate(rule_context) if result.state is RuleState.PASS: passed_rules += 1 diff --git a/src/geodebug/engine/policy.py b/src/geodebug/engine/policy.py index 30fcba4..a1548bf 100644 --- a/src/geodebug/engine/policy.py +++ b/src/geodebug/engine/policy.py @@ -1,13 +1,60 @@ +from __future__ import annotations + from dataclasses import dataclass, field +from fnmatch import fnmatchcase +from types import MappingProxyType +from typing import Mapping +from geodebug.models.context import EvaluationContext from geodebug.models.enums import Severity from geodebug.rules.base import RuleSpec +@dataclass(frozen=True, slots=True) +class Suppression: + rule_id: str + path_pattern: str + reason: str + + def matches(self, rule_id: str, context: EvaluationContext) -> bool: + if self.rule_id != rule_id: + return False + for snapshot in context.subjects: + subject = snapshot.subject + target = subject.uri or subject.id + if fnmatchcase(target, self.path_pattern): + return True + return False + + @dataclass(frozen=True, slots=True) class Policy: profile: str = "default" - severity_overrides: dict[str, Severity] = field(default_factory=dict) + severity_overrides: Mapping[str, Severity] = field(default_factory=dict) + disabled_rules: frozenset[str] = frozenset() + suppressions: tuple[Suppression, ...] = () + + def __post_init__(self) -> None: + object.__setattr__( + self, + "severity_overrides", + MappingProxyType(dict(self.severity_overrides)), + ) def severity_for(self, spec: RuleSpec) -> Severity: - return self.severity_overrides.get(spec.id, spec.default_severity) + override = self.severity_overrides.get(spec.id) + if override is not None: + return override + + severity = spec.default_severity + if self.profile == "strict" and severity is Severity.WARNING: + return Severity.ERROR + if self.profile == "exploratory" and severity is Severity.WARNING: + return Severity.NOTE + return severity + + def enables(self, spec: RuleSpec) -> bool: + return spec.id not in self.disabled_rules + + def suppresses(self, spec: RuleSpec, context: EvaluationContext) -> bool: + return any(item.matches(spec.id, context) for item in self.suppressions) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..b1904f9 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,70 @@ +from datetime import date + +import pytest + +from geodebug.config.loader import ConfigError, load_config +from geodebug.config.model import ProfileName +from geodebug.models.enums import Severity + + +def test_config_builds_policy_with_active_suppression(tmp_path) -> None: + config_path = tmp_path / ".geodebug.toml" + config_path.write_text( + """ +profile = "strict" +fail_on = "warning" + +[rules] +disable = ["geo201"] + +[rules.severity] +GEO101 = "note" + +[[suppress]] +rule = "GEO103" +path = "*.geojson" +reason = "Known wrapped source" +expires = 2030-01-01 +""".strip(), + encoding="utf-8", + ) + + config = load_config(config_path) + policy = config.to_policy(today=date(2026, 9, 19)) + + assert config.profile is ProfileName.STRICT + assert policy.disabled_rules == frozenset({"GEO201"}) + assert policy.severity_overrides["GEO101"] is Severity.NOTE + assert len(policy.suppressions) == 1 + + +def test_expired_suppression_is_not_loaded_into_policy(tmp_path) -> None: + config_path = tmp_path / ".geodebug.toml" + config_path.write_text( + """ +[[suppress]] +rule = "GEO101" +path = "*" +reason = "Temporary" +expires = 2020-01-01 +""".strip(), + encoding="utf-8", + ) + + policy = load_config(config_path).to_policy(today=date(2026, 9, 19)) + + assert policy.suppressions == () + + +def test_invalid_rule_id_is_rejected(tmp_path) -> None: + config_path = tmp_path / ".geodebug.toml" + config_path.write_text( + """ +[rules] +disable = ["BAD"] +""".strip(), + encoding="utf-8", + ) + + with pytest.raises(ConfigError, match="GEO###"): + load_config(config_path) diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py new file mode 100644 index 0000000..49ebc49 --- /dev/null +++ b/tests/unit/test_policy.py @@ -0,0 +1,71 @@ +from geodebug.engine.policy import Policy, Suppression +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import ( + Certainty, + FixSafety, + RuleScope, + Severity, + SubjectKind, +) +from geodebug.models.facts import FactStore +from geodebug.models.subjects import DatasetSnapshot, SubjectRef +from geodebug.rules.base import RuleSpec + + +SPEC = RuleSpec( + id="GEO999", + name="test-rule", + category="test", + scope=RuleScope.DATASET, + default_severity=Severity.WARNING, + certainty=Certainty.DETERMINISTIC, + fix_safety=FixSafety.NONE, +) + + +def _context(uri: str) -> EvaluationContext: + return EvaluationContext( + subjects=( + DatasetSnapshot( + subject=SubjectRef( + id="subject:test", + kind=SubjectKind.VECTOR, + uri=uri, + adapter="test", + ), + facts=FactStore(), + ), + ) + ) + + +def test_strict_profile_promotes_warnings() -> None: + assert Policy(profile="strict").severity_for(SPEC) is Severity.ERROR + + +def test_exploratory_profile_demotes_warnings() -> None: + assert Policy(profile="exploratory").severity_for(SPEC) is Severity.NOTE + + +def test_explicit_override_wins_over_profile() -> None: + policy = Policy( + profile="strict", + severity_overrides={"GEO999": Severity.NOTE}, + ) + + assert policy.severity_for(SPEC) is Severity.NOTE + + +def test_path_suppression_matches_subject_uri() -> None: + policy = Policy( + suppressions=( + Suppression( + rule_id="GEO999", + path_pattern="legacy/*.gpkg", + reason="Known source issue", + ), + ) + ) + + assert policy.suppresses(SPEC, _context("legacy/data.gpkg")) + assert not policy.suppresses(SPEC, _context("clean/data.gpkg")) From a772e31a821e0b53f18c72bd783cd8a0005d995a Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:33:08 +0800 Subject: [PATCH 2/5] feat: add high-signal correctness rules --- src/geodebug/adapters/rasterio.py | 50 ++++++++++- src/geodebug/engine/defaults.py | 12 +++ src/geodebug/facts/crs.py | 65 +++++++++++--- src/geodebug/facts/keys.py | 3 + src/geodebug/facts/spatial.py | 52 +++++++++++ src/geodebug/rules/crs/geo103.py | 78 +++++++++++++++++ src/geodebug/rules/crs/geo105.py | 90 ++++++++++++++++++++ src/geodebug/rules/operations/_geographic.py | 39 +++++++++ src/geodebug/rules/operations/geo501.py | 33 ++----- src/geodebug/rules/operations/geo502.py | 43 ++++++++++ src/geodebug/rules/operations/geo503.py | 43 ++++++++++ src/geodebug/rules/raster/__init__.py | 0 src/geodebug/rules/raster/geo301.py | 74 ++++++++++++++++ src/geodebug/rules/raster/geo304.py | 52 +++++++++++ 14 files changed, 597 insertions(+), 37 deletions(-) create mode 100644 src/geodebug/facts/spatial.py create mode 100644 src/geodebug/rules/crs/geo103.py create mode 100644 src/geodebug/rules/crs/geo105.py create mode 100644 src/geodebug/rules/operations/_geographic.py create mode 100644 src/geodebug/rules/operations/geo502.py create mode 100644 src/geodebug/rules/operations/geo503.py create mode 100644 src/geodebug/rules/raster/__init__.py create mode 100644 src/geodebug/rules/raster/geo301.py create mode 100644 src/geodebug/rules/raster/geo304.py diff --git a/src/geodebug/adapters/rasterio.py b/src/geodebug/adapters/rasterio.py index 9877f8e..6c2fe80 100644 --- a/src/geodebug/adapters/rasterio.py +++ b/src/geodebug/adapters/rasterio.py @@ -15,12 +15,13 @@ RASTER_DTYPES, RASTER_HEIGHT, RASTER_NODATA, + RASTER_NODATA_COLLISION_BANDS, RASTER_RESOLUTION, RASTER_TRANSFORM, RASTER_WIDTH, SPATIAL_BOUNDS, ) -from geodebug.models.enums import SubjectKind +from geodebug.models.enums import FactState, SubjectKind from geodebug.models.facts import FactProvenance, FactRecord, FactStore from geodebug.models.subjects import DatasetSnapshot, SubjectRef @@ -35,7 +36,6 @@ def supports(self, target: Any) -> int: 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: @@ -97,6 +97,13 @@ def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: ), ) ) + records.append( + _nodata_collision_fact( + dataset, + deep=options.deep, + provenance=provenance, + ) + ) except Exception as exc: raise AdapterError(f"unable to inspect raster dataset: {path}") from exc @@ -110,3 +117,42 @@ def inspect(self, target: Any, options: InspectOptions) -> DatasetSnapshot: ), facts=FactStore(records), ) + + +def _nodata_collision_fact( + dataset: Any, + *, + deep: bool, + provenance: FactProvenance, +) -> FactRecord: + nodata = dataset.nodata + if nodata is None: + return FactRecord( + key=RASTER_NODATA_COLLISION_BANDS, + state=FactState.NOT_APPLICABLE, + provenance=provenance, + ) + if not deep: + return FactRecord.unknown( + RASTER_NODATA_COLLISION_BANDS, + provenance=provenance, + ) + + collisions: list[int] = [] + for band_index in range(1, dataset.count + 1): + found = False + for _, window in dataset.block_windows(band_index): + values = dataset.read(band_index, window=window) + valid_mask = dataset.dataset_mask(window=window) != 0 + nodata_values = values != values if nodata != nodata else values == nodata + if (nodata_values & valid_mask).any(): + found = True + break + if found: + collisions.append(band_index) + + return FactRecord.known( + RASTER_NODATA_COLLISION_BANDS, + tuple(collisions), + provenance=FactProvenance(origin="rasterio", method="deep.nodata-mask-scan"), + ) diff --git a/src/geodebug/engine/defaults.py b/src/geodebug/engine/defaults.py index adfb446..cfb8ec4 100644 --- a/src/geodebug/engine/defaults.py +++ b/src/geodebug/engine/defaults.py @@ -1,6 +1,12 @@ from geodebug.engine.registry import RuleRegistry from geodebug.rules.crs.geo101 import MissingCRSRule +from geodebug.rules.crs.geo103 import GeographicCoordinateRangeRule +from geodebug.rules.crs.geo105 import CRSAreaOfUseRule from geodebug.rules.operations.geo501 import AngularCRSBufferRule +from geodebug.rules.operations.geo502 import PlanarAreaOnGeographicCRSRule +from geodebug.rules.operations.geo503 import PlanarDistanceOnGeographicCRSRule +from geodebug.rules.raster.geo301 import InvalidRasterTransformRule +from geodebug.rules.raster.geo304 import NoDataMaskCollisionRule from geodebug.rules.relations.geo402 import NoSpatialOverlapRule from geodebug.rules.relations.geo404 import RasterGridMisalignmentRule from geodebug.rules.vector.geo201 import InvalidGeometryRule @@ -9,8 +15,14 @@ def build_default_registry() -> RuleRegistry: registry = RuleRegistry() registry.register(MissingCRSRule()) + registry.register(GeographicCoordinateRangeRule()) + registry.register(CRSAreaOfUseRule()) registry.register(InvalidGeometryRule()) + registry.register(InvalidRasterTransformRule()) + registry.register(NoDataMaskCollisionRule()) registry.register(NoSpatialOverlapRule()) registry.register(RasterGridMisalignmentRule()) registry.register(AngularCRSBufferRule()) + registry.register(PlanarAreaOnGeographicCRSRule()) + registry.register(PlanarDistanceOnGeographicCRSRule()) return registry diff --git a/src/geodebug/facts/crs.py b/src/geodebug/facts/crs.py index 080673e..d8d1434 100644 --- a/src/geodebug/facts/crs.py +++ b/src/geodebug/facts/crs.py @@ -6,6 +6,8 @@ from pyproj.exceptions import CRSError from geodebug.facts.keys import ( + CRS_AREA_OF_USE_BOUNDS, + CRS_AREA_OF_USE_NAME, CRS_AUTHORITY, CRS_AXIS_UNITS, CRS_CODE, @@ -24,21 +26,17 @@ def build_crs_facts( ) -> 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), + return _unknown_crs_facts( + provenance, + present=False, ) 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), + return _unknown_crs_facts( + provenance, + present=True, ) authority = crs.to_authority() @@ -61,6 +59,36 @@ def build_crs_facts( provenance=provenance, ), ] + + area = crs.area_of_use + if area is None: + records.extend( + ( + FactRecord.unknown(CRS_AREA_OF_USE_BOUNDS, provenance=provenance), + FactRecord.unknown(CRS_AREA_OF_USE_NAME, provenance=provenance), + ) + ) + else: + records.extend( + ( + FactRecord.known( + CRS_AREA_OF_USE_BOUNDS, + ( + float(area.west), + float(area.south), + float(area.east), + float(area.north), + ), + provenance=provenance, + ), + FactRecord.known( + CRS_AREA_OF_USE_NAME, + area.name, + provenance=provenance, + ), + ) + ) + if authority is None: records.extend( ( @@ -76,3 +104,20 @@ def build_crs_facts( ) ) return tuple(records) + + +def _unknown_crs_facts( + provenance: FactProvenance, + *, + present: bool, +) -> tuple[FactRecord, ...]: + return ( + FactRecord.known(CRS_PRESENT, present, provenance=provenance), + FactRecord.unknown(CRS_WKT, provenance=provenance), + FactRecord.unknown(CRS_KIND, provenance=provenance), + FactRecord.unknown(CRS_AXIS_UNITS, provenance=provenance), + FactRecord.unknown(CRS_AREA_OF_USE_BOUNDS, provenance=provenance), + FactRecord.unknown(CRS_AREA_OF_USE_NAME, provenance=provenance), + FactRecord.unknown(CRS_AUTHORITY, provenance=provenance), + FactRecord.unknown(CRS_CODE, provenance=provenance), + ) diff --git a/src/geodebug/facts/keys.py b/src/geodebug/facts/keys.py index 7d9372c..2489910 100644 --- a/src/geodebug/facts/keys.py +++ b/src/geodebug/facts/keys.py @@ -4,6 +4,8 @@ CRS_CODE = "crs.code" CRS_KIND = "crs.kind" CRS_AXIS_UNITS = "crs.axis_units" +CRS_AREA_OF_USE_BOUNDS = "crs.area_of_use.bounds" +CRS_AREA_OF_USE_NAME = "crs.area_of_use.name" SPATIAL_BOUNDS = "spatial.bounds" @@ -19,6 +21,7 @@ RASTER_BAND_COUNT = "raster.band_count" RASTER_DTYPES = "raster.dtypes" RASTER_NODATA = "raster.nodata" +RASTER_NODATA_COLLISION_BANDS = "raster.nodata_collision_bands" RELATION_BOUNDS_OVERLAP = "relation.bounds.overlap" RELATION_OVERLAP_RATIO = "relation.bounds.overlap_ratio" diff --git a/src/geodebug/facts/spatial.py b/src/geodebug/facts/spatial.py new file mode 100644 index 0000000..218996e --- /dev/null +++ b/src/geodebug/facts/spatial.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import math + +import pyproj + + +Bounds = tuple[float, float, float, float] + + +def transform_bounds( + bounds: Bounds, + source_wkt: str, + target: str = "OGC:CRS84", +) -> Bounds | None: + try: + source = pyproj.CRS.from_wkt(source_wkt) + target_crs = pyproj.CRS.from_user_input(target) + if source == target_crs: + return bounds + transformer = pyproj.Transformer.from_crs(source, target_crs, always_xy=True) + transformed = transformer.transform_bounds(*bounds, densify_pts=21) + except (pyproj.exceptions.CRSError, pyproj.exceptions.ProjError, ValueError): + return None + + if not all(math.isfinite(value) for value in transformed): + return None + return ( + float(transformed[0]), + float(transformed[1]), + float(transformed[2]), + float(transformed[3]), + ) + + +def geographic_bounds_overlap(left: Bounds, right: Bounds) -> bool: + if max(left[1], right[1]) > min(left[3], right[3]): + return False + + left_intervals = _longitude_intervals(left[0], left[2]) + right_intervals = _longitude_intervals(right[0], right[2]) + return any( + max(left_west, right_west) <= min(left_east, right_east) + for left_west, left_east in left_intervals + for right_west, right_east in right_intervals + ) + + +def _longitude_intervals(west: float, east: float) -> tuple[tuple[float, float], ...]: + if west <= east: + return ((west, east),) + return ((west, 180.0), (-180.0, east)) diff --git a/src/geodebug/rules/crs/geo103.py b/src/geodebug/rules/crs/geo103.py new file mode 100644 index 0000000..9eeac7a --- /dev/null +++ b/src/geodebug/rules/crs/geo103.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import math + +from geodebug.facts.keys import CRS_KIND, SPATIAL_BOUNDS +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 GeographicCoordinateRangeRule: + spec = RuleSpec( + id="GEO103", + name="geographic-coordinate-out-of-range", + category="crs.coordinates", + scope=RuleScope.DATASET, + default_severity=Severity.ERROR, + certainty=Certainty.DETERMINISTIC, + requires=(CRS_KIND, SPATIAL_BOUNDS), + cost=CostClass.METADATA, + fix_safety=FixSafety.REVIEW_REQUIRED, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + subject = context.primary + if subject is None: + return RuleResult.not_applicable() + + kind = subject.facts.get(CRS_KIND) + if kind is None or kind.state is FactState.UNKNOWN: + return RuleResult.unknown("CRS type could not be established.") + if kind.state is FactState.NOT_APPLICABLE: + return RuleResult.not_applicable() + if str(kind.value).casefold() != "geographic": + return RuleResult.not_applicable() + + bounds_fact = subject.facts.get(SPATIAL_BOUNDS) + if bounds_fact is None or bounds_fact.state is FactState.UNKNOWN: + return RuleResult.unknown("Dataset bounds could not be established.") + if bounds_fact.state is FactState.NOT_APPLICABLE: + return RuleResult.not_applicable() + + bounds = _bounds(bounds_fact.value) + if bounds is None: + return RuleResult.unknown("Dataset bounds are not finite numeric values.") + + xmin, ymin, xmax, ymax = bounds + if ymin >= -90.0 and ymax <= 90.0 and xmin >= -360.0 and xmax <= 360.0: + return RuleResult.passed() + + return RuleResult( + state=RuleState.FAIL, + message="Geographic coordinates exceed plausible angular ranges.", + evidence=(Evidence(key=SPATIAL_BOUNDS, value=bounds),), + implication=( + "The declared geographic CRS is inconsistent with the observed coordinate range." + ), + ) + + +def _bounds(value: object) -> tuple[float, float, float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) != 4: + 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 (numbers[0], numbers[1], numbers[2], numbers[3]) diff --git a/src/geodebug/rules/crs/geo105.py b/src/geodebug/rules/crs/geo105.py new file mode 100644 index 0000000..4b3e28e --- /dev/null +++ b/src/geodebug/rules/crs/geo105.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import math + +from geodebug.facts.keys import ( + CRS_AREA_OF_USE_BOUNDS, + CRS_AREA_OF_USE_NAME, + CRS_WKT, + SPATIAL_BOUNDS, +) +from geodebug.facts.spatial import geographic_bounds_overlap, transform_bounds +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 CRSAreaOfUseRule: + spec = RuleSpec( + id="GEO105", + name="outside-crs-area-of-use", + category="crs.area_of_use", + scope=RuleScope.DATASET, + default_severity=Severity.WARNING, + certainty=Certainty.DETERMINISTIC, + requires=(CRS_WKT, CRS_AREA_OF_USE_BOUNDS, SPATIAL_BOUNDS), + cost=CostClass.METADATA, + fix_safety=FixSafety.REVIEW_REQUIRED, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + subject = context.primary + if subject is None: + return RuleResult.not_applicable() + + wkt = _known(subject.facts.get(CRS_WKT)) + area = _bounds(_known(subject.facts.get(CRS_AREA_OF_USE_BOUNDS))) + observed = _bounds(_known(subject.facts.get(SPATIAL_BOUNDS))) + if not isinstance(wkt, str) or area is None or observed is None: + return RuleResult.unknown("CRS area-of-use comparison could not be established.") + + geographic = transform_bounds(observed, wkt) + if geographic is None: + return RuleResult.unknown("Dataset bounds could not be transformed to geographic coordinates.") + if geographic_bounds_overlap(geographic, area): + return RuleResult.passed() + + evidence = [ + Evidence(key="spatial.bounds.geographic", value=geographic), + Evidence(key=CRS_AREA_OF_USE_BOUNDS, value=area), + ] + area_name = _known(subject.facts.get(CRS_AREA_OF_USE_NAME)) + if isinstance(area_name, str): + evidence.append(Evidence(key=CRS_AREA_OF_USE_NAME, value=area_name)) + + return RuleResult( + state=RuleState.FAIL, + message="Dataset lies outside the declared CRS area of use.", + evidence=tuple(evidence), + implication=( + "The CRS may be inappropriate for this dataset or the CRS metadata may be incorrect." + ), + ) + + +def _known(fact: object) -> object | None: + if fact is None or not hasattr(fact, "state") or not hasattr(fact, "value"): + return None + if fact.state is not FactState.KNOWN: + return None + return fact.value + + +def _bounds(value: object) -> tuple[float, float, float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) != 4: + return None + result: list[float] = [] + for item in value: + if not isinstance(item, (int, float)) or not math.isfinite(item): + return None + result.append(float(item)) + return (result[0], result[1], result[2], result[3]) diff --git a/src/geodebug/rules/operations/_geographic.py b/src/geodebug/rules/operations/_geographic.py new file mode 100644 index 0000000..6fb0a76 --- /dev/null +++ b/src/geodebug/rules/operations/_geographic.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from geodebug.facts.keys import CRS_AXIS_UNITS, CRS_KIND +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import FactState, RuleState +from geodebug.models.evidence import Evidence + + +def geographic_crs_status( + context: EvaluationContext, +) -> tuple[RuleState, tuple[Evidence, ...], str | None]: + subject = context.primary + if subject is None: + return (RuleState.UNKNOWN, (), "Operation input is unavailable.") + + kind = subject.facts.get(CRS_KIND) + if kind is None or kind.state is not FactState.KNOWN: + return (RuleState.UNKNOWN, (), "CRS type could not be established.") + if str(kind.value).casefold() != "geographic": + return (RuleState.PASS, (), None) + + units = subject.facts.get(CRS_AXIS_UNITS) + if units is None or units.state is not FactState.KNOWN: + return (RuleState.UNKNOWN, (), "CRS axis units could not be established.") + + units_value = units.value + if isinstance(units_value, (list, tuple)): + normalized_units = tuple(str(value) for value in units_value) + else: + normalized_units = (str(units_value),) + + return ( + RuleState.FAIL, + ( + Evidence(key=CRS_KIND, value="geographic"), + Evidence(key=CRS_AXIS_UNITS, value=normalized_units), + ), + None, + ) diff --git a/src/geodebug/rules/operations/geo501.py b/src/geodebug/rules/operations/geo501.py index 72af0c0..656ea76 100644 --- a/src/geodebug/rules/operations/geo501.py +++ b/src/geodebug/rules/operations/geo501.py @@ -1,12 +1,8 @@ -from collections.abc import Sequence - -from geodebug.facts.keys import CRS_AXIS_UNITS, CRS_KIND from geodebug.models.context import EvaluationContext from geodebug.models.diagnostics import SuggestedAction from geodebug.models.enums import ( Certainty, CostClass, - FactState, FixSafety, RuleScope, RuleState, @@ -14,6 +10,7 @@ ) from geodebug.models.evidence import Evidence from geodebug.rules.base import RuleResult, RuleSpec +from geodebug.rules.operations._geographic import geographic_crs_status class AngularCRSBufferRule: @@ -24,7 +21,7 @@ class AngularCRSBufferRule: scope=RuleScope.OPERATION, default_severity=Severity.ERROR, certainty=Certainty.DETERMINISTIC, - requires=(CRS_KIND, CRS_AXIS_UNITS, "operation.distance"), + requires=("crs.kind", "crs.axis_units", "operation.distance"), cost=CostClass.METADATA, fix_safety=FixSafety.REVIEW_REQUIRED, ) @@ -33,35 +30,21 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: operation = context.operation if operation is None or operation.name.casefold() != "buffer": return RuleResult.not_applicable() - - subject = context.primary - if subject is None: - return RuleResult.unknown("Buffer input is unavailable.") - - crs_kind = subject.facts.get(CRS_KIND) - axis_units = subject.facts.get(CRS_AXIS_UNITS) - if crs_kind is None or crs_kind.state is not FactState.KNOWN: - return RuleResult.unknown("CRS type could not be established.") - if str(crs_kind.value).casefold() != "geographic": - return RuleResult.passed() - if axis_units is None or axis_units.state is not FactState.KNOWN: - return RuleResult.unknown("CRS axis units could not be established.") if "distance" not in operation.parameters: return RuleResult.unknown("Buffer distance was not provided.") - units_value = axis_units.value - if isinstance(units_value, Sequence) and not isinstance(units_value, str): - units = tuple(str(unit) for unit in units_value) - else: - units = (str(units_value),) + state, evidence, message = geographic_crs_status(context) + if state is RuleState.PASS: + return RuleResult.passed() + if state is RuleState.UNKNOWN: + return RuleResult.unknown(message) distance = operation.parameters["distance"] return RuleResult( state=RuleState.FAIL, message="Buffer distance is interpreted in angular coordinate units.", evidence=( - Evidence(key=CRS_KIND, value="geographic"), - Evidence(key=CRS_AXIS_UNITS, value=units), + *evidence, Evidence(key="operation.distance", value=distance, origin="operation"), ), implication="The distance does not represent a metric buffer distance.", diff --git a/src/geodebug/rules/operations/geo502.py b/src/geodebug/rules/operations/geo502.py new file mode 100644 index 0000000..d7bcec8 --- /dev/null +++ b/src/geodebug/rules/operations/geo502.py @@ -0,0 +1,43 @@ +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import ( + Certainty, + CostClass, + FixSafety, + RuleScope, + RuleState, + Severity, +) +from geodebug.rules.base import RuleResult, RuleSpec +from geodebug.rules.operations._geographic import geographic_crs_status + + +class PlanarAreaOnGeographicCRSRule: + spec = RuleSpec( + id="GEO502", + name="planar-area-on-geographic-crs", + category="operation.measurement", + scope=RuleScope.OPERATION, + default_severity=Severity.ERROR, + certainty=Certainty.DETERMINISTIC, + requires=("crs.kind", "crs.axis_units"), + cost=CostClass.METADATA, + fix_safety=FixSafety.REVIEW_REQUIRED, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + operation = context.operation + if operation is None or operation.name.casefold() != "area": + return RuleResult.not_applicable() + + state, evidence, message = geographic_crs_status(context) + if state is RuleState.PASS: + return RuleResult.passed() + if state is RuleState.UNKNOWN: + return RuleResult.unknown(message) + + return RuleResult( + state=RuleState.FAIL, + message="Planar area is being evaluated in a geographic CRS.", + evidence=evidence, + implication="The resulting area is expressed in angular-coordinate units, not square metres.", + ) diff --git a/src/geodebug/rules/operations/geo503.py b/src/geodebug/rules/operations/geo503.py new file mode 100644 index 0000000..1c02e63 --- /dev/null +++ b/src/geodebug/rules/operations/geo503.py @@ -0,0 +1,43 @@ +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import ( + Certainty, + CostClass, + FixSafety, + RuleScope, + RuleState, + Severity, +) +from geodebug.rules.base import RuleResult, RuleSpec +from geodebug.rules.operations._geographic import geographic_crs_status + + +class PlanarDistanceOnGeographicCRSRule: + spec = RuleSpec( + id="GEO503", + name="planar-distance-on-geographic-crs", + category="operation.measurement", + scope=RuleScope.OPERATION, + default_severity=Severity.ERROR, + certainty=Certainty.DETERMINISTIC, + requires=("crs.kind", "crs.axis_units"), + cost=CostClass.METADATA, + fix_safety=FixSafety.REVIEW_REQUIRED, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + operation = context.operation + if operation is None or operation.name.casefold() not in {"distance", "length"}: + return RuleResult.not_applicable() + + state, evidence, message = geographic_crs_status(context) + if state is RuleState.PASS: + return RuleResult.passed() + if state is RuleState.UNKNOWN: + return RuleResult.unknown(message) + + return RuleResult( + state=RuleState.FAIL, + message="Planar distance is being evaluated in a geographic CRS.", + evidence=evidence, + implication="The result is expressed in angular-coordinate units, not linear distance.", + ) diff --git a/src/geodebug/rules/raster/__init__.py b/src/geodebug/rules/raster/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geodebug/rules/raster/geo301.py b/src/geodebug/rules/raster/geo301.py new file mode 100644 index 0000000..e6574a5 --- /dev/null +++ b/src/geodebug/rules/raster/geo301.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import math + +from geodebug.facts.keys import RASTER_TRANSFORM +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import ( + Certainty, + CostClass, + FactState, + FixSafety, + RuleScope, + RuleState, + Severity, + SubjectKind, +) +from geodebug.models.evidence import Evidence +from geodebug.rules.base import RuleResult, RuleSpec + + +class InvalidRasterTransformRule: + spec = RuleSpec( + id="GEO301", + name="invalid-or-singular-transform", + category="raster.grid", + scope=RuleScope.DATASET, + default_severity=Severity.ERROR, + certainty=Certainty.DETERMINISTIC, + requires=(RASTER_TRANSFORM,), + cost=CostClass.METADATA, + fix_safety=FixSafety.REVIEW_REQUIRED, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + subject = context.primary + if subject is None or subject.subject.kind is not SubjectKind.RASTER: + return RuleResult.not_applicable() + + fact = subject.facts.get(RASTER_TRANSFORM) + if fact is None or fact.state is FactState.UNKNOWN: + return RuleResult.unknown("Raster transform could not be established.") + if fact.state is FactState.NOT_APPLICABLE: + return RuleResult.not_applicable() + + transform = _transform(fact.value) + if transform is None: + return RuleResult( + state=RuleState.FAIL, + message="Raster transform contains invalid coefficients.", + evidence=(Evidence(key="raster.transform.issue", value="non-finite-or-malformed"),), + ) + + a, b, _, d, e, _ = transform + determinant = a * e - b * d + if not math.isclose(determinant, 0.0, abs_tol=1e-15): + return RuleResult.passed() + + return RuleResult( + state=RuleState.FAIL, + message="Raster transform is singular.", + evidence=(Evidence(key="raster.transform.determinant", value=determinant),), + implication="Pixel coordinates cannot be mapped uniquely into spatial coordinates.", + ) + + +def _transform(value: object) -> tuple[float, float, float, float, float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) != 6: + return None + result: list[float] = [] + for item in value: + if not isinstance(item, (int, float)) or not math.isfinite(item): + return None + result.append(float(item)) + return (result[0], result[1], result[2], result[3], result[4], result[5]) diff --git a/src/geodebug/rules/raster/geo304.py b/src/geodebug/rules/raster/geo304.py new file mode 100644 index 0000000..3167177 --- /dev/null +++ b/src/geodebug/rules/raster/geo304.py @@ -0,0 +1,52 @@ +from geodebug.facts.keys import RASTER_NODATA_COLLISION_BANDS +from geodebug.models.context import EvaluationContext +from geodebug.models.enums import ( + Certainty, + CostClass, + FactState, + FixSafety, + RuleScope, + RuleState, + Severity, + SubjectKind, +) +from geodebug.models.evidence import Evidence +from geodebug.rules.base import RuleResult, RuleSpec + + +class NoDataMaskCollisionRule: + spec = RuleSpec( + id="GEO304", + name="nodata-collides-with-valid-mask", + category="raster.nodata", + scope=RuleScope.DATASET, + default_severity=Severity.WARNING, + certainty=Certainty.DETERMINISTIC, + requires=(RASTER_NODATA_COLLISION_BANDS,), + cost=CostClass.FULL_SCAN, + fix_safety=FixSafety.REVIEW_REQUIRED, + ) + + def evaluate(self, context: EvaluationContext) -> RuleResult: + subject = context.primary + if subject is None or subject.subject.kind is not SubjectKind.RASTER: + return RuleResult.not_applicable() + + fact = subject.facts.get(RASTER_NODATA_COLLISION_BANDS) + if fact is None or fact.state is FactState.UNKNOWN: + return RuleResult.unknown("NoData and mask consistency was not scanned.") + if fact.state is FactState.NOT_APPLICABLE: + return RuleResult.not_applicable() + + bands = tuple(int(value) for value in fact.value) + if not bands: + return RuleResult.passed() + + return RuleResult( + state=RuleState.FAIL, + message="NoData-valued pixels are marked valid by the dataset mask.", + evidence=(Evidence(key=RASTER_NODATA_COLLISION_BANDS, value=bands),), + implication=( + "NoData metadata and validity-mask semantics disagree for one or more bands." + ), + ) From 5acbf277d147ff9dff5373ae27f6b890a8fbe7fc Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:34:22 +0800 Subject: [PATCH 3/5] feat: expose preflight and configured policy --- src/geodebug/__init__.py | 12 ++- src/geodebug/api.py | 22 ++++ src/geodebug/cli/app.py | 120 ++++++++++++++++----- src/geodebug/rules/crs/geo105.py | 10 +- tests/integration/test_preflight.py | 21 ++++ tests/integration/test_rasterio_adapter.py | 32 ++++++ tests/rules/test_geo103.py | 36 +++++++ tests/rules/test_geo301_geo304.py | 44 ++++++++ tests/rules/test_geo502_geo503.py | 40 +++++++ tests/unit/test_cli.py | 70 +++++++++++- 10 files changed, 375 insertions(+), 32 deletions(-) create mode 100644 tests/integration/test_preflight.py create mode 100644 tests/rules/test_geo103.py create mode 100644 tests/rules/test_geo301_geo304.py create mode 100644 tests/rules/test_geo502_geo503.py diff --git a/src/geodebug/__init__.py b/src/geodebug/__init__.py index f640fdd..40a1a3c 100644 --- a/src/geodebug/__init__.py +++ b/src/geodebug/__init__.py @@ -1,5 +1,13 @@ -from geodebug.api import check, compare, evaluate, inspect +from geodebug.api import check, compare, evaluate, inspect, preflight from geodebug.models.report import Report from geodebug.version import __version__ -__all__ = ["Report", "__version__", "check", "compare", "evaluate", "inspect"] +__all__ = [ + "Report", + "__version__", + "check", + "compare", + "evaluate", + "inspect", + "preflight", +] diff --git a/src/geodebug/api.py b/src/geodebug/api.py index c3dae28..b1bee07 100644 --- a/src/geodebug/api.py +++ b/src/geodebug/api.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from geodebug.adapters.base import InspectOptions @@ -9,6 +10,7 @@ from geodebug.engine.policy import Policy from geodebug.facts.relations import build_relation_facts from geodebug.models.context import EvaluationContext +from geodebug.models.operations import OperationContext from geodebug.models.report import Report from geodebug.models.subjects import DatasetSnapshot @@ -50,6 +52,26 @@ def compare( return evaluate(context, policy=policy) +def preflight( + target: Any, + *, + operation: str, + parameters: Mapping[str, Any] | None = None, + deep: bool = False, + policy: Policy | None = None, +) -> Report: + """Evaluate operation-aware diagnostics before an operation is executed.""" + snapshot = inspect(target, deep=deep) + context = EvaluationContext( + subjects=(snapshot,), + operation=OperationContext( + name=operation, + parameters=parameters or {}, + ), + ) + return evaluate(context, policy=policy) + + def evaluate(context: EvaluationContext, *, policy: Policy | None = None) -> Report: """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 ced1340..0375089 100644 --- a/src/geodebug/cli/app.py +++ b/src/geodebug/cli/app.py @@ -13,7 +13,11 @@ 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.api import preflight as preflight_target +from geodebug.config.loader import ConfigError, load_config +from geodebug.config.model import FailOnName, ProfileName from geodebug.engine.defaults import build_default_registry +from geodebug.engine.policy import Policy from geodebug.models.report import Report from geodebug.reporters.terminal import print_report, print_snapshot from geodebug.version import __version__ @@ -30,12 +34,6 @@ class OutputFormat(StrEnum): JSON = "json" -class FailOn(StrEnum): - ERROR = "error" - WARNING = "warning" - NONE = "none" - - def _version_callback(value: bool) -> None: if value: typer.echo(__version__) @@ -60,7 +58,7 @@ def root( @app.command("inspect") def inspect_command( target: Path, - deep: Annotated[bool, typer.Option(help="Allow full geometry scans.")] = False, + deep: Annotated[bool, typer.Option(help="Allow full data scans.")] = False, ) -> None: """Inspect normalized facts without running diagnostic rules.""" try: @@ -74,48 +72,113 @@ def inspect_command( @app.command("check") def check_command( target: Path, - deep: Annotated[bool, typer.Option(help="Allow full geometry scans.")] = False, + deep: Annotated[bool, typer.Option(help="Allow full data scans.")] = False, output_format: Annotated[ OutputFormat, typer.Option("--format", help="Output format."), ] = OutputFormat.TERMINAL, + config_path: Annotated[ + Path | None, + typer.Option("--config", help="Path to .geodebug.toml."), + ] = None, + profile: Annotated[ + ProfileName | None, + typer.Option(help="Override the configured policy profile."), + ] = None, fail_on: Annotated[ - FailOn, - typer.Option(help="Diagnostic severity that fails the command."), - ] = FailOn.ERROR, + FailOnName | None, + typer.Option(help="Override the configured CI failure threshold."), + ] = None, ) -> None: """Run diagnostics against one dataset.""" try: - report = check_target(target, deep=deep) - except AdapterError as exc: + policy, threshold = _runtime_policy(config_path, profile, fail_on) + report = check_target(target, deep=deep, policy=policy) + except (AdapterError, ConfigError) as exc: 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)) + raise typer.Exit(code=_exit_code(report, threshold)) @app.command("compare") def compare_command( left: Path, right: Path, - deep: Annotated[bool, typer.Option(help="Allow full geometry scans.")] = False, + deep: Annotated[bool, typer.Option(help="Allow full data scans.")] = False, output_format: Annotated[ OutputFormat, typer.Option("--format", help="Output format."), ] = OutputFormat.TERMINAL, + config_path: Annotated[ + Path | None, + typer.Option("--config", help="Path to .geodebug.toml."), + ] = None, + profile: Annotated[ + ProfileName | None, + typer.Option(help="Override the configured policy profile."), + ] = None, fail_on: Annotated[ - FailOn, - typer.Option(help="Diagnostic severity that fails the command."), - ] = FailOn.ERROR, + FailOnName | None, + typer.Option(help="Override the configured CI failure threshold."), + ] = None, ) -> None: """Run dataset and relational diagnostics against two datasets.""" try: - report = compare_targets(left, right, deep=deep) - except AdapterError as exc: + policy, threshold = _runtime_policy(config_path, profile, fail_on) + report = compare_targets(left, right, deep=deep, policy=policy) + except (AdapterError, ConfigError) as exc: + 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, threshold)) + + +@app.command("preflight") +def preflight_command( + target: Path, + operation: Annotated[ + str, + typer.Option(help="Operation name, such as buffer, area, distance, or length."), + ], + distance: Annotated[ + float | None, + typer.Option(help="Distance parameter for operations that require one."), + ] = None, + deep: Annotated[bool, typer.Option(help="Allow full data scans.")] = False, + output_format: Annotated[ + OutputFormat, + typer.Option("--format", help="Output format."), + ] = OutputFormat.TERMINAL, + config_path: Annotated[ + Path | None, + typer.Option("--config", help="Path to .geodebug.toml."), + ] = None, + profile: Annotated[ + ProfileName | None, + typer.Option(help="Override the configured policy profile."), + ] = None, + fail_on: Annotated[ + FailOnName | None, + typer.Option(help="Override the configured CI failure threshold."), + ] = None, +) -> None: + """Check spatial semantics before executing an operation.""" + parameters = {"distance": distance} if distance is not None else {} + try: + policy, threshold = _runtime_policy(config_path, profile, fail_on) + report = preflight_target( + target, + operation=operation, + parameters=parameters, + deep=deep, + policy=policy, + ) + except (AdapterError, ConfigError) as exc: 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)) + raise typer.Exit(code=_exit_code(report, threshold)) @rules_app.command("list") @@ -169,6 +232,15 @@ def schema() -> None: ) +def _runtime_policy( + config_path: Path | None, + profile: ProfileName | None, + fail_on: FailOnName | None, +) -> tuple[Policy, FailOnName]: + config = load_config(config_path) + return (config.to_policy(profile=profile), fail_on or config.fail_on) + + def _emit_report(report: Report, output_format: OutputFormat) -> None: if output_format is OutputFormat.JSON: typer.echo(report.model_dump_json(indent=2)) @@ -176,10 +248,10 @@ def _emit_report(report: Report, output_format: OutputFormat) -> None: print_report(report, console=console) -def _exit_code(report: Report, fail_on: FailOn) -> int: - if fail_on is FailOn.NONE: +def _exit_code(report: Report, fail_on: FailOnName) -> int: + if fail_on is FailOnName.NONE: return 0 - if fail_on is FailOn.WARNING: + if fail_on is FailOnName.WARNING: return 1 if report.summary.errors or report.summary.warnings else 0 return 1 if report.summary.errors else 0 diff --git a/src/geodebug/rules/crs/geo105.py b/src/geodebug/rules/crs/geo105.py index 4b3e28e..a0fcb61 100644 --- a/src/geodebug/rules/crs/geo105.py +++ b/src/geodebug/rules/crs/geo105.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from typing import cast from geodebug.facts.keys import ( CRS_AREA_OF_USE_BOUNDS, @@ -20,6 +21,7 @@ Severity, ) from geodebug.models.evidence import Evidence +from geodebug.models.facts import FactRecord from geodebug.rules.base import RuleResult, RuleSpec @@ -71,12 +73,10 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: ) -def _known(fact: object) -> object | None: - if fact is None or not hasattr(fact, "state") or not hasattr(fact, "value"): +def _known(fact: FactRecord | None) -> object | None: + if fact is None or fact.state is not FactState.KNOWN: return None - if fact.state is not FactState.KNOWN: - return None - return fact.value + return cast(object, fact.value) def _bounds(value: object) -> tuple[float, float, float, float] | None: diff --git a/tests/integration/test_preflight.py b/tests/integration/test_preflight.py new file mode 100644 index 0000000..f4764bd --- /dev/null +++ b/tests/integration/test_preflight.py @@ -0,0 +1,21 @@ +import json + +from geodebug import preflight + + +def test_preflight_detects_planar_area_on_geojson(tmp_path) -> None: + path = tmp_path / "polygon.geojson" + path.write_text( + json.dumps( + { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + ), + encoding="utf-8", + ) + + report = preflight(path, operation="area") + + ids = [diagnostic.rule_id for diagnostic in report.diagnostics] + assert ids == ["GEO502"] diff --git a/tests/integration/test_rasterio_adapter.py b/tests/integration/test_rasterio_adapter.py index 745fb24..917e85e 100644 --- a/tests/integration/test_rasterio_adapter.py +++ b/tests/integration/test_rasterio_adapter.py @@ -47,3 +47,35 @@ def test_compare_detects_half_pixel_raster_misalignment(tmp_path) -> None: ids = [diagnostic.rule_id for diagnostic in report.diagnostics] assert "GEO404" in ids + + +def test_deep_check_detects_nodata_mask_collision(tmp_path) -> None: + import numpy as np + + rasterio = pytest.importorskip("rasterio") + from rasterio.transform import from_origin + + path = tmp_path / "nodata-mask-conflict.tif" + data = np.ones((1, 4, 4), dtype="uint8") + data[0, 0, 0] = 0 + with rasterio.open( + path, + "w", + driver="GTiff", + width=4, + height=4, + count=1, + dtype="uint8", + crs="EPSG:32648", + nodata=0, + transform=from_origin(0.0, 40.0, 10.0, 10.0), + ) as dataset: + dataset.write(data) + dataset.write_mask(np.full((4, 4), 255, dtype="uint8")) + + from geodebug import check + + report = check(path, deep=True) + + ids = [diagnostic.rule_id for diagnostic in report.diagnostics] + assert "GEO304" in ids diff --git a/tests/rules/test_geo103.py b/tests/rules/test_geo103.py new file mode 100644 index 0000000..3c9277e --- /dev/null +++ b/tests/rules/test_geo103.py @@ -0,0 +1,36 @@ +import pytest + +from geodebug.facts.keys import CRS_KIND, SPATIAL_BOUNDS +from geodebug.models.enums import RuleState +from geodebug.models.facts import FactRecord +from geodebug.rules.crs.geo103 import GeographicCoordinateRangeRule + + +@pytest.mark.parametrize( + ("bounds", "expected"), + [ + ((103.0, 1.0, 104.0, 2.0), RuleState.PASS), + ((0.0, 91.0, 1.0, 92.0), RuleState.FAIL), + ((361.0, 0.0, 362.0, 1.0), RuleState.FAIL), + ], +) +def test_geo103_geographic_ranges(make_context, bounds, expected) -> None: + context = make_context( + facts=[ + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.known(SPATIAL_BOUNDS, bounds), + ] + ) + + assert GeographicCoordinateRangeRule().evaluate(context).state is expected + + +def test_geo103_projected_crs_is_not_applicable(make_context) -> None: + context = make_context( + facts=[ + FactRecord.known(CRS_KIND, "projected"), + FactRecord.known(SPATIAL_BOUNDS, (0, 0, 1000, 1000)), + ] + ) + + assert GeographicCoordinateRangeRule().evaluate(context).state is RuleState.NOT_APPLICABLE diff --git a/tests/rules/test_geo301_geo304.py b/tests/rules/test_geo301_geo304.py new file mode 100644 index 0000000..a14b8e6 --- /dev/null +++ b/tests/rules/test_geo301_geo304.py @@ -0,0 +1,44 @@ +import pytest + +from geodebug.facts.keys import RASTER_NODATA_COLLISION_BANDS, RASTER_TRANSFORM +from geodebug.models.enums import FactState, RuleState, SubjectKind +from geodebug.models.facts import FactRecord +from geodebug.rules.raster.geo301 import InvalidRasterTransformRule +from geodebug.rules.raster.geo304 import NoDataMaskCollisionRule + + +@pytest.mark.parametrize( + ("transform", "expected"), + [ + ((10.0, 0.0, 0.0, 0.0, -10.0, 100.0), RuleState.PASS), + ((0.0, 0.0, 0.0, 0.0, 0.0, 100.0), RuleState.FAIL), + ], +) +def test_geo301_transform_determinant(make_context, transform, expected) -> None: + context = make_context( + kind=SubjectKind.RASTER, + facts=[FactRecord.known(RASTER_TRANSFORM, transform)], + ) + + assert InvalidRasterTransformRule().evaluate(context).state is expected + + +@pytest.mark.parametrize( + ("fact", "expected"), + [ + (FactRecord.known(RASTER_NODATA_COLLISION_BANDS, ()), RuleState.PASS), + (FactRecord.known(RASTER_NODATA_COLLISION_BANDS, (1,)), RuleState.FAIL), + (FactRecord.unknown(RASTER_NODATA_COLLISION_BANDS), RuleState.UNKNOWN), + ( + FactRecord( + key=RASTER_NODATA_COLLISION_BANDS, + state=FactState.NOT_APPLICABLE, + ), + RuleState.NOT_APPLICABLE, + ), + ], +) +def test_geo304_truth_table(make_context, fact, expected) -> None: + context = make_context(kind=SubjectKind.RASTER, facts=[fact]) + + assert NoDataMaskCollisionRule().evaluate(context).state is expected diff --git a/tests/rules/test_geo502_geo503.py b/tests/rules/test_geo502_geo503.py new file mode 100644 index 0000000..c468edf --- /dev/null +++ b/tests/rules/test_geo502_geo503.py @@ -0,0 +1,40 @@ +import pytest + +from geodebug.facts.keys import CRS_AXIS_UNITS, CRS_KIND +from geodebug.models.enums import RuleState +from geodebug.models.facts import FactRecord +from geodebug.models.operations import OperationContext +from geodebug.rules.operations.geo502 import PlanarAreaOnGeographicCRSRule +from geodebug.rules.operations.geo503 import PlanarDistanceOnGeographicCRSRule + + +@pytest.mark.parametrize( + ("rule", "operation"), + [ + (PlanarAreaOnGeographicCRSRule(), "area"), + (PlanarDistanceOnGeographicCRSRule(), "distance"), + (PlanarDistanceOnGeographicCRSRule(), "length"), + ], +) +def test_measurement_rules_fail_on_geographic_crs(make_context, rule, operation) -> None: + context = make_context( + facts=[ + FactRecord.known(CRS_KIND, "geographic"), + FactRecord.known(CRS_AXIS_UNITS, ("degree", "degree")), + ], + operation=OperationContext(name=operation), + ) + + assert rule.evaluate(context).state is RuleState.FAIL + + +def test_area_rule_passes_on_projected_crs(make_context) -> None: + context = make_context( + facts=[ + FactRecord.known(CRS_KIND, "projected"), + FactRecord.known(CRS_AXIS_UNITS, ("metre", "metre")), + ], + operation=OperationContext(name="area"), + ) + + assert PlanarAreaOnGeographicCRSRule().evaluate(context).state is RuleState.PASS diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index eb35e99..4e8c8c4 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -20,7 +20,7 @@ def test_rules_list_contains_usable_core_rules() -> None: result = runner.invoke(app, ["rules", "list"]) assert result.exit_code == 0 - for rule_id in ("GEO101", "GEO201", "GEO402", "GEO404", "GEO501"): + for rule_id in ("GEO101", "GEO103", "GEO201", "GEO301", "GEO402", "GEO501", "GEO502"): assert rule_id in result.stdout @@ -42,3 +42,71 @@ def test_check_json_exit_code_is_zero_for_clean_geojson(tmp_path) -> None: assert result.exit_code == 0 report = json.loads(result.stdout) assert report["diagnostics"] == [] + + +def test_config_can_suppress_rule_from_cli(tmp_path) -> None: + data_path = tmp_path / "bad.geojson" + data_path.write_text( + json.dumps( + { + "type": "Point", + "coordinates": [0, 95], + } + ), + encoding="utf-8", + ) + config_path = tmp_path / ".geodebug.toml" + config_path.write_text( + """ +[[suppress]] +rule = "GEO103" +path = "*.geojson" +reason = "Fixture intentionally uses invalid latitude" +""".strip(), + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "check", + str(data_path), + "--config", + str(config_path), + "--format", + "json", + ], + ) + + assert result.exit_code == 0 + report = json.loads(result.stdout) + assert report["diagnostics"] == [] + + +def test_preflight_cli_detects_area_in_geographic_crs(tmp_path) -> None: + data_path = tmp_path / "polygon.geojson" + data_path.write_text( + json.dumps( + { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + [ + "preflight", + str(data_path), + "--operation", + "area", + "--format", + "json", + ], + ) + + assert result.exit_code == 1 + report = json.loads(result.stdout) + assert [item["rule_id"] for item in report["diagnostics"]] == ["GEO502"] From 59435faebcf814da56e72e08ba71d092730425e4 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:35:33 +0800 Subject: [PATCH 4/5] test: add golden rule manifest --- .geodebug.toml.example | 18 +++++++++++ README.md | 50 ++++++++++++++++++++++++++--- src/geodebug/rules/crs/geo105.py | 13 ++++++-- tests/golden/cases.toml | 20 ++++++++++++ tests/golden/test_manifest_cases.py | 47 +++++++++++++++++++++++++++ tests/rules/test_geo105.py | 42 ++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 .geodebug.toml.example create mode 100644 tests/golden/cases.toml create mode 100644 tests/golden/test_manifest_cases.py create mode 100644 tests/rules/test_geo105.py diff --git a/.geodebug.toml.example b/.geodebug.toml.example new file mode 100644 index 0000000..185d768 --- /dev/null +++ b/.geodebug.toml.example @@ -0,0 +1,18 @@ +# GeoDebug project policy. +profile = "default" +fail_on = "error" + +[rules] +disable = [] + +# Explicit severity overrides take precedence over the selected profile. +[rules.severity] +# GEO101 = "error" + +# Suppressions require an explanation. An optional expiry date keeps temporary +# exceptions from becoming permanent policy. +# [[suppress]] +# rule = "GEO103" +# path = "legacy/*.geojson" +# reason = "Known upstream coordinate convention" +# expires = 2027-01-01 diff --git a/README.md b/README.md index 310b37e..60ce495 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Run dataset checks: ```bash geodebug check roads.geojson -geodebug check roads.geojson --deep +geodebug check landcover.tif --deep ``` Compare two datasets: @@ -47,29 +47,68 @@ Compare two datasets: geodebug compare dem-a.tif dem-b.tif ``` +Check an operation before execution: + +```bash +geodebug preflight roads.geojson --operation buffer --distance 500 +geodebug preflight parcels.geojson --operation area +``` + 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`. +The default path is metadata-first. Full geometry or raster scans are opt-in with `--deep`. + +## Project policy + +GeoDebug searches the current directory and its parents for `.geodebug.toml`. An explicit +config path can be supplied with `--config`. + +```toml +profile = "default" +fail_on = "error" + +[rules] +disable = [] + +[rules.severity] +GEO101 = "error" + +[[suppress]] +rule = "GEO103" +path = "legacy/*.geojson" +reason = "Known upstream coordinate convention" +expires = 2027-01-01 +``` + +Profiles do not change rule truth values. `strict` promotes warnings to errors; +`exploratory` demotes warnings to notes. Explicit severity overrides take precedence. +Suppressions require a reason and may expire. ## Built-in rules -The v0.1 usable core currently includes: +The current v0.1 rule set includes: - `GEO101` — missing CRS +- `GEO103` — geographic coordinate out of range +- `GEO105` — projected data outside CRS area of use - `GEO201` — invalid geometry +- `GEO301` — invalid or singular raster transform +- `GEO304` — NoData value conflicts with the validity mask - `GEO402` — no spatial overlap - `GEO404` — raster grid misalignment - `GEO501` — metric buffer on a geographic CRS +- `GEO502` — planar area on a geographic CRS +- `GEO503` — planar distance or length on a geographic CRS Inspect rule contracts with: ```bash geodebug rules list -geodebug rules show GEO404 +geodebug rules show GEO304 ``` ## Architecture @@ -94,3 +133,6 @@ ruff check . mypy src/geodebug pytest ``` + +Golden rule cases live in `tests/golden/cases.toml`. Each case declares both expected +diagnostics and rules that must not be reported. diff --git a/src/geodebug/rules/crs/geo105.py b/src/geodebug/rules/crs/geo105.py index a0fcb61..0f32c4d 100644 --- a/src/geodebug/rules/crs/geo105.py +++ b/src/geodebug/rules/crs/geo105.py @@ -6,6 +6,7 @@ from geodebug.facts.keys import ( CRS_AREA_OF_USE_BOUNDS, CRS_AREA_OF_USE_NAME, + CRS_KIND, CRS_WKT, SPATIAL_BOUNDS, ) @@ -33,7 +34,7 @@ class CRSAreaOfUseRule: scope=RuleScope.DATASET, default_severity=Severity.WARNING, certainty=Certainty.DETERMINISTIC, - requires=(CRS_WKT, CRS_AREA_OF_USE_BOUNDS, SPATIAL_BOUNDS), + requires=(CRS_KIND, CRS_WKT, CRS_AREA_OF_USE_BOUNDS, SPATIAL_BOUNDS), cost=CostClass.METADATA, fix_safety=FixSafety.REVIEW_REQUIRED, ) @@ -43,6 +44,12 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: if subject is None: return RuleResult.not_applicable() + kind = _known(subject.facts.get(CRS_KIND)) + if kind is None: + return RuleResult.unknown("CRS type could not be established.") + if str(kind).casefold() != "projected": + return RuleResult.not_applicable() + wkt = _known(subject.facts.get(CRS_WKT)) area = _bounds(_known(subject.facts.get(CRS_AREA_OF_USE_BOUNDS))) observed = _bounds(_known(subject.facts.get(SPATIAL_BOUNDS))) @@ -51,7 +58,9 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: geographic = transform_bounds(observed, wkt) if geographic is None: - return RuleResult.unknown("Dataset bounds could not be transformed to geographic coordinates.") + return RuleResult.unknown( + "Dataset bounds could not be transformed to geographic coordinates." + ) if geographic_bounds_overlap(geographic, area): return RuleResult.passed() diff --git a/tests/golden/cases.toml b/tests/golden/cases.toml new file mode 100644 index 0000000..be788cc --- /dev/null +++ b/tests/golden/cases.toml @@ -0,0 +1,20 @@ +[[case]] +id = "clean-point" +fixture = "clean-point" +deep = false +expected = [] +must_not_report = ["GEO101", "GEO103", "GEO105", "GEO201"] + +[[case]] +id = "invalid-latitude" +fixture = "invalid-latitude" +deep = false +expected = ["GEO103"] +must_not_report = ["GEO101", "GEO105", "GEO201"] + +[[case]] +id = "invalid-bowtie" +fixture = "invalid-bowtie" +deep = true +expected = ["GEO201"] +must_not_report = ["GEO101", "GEO103", "GEO105"] diff --git a/tests/golden/test_manifest_cases.py b/tests/golden/test_manifest_cases.py new file mode 100644 index 0000000..fa28e17 --- /dev/null +++ b/tests/golden/test_manifest_cases.py @@ -0,0 +1,47 @@ +import json +import tomllib +from pathlib import Path + +import pytest + +from geodebug import check + + +MANIFEST = Path(__file__).with_name("cases.toml") + + +def _write_fixture(tmp_path: Path, name: str) -> Path: + path = tmp_path / f"{name}.geojson" + if name == "clean-point": + geometry = {"type": "Point", "coordinates": [0, 0]} + elif name == "invalid-latitude": + geometry = {"type": "Point", "coordinates": [0, 95]} + elif name == "invalid-bowtie": + geometry = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]], + } + else: + raise AssertionError(f"unknown golden fixture: {name}") + + path.write_text( + json.dumps({"type": "Feature", "properties": {}, "geometry": geometry}), + encoding="utf-8", + ) + return path + + +def _cases(): + with MANIFEST.open("rb") as handle: + return tomllib.load(handle)["case"] + + +@pytest.mark.parametrize("case", _cases(), ids=lambda item: item["id"]) +def test_golden_rule_manifest(case, tmp_path) -> None: + target = _write_fixture(tmp_path, case["fixture"]) + + report = check(target, deep=case["deep"]) + reported = {item.rule_id for item in report.diagnostics} + + assert reported == set(case["expected"]) + assert reported.isdisjoint(case["must_not_report"]) diff --git a/tests/rules/test_geo105.py b/tests/rules/test_geo105.py new file mode 100644 index 0000000..8286ec5 --- /dev/null +++ b/tests/rules/test_geo105.py @@ -0,0 +1,42 @@ +from pyproj import CRS, Transformer + +from geodebug.facts.crs import build_crs_facts +from geodebug.facts.keys import SPATIAL_BOUNDS +from geodebug.models.enums import RuleState +from geodebug.models.facts import FactRecord +from geodebug.rules.crs.geo105 import CRSAreaOfUseRule + + +def _projected_context(make_context, lon: float, lat: float): + crs = CRS.from_epsg(32648) + transformer = Transformer.from_crs("OGC:CRS84", crs, always_xy=True) + x, y = transformer.transform(lon, lat) + facts = [ + *build_crs_facts(crs.to_wkt(), origin="test", method="fixture"), + FactRecord.known( + SPATIAL_BOUNDS, + (x - 1000.0, y - 1000.0, x + 1000.0, y + 1000.0), + ), + ] + return make_context(facts=facts) + + +def test_geo105_passes_inside_area_of_use(make_context) -> None: + context = _projected_context(make_context, 103.8, 1.3) + + assert CRSAreaOfUseRule().evaluate(context).state is RuleState.PASS + + +def test_geo105_fails_outside_area_of_use(make_context) -> None: + context = _projected_context(make_context, 120.0, 1.3) + + assert CRSAreaOfUseRule().evaluate(context).state is RuleState.FAIL + + +def test_geo105_geographic_crs_is_not_applicable(make_context) -> None: + facts = [ + *build_crs_facts("OGC:CRS84", origin="test", method="fixture"), + FactRecord.known(SPATIAL_BOUNDS, (0.0, 0.0, 1.0, 1.0)), + ] + + assert CRSAreaOfUseRule().evaluate(make_context(facts=facts)).state is RuleState.NOT_APPLICABLE From 77d36ff87667aa0e60493aac15049aea857a2175 Mon Sep 17 00:00:00 2001 From: GeoGeek Date: Sat, 19 Sep 2026 22:37:01 +0800 Subject: [PATCH 5/5] style: satisfy hardening lint checks --- src/geodebug/engine/policy.py | 2 +- src/geodebug/facts/spatial.py | 1 - src/geodebug/rules/crs/geo105.py | 3 ++- src/geodebug/rules/operations/geo502.py | 5 ++++- tests/golden/test_manifest_cases.py | 1 - tests/unit/test_policy.py | 1 - 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/geodebug/engine/policy.py b/src/geodebug/engine/policy.py index a1548bf..88903b7 100644 --- a/src/geodebug/engine/policy.py +++ b/src/geodebug/engine/policy.py @@ -1,9 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field from fnmatch import fnmatchcase from types import MappingProxyType -from typing import Mapping from geodebug.models.context import EvaluationContext from geodebug.models.enums import Severity diff --git a/src/geodebug/facts/spatial.py b/src/geodebug/facts/spatial.py index 218996e..05d1a45 100644 --- a/src/geodebug/facts/spatial.py +++ b/src/geodebug/facts/spatial.py @@ -4,7 +4,6 @@ import pyproj - Bounds = tuple[float, float, float, float] diff --git a/src/geodebug/rules/crs/geo105.py b/src/geodebug/rules/crs/geo105.py index 0f32c4d..9bc2b5d 100644 --- a/src/geodebug/rules/crs/geo105.py +++ b/src/geodebug/rules/crs/geo105.py @@ -77,7 +77,8 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: message="Dataset lies outside the declared CRS area of use.", evidence=tuple(evidence), implication=( - "The CRS may be inappropriate for this dataset or the CRS metadata may be incorrect." + "The CRS may be inappropriate for this dataset or the CRS metadata " + "may be incorrect." ), ) diff --git a/src/geodebug/rules/operations/geo502.py b/src/geodebug/rules/operations/geo502.py index d7bcec8..7d54853 100644 --- a/src/geodebug/rules/operations/geo502.py +++ b/src/geodebug/rules/operations/geo502.py @@ -39,5 +39,8 @@ def evaluate(self, context: EvaluationContext) -> RuleResult: state=RuleState.FAIL, message="Planar area is being evaluated in a geographic CRS.", evidence=evidence, - implication="The resulting area is expressed in angular-coordinate units, not square metres.", + implication=( + "The resulting area is expressed in angular-coordinate units, " + "not square metres." + ), ) diff --git a/tests/golden/test_manifest_cases.py b/tests/golden/test_manifest_cases.py index fa28e17..9743d67 100644 --- a/tests/golden/test_manifest_cases.py +++ b/tests/golden/test_manifest_cases.py @@ -6,7 +6,6 @@ from geodebug import check - MANIFEST = Path(__file__).with_name("cases.toml") diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 49ebc49..d8691eb 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -11,7 +11,6 @@ from geodebug.models.subjects import DatasetSnapshot, SubjectRef from geodebug.rules.base import RuleSpec - SPEC = RuleSpec( id="GEO999", name="test-rule",