From 471ab475c5a95a0954ab9793d2ee239332845528 Mon Sep 17 00:00:00 2001 From: Claudio Ortega Date: Sun, 9 Aug 2026 21:40:27 -0700 Subject: [PATCH] Replace OSRM scripts with safe Python lifecycle --- .trunk/trunk.yaml | 6 - docs/source/index.rst | 1 + docs/source/usage/osrm.rst | 94 ++++++ notebooks/executables/run_osrm.sh | 9 - pyproject.toml | 5 + tests/osrm_test.py | 359 ++++++++++++++++++++ urbanpy/_clients/osrm.py | 46 +++ urbanpy/models/__init__.py | 25 +- urbanpy/models/osrm.py | 174 ++++++++++ urbanpy/routing/__init__.py | 18 +- urbanpy/routing/_docker.py | 110 ++++++ urbanpy/routing/_download.py | 122 +++++++ urbanpy/routing/osrm.py | 486 +++++++++++++++++++++++++++ urbanpy/routing/osrm_client.py | 124 +++++++ urbanpy/routing/osrm_routing.py | 265 --------------- urbanpy/routing/routing.py | 229 ++++--------- urbanpy/routing/unix_download.sh | 34 -- urbanpy/routing/windows_download.ps1 | 20 -- uv.lock | 2 + 19 files changed, 1637 insertions(+), 492 deletions(-) create mode 100644 docs/source/usage/osrm.rst delete mode 100644 notebooks/executables/run_osrm.sh create mode 100644 tests/osrm_test.py create mode 100644 urbanpy/_clients/osrm.py create mode 100644 urbanpy/models/osrm.py create mode 100644 urbanpy/routing/_docker.py create mode 100644 urbanpy/routing/_download.py create mode 100644 urbanpy/routing/osrm.py create mode 100644 urbanpy/routing/osrm_client.py delete mode 100644 urbanpy/routing/osrm_routing.py delete mode 100644 urbanpy/routing/unix_download.sh delete mode 100644 urbanpy/routing/windows_download.ps1 diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 4d67693..8508bb1 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -38,12 +38,6 @@ lint: - CODE_OF_CONDUCT.md - README.md - docs/development/** - - linters: [shellcheck, shfmt] - paths: - # These legacy scripts are deleted by the OSRM lifecycle PR. Trunk is - # not used to preserve unsafe orchestration logic. - - notebooks/executables/*.sh - - urbanpy/routing/*.sh actions: disabled: - trunk-announce diff --git a/docs/source/index.rst b/docs/source/index.rst index 3314c67..a9e4a34 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -156,6 +156,7 @@ Indices and tables usage/installation usage/quickstart usage/geofabrik + usage/osrm urbanpy license contributing diff --git a/docs/source/usage/osrm.rst b/docs/source/usage/osrm.rst new file mode 100644 index 0000000..255a055 --- /dev/null +++ b/docs/source/usage/osrm.rst @@ -0,0 +1,94 @@ +Local OSRM service +================== + +UrbanPy 0.3 manages a local `OSRM `__ service +through a cross-platform Python API. The former Bash and PowerShell scripts +have been removed. Trunk still checks any ordinary shell scripts in the +repository, but a linter cannot make lifecycle orchestration, partial +downloads, container ownership, or cleanup safe. + +Requirements and constraints +---------------------------- + +Install Docker Desktop (macOS or Windows) or Docker Engine (Linux), start its +daemon, and allow enough disk space for both the Geofabrik PBF and prepared +OSRM files. Processing a country can take substantial time and disk space. + +UrbanPy pins the official ``osrm/osrm-backend:v5.25.0`` image by digest for +reproducibility. The current official image is published for ``linux/amd64``; +Docker may therefore use emulation on Apple Silicon and other ARM systems. +Do not replace the digest with ``latest`` in production automation. + +Prepare and start +----------------- + +Use the canonical Geofabrik ``properties.id`` value. See :doc:`geofabrik` for +catalog discovery and the deliberately narrow ISO-code aliases. + +.. code-block:: python + + from urbanpy.models import OSRMConfig, TravelProfile + from urbanpy.routing import OSRMManager + + config = OSRMConfig( + region_id="south-america/peru", + profile=TravelProfile.WALKING, + ) + manager = OSRMManager(config) + + plan = manager.plan() # no Docker or filesystem mutation + manager.prepare() # download, extract, partition, customize + status = manager.start() # waits until the HTTP API is ready + print(status.endpoint) + +The MLD pipeline intentionally gives ``osrm-extract`` the +``data.osm.pbf`` input and gives ``osrm-partition``, ``osrm-customize``, and +``osrm-routed`` the resulting ``data.osrm`` base path. UrbanPy stages the +result and only publishes a complete dataset with a matching manifest. + +Query independently of Docker +----------------------------- + +``OSRMClient`` can call a service managed by UrbanPy or any compatible remote +OSRM endpoint. Coordinates are always longitude first, and returned durations +are seconds. + +.. code-block:: python + + from urbanpy.models import Coordinate + from urbanpy.routing import OSRMClient + + client = OSRMClient("http://127.0.0.1:5000") + route = client.route( + Coordinate(longitude=-77.0428, latitude=-12.0464), + Coordinate(longitude=-77.0282, latitude=-12.1191), + ) + print(route.distance_m, route.duration_s) + +Operate and clean up +-------------------- + +.. code-block:: python + + manager.status() + manager.logs(tail=100) + manager.stop() + + # Destructive cleanup is a dry run unless explicitly confirmed. + print(manager.clean(container=True, prepared=True, dry_run=True)) + manager.clean(container=True, prepared=True, dry_run=False) + +The default bind address is loopback because the local OSRM server has no +authentication. A non-loopback address requires ``allow_external=True``. +UrbanPy only stops or removes containers carrying the exact ownership and +dataset labels it created; a colliding user-owned container is never adopted. + +Migration from 0.2 +------------------ + +``start_osrm_server`` and ``stop_osrm_server`` remain as warning-emitting +adapters for one release. They now validate the country against the official +catalog and raise typed exceptions instead of printing subprocess failures. +Move new code to ``OSRMConfig`` and ``OSRMManager``. The deleted +``unix_download.sh``, ``windows_download.ps1``, and notebook launcher are not +supported interfaces. diff --git a/notebooks/executables/run_osrm.sh b/notebooks/executables/run_osrm.sh deleted file mode 100644 index 04f1e9d..0000000 --- a/notebooks/executables/run_osrm.sh +++ /dev/null @@ -1,9 +0,0 @@ -docker pull osrm/osrm-backend; -mkdir -p ~/data/osrm/; -cd ~/data/osrm/; -wget https://download.geofabrik.de/south-america/peru-latest.osm.pbf; -docker run -t --name osrm_extract -v $(pwd):/data osrm/osrm-backend osrm-extract -p /opt/foot.lua /data/peru-latest.osm.pbf; -docker run -t --name osrm_partition -v $(pwd):/data osrm/osrm-backend osrm-partition /data/peru-latest.osm.pbf; -docker run -t --name osrm_customize -v $(pwd):/data osrm/osrm-backend osrm-customize /data/peru-latest.osm.pbf; -docker container rm osrm_extract osrm_partition osrm_customize; -docker run -t --name osrm_routing_server -p 5000:5000 -v $(pwd):/data osrm/osrm-backend osrm-routed --algorithm mld /data/peru-latest.osm.pbf; diff --git a/pyproject.toml b/pyproject.toml index 6b80011..a3f1299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: GIS", ] dependencies = [ + "filelock>=3.16", "geopandas>=1.0", "googlemaps>=4.10", "h3>=4.1", @@ -156,5 +157,9 @@ module = [ "urbanpy.errors", "urbanpy.geofabrik", "urbanpy.models.*", + "urbanpy.routing._docker", + "urbanpy.routing._download", + "urbanpy.routing.osrm", + "urbanpy.routing.osrm_client", ] strict = true diff --git a/tests/osrm_test.py b/tests/osrm_test.py new file mode 100644 index 0000000..265289a --- /dev/null +++ b/tests/osrm_test.py @@ -0,0 +1,359 @@ +import json +import subprocess +from pathlib import Path +from unittest.mock import Mock + +import pytest +from pydantic import ValidationError + +from urbanpy.geofabrik import GeofabrikCatalog +from urbanpy.models import Coordinate, OSRMConfig, OSRMState, TravelProfile +from urbanpy.routing._docker import ( + DockerCommandError, + DockerUnavailableError, + SubprocessRunner, +) +from urbanpy.routing._download import DownloadError, download_pbf +from urbanpy.routing.osrm import ( + DATASET_LABEL, + OWNER_LABEL, + OWNER_VALUE, + OSRMManager, + OSRMOwnershipError, + safe_resource_id, +) +from urbanpy.routing.osrm_client import OSRMClient, OSRMClientError +from urbanpy.routing import routing as legacy_routing + + +def _catalog(): + return GeofabrikCatalog.from_payload( + { + "type": "FeatureCollection", + "features": [ + { + "properties": { + "id": "us/california", + "name": "us/california", + "parent": "north-america", + "iso3166-2": ["US-CA"], + "urls": { + "pbf": "https://download.geofabrik.de/north-america/us/california-latest.osm.pbf" + }, + } + } + ], + } + ) + + +def _config(tmp_path, **updates): + values = { + "region_id": "us/california", + "profile": TravelProfile.WALKING, + "data_dir": tmp_path, + "port": 5017, + "readiness_timeout_s": 1, + } + values.update(updates) + return OSRMConfig(**values) + + +def test_config_defaults_to_loopback_and_requires_external_opt_in(tmp_path): + config = _config(tmp_path) + + assert config.endpoint == "http://127.0.0.1:5017" + assert "@sha256:" in config.image + with pytest.raises(ValidationError, match="allow_external"): + _config(tmp_path, bind_host="0.0.0.0") + assert _config(tmp_path, bind_host="0.0.0.0", allow_external=True).allow_external + + +def test_plan_uses_safe_identity_catalog_url_and_correct_mld_extensions(tmp_path): + plan = OSRMManager(_config(tmp_path), catalog=_catalog()).plan() + + assert "/" not in plan.container_name + assert str(plan.pbf_url).endswith("/us/california-latest.osm.pbf") + assert plan.prepare_commands[0][-4:] == ( + "osrm-extract", + "-p", + "/opt/foot.lua", + "/data/data.osm.pbf", + ) + assert plan.prepare_commands[1][-2:] == ("osrm-partition", "/data/data.osrm") + assert plan.prepare_commands[2][-2:] == ("osrm-customize", "/data/data.osrm") + assert plan.start_command[-4:] == ( + "osrm-routed", + "--algorithm", + "mld", + "/data/data.osrm", + ) + assert all( + isinstance(part, str) for command in plan.prepare_commands for part in command + ) + assert "127.0.0.1:5017:5000" in plan.start_command + assert "readonly" in next( + part for part in plan.start_command if "target=/data" in part + ) + + +def test_safe_resource_id_is_stable_and_collision_resistant(): + first = safe_resource_id("us/california", TravelProfile.WALKING) + + assert first == safe_resource_id("us/california", TravelProfile.WALKING) + assert first != safe_resource_id("us-california", TravelProfile.WALKING) + assert first != safe_resource_id("us/california", TravelProfile.DRIVING) + assert "/" not in first + + +class _DownloadResponse: + def __init__(self, content, *, status_code=200, headers=None, payload=None): + self.content = content + self.status_code = status_code + self.headers = headers or {} + self._payload = payload + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + del chunk_size + yield self.content + + def json(self): + return self._payload + + +def test_atomic_downloader_publishes_digest_and_resumes_partial_file(tmp_path): + target = tmp_path / "data.osm.pbf" + session = Mock() + session.get.return_value = _DownloadResponse(b"abc", headers={"ETag": '"v1"'}) + + result = download_pbf( + "https://download.geofabrik.de/test-latest.osm.pbf", + target, + session=session, + ) + + assert target.read_bytes() == b"abc" + assert ( + result.sha256 + == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + assert not target.with_name("data.osm.pbf.part").exists() + + target.unlink() + target.with_name("data.osm.pbf.part").write_bytes(b"ab") + target.with_name("data.osm.pbf.part.json").write_text( + json.dumps( + { + "url": "https://download.geofabrik.de/test-latest.osm.pbf", + "etag": '"v1"', + } + ) + ) + session.get.return_value = _DownloadResponse(b"c", status_code=206) + resumed = download_pbf( + "https://download.geofabrik.de/test-latest.osm.pbf", + target, + session=session, + ) + assert target.read_bytes() == b"abc" + assert resumed.size == 3 + assert session.get.call_args.kwargs["headers"]["Range"] == "bytes=2-" + + +def test_downloader_rejects_unofficial_sources_and_size_overflow(tmp_path): + with pytest.raises(DownloadError, match="official"): + download_pbf("https://example.org/data.osm.pbf", tmp_path / "data") + session = Mock() + session.get.return_value = _DownloadResponse(b"too-large") + with pytest.raises(DownloadError, match="size limit"): + download_pbf( + "https://download.geofabrik.de/test-latest.osm.pbf", + tmp_path / "data", + session=session, + max_bytes=2, + ) + + +class _Runner: + def __init__(self): + self.calls = [] + self.running = False + self.labels = None + + def run(self, command, *, timeout, check=True): + del timeout + command = tuple(command) + self.calls.append(command) + if command[:3] == ("docker", "image", "inspect"): + return subprocess.CompletedProcess(command, 0, "", "") + if command[:3] == ("docker", "inspect", "--format"): + if self.labels is None: + return subprocess.CompletedProcess(command, 1, "", "missing") + payload = { + "Config": {"Labels": self.labels}, + "State": {"Running": self.running}, + } + return subprocess.CompletedProcess(command, 0, json.dumps(payload), "") + if "osrm-extract" in command: + mount = command[command.index("--mount") + 1] + source = mount.split(",source=", 1)[1].split(",target=", 1)[0] + Path(source, "data.osrm").touch() + if command[:3] == ("docker", "run", "--detach"): + self.running = True + self.labels = { + command[index + 1].split("=", 1)[0]: command[index + 1].split("=", 1)[1] + for index, value in enumerate(command) + if value == "--label" + } + if command[:2] == ("docker", "stop"): + self.running = False + if check and False: # pragma: no cover - protocol shape only + raise AssertionError + return subprocess.CompletedProcess(command, 0, "container-id", "") + + +class _Session: + def get(self, url, **kwargs): + del kwargs + if url.endswith(".osm.pbf"): + return _DownloadResponse(b"pbf-data") + return _DownloadResponse(b'{"code":"NoSegment"}', payload={"code": "NoSegment"}) + + +def test_manager_prepares_stages_publishes_starts_and_stops_owned_service( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "urbanpy.routing.osrm._require_port_available", lambda _config: None + ) + runner = _Runner() + manager = OSRMManager( + _config(tmp_path), catalog=_catalog(), runner=runner, session=_Session() + ) + + prepared = manager.prepare() + assert prepared.reusable + assert (prepared.prepared_dir / "data.osrm").exists() + assert (prepared.prepared_dir / "urbanpy-osrm-manifest.json").exists() + extract, partition, customize = [ + command + for command in runner.calls + if any( + tool in command + for tool in ("osrm-extract", "osrm-partition", "osrm-customize") + ) + ] + assert extract[-1] == "/data/data.osm.pbf" + assert partition[-1] == customize[-1] == "/data/data.osrm" + + status = manager.start() + assert status.state is OSRMState.RUNNING + assert runner.labels[OWNER_LABEL] == OWNER_VALUE + assert runner.labels[DATASET_LABEL] == safe_resource_id( + "us/california", TravelProfile.WALKING + ) + assert manager.stop().state is OSRMState.STOPPED + + +def test_manager_refuses_colliding_unowned_container(tmp_path): + runner = _Runner() + runner.labels = {OWNER_LABEL: "someone-else", DATASET_LABEL: "other"} + manager = OSRMManager( + _config(tmp_path), catalog=_catalog(), runner=runner, session=_Session() + ) + manager.prepare() + + with pytest.raises(OSRMOwnershipError): + manager.start() + + +def test_legacy_start_adapter_resolves_catalog_and_warns(monkeypatch): + manager = Mock() + manager_type = Mock(return_value=manager) + monkeypatch.setattr( + legacy_routing.GeofabrikCatalog, "fetch", Mock(return_value=_catalog()) + ) + monkeypatch.setattr(legacy_routing, "OSRMManager", manager_type) + + with pytest.warns(FutureWarning, match="deprecated"): + legacy_routing.start_osrm_server("US-CA", "north-america", "foot") + + config = manager_type.call_args.args[0] + assert config.region_id == "us/california" + assert config.profile is TravelProfile.WALKING + manager.start.assert_called_once_with() + + +def test_legacy_adapter_rejects_wrong_catalog_parent(monkeypatch): + monkeypatch.setattr( + legacy_routing.GeofabrikCatalog, "fetch", Mock(return_value=_catalog()) + ) + + with pytest.warns(FutureWarning), pytest.raises(ValueError, match="does not match"): + legacy_routing.stop_osrm_server("US-CA", "south-america", "car") + + +def test_docker_runner_translates_missing_cli_and_nonzero(monkeypatch): + runner = SubprocessRunner() + monkeypatch.setattr(subprocess, "run", Mock(side_effect=FileNotFoundError)) + with pytest.raises(DockerUnavailableError): + runner.run(["docker", "version"], timeout=1) + + monkeypatch.setattr( + subprocess, + "run", + Mock(return_value=subprocess.CompletedProcess(["docker"], 2, "", "denied")), + ) + with pytest.raises(DockerCommandError) as captured: + runner.run(["docker", "version"], timeout=1) + assert captured.value.returncode == 2 + assert captured.value.stderr == "denied" + + +def test_http_client_preserves_units_coordinates_and_matrix_nulls(): + session = Mock() + route_response = _DownloadResponse( + b"route", + payload={ + "code": "Ok", + "routes": [{"distance": 123.4, "duration": 56.7, "extra": True}], + }, + ) + table_response = _DownloadResponse( + b"table", + payload={ + "code": "Ok", + "distances": [[0.0, None]], + "durations": [[0.0, None]], + }, + ) + session.get.side_effect = [route_response, table_response] + client = OSRMClient("http://127.0.0.1:5000", session=session) + origin = Coordinate(longitude=-77.0, latitude=-12.0) + destination = Coordinate(longitude=-77.1, latitude=-12.1) + + route = client.route(origin, destination, profile=TravelProfile.WALKING) + table = client.table([origin], [origin, destination]) + + assert route.distance_m == 123.4 + assert route.duration_s == 56.7 + assert table.distances_m == ((0.0, None),) + assert ( + "/route/v1/walking/-77.00000000,-12.00000000;" + in session.get.call_args_list[0].args[0] + ) + assert session.get.call_args_list[1].kwargs["params"]["sources"] == "0" + + +def test_http_client_translates_invalid_or_oversized_responses(): + session = Mock() + session.get.return_value = _DownloadResponse(b"x" * 4, payload={"code": "Ok"}) + client = OSRMClient("http://localhost:5000", session=session, max_response_bytes=2) + + with pytest.raises(OSRMClientError, match="size limit"): + client.route( + Coordinate(longitude=0, latitude=0), Coordinate(longitude=1, latitude=1) + ) diff --git a/urbanpy/_clients/osrm.py b/urbanpy/_clients/osrm.py new file mode 100644 index 0000000..6775f74 --- /dev/null +++ b/urbanpy/_clients/osrm.py @@ -0,0 +1,46 @@ +"""Internal schemas for the small subset of OSRM responses UrbanPy consumes.""" + +from pydantic import BaseModel, ConfigDict, Field + +from urbanpy.models import RouteResult, TableResult + + +class _TransportModel(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + +class _Route(_TransportModel): + distance: float = Field(ge=0, allow_inf_nan=False) + duration: float = Field(ge=0, allow_inf_nan=False) + + +class RouteResponse(_TransportModel): + code: str + routes: list[_Route] + + def to_result(self) -> RouteResult: + if self.code != "Ok" or not self.routes: + raise ValueError(f"OSRM route failed with code {self.code!r}") + route = self.routes[0] + return RouteResult(distance_m=route.distance, duration_s=route.duration) + + +class TableResponse(_TransportModel): + code: str + distances: list[list[float | None]] + durations: list[list[float | None]] + + def to_result(self) -> TableResult: + if self.code != "Ok": + raise ValueError(f"OSRM table failed with code {self.code!r}") + return TableResult( + distances_m=tuple(tuple(row) for row in self.distances), + durations_s=tuple(tuple(row) for row in self.durations), + ) + + +class NearestResponse(_TransportModel): + code: str + + +__all__ = ["NearestResponse", "RouteResponse", "TableResponse"] diff --git a/urbanpy/models/__init__.py b/urbanpy/models/__init__.py index 46c0cea..9547502 100644 --- a/urbanpy/models/__init__.py +++ b/urbanpy/models/__init__.py @@ -1,6 +1,29 @@ """Validated public value models used at UrbanPy I/O boundaries.""" from .geofabrik import GeofabrikRegion +from .osrm import ( + DEFAULT_OSRM_IMAGE, + OSRMConfig, + OSRMManifest, + OSRMPlan, + OSRMState, + OSRMStatus, + RouteResult, + TableResult, +) from .spatial import BoundingBox, Coordinate, TravelProfile -__all__ = ["BoundingBox", "Coordinate", "GeofabrikRegion", "TravelProfile"] +__all__ = [ + "BoundingBox", + "Coordinate", + "DEFAULT_OSRM_IMAGE", + "GeofabrikRegion", + "OSRMConfig", + "OSRMManifest", + "OSRMPlan", + "OSRMState", + "OSRMStatus", + "RouteResult", + "TableResult", + "TravelProfile", +] diff --git a/urbanpy/models/osrm.py b/urbanpy/models/osrm.py new file mode 100644 index 0000000..a8220c3 --- /dev/null +++ b/urbanpy/models/osrm.py @@ -0,0 +1,174 @@ +"""Public configuration and result contracts for OSRM.""" + +from datetime import datetime +from enum import StrEnum +from ipaddress import IPv4Address, IPv6Address +from pathlib import Path +from typing import Annotated, Literal, Self + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + HttpUrl, + IPvAnyAddress, + StrictInt, + model_validator, +) + +from .geofabrik import RegionId +from .spatial import TravelProfile + +DEFAULT_OSRM_IMAGE = ( + "osrm/osrm-backend@" + "sha256:bdfa60e64ae1376bff6ff5605991be50600132a27469a4a9e77c23afd3a6d555" +) + + +def default_osrm_data_dir() -> Path: + """Return a platform-appropriate cache location without creating it.""" + import os + import sys + + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "urbanpy" / "osrm" + if sys.platform == "win32": + root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + return root / "urbanpy" / "osrm" + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) + return root / "urbanpy" / "osrm" + + +class OSRMState(StrEnum): + MISSING = "missing" + PREPARED = "prepared" + STARTING = "starting" + RUNNING = "running" + STOPPED = "stopped" + ERROR = "error" + + +class OSRMConfig(BaseModel): + """Safe configuration for one local OSRM dataset and service.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + region_id: RegionId + profile: TravelProfile = TravelProfile.DRIVING + algorithm: Literal["mld"] = "mld" + bind_host: IPvAnyAddress = IPv4Address("127.0.0.1") + port: Annotated[StrictInt, Field(ge=1, le=65535)] = 5000 + allow_external: bool = False + data_dir: Path = Field(default_factory=default_osrm_data_dir) + image: str = Field( + default=DEFAULT_OSRM_IMAGE, + pattern=r"^osrm/osrm-backend@sha256:[0-9a-f]{64}$", + ) + command_timeout_s: Annotated[float, Field(gt=0)] = 7200.0 + readiness_timeout_s: Annotated[float, Field(gt=0)] = 60.0 + download_timeout_s: Annotated[float, Field(gt=0)] = 120.0 + lock_timeout_s: Annotated[float, Field(gt=0)] = 30.0 + + @model_validator(mode="after") + def require_explicit_external_binding(self) -> Self: + host: IPv4Address | IPv6Address = self.bind_host + if not host.is_loopback and not self.allow_external: + raise ValueError( + "non-loopback binding requires allow_external=True; local OSRM " + "has no authentication" + ) + return self + + @property + def endpoint(self) -> str: + host = ( + f"[{self.bind_host}]" + if self.bind_host.version == 6 + else str(self.bind_host) + ) + return f"http://{host}:{self.port}" + + +class OSRMManifest(BaseModel): + """Identity of one completely prepared immutable dataset.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + format_version: Literal[1] = 1 + region_id: RegionId + profile: TravelProfile + algorithm: Literal["mld"] + pbf_url: HttpUrl + pbf_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + pbf_size: Annotated[StrictInt, Field(gt=0)] + image: str + created_at: datetime + + +class OSRMStatus(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + state: OSRMState + region_id: RegionId + profile: TravelProfile + endpoint: str + container_name: str + prepared_dir: Path + message: str | None = None + + +class OSRMPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + region_id: RegionId + profile: TravelProfile + pbf_url: HttpUrl + pbf_path: Path + prepared_dir: Path + container_name: str + endpoint: str + prepare_commands: tuple[tuple[str, ...], ...] + start_command: tuple[str, ...] + reusable: bool + + +class RouteResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + distance_m: Annotated[float, Field(ge=0, allow_inf_nan=False)] + duration_s: Annotated[float, Field(ge=0, allow_inf_nan=False)] + + +class TableResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + distances_m: tuple[tuple[float | None, ...], ...] + durations_s: tuple[tuple[float | None, ...], ...] + + @model_validator(mode="after") + def require_matching_rectangular_matrices(self) -> Self: + distance_widths = {len(row) for row in self.distances_m} + duration_widths = {len(row) for row in self.durations_s} + if len(distance_widths) > 1 or len(duration_widths) > 1: + raise ValueError("OSRM table matrices must be rectangular") + if ( + len(self.distances_m) != len(self.durations_s) + or distance_widths != duration_widths + ): + raise ValueError( + "distance and duration matrices must have matching dimensions" + ) + return self + + +__all__ = [ + "DEFAULT_OSRM_IMAGE", + "OSRMConfig", + "OSRMManifest", + "OSRMPlan", + "OSRMState", + "OSRMStatus", + "RouteResult", + "TableResult", + "default_osrm_data_dir", +] diff --git a/urbanpy/routing/__init__.py b/urbanpy/routing/__init__.py index 8263c48..a7f86dd 100644 --- a/urbanpy/routing/__init__.py +++ b/urbanpy/routing/__init__.py @@ -1,3 +1,19 @@ +from .osrm import ( + OSRMLifecycleError, + OSRMManager, + OSRMOwnershipError, + OSRMReadinessError, +) +from .osrm_client import OSRMClient, OSRMClientError from .routing import * +from .routing import __all__ as _routing_all -__all__ = routing.__all__ +__all__ = [ + *_routing_all, + "OSRMClient", + "OSRMClientError", + "OSRMLifecycleError", + "OSRMManager", + "OSRMOwnershipError", + "OSRMReadinessError", +] diff --git a/urbanpy/routing/_docker.py b/urbanpy/routing/_docker.py new file mode 100644 index 0000000..0ef44b8 --- /dev/null +++ b/urbanpy/routing/_docker.py @@ -0,0 +1,110 @@ +"""Injectable, shell-free Docker command execution for OSRM.""" + +import json +import subprocess +from dataclasses import dataclass +from typing import Protocol, Sequence + +from urbanpy.errors import UrbanPyError + + +class DockerError(UrbanPyError): + """Base class for local Docker lifecycle failures.""" + + +class DockerUnavailableError(DockerError): + """The Docker CLI or daemon is unavailable.""" + + +class DockerCommandError(DockerError): + """A Docker command failed with sanitized diagnostic output.""" + + def __init__( + self, command: Sequence[str], returncode: int, stderr: str = "" + ) -> None: + self.command = tuple(command) + self.returncode = returncode + self.stderr = stderr[-2000:] + executable = command[0] if command else "docker" + super().__init__(f"{executable} command failed with exit code {returncode}.") + + +class DockerTimeoutError(DockerError): + """A Docker command exceeded its explicit deadline.""" + + +class CommandRunner(Protocol): + def run( + self, + command: Sequence[str], + *, + timeout: float, + check: bool = True, + ) -> subprocess.CompletedProcess[str]: ... + + +@dataclass(frozen=True, slots=True) +class SubprocessRunner: + """Run argument arrays with ``shell=False`` and captured diagnostics.""" + + def run( + self, + command: Sequence[str], + *, + timeout: float, + check: bool = True, + ) -> subprocess.CompletedProcess[str]: + try: + completed = subprocess.run( + list(command), + capture_output=True, + check=False, + shell=False, + text=True, + timeout=timeout, + ) + except FileNotFoundError as error: + raise DockerUnavailableError( + "Docker CLI was not found; install Docker and ensure it is on PATH." + ) from error + except subprocess.TimeoutExpired as error: + raise DockerTimeoutError( + f"Docker command exceeded its {timeout:g}-second timeout." + ) from error + if check and completed.returncode != 0: + raise DockerCommandError(command, completed.returncode, completed.stderr) + return completed + + +def inspect_container( + runner: CommandRunner, name: str, *, timeout: float +) -> dict[str, object] | None: + result = runner.run( + ["docker", "inspect", "--format", "{{json .}}", name], + timeout=timeout, + check=False, + ) + if result.returncode != 0: + return None + try: + value = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise DockerCommandError( + ["docker", "inspect", name], 0, "Docker returned invalid inspect JSON." + ) from error + if not isinstance(value, dict): + raise DockerCommandError( + ["docker", "inspect", name], 0, "Docker inspect JSON is not an object." + ) + return value + + +__all__ = [ + "CommandRunner", + "DockerCommandError", + "DockerError", + "DockerTimeoutError", + "DockerUnavailableError", + "SubprocessRunner", + "inspect_container", +] diff --git a/urbanpy/routing/_download.py b/urbanpy/routing/_download.py new file mode 100644 index 0000000..4f56537 --- /dev/null +++ b/urbanpy/routing/_download.py @@ -0,0 +1,122 @@ +"""Atomic, safely resumable Geofabrik PBF downloads.""" + +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Final +from urllib.parse import urlparse + +import requests + +from urbanpy.errors import UrbanPyError +from urbanpy.geofabrik import USER_AGENT + +DEFAULT_MAX_PBF_BYTES: Final = 25 * 1024 * 1024 * 1024 + + +class DownloadError(UrbanPyError): + """A PBF download failed or violated a safety constraint.""" + + +@dataclass(frozen=True, slots=True) +class DownloadResult: + path: Path + size: int + sha256: str + etag: str | None + last_modified: str | None + + +def download_pbf( + url: str, + target: Path, + *, + session: requests.Session | None = None, + timeout: tuple[float, float] = (5.0, 120.0), + max_bytes: int = DEFAULT_MAX_PBF_BYTES, + chunk_size: int = 1024 * 1024, +) -> DownloadResult: + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.hostname != "download.geofabrik.de": + raise DownloadError("PBF source must be an official Geofabrik HTTPS URL.") + + target.parent.mkdir(parents=True, exist_ok=True) + part = target.with_name(f"{target.name}.part") + metadata_path = target.with_name(f"{target.name}.part.json") + metadata = _read_metadata(metadata_path) + offset = part.stat().st_size if part.exists() and metadata.get("url") == url else 0 + if offset > max_bytes: + raise DownloadError("Existing partial PBF exceeds the configured size limit.") + + headers = {"Accept": "application/octet-stream", "User-Agent": USER_AGENT} + if offset: + headers["Range"] = f"bytes={offset}-" + validator = metadata.get("etag") or metadata.get("last_modified") + if isinstance(validator, str): + headers["If-Range"] = validator + + client = session or requests.Session() + try: + response = client.get(url, headers=headers, timeout=timeout, stream=True) + response.raise_for_status() + except requests.RequestException as error: + raise DownloadError("Could not download the Geofabrik PBF.") from error + + append = offset > 0 and response.status_code == 206 + if not append: + offset = 0 + etag = response.headers.get("ETag") + last_modified = response.headers.get("Last-Modified") + _write_metadata( + metadata_path, + {"url": url, "etag": etag, "last_modified": last_modified}, + ) + + mode = "ab" if append else "wb" + size = offset + try: + with part.open(mode) as destination: + for chunk in response.iter_content(chunk_size=chunk_size): + if not chunk: + continue + size += len(chunk) + if size > max_bytes: + raise DownloadError("PBF exceeds the configured size limit.") + destination.write(chunk) + destination.flush() + os.fsync(destination.fileno()) + except OSError as error: + raise DownloadError(f"Could not write PBF download to {part}.") from error + + digest = sha256_file(part) + os.replace(part, target) + metadata_path.unlink(missing_ok=True) + return DownloadResult(target, size, digest, etag, last_modified) + + +def sha256_file(path: Path) -> str: + """Hash a potentially large file without loading it into memory.""" + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _read_metadata(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + return value if isinstance(value, dict) else {} + + +def _write_metadata(path: Path, value: dict[str, object]) -> None: + temporary = path.with_suffix(f"{path.suffix}.tmp") + temporary.write_text(json.dumps(value, sort_keys=True), encoding="utf-8") + os.replace(temporary, path) + + +__all__ = ["DownloadError", "DownloadResult", "download_pbf", "sha256_file"] diff --git a/urbanpy/routing/osrm.py b/urbanpy/routing/osrm.py new file mode 100644 index 0000000..41c5268 --- /dev/null +++ b/urbanpy/routing/osrm.py @@ -0,0 +1,486 @@ +"""Cross-platform Python-owned lifecycle for a local OSRM service.""" + +import hashlib +import json +import os +import shutil +import socket +import tempfile +import time +from datetime import UTC, datetime +from pathlib import Path +from typing import Final + +import requests +from filelock import FileLock, Timeout + +from urbanpy.errors import UrbanPyError +from urbanpy.geofabrik import GeofabrikCatalog +from urbanpy.models import ( + OSRMConfig, + OSRMManifest, + OSRMPlan, + OSRMState, + OSRMStatus, + TravelProfile, +) +from urbanpy.routing._docker import CommandRunner, SubprocessRunner, inspect_container +from urbanpy.routing._download import DownloadResult, download_pbf, sha256_file +from urbanpy.routing.osrm_client import OSRMClient + +OWNER_LABEL: Final = "io.github.el-bid.urbanpy.owner" +DATASET_LABEL: Final = "io.github.el-bid.urbanpy.dataset" +REGION_LABEL: Final = "io.github.el-bid.urbanpy.region" +PROFILE_LABEL: Final = "io.github.el-bid.urbanpy.profile" +OWNER_VALUE: Final = "osrm" +CONTAINER_PLATFORM: Final = "linux/amd64" + +PROFILE_SETTINGS: Final = { + TravelProfile.DRIVING: ("car.lua", "driving"), + TravelProfile.CYCLING: ("bicycle.lua", "cycling"), + TravelProfile.WALKING: ("foot.lua", "walking"), +} + + +class OSRMLifecycleError(UrbanPyError): + """A local OSRM lifecycle operation could not complete safely.""" + + +class OSRMOwnershipError(OSRMLifecycleError): + """A colliding Docker resource is not owned by this UrbanPy dataset.""" + + +class OSRMReadinessError(OSRMLifecycleError): + """The OSRM service did not become ready before its deadline.""" + + +class OSRMManager: + """Prepare and operate exactly one region/profile OSRM service.""" + + def __init__( + self, + config: OSRMConfig, + *, + catalog: GeofabrikCatalog | None = None, + runner: CommandRunner | None = None, + session: requests.Session | None = None, + ) -> None: + self.config = config + self.catalog = catalog + self.runner = runner or SubprocessRunner() + self.session = session + self._started_here = False + + def __enter__(self) -> OSRMStatus: + return self.start() + + def __exit__(self, *_exception: object) -> None: + if self._started_here: + self.stop() + + def plan(self) -> OSRMPlan: + catalog = self.catalog or GeofabrikCatalog.fetch(session=self.session) + region = catalog.resolve(self.config.region_id) + identity = safe_resource_id(region.id, self.config.profile) + pbf_path = ( + self.config.data_dir + / "downloads" + / safe_resource_id(region.id) + / "data.osm.pbf" + ) + prepared_dir = self.config.data_dir / "prepared" / identity + container_name = f"urbanpy-osrm-{identity}"[:120] + prepare_commands = _prepare_commands(self.config, prepared_dir, identity) + start_command = _start_command( + self.config, prepared_dir, container_name, identity, region.id + ) + return OSRMPlan( + region_id=region.id, + profile=self.config.profile, + pbf_url=region.pbf_url, + pbf_path=pbf_path, + prepared_dir=prepared_dir, + container_name=container_name, + endpoint=self.config.endpoint, + prepare_commands=prepare_commands, + start_command=start_command, + reusable=_manifest_matches( + prepared_dir / "urbanpy-osrm-manifest.json", self.config, region.id + ), + ) + + def prepare(self) -> OSRMPlan: + plan = self.plan() + lock_path = ( + self.config.data_dir + / "locks" + / f"{safe_resource_id(plan.region_id, plan.profile)}.lock" + ) + lock_path.parent.mkdir(parents=True, exist_ok=True) + try: + with FileLock(lock_path, timeout=self.config.lock_timeout_s): + return self._prepare_locked(plan) + except Timeout as error: + raise OSRMLifecycleError( + f"Another process is preparing {plan.region_id}/{plan.profile.value}." + ) from error + + def _prepare_locked(self, plan: OSRMPlan) -> OSRMPlan: + if plan.reusable and (plan.prepared_dir / "data.osrm").exists(): + return plan + + download = _existing_download(plan.pbf_path) + if download is None: + download = download_pbf( + str(plan.pbf_url), + plan.pbf_path, + session=self.session, + timeout=(5.0, self.config.download_timeout_s), + ) + + plan.prepared_dir.parent.mkdir(parents=True, exist_ok=True) + stage = Path( + tempfile.mkdtemp( + prefix=f".{plan.prepared_dir.name}-", dir=plan.prepared_dir.parent + ) + ) + try: + shutil.copy2(download.path, stage / "data.osm.pbf") + self._ensure_image() + for command in _prepare_commands( + self.config, stage, safe_resource_id(plan.region_id, plan.profile) + ): + self.runner.run( + command, timeout=self.config.command_timeout_s, check=True + ) + if not (stage / "data.osrm").exists(): + raise OSRMLifecycleError( + "osrm-extract completed without producing data.osrm." + ) + manifest = OSRMManifest( + region_id=plan.region_id, + profile=plan.profile, + algorithm=self.config.algorithm, + pbf_url=plan.pbf_url, + pbf_sha256=download.sha256, + pbf_size=download.size, + image=self.config.image, + created_at=datetime.now(UTC), + ) + _atomic_write( + stage / "urbanpy-osrm-manifest.json", + manifest.model_dump_json(indent=2), + ) + _publish_directory(stage, plan.prepared_dir) + except BaseException: + shutil.rmtree(stage, ignore_errors=True) + raise + return plan.model_copy(update={"reusable": True}) + + def start(self) -> OSRMStatus: + plan = self.prepare() + existing = inspect_container( + self.runner, plan.container_name, timeout=self.config.command_timeout_s + ) + created = False + if existing is not None: + _require_ownership(existing, plan) + if _is_running(existing): + return self._wait_until_ready(plan, created=False) + _require_port_available(self.config) + self.runner.run( + ["docker", "start", plan.container_name], + timeout=self.config.command_timeout_s, + ) + else: + _require_port_available(self.config) + self.runner.run(plan.start_command, timeout=self.config.command_timeout_s) + created = True + self._started_here = True + return self._wait_until_ready(plan, created=created) + + def _wait_until_ready(self, plan: OSRMPlan, *, created: bool) -> OSRMStatus: + client = OSRMClient( + plan.endpoint, + session=self.session, + timeout=(1.0, min(5.0, self.config.readiness_timeout_s)), + ) + deadline = time.monotonic() + self.config.readiness_timeout_s + while time.monotonic() < deadline: + if client.ready(profile=plan.profile): + return _status(plan, OSRMState.RUNNING) + time.sleep(0.25) + if created: + self.runner.run( + ["docker", "stop", plan.container_name], + timeout=self.config.command_timeout_s, + check=False, + ) + raise OSRMReadinessError( + f"OSRM did not become ready at {plan.endpoint} within " + f"{self.config.readiness_timeout_s:g} seconds." + ) + + def status(self) -> OSRMStatus: + plan = self.plan() + container = inspect_container( + self.runner, plan.container_name, timeout=self.config.command_timeout_s + ) + if container is None: + state = OSRMState.PREPARED if plan.reusable else OSRMState.MISSING + return _status(plan, state) + _require_ownership(container, plan) + return _status( + plan, OSRMState.RUNNING if _is_running(container) else OSRMState.STOPPED + ) + + def stop(self) -> OSRMStatus: + plan = self.plan() + container = inspect_container( + self.runner, plan.container_name, timeout=self.config.command_timeout_s + ) + if container is None: + return _status( + plan, OSRMState.PREPARED if plan.reusable else OSRMState.MISSING + ) + _require_ownership(container, plan) + if _is_running(container): + self.runner.run( + ["docker", "stop", plan.container_name], + timeout=self.config.command_timeout_s, + ) + self._started_here = False + return _status(plan, OSRMState.STOPPED) + + def logs(self, *, tail: int = 200) -> str: + if tail < 1: + raise ValueError("tail must be positive") + plan = self.plan() + container = inspect_container( + self.runner, plan.container_name, timeout=self.config.command_timeout_s + ) + if container is None: + raise OSRMLifecycleError("OSRM container does not exist.") + _require_ownership(container, plan) + result = self.runner.run( + ["docker", "logs", "--tail", str(tail), plan.container_name], + timeout=self.config.command_timeout_s, + ) + return result.stdout + + def clean( + self, + *, + container: bool = False, + prepared: bool = False, + pbf: bool = False, + dry_run: bool = True, + ) -> tuple[Path | str, ...]: + if not any((container, prepared, pbf)): + raise ValueError("Select at least one clean scope.") + plan = self.plan() + targets: list[Path | str] = [] + if container: + existing = inspect_container( + self.runner, plan.container_name, timeout=self.config.command_timeout_s + ) + if existing is not None: + _require_ownership(existing, plan) + targets.append(plan.container_name) + if not dry_run: + self.runner.run( + ["docker", "rm", "--force", plan.container_name], + timeout=self.config.command_timeout_s, + ) + for enabled, path in ((prepared, plan.prepared_dir), (pbf, plan.pbf_path)): + if enabled and path.exists(): + targets.append(path) + if not dry_run: + shutil.rmtree(path) if path.is_dir() else path.unlink() + return tuple(targets) + + def _ensure_image(self) -> None: + self.runner.run( + ["docker", "version", "--format", "{{.Server.Version}}"], + timeout=self.config.command_timeout_s, + ) + present = self.runner.run( + ["docker", "image", "inspect", self.config.image], + timeout=self.config.command_timeout_s, + check=False, + ) + if present.returncode != 0: + self.runner.run( + ["docker", "pull", "--platform", CONTAINER_PLATFORM, self.config.image], + timeout=self.config.command_timeout_s, + ) + + +def safe_resource_id(region_id: str, profile: TravelProfile | None = None) -> str: + readable = "".join( + character if character.isalnum() else "-" for character in region_id.casefold() + ).strip("-") + readable = "-".join(filter(None, readable.split("-")))[:48] or "region" + raw = f"{region_id}\0{profile.value if profile else ''}" + digest = hashlib.blake2s(raw.encode(), digest_size=5).hexdigest() + suffix = f"-{profile.value}" if profile else "" + return f"{readable}{suffix}-{digest}" + + +def _prepare_commands( + config: OSRMConfig, directory: Path, identity: str +) -> tuple[tuple[str, ...], ...]: + profile_file, _api_profile = PROFILE_SETTINGS[config.profile] + mount = f"type=bind,source={directory.resolve()},target=/data" + labels = _label_arguments(identity, config.region_id, config.profile) + base = ( + "docker", + "run", + "--rm", + "--platform", + CONTAINER_PLATFORM, + *labels, + "--mount", + mount, + config.image, + ) + return ( + (*base, "osrm-extract", "-p", f"/opt/{profile_file}", "/data/data.osm.pbf"), + (*base, "osrm-partition", "/data/data.osrm"), + (*base, "osrm-customize", "/data/data.osrm"), + ) + + +def _start_command( + config: OSRMConfig, + directory: Path, + container_name: str, + identity: str, + region_id: str, +) -> tuple[str, ...]: + mount = f"type=bind,source={directory.resolve()},target=/data,readonly" + return ( + "docker", + "run", + "--detach", + "--name", + container_name, + "--platform", + CONTAINER_PLATFORM, + *_label_arguments(identity, region_id, config.profile), + "--publish", + f"{config.bind_host}:{config.port}:5000", + "--mount", + mount, + config.image, + "osrm-routed", + "--algorithm", + config.algorithm, + "/data/data.osrm", + ) + + +def _label_arguments( + identity: str, region_id: str, profile: TravelProfile +) -> tuple[str, ...]: + labels = { + OWNER_LABEL: OWNER_VALUE, + DATASET_LABEL: identity, + REGION_LABEL: region_id, + PROFILE_LABEL: profile.value, + } + return tuple( + part for key, value in labels.items() for part in ("--label", f"{key}={value}") + ) + + +def _manifest_matches(path: Path, config: OSRMConfig, region_id: str) -> bool: + try: + manifest = OSRMManifest.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return ( + manifest.region_id == region_id + and manifest.profile == config.profile + and manifest.algorithm == config.algorithm + and manifest.image == config.image + ) + + +def _existing_download(path: Path) -> DownloadResult | None: + if not path.is_file() or path.stat().st_size == 0: + return None + digest = sha256_file(path) + return DownloadResult(path, path.stat().st_size, digest, None, None) + + +def _publish_directory(stage: Path, destination: Path) -> None: + backup = destination.with_name(f".{destination.name}.previous") + if backup.exists(): + shutil.rmtree(backup) + if destination.exists(): + os.replace(destination, backup) + try: + os.replace(stage, destination) + except BaseException: + if backup.exists() and not destination.exists(): + os.replace(backup, destination) + raise + shutil.rmtree(backup, ignore_errors=True) + + +def _atomic_write(path: Path, value: str) -> None: + temporary = path.with_suffix(f"{path.suffix}.tmp") + temporary.write_text(value, encoding="utf-8") + os.replace(temporary, path) + + +def _require_port_available(config: OSRMConfig) -> None: + family = socket.AF_INET6 if config.bind_host.version == 6 else socket.AF_INET + with socket.socket(family, socket.SOCK_STREAM) as probe: + try: + probe.bind((str(config.bind_host), config.port)) + except OSError as error: + raise OSRMLifecycleError( + f"Port {config.port} is unavailable on {config.bind_host}." + ) from error + + +def _require_ownership(container: dict[str, object], plan: OSRMPlan) -> None: + config = container.get("Config") + labels = config.get("Labels") if isinstance(config, dict) else None + expected = safe_resource_id(plan.region_id, plan.profile) + owned = ( + isinstance(labels, dict) + and labels.get(OWNER_LABEL) == OWNER_VALUE + and labels.get(DATASET_LABEL) == expected + ) + if not owned: + raise OSRMOwnershipError( + f"Container {plan.container_name} is not owned by this UrbanPy dataset." + ) + + +def _is_running(container: dict[str, object]) -> bool: + state = container.get("State") + return bool(state.get("Running")) if isinstance(state, dict) else False + + +def _status(plan: OSRMPlan, state: OSRMState) -> OSRMStatus: + return OSRMStatus( + state=state, + region_id=plan.region_id, + profile=plan.profile, + endpoint=plan.endpoint, + container_name=plan.container_name, + prepared_dir=plan.prepared_dir, + ) + + +__all__ = [ + "OSRMLifecycleError", + "OSRMManager", + "OSRMOwnershipError", + "OSRMReadinessError", + "safe_resource_id", +] diff --git a/urbanpy/routing/osrm_client.py b/urbanpy/routing/osrm_client.py new file mode 100644 index 0000000..e88a1a9 --- /dev/null +++ b/urbanpy/routing/osrm_client.py @@ -0,0 +1,124 @@ +"""Docker-independent typed client for an OSRM HTTP endpoint.""" + +from collections.abc import Sequence +from typing import Any, Final + +import requests +from pydantic import ValidationError + +from urbanpy._clients.osrm import NearestResponse, RouteResponse, TableResponse +from urbanpy.errors import BoundaryValidationError, UrbanPyError +from urbanpy.models import Coordinate, RouteResult, TableResult, TravelProfile + +MAX_RESPONSE_BYTES: Final = 20 * 1024 * 1024 +API_PROFILES: Final = { + TravelProfile.DRIVING: "driving", + TravelProfile.CYCLING: "cycling", + TravelProfile.WALKING: "walking", +} + + +class OSRMClientError(UrbanPyError): + """An OSRM endpoint or response failed.""" + + +class OSRMClient: + """Call a managed local or independently operated OSRM endpoint.""" + + def __init__( + self, + base_url: str, + *, + session: requests.Session | None = None, + timeout: tuple[float, float] = (3.0, 30.0), + max_response_bytes: int = MAX_RESPONSE_BYTES, + ) -> None: + self.base_url = base_url.rstrip("/") + self.session = session or requests.Session() + self.timeout = timeout + self.max_response_bytes = max_response_bytes + + def route( + self, + origin: Coordinate, + destination: Coordinate, + *, + profile: TravelProfile = TravelProfile.DRIVING, + ) -> RouteResult: + coordinates = f"{_coordinate(origin)};{_coordinate(destination)}" + payload = self._get( + f"/route/v1/{API_PROFILES[profile]}/{coordinates}", + params={"overview": "false"}, + ) + try: + return RouteResponse.model_validate(payload).to_result() + except ValidationError as error: + raise BoundaryValidationError.from_pydantic( + "OSRM route response", error + ) from error + except ValueError as error: + raise OSRMClientError(str(error)) from error + + def table( + self, + origins: Sequence[Coordinate], + destinations: Sequence[Coordinate], + *, + profile: TravelProfile = TravelProfile.DRIVING, + ) -> TableResult: + if not origins or not destinations: + raise ValueError("OSRM table requires at least one origin and destination.") + all_coordinates = [*origins, *destinations] + coordinates = ";".join(_coordinate(value) for value in all_coordinates) + source_indices = ";".join(str(index) for index in range(len(origins))) + destination_indices = ";".join( + str(index) for index in range(len(origins), len(all_coordinates)) + ) + payload = self._get( + f"/table/v1/{API_PROFILES[profile]}/{coordinates}", + params={ + "annotations": "distance,duration", + "destinations": destination_indices, + "sources": source_indices, + }, + ) + try: + return TableResponse.model_validate(payload).to_result() + except ValidationError as error: + raise BoundaryValidationError.from_pydantic( + "OSRM table response", error + ) from error + except ValueError as error: + raise OSRMClientError(str(error)) from error + + def ready(self, *, profile: TravelProfile = TravelProfile.DRIVING) -> bool: + try: + payload = self._get( + f"/nearest/v1/{API_PROFILES[profile]}/0,0", params={"number": "1"} + ) + response = NearestResponse.model_validate(payload) + except (OSRMClientError, BoundaryValidationError, ValidationError): + return False + return response.code in {"Ok", "NoSegment"} + + def _get(self, path: str, *, params: dict[str, str]) -> Any: + try: + response = self.session.get( + f"{self.base_url}{path}", params=params, timeout=self.timeout + ) + response.raise_for_status() + except requests.RequestException as error: + raise OSRMClientError("OSRM request failed.") from error + if len(response.content) > self.max_response_bytes: + raise OSRMClientError("OSRM response exceeds the configured size limit.") + try: + return response.json() + except requests.JSONDecodeError as error: + raise OSRMClientError("OSRM response is not valid JSON.") from error + + +def _coordinate(value: Coordinate) -> str: + return f"{value.longitude:.8f},{value.latitude:.8f}" + + +__all__ = ["OSRMClient", "OSRMClientError"] diff --git a/urbanpy/routing/osrm_routing.py b/urbanpy/routing/osrm_routing.py deleted file mode 100644 index 549eafb..0000000 --- a/urbanpy/routing/osrm_routing.py +++ /dev/null @@ -1,265 +0,0 @@ -import time -import sys -import requests -import subprocess - -CONTAINER_NAME = "osrm_routing_server" - - -class RoutingServer(object): - def __init__(self, country, continent): - self.country = country - self.continent = continent - self.url = "http://localhost:5000/route/v1/{profile}/{orig};{dest}" - - def __enter__(self): - self.start_osrm_server(self.country, self.continent) - return self - - def __exit__(self, exc_type, exc_value, tb): - self.stop_osrm_server(self.country, self.continent) - - def start_osrm_server(self, country, continent): - """ - Download data for OSRM, process it and start local osrm server - - Parameters - ---------- - - country: str - Which country to download data from. Expected in lower case & dashes replace spaces. - continent: str - Which continent of the given country. Expected in lower case & dashes replace spaces. - - Examples - -------- - - >>> urbanpy.routing.start_osrm_server('peru', 'south-america') - Starting server ... - Server was started succesfully. - - """ - - # Download, process and run server command sequence - dwn_str_unix = f""" - docker pull osrm/osrm-backend; - mkdir -p ~/data/osrm/; - cd ~/data/osrm/; - wget https://download.geofabrik.de/{continent}/{country}-latest.osm.pbf; - docker run -t --name osrm_extract -v $(pwd):/data osrm/osrm-backend osrm-extract -p /opt/foot.lua /data/{country}-latest.osm.pbf; - docker run -t --name osrm_partition -v $(pwd):/data osrm/osrm-backend osrm-partition /data/{country}-latest.osm.pbf; - docker run -t --name osrm_customize -v $(pwd):/data osrm/osrm-backend osrm-customize /data/{country}-latest.osm.pbf; - docker container rm osrm_extract osrm_partition osrm_customize; - docker run -t --name {CONTAINER_NAME}_{continent}_{country} -p 5000:5000 -v $(pwd):/data osrm/osrm-backend osrm-routed --algorithm mld /data/{country}-latest.osm.pbf; - """ - - container_running = self.check_container_is_running( - CONTAINER_NAME + f"_{continent}_{country}" - ) - - # Check platform - if sys.platform in ["darwin", "linux"]: - container_check = [ - "docker", - "inspect", - CONTAINER_NAME + f"_{continent}_{country}", - ] - container_start = [ - "docker", - "start", - CONTAINER_NAME + f"_{continent}_{country}", - ] - download_command = dwn_str_unix - else: - container_check = [ - "powershell.exe", - "docker", - "inspect", - CONTAINER_NAME + f"_{continent}_{country}", - ] - container_start = [ - "powershell.exe", - "docker", - "start", - CONTAINER_NAME + f"_{continent}_{country}", - ] - download_command = [ - "powershell.exe", - "./download_script_windows.ps1", - CONTAINER_NAME, - country, - continent, - ] - - # Check if container exists: - if subprocess.run(container_check).returncode == 0: - if container_running: - print("Server is already running.") - else: - try: - print("Starting server ...") - subprocess.run(container_start, check=True) - time.sleep(5) # Wait server to be prepared to receive requests - print("Server was started succesfully") - except subprocess.CalledProcessError as error: - print( - f"Something went wrong. Please check if port 5000 is being used or check your docker installation.\nError: {error}" - ) - - else: - try: - print( - "This is the first time you used this function.\nInitializing server setup. This may take several minutes..." - ) - subprocess.Popen(download_command, shell=True) - - # Verify container is running - while container_running == False: - container_running = self.check_container_is_running( - CONTAINER_NAME + f"_{continent}_{country}" - ) - - print("Server was started succesfully") - time.sleep(5) # Wait server to be prepared to receive requests - - except subprocess.CalledProcessError as error: - print( - f"Something went wrong. Please check your docker installation.\nError: {error}" - ) - - time.sleep(5) - - def stop_osrm_server(self, country, continent): - """ - Run docker stop on the server's container. - - Parameters - ---------- - - country: str - Which country osrm to stop. Expected in lower case & dashes replace spaces. - continent: str - Continent of the given country. Expected in lower case & dashes replace spaces. - - Examples - -------- - - >>> urbanpy.routing.stop_osrm_server('peru', 'south-america') - Server stopped succesfully - - """ - - if sys.platform in ["darwin", "linux"]: - docker_top = ["docker", "top", CONTAINER_NAME + f"_{continent}_{country}"] - docker_stop = ["docker", "stop", CONTAINER_NAME + f"_{continent}_{country}"] - else: - docker_top = [ - "powershell.exe", - "docker", - "top", - CONTAINER_NAME + f"_{continent}_{country}", - ] - docker_stop = [ - "powershell.exe", - "docker", - "stop", - CONTAINER_NAME + f"_{continent}_{country}", - ] - - # Check if container exists: - if subprocess.run(docker_top).returncode == 0: - if ( - self.check_container_is_running( - CONTAINER_NAME + f"_{continent}_{country}" - ) - == True - ): - try: - subprocess.run(docker_stop, check=True) - # subprocess.run(['docker', 'container', 'rm', 'osrm_routing_server']) - print("Server was stoped succesfully") - except subprocess.CalledProcessError as error: - print( - f"Something went wrong. Please check your docker installation.\nError: {error}" - ) - else: - print("Server is not running.") - - else: - print("Server does not exist.") - - def get_distance(self, origin, destination, profile): - """ - Query an OSRM routing server for routes between an origin and a destination - using a specified profile. - - Parameters - ---------- - - origin: DataFrame with columns x and y or Point geometry - Input origin in lat lon pairs (y, x) to pass into the routing engine - - destination: DataFrame with columns x and y or Point geometry - Input destination in lat lon pairs (y,x) to pass to the routing engine - - profile: str. One of {'foot', 'car', 'bicycle'} - Behavior to use when routing and estimating travel time. - - Returns - ------- - - distance: float - Total travel distance from origin to destination in meters - duration: float - Total travel time in minutes - - """ - orig = f"{origin.x},{origin.y}" - dest = f"{destination.x},{destination.y}" - url = self.url.format(profile=profile, orig=orig, dest=dest) - # url = f'http://localhost:5000/route/v1/{profile}/{orig};{dest}' # Local osrm server - response = requests.get(url, params={"overview": "false"}) - - try: - data = response.json()["routes"][0] - distance, duration = data["distance"], data["duration"] - return distance, duration - except Exception as err: - # print(err) - return None, None - - def check_container_is_running(self, container_name): - """ - Checks if a container is running - - Parameters - ---------- - - container_name: str - Name of container to check - - Returns - ------- - - container_running: bool - True if container is running, False otherwise. - - """ - completed_process = subprocess.run( - ["docker", "ps"], check=True, capture_output=True - ) - stdout_str = completed_process.stdout.decode("utf-8") - container_running = container_name in stdout_str - - return container_running - - -if __name__ == "__main__": - print("Hello") - - from shapely.geometry import Point - - with RoutingServer("peru", "south-america") as server: - orig = Point(-77, -12) - destin = Point(-77.95, -12.43) - print(server.get_distance(orig, destin, "foot")) diff --git a/urbanpy/routing/routing.py b/urbanpy/routing/routing.py index 228e3bf..7a61fc2 100644 --- a/urbanpy/routing/routing.py +++ b/urbanpy/routing/routing.py @@ -1,7 +1,4 @@ -import time -import subprocess -import sys -import pathlib +import warnings import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -15,6 +12,11 @@ from typing import Union, Tuple from rich.progress import Progress +from urbanpy.geofabrik import GeofabrikCatalog +from urbanpy.models import Coordinate, OSRMConfig, TravelProfile +from urbanpy.routing.osrm import OSRMManager +from urbanpy.routing.osrm_client import OSRMClient, OSRMClientError + __all__ = [ "start_osrm_server", "stop_osrm_server", @@ -28,9 +30,6 @@ "isochrone_from_graph", ] -ROUTING_MODUEL_DIR = pathlib.Path(__file__).parent.resolve() -CONTAINER_NAME = "osrm_routing_server" - def _build_session() -> requests.Session: """Module-level session with connection pooling and retry/backoff.""" @@ -50,32 +49,6 @@ def _build_session() -> requests.Session: _SESSION = _build_session() -def check_container_is_running(container_name: str) -> bool: - """ - Checks if a container is running - - Parameters - ---------- - - container_name: str - Name of container to check - - Returns - ------- - - container_running: bool - True if container is running, False otherwise. - - """ - completed_process = subprocess.run( - ["docker", "ps"], capture_output=True, check=True - ) - stdout_str = completed_process.stdout.decode("utf-8") - container_running = container_name in stdout_str - - return container_running - - def start_osrm_server(country: str, continent: str, profile: str) -> None: """ Download data for OSRM, process it and start a local osrm server @@ -101,70 +74,21 @@ def start_osrm_server(country: str, continent: str, profile: str) -> None: """ - container_name = f"{CONTAINER_NAME}_{continent}_{country}_{profile}" - - container_running = check_container_is_running(container_name) - - # Check platform - if sys.platform in ["darwin", "linux"]: - container_check = ["docker", "inspect", container_name] - container_start = ["docker", "start", container_name] - download_command = [ - "bash", - str(pathlib.PosixPath(ROUTING_MODUEL_DIR, "unix_download.sh")), - CONTAINER_NAME, - country, - continent, - profile, - ] - else: - container_check = ["powershell.exe", "docker", "inspect", container_name] - container_start = ["powershell.exe", "docker", "start", container_name] - download_command = [ - "powershell.exe", - str(pathlib.WindowsPath(ROUTING_MODUEL_DIR, "windows_download.ps1")), - CONTAINER_NAME, - country, - continent, - profile, - ] - - # Check if container exists: - if subprocess.run(container_check, capture_output=True).returncode == 0: - if container_running: - print("Server is already running.") - else: - try: - print("Starting server ...") - subprocess.run(container_start, check=True) - time.sleep(5) # Wait server to be prepared to receive requests - print("Server was started succesfully") - except subprocess.CalledProcessError as error: - print( - "Something went wrong. Please check if port 5000 is being used or check your docker installation." - ) - print(f"Error: {error}") - else: - try: - print( - f"This is the first time you initialized a server for {country} on {profile}." - ) - print("Initializing server setup. This may take several minutes ...") - print("To view the detailed logs run the following command from terminal:") - print( - f"$ watch -n 5 tail -20 ~/data/osrm/{continent}/{country}/logs/{profile}.txt" - ) - - subprocess.run(download_command, check=True) - time.sleep(5) # Wait server to be prepared to receive requests - - print("Server was started succesfully") - - except subprocess.CalledProcessError as error: - print( - "Something went wrong. Please check if port 5000 is being used or your docker installation." - ) - print(f"Error: {error}") + warnings.warn( + "start_osrm_server(country, continent, profile) is deprecated; use " + "OSRMManager(OSRMConfig(region_id=...)).start().", + FutureWarning, + stacklevel=2, + ) + catalog = GeofabrikCatalog.fetch(session=_SESSION) + region = catalog.resolve(country) + if region.parent != continent: + raise ValueError( + f"Legacy continent {continent!r} does not match catalog parent " + f"{region.parent!r} for {region.id!r}." + ) + config = OSRMConfig(region_id=region.id, profile=_legacy_profile(profile)) + OSRMManager(config, catalog=catalog, session=_SESSION).start() def stop_osrm_server(country: str, continent: str, profile: str) -> None: @@ -191,32 +115,38 @@ def stop_osrm_server(country: str, continent: str, profile: str) -> None: """ - container_name = f"{CONTAINER_NAME}_{continent}_{country}_{profile}" - - # Check platform - if sys.platform in ["darwin", "linux"]: - docker_top = ["docker", "top", container_name] - docker_stop = ["docker", "stop", container_name] - else: - docker_top = ["powershell.exe", "docker", "top", container_name] - docker_stop = ["powershell.exe", "docker", "stop", container_name] - - # Check if container exists: - if subprocess.run(docker_top, capture_output=True).returncode == 0: - if check_container_is_running(container_name) == True: - try: - subprocess.run(docker_stop, capture_output=True, check=True) - # subprocess.run(['docker', 'container', 'rm', 'osrm_routing_server']) - print("Server was stoped succesfully") - except subprocess.CalledProcessError as error: - print( - f"Something went wrong. Please check your docker installation.\nError: {error}" - ) - else: - print("Server is not running.") - - else: - print("Server does not exist.") + warnings.warn( + "stop_osrm_server(country, continent, profile) is deprecated; use " + "OSRMManager(OSRMConfig(region_id=...)).stop().", + FutureWarning, + stacklevel=2, + ) + catalog = GeofabrikCatalog.fetch(session=_SESSION) + region = catalog.resolve(country) + if region.parent != continent: + raise ValueError( + f"Legacy continent {continent!r} does not match catalog parent " + f"{region.parent!r} for {region.id!r}." + ) + config = OSRMConfig(region_id=region.id, profile=_legacy_profile(profile)) + OSRMManager(config, catalog=catalog, session=_SESSION).stop() + + +def _legacy_profile(profile: str) -> TravelProfile: + aliases = { + "bicycle": TravelProfile.CYCLING, + "car": TravelProfile.DRIVING, + "cycling": TravelProfile.CYCLING, + "driving": TravelProfile.DRIVING, + "foot": TravelProfile.WALKING, + "walking": TravelProfile.WALKING, + } + try: + return aliases[profile.casefold()] + except KeyError as error: + raise ValueError( + "profile must be one of car, bicycle, foot, driving, cycling, walking" + ) from error def osrm_route( @@ -246,26 +176,16 @@ def osrm_route( distance: float Total travel distance from origin to destination in meters duration: float - Total travel time in minutes + Total travel time in seconds (the native OSRM API unit) """ - orig = f"{origin.x},{origin.y}" - dest = f"{destination.x},{destination.y}" - # If "profile" is passed in the url the default profile is used by the local osrm server - url = f"http://localhost:5000/route/v1/profile/{orig};{dest}" - - try: - response = _SESSION.get(url, params={"overview": "false"}) - except requests.exceptions.ConnectionError: - print("Waiting for server to be ready ...") - time.sleep(5) - response = _SESSION.get(url, params={"overview": "false"}) - try: - data = response.json()["routes"][0] - distance, duration = data["distance"], data["duration"] - return distance, duration - except Exception: + result = OSRMClient("http://127.0.0.1:5000", session=_SESSION).route( + Coordinate(longitude=float(origin.x), latitude=float(origin.y)), + Coordinate(longitude=float(destination.x), latitude=float(destination.y)), + ) + return result.distance_m, result.duration_s + except (OSRMClientError, ValueError): return np.nan, np.nan @@ -468,24 +388,21 @@ def compute_osrm_dist_matrix(origins, destinations): n_orig, n_dest = origins.shape[0], destinations.shape[0] orig_points = list(origins.geometry) dest_points = list(destinations.geometry) - coords = ";".join(f"{p.x},{p.y}" for p in orig_points + dest_points) - sources = ";".join(str(i) for i in range(n_orig)) - destinations_idx = ";".join(str(i + n_orig) for i in range(n_dest)) - - url = f"http://localhost:5000/table/v1/profile/{coords}" + client = OSRMClient("http://127.0.0.1:5000", session=_SESSION) try: - response = _SESSION.get( - url, - params={ - "sources": sources, - "destinations": destinations_idx, - "annotations": "distance,duration", - }, + table = client.table( + [ + Coordinate(longitude=float(point.x), latitude=float(point.y)) + for point in orig_points + ], + [ + Coordinate(longitude=float(point.x), latitude=float(point.y)) + for point in dest_points + ], ) - data = response.json() - dist_matrix = np.array(data["distances"], dtype=float) - dur_matrix = np.array(data["durations"], dtype=float) - except Exception: + dist_matrix = np.array(table.distances_m, dtype=float) + dur_matrix = np.array(table.durations_s, dtype=float) + except (OSRMClientError, ValueError): # Fall back to per-pair /route if /table is unavailable (older OSRM builds) dist_matrix = np.full((n_orig, n_dest), np.nan) dur_matrix = np.full((n_orig, n_dest), np.nan) diff --git a/urbanpy/routing/unix_download.sh b/urbanpy/routing/unix_download.sh deleted file mode 100644 index 51f879d..0000000 --- a/urbanpy/routing/unix_download.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -# exit when any command fails -set -e - -# keep track of the last executed command -trap 'last_command=$current_command; current_command=$BASH_COMMAND' DEBUG -# echo an error message before exiting -trap 'if [[ $? -ne 0 ]]; then echo "\"${last_command}\" command failed with exit code $?."; fi' EXIT - -# Download, process and run server command sequence -mkdir -p ~/data/osrm/$3/$2; -cd ~/data/osrm/$3/$2; -mkdir -p logs; -docker pull osrm/osrm-backend > $(pwd)/logs/$4.txt; -# container 1 country 2 continent 3 profile 4 -echo "Downloading osm data from geofabrik ... (1/5)" -wget https://download.geofabrik.de/$3/$2-latest.osm.pbf -a $(pwd)/logs/$4.txt; -echo "Done (1/5)" -echo "Running osrm extract process ... (2/5)" -docker run -t --name osrm_extract -v $(pwd):/data osrm/osrm-backend osrm-extract -p /opt/$4.lua /data/$2-latest.osm.pbf >> $(pwd)/logs/$4.txt ; -echo "Done (2/5)" -echo "Running osrm partition process ... (3/5)" -docker run -t --name osrm_partition -v $(pwd):/data osrm/osrm-backend osrm-partition /data/$2-latest.osm.pbf >> $(pwd)/logs/$4.txt; -echo "Done (3/5)" -echo "Running osrm customize process ... (4/5)" -docker run -t --name osrm_customize -v $(pwd):/data osrm/osrm-backend osrm-customize /data/$2-latest.osm.pbf >> $(pwd)/logs/$4.txt; -echo "Done (4/5)" -echo "Removing osrm processing containers ... (5/5)" -docker container rm osrm_extract osrm_partition osrm_customize >> $(pwd)/logs/$4.txt; -echo "Done (5/5)" -echo "Starting osrm server ..." -CONTAINER_ID=$(docker run -d -t --name $1_$3_$2_$4 -p 5000:5000 -v $(pwd):/data osrm/osrm-backend osrm-routed --algorithm mld /data/$2-latest.osm.pbf); -echo "Docker Container ID: ${CONTAINER_ID}" diff --git a/urbanpy/routing/windows_download.ps1 b/urbanpy/routing/windows_download.ps1 deleted file mode 100644 index aff5558..0000000 --- a/urbanpy/routing/windows_download.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -$container_name = $args[0] -$country = $args[1] -$continent = $args[2] -$profile = $args[3] - -docker pull osrm/osrm-backend; - -if (!(Test-Path -Path \data\osrm\)){ - mkdir \data\osrm\; -} - -Set-Location \data\osrm\; -Invoke-WebRequest -URI https://download.geofabrik.de/$continent/$country-latest.osm.pbf -OutFile $country-latest.osm.pbf; -docker run -t --name osrm_extract -v ${PWD}:/data osrm/osrm-backend osrm-extract -p /opt/$profile.lua /data/$country-latest.osm.pbf; -docker run -t --name osrm_partition -v ${PWD}:/data osrm/osrm-backend osrm-partition /data/$country-latest.osm.pbf; -docker run -t --name osrm_customize -v ${PWD}:/data osrm/osrm-backend osrm-customize /data/$country-latest.osm.pbf; -docker container rm osrm_extract osrm_partition osrm_customize; -docker run -t --name "$($CONTAINER_NAME)_$($continent)_$($country)_$($profile)" -p 5000:5000 -v ${PWD}:/data osrm/osrm-backend osrm-routed --algorithm mld /data/$country-latest.osm.pbf; - -# Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Scope CurrentUser diff --git a/uv.lock b/uv.lock index f3f3ee5..ec622eb 100644 --- a/uv.lock +++ b/uv.lock @@ -3428,6 +3428,7 @@ name = "urbanpy" version = "0.3.0a0" source = { editable = "." } dependencies = [ + { name = "filelock" }, { name = "geopandas" }, { name = "googlemaps" }, { name = "h3" }, @@ -3491,6 +3492,7 @@ test = [ [package.metadata] requires-dist = [ + { name = "filelock", specifier = ">=3.16" }, { name = "geopandas", specifier = ">=1.0" }, { name = "googlemaps", specifier = ">=4.10" }, { name = "h3", specifier = ">=4.1" },