From f78bf21a209185159eaaf4b8fa6df1968a04d9e7 Mon Sep 17 00:00:00 2001 From: Claudio Ortega Date: Sun, 9 Aug 2026 21:21:20 -0700 Subject: [PATCH] feat: add validated spatial boundary models --- .github/workflows/main.yml | 3 ++ pyproject.toml | 8 +++- tests/models_test.py | 96 ++++++++++++++++++++++++++++++++++++++ urbanpy/__init__.py | 7 ++- urbanpy/errors.py | 55 ++++++++++++++++++++++ urbanpy/models/__init__.py | 5 ++ urbanpy/models/spatial.py | 75 +++++++++++++++++++++++++++++ 7 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 tests/models_test.py create mode 100644 urbanpy/errors.py create mode 100644 urbanpy/models/__init__.py create mode 100644 urbanpy/models/spatial.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2cef12b..d6f6a45 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,6 +52,9 @@ jobs: - name: Check source run: uv run --python 3.11 ruff check urbanpy tests + - name: Type-check strict boundary modules + run: uv run --python 3.11 mypy urbanpy/errors.py urbanpy/models + - name: Run hermetic tests with coverage run: uv run --python 3.11 pytest --cov=urbanpy --cov-report=term-missing --cov-report=xml diff --git a/pyproject.toml b/pyproject.toml index 88bbe33..6d4b9e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,7 +143,13 @@ target-version = "py311" select = ["E9", "F63", "F7", "F82"] [tool.mypy] -python_version = "3.11" +# Current NumPy stubs use PEP 695 syntax. Runtime CI separately proves Python +# 3.11 support; the static-analysis environment therefore targets 3.12. +python_version = "3.12" plugins = ["pydantic.mypy"] show_error_codes = true warn_unused_configs = true + +[[tool.mypy.overrides]] +module = ["urbanpy.errors", "urbanpy.models.*"] +strict = true diff --git a/tests/models_test.py b/tests/models_test.py new file mode 100644 index 0000000..53b8dc7 --- /dev/null +++ b/tests/models_test.py @@ -0,0 +1,96 @@ +import math + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from pydantic import ValidationError + +from urbanpy.errors import BoundaryValidationError +from urbanpy.models import BoundingBox, Coordinate, TravelProfile + + +@given( + longitude=st.floats(min_value=-180, max_value=180, allow_nan=False), + latitude=st.floats(min_value=-90, max_value=90, allow_nan=False), +) +def test_coordinate_round_trips_valid_wgs84_values(longitude, latitude): + coordinate = Coordinate(longitude=longitude, latitude=latitude) + + assert coordinate.as_lon_lat() == (longitude, latitude) + assert Coordinate.from_lon_lat(coordinate.as_lon_lat()) == coordinate + + +@pytest.mark.parametrize( + ("longitude", "latitude"), + [(-180.1, 0), (180.1, 0), (0, -90.1), (0, 90.1), (math.inf, 0), (0, math.nan)], +) +def test_coordinate_rejects_out_of_range_or_non_finite_values(longitude, latitude): + with pytest.raises(ValidationError): + Coordinate(longitude=longitude, latitude=latitude) + + +def test_boundary_models_are_strict_frozen_and_forbid_extra_fields(): + coordinate = Coordinate(longitude=-77.04, latitude=-12.06) + + with pytest.raises(ValidationError): + Coordinate.model_validate({"longitude": "-77.04", "latitude": -12.06}) + with pytest.raises(ValidationError): + Coordinate(longitude=-77.04, latitude=-12.06, altitude=10) + with pytest.raises(ValidationError): + coordinate.latitude = 0 + + +@given( + west=st.floats(min_value=-180, max_value=179, allow_nan=False), + width=st.floats(min_value=0.000001, max_value=1, allow_nan=False), + south=st.floats(min_value=-90, max_value=89, allow_nan=False), + height=st.floats(min_value=0.000001, max_value=1, allow_nan=False), +) +def test_bounding_box_round_trips_ordered_values(west, width, south, height): + east = min(west + width, 180) + north = min(south + height, 90) + box = BoundingBox(west=west, south=south, east=east, north=north) + + assert BoundingBox.from_sequence(box.as_tuple()) == box + + +@pytest.mark.parametrize( + "values", + [(-77, -12, -77, -11), (-76, -12, -77, -11), (-77, -11, -76, -11)], +) +def test_bounding_box_rejects_empty_reversed_or_antimeridian_bounds(values): + with pytest.raises(ValidationError): + BoundingBox.from_sequence(values) + + +def test_travel_profiles_have_stable_provider_neutral_values(): + assert [profile.value for profile in TravelProfile] == [ + "driving", + "cycling", + "walking", + ] + + +def test_safe_boundary_error_does_not_echo_rejected_input(): + secret_like_value = "token-that-must-not-appear" + with pytest.raises(ValidationError) as captured: + Coordinate.model_validate( + {"longitude": secret_like_value, "latitude": -12.06} + ) + + error = BoundaryValidationError.from_pydantic("coordinate", captured.value) + + assert str(error) == "Invalid coordinate at longitude." + assert error.issues[0].field_path == "longitude" + assert secret_like_value not in str(error) + assert secret_like_value not in repr(error.issues) + + +def test_public_model_schemas_describe_units_ranges_and_required_fields(): + coordinate_schema = Coordinate.model_json_schema() + bounds_schema = BoundingBox.model_json_schema() + + assert coordinate_schema["required"] == ["longitude", "latitude"] + assert coordinate_schema["properties"]["longitude"]["minimum"] == -180 + assert coordinate_schema["properties"]["latitude"]["maximum"] == 90 + assert bounds_schema["required"] == ["west", "south", "east", "north"] diff --git a/urbanpy/__init__.py b/urbanpy/__init__.py index 7c06e24..14e1329 100644 --- a/urbanpy/__init__.py +++ b/urbanpy/__init__.py @@ -1,6 +1,7 @@ from importlib.metadata import PackageNotFoundError, version -from . import accessibility, download, geom, plotting, routing, utils +from . import accessibility, download, geom, models, plotting, routing, utils +from .errors import BoundaryIssue, BoundaryValidationError, UrbanPyError try: __version__ = version("urbanpy") @@ -19,9 +20,13 @@ __all__ = [ "__version__", "accessibility", + "BoundaryIssue", + "BoundaryValidationError", "download", "geom", + "models", "plotting", "routing", "utils", + "UrbanPyError", ] diff --git a/urbanpy/errors.py b/urbanpy/errors.py new file mode 100644 index 0000000..6a2f387 --- /dev/null +++ b/urbanpy/errors.py @@ -0,0 +1,55 @@ +"""Stable UrbanPy exception types and safe boundary-error translation.""" + +from dataclasses import dataclass +from typing import Any + +from pydantic import ValidationError + + +class UrbanPyError(Exception): + """Base class for documented UrbanPy failures.""" + + +@dataclass(frozen=True, slots=True) +class BoundaryIssue: + """One sanitized validation issue without the rejected input value.""" + + field_path: str + message: str + error_type: str + + +class BoundaryValidationError(UrbanPyError, ValueError): + """An invalid public or provider boundary value. + + Full input payloads are intentionally excluded from both the exception text + and the structured issues so credentials and large provider responses cannot + leak into logs. + """ + + def __init__(self, category: str, issues: tuple[BoundaryIssue, ...]) -> None: + self.category = category + self.issues = issues + paths = ", ".join(issue.field_path for issue in issues) or "" + super().__init__(f"Invalid {category} at {paths}.") + + @classmethod + def from_pydantic( + cls, category: str, error: ValidationError + ) -> "BoundaryValidationError": + issues = tuple( + BoundaryIssue( + field_path=_format_location(item["loc"]), + message=str(item["msg"]), + error_type=str(item["type"]), + ) + for item in error.errors(include_input=False, include_url=False) + ) + return cls(category, issues) + + +def _format_location(location: tuple[Any, ...]) -> str: + return ".".join(str(part) for part in location) or "" + + +__all__ = ["BoundaryIssue", "BoundaryValidationError", "UrbanPyError"] diff --git a/urbanpy/models/__init__.py b/urbanpy/models/__init__.py new file mode 100644 index 0000000..39d597a --- /dev/null +++ b/urbanpy/models/__init__.py @@ -0,0 +1,5 @@ +"""Validated public value models used at UrbanPy I/O boundaries.""" + +from .spatial import BoundingBox, Coordinate, TravelProfile + +__all__ = ["BoundingBox", "Coordinate", "TravelProfile"] diff --git a/urbanpy/models/spatial.py b/urbanpy/models/spatial.py new file mode 100644 index 0000000..2daeeaf --- /dev/null +++ b/urbanpy/models/spatial.py @@ -0,0 +1,75 @@ +"""Small, immutable spatial and routing boundary values.""" + +from enum import StrEnum +from typing import Annotated, Self, Sequence + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +Longitude = Annotated[float, Field(ge=-180, le=180, allow_inf_nan=False)] +Latitude = Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)] + + +class _FrozenBoundaryModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class Coordinate(_FrozenBoundaryModel): + """A WGS84 longitude/latitude pair with unambiguous field names.""" + + longitude: Longitude + latitude: Latitude + + @classmethod + def from_lon_lat(cls, values: Sequence[float]) -> Self: + """Validate a two-item sequence in ``(longitude, latitude)`` order.""" + if len(values) != 2: + raise ValueError("A coordinate requires exactly two values: (lon, lat).") + return cls(longitude=values[0], latitude=values[1]) + + def as_lon_lat(self) -> tuple[float, float]: + """Return the coordinate in explicit ``(longitude, latitude)`` order.""" + return (self.longitude, self.latitude) + + +class BoundingBox(_FrozenBoundaryModel): + """A non-antimeridian WGS84 box ordered west, south, east, north.""" + + west: Longitude + south: Latitude + east: Longitude + north: Latitude + + @model_validator(mode="after") + def validate_order(self) -> Self: + if self.west >= self.east: + raise ValueError( + "west must be less than east; antimeridian-crossing bounds " + "are not supported" + ) + if self.south >= self.north: + raise ValueError("south must be less than north") + return self + + @classmethod + def from_sequence(cls, values: Sequence[float]) -> Self: + """Validate ``(west, south, east, north)`` values.""" + if len(values) != 4: + raise ValueError( + "A bounding box requires four values: (west, south, east, north)." + ) + return cls(west=values[0], south=values[1], east=values[2], north=values[3]) + + def as_tuple(self) -> tuple[float, float, float, float]: + """Return ``(west, south, east, north)``.""" + return (self.west, self.south, self.east, self.north) + + +class TravelProfile(StrEnum): + """Provider-neutral travel modes supported by UrbanPy routing APIs.""" + + DRIVING = "driving" + CYCLING = "cycling" + WALKING = "walking" + + +__all__ = ["BoundingBox", "Coordinate", "TravelProfile"]