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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
96 changes: 96 additions & 0 deletions tests/models_test.py
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 6 additions & 1 deletion urbanpy/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -19,9 +20,13 @@
__all__ = [
"__version__",
"accessibility",
"BoundaryIssue",
"BoundaryValidationError",
"download",
"geom",
"models",
"plotting",
"routing",
"utils",
"UrbanPyError",
]
55 changes: 55 additions & 0 deletions urbanpy/errors.py
Original file line number Diff line number Diff line change
@@ -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 "<root>"
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 "<root>"


__all__ = ["BoundaryIssue", "BoundaryValidationError", "UrbanPyError"]
5 changes: 5 additions & 0 deletions urbanpy/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Validated public value models used at UrbanPy I/O boundaries."""

from .spatial import BoundingBox, Coordinate, TravelProfile

__all__ = ["BoundingBox", "Coordinate", "TravelProfile"]
75 changes: 75 additions & 0 deletions urbanpy/models/spatial.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading