diff --git a/README.md b/README.md index b0c9a07..33eef35 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ The SDK provides both synchronous (`Client`) and asynchronous (`AsyncClient`) ve - [`h3_fast()`](https://docs.traveltime.com/api/reference/h3-fast) - Fast H3 analysis - [`geohash()`](https://docs.traveltime.com/api/reference/geohash) - Geohash analysis - [`geohash_fast()`](https://docs.traveltime.com/api/reference/geohash-fast) - Fast geohash analysis +- [`geohash_fast_proto()`](https://docs.traveltime.com/api/reference/geohash-fast) - Ultra-fast protocol buffer geohash analysis (requires `pip install 'traveltimepy[proto]'`) - [`postcodes()`](https://docs.traveltime.com/api/reference/postcode-search) - UK postcode analysis - [`postcode_districts()`](https://docs.traveltime.com/api/reference/postcode-district-filter) - UK postcode district analysis - [`postcode_sectors()`](https://docs.traveltime.com/api/reference/postcode-sector-filter) - UK postcode sector analysis @@ -317,7 +318,7 @@ See [examples/README.md](examples/README.md) for setup instructions and detailed ## Performance Tips - Use `*_fast()` methods for high-volume use cases -- Use `time_filter_proto()` for maximum performance with large datasets (install with `pip install 'traveltimepy[proto]'`) +- Use `time_filter_proto()` and `geohash_fast_proto()` for maximum performance with large datasets (install with `pip install 'traveltimepy[proto]'`) - Use async methods for I/O-bound applications ## Documentation diff --git a/examples/README.md b/examples/README.md index 0533652..18de4d2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,6 +34,13 @@ Find the 3 closest shops by driving time using the Protocol Buffers API for maxi python examples/time_filter_fast_proto.py ``` +### geohash_fast_proto.py - Geohash Travel Time Analysis +High-performance geohash-based travel time calculation using the Protocol Buffers API. Finds reachable geohash cells within a travel time limit and returns min/max/mean travel times per cell. + +```bash +python examples/geohash_fast_proto.py +``` + ### geocoding.py - London Walking Tour Planner Plan a walking tour of famous London landmarks. Uses geocoding to get coordinates, then calculates total walking time including 30-minute stops at each location. @@ -48,6 +55,7 @@ Each example can be run directly from the project root: ```bash PYTHONPATH=. python examples/time_filter.py PYTHONPATH=. python examples/time_filter_fast_proto.py +PYTHONPATH=. python examples/geohash_fast_proto.py PYTHONPATH=. python examples/geocoding.py ``` diff --git a/examples/geohash_fast_proto.py b/examples/geohash_fast_proto.py new file mode 100644 index 0000000..160c07a --- /dev/null +++ b/examples/geohash_fast_proto.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Geohash travel time analysis using Protocol Buffers API. + +High-performance geohash-based travel time calculation. +""" + +import asyncio +import os +from traveltimepy import AsyncClient +from traveltimepy.requests.common import Coordinates +from traveltimepy.requests.time_filter_proto import ( + ProtoTransportation, + ProtoCountry, + RequestType, +) +from traveltimepy.requests.geohash_fast_proto import ProtoCellProperty + + +async def main(): + app_id = os.environ.get("TRAVELTIME_APP_ID") + api_key = os.environ.get("TRAVELTIME_API_KEY") + + if not app_id or not api_key: + print( + "Error: Please set TRAVELTIME_APP_ID and TRAVELTIME_API_KEY environment variables" + ) + exit(1) + + origin = Coordinates(lat=51.4107, lng=-0.1554) + + async with AsyncClient(app_id, api_key) as client: + response = await client.geohash_fast_proto( + origin_coordinate=origin, + transportation=ProtoTransportation.DRIVING_FERRY, + travel_time=3600, # 1 hour max + request_type=RequestType.ONE_TO_MANY, + country=ProtoCountry.UNITED_KINGDOM, + resolution=4, + properties=[ + ProtoCellProperty.MIN, + ProtoCellProperty.MAX, + ProtoCellProperty.MEAN, + ], + ) + + print(f"Found {len(response.ids)} reachable geohash cells") + for i, cell_id in enumerate(response.ids[:5]): + print( + f" {cell_id}: min={response.min_travel_times[i]}s, " + f"max={response.max_travel_times[i]}s, " + f"mean={response.mean_travel_times[i]}s" + ) + if len(response.ids) > 5: + print(f" ... and {len(response.ids) - 5} more") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/proto/GeohashFastRequest.proto b/proto/GeohashFastRequest.proto new file mode 100644 index 0000000..c254a22 --- /dev/null +++ b/proto/GeohashFastRequest.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package com.igeolise.traveltime.rabbitmq.requests; + +import "RequestsCommon.proto"; + +message GeohashFastRequest { + message OneToMany { + Coords departureLocation = 1; + Transportation transportation = 2; + TimePeriod arrivalTimePeriod = 3; + sint32 travelTime = 4; + uint32 resolution = 5; + repeated CellPropertyType properties = 6; + } + + message ManyToOne { + Coords arrivalLocation = 1; + Transportation transportation = 2; + TimePeriod arrivalTimePeriod = 3; + sint32 travelTime = 4; + uint32 resolution = 5; + repeated CellPropertyType properties = 6; + } + + OneToMany oneToManyRequest = 1; + ManyToOne manyToOneRequest = 2; +} diff --git a/proto/GeohashFastResponse.proto b/proto/GeohashFastResponse.proto new file mode 100644 index 0000000..05cb01d --- /dev/null +++ b/proto/GeohashFastResponse.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package com.igeolise.traveltime.rabbitmq.responses; + +message GeohashFastResponse { + message Cells { + repeated string ids = 1; + repeated sint32 minTravelTimes = 2; + repeated sint32 maxTravelTimes = 3; + repeated sint32 meanTravelTimes = 4; + } + + Cells cells = 1; +} diff --git a/proto/RequestsCommon.proto b/proto/RequestsCommon.proto index 7c7cdb6..e9ab120 100644 --- a/proto/RequestsCommon.proto +++ b/proto/RequestsCommon.proto @@ -25,7 +25,7 @@ message PublicTransportDetails { // travel time limit // // walkingTimeToStation must be 0 > and <= 1800 - // if walkingTimeToStation is not set: use service default + // if walkingTimeToStation is not set: use service default // if walkingTimeToStation > 0: limit the path to at most this value OptionalPositiveUInt32 walkingTimeToStation = 1; } @@ -68,10 +68,10 @@ enum TransportationType { // Considers all paths found by the following steps: // * up to 30 minutes of walking (always included even if no stops found) // * all connections in the 30 minute walking range from public transport - // stops to other public transport stops in travel_time_limit, AND + // stops to other public transport stops in travel_time_limit, AND // * up to 30 minutes of walking from public transport stops that were visited // by public transport (IOW a path - // [origin]--walking->[stop]--walking-->[destination] is not possible but + // [origin]--walking->[stop]--walking-->[destination] is not possible but // [origin]--walking->[stop]--public_transport-->[stop]--walking--> is. PUBLIC_TRANSPORT = 0; // Considers all paths found traveling by car from origin(s) to @@ -83,9 +83,9 @@ enum TransportationType { // to other public transport stops in travel_time_limit, AND // * up to 30 minutes of walking from public transport stops that were visited // by public transport (IOW a path - // [origin]--driving->[stop]--walking-->[destination] is not possible but + // [origin]--driving->[stop]--walking-->[destination] is not possible but // [origin]--driving->[stop]--public_transport-->[stop]--walking--> is. - // AND/OR + // AND/OR // * up to 30 minutes of walking // DRIVING_AND_PUBLIC_TRANSPORT = 2; @@ -133,4 +133,4 @@ message OptionalPositiveUInt32 { // be verified by the server message OptionalNonNegativeUInt32 { uint32 value = 1; -} \ No newline at end of file +} diff --git a/proto/TimeFilterFastRequest.proto b/proto/TimeFilterFastRequest.proto index 45be0bb..7056021 100644 --- a/proto/TimeFilterFastRequest.proto +++ b/proto/TimeFilterFastRequest.proto @@ -55,4 +55,4 @@ message TimeFilterFastRequest { OneToMany oneToManyRequest = 1; ManyToOne manyToOneRequest = 2; -} \ No newline at end of file +} diff --git a/proto/TimeFilterFastResponse.proto b/proto/TimeFilterFastResponse.proto index 4ddcdcc..f08a500 100644 --- a/proto/TimeFilterFastResponse.proto +++ b/proto/TimeFilterFastResponse.proto @@ -10,89 +10,139 @@ message TimeFilterFastResponse { } message Error { - ErrorType type = 1; + ErrorType type = 1; } enum ErrorType { - /* - * Catch all unknown error type - */ - UNKNOWN = 0 [deprecated = true]; - /* - * oneToManyRequest to many field must not be null - */ - ONE_TO_MANY_MUST_NOT_BE_NULL = 1 [deprecated = true]; - /* - * Source (either departure or arrival location) must not be null - */ - SOURCE_MUST_NOT_BE_NULL = 2 [deprecated = true]; - /* - * Transportation mode must not be null. - */ - TRANSPORTATION_MUST_NOT_BE_NULL = 3 [deprecated = true]; - /* - * Source (either departure or arrival location) must not be null - */ - SOURCE_NOT_IN_GEOMETRY = 4 [deprecated = true]; - - /* - * Transportation mode unrecognized. - */ - UNRECOGNIZED_TRANSPORTATION_MODE = 5 [deprecated = true]; - - /* - * The travel time limit is too low to process this request. - */ - TRAVEL_TIME_LIMIT_TOO_LOW = 6 [deprecated = true]; - - /* - * The travel time limit is too high to process this request. - */ - TRAVEL_TIME_LIMIT_TOO_HIGH = 7 [deprecated = true]; - - /* - * User id not set. - */ - AUTH_ERROR_NO_USER_ID = 8 [deprecated = true]; - - /* - * Message sent to wrong queue - transportation mode cannot be handled. - */ - SERVICE_MISMATCH_WRONG_TRANSPORTATION_MODE = 9 [deprecated = true]; - - /* - * Source is in a area that doesn't have any points that can be out of - * search e.g a lake, mountains or other desolate areas. - */ - SOURCE_OUT_OF_REACH = 10 [deprecated = true]; - - /* - * The interleaved deltas array should have (lat/lon) deltas and have an - * even number of elements - */ - INTERLEAVED_DELTAS_INVALID_COORDINATE_PAIRS = 11 [deprecated = true]; - - /* - * Public transport requests do not support returning distances for - * returned points. - */ - DISTANCE_PROPERTY_NOT_SUPPORTED = 12 [deprecated = true]; - - /* - * ManyToOne and OneToMany cannot be sent at the same time - */ - BOTH_MANY_TO_ONE_AND_ONE_TO_MANY_CANNOT_BE_SENT = 13 [deprecated = true]; - - /* - * ManyToOne or OneToMany cannot be null - */ - ONE_TO_MANY_OR_MANY_TO_ONE_MUST_NOT_BE_NULL = 14 [deprecated = true]; - /* - * Invalid proto request - */ - INVALID_PROTO_REQUEST = 15 [deprecated = true]; + /* + * Catch all unknown error type + */ + UNKNOWN = 0 [deprecated = true]; + /* + * oneToManyRequest to many field must not be null + */ + ONE_TO_MANY_MUST_NOT_BE_NULL = 1 [deprecated = true]; + /* + * Source (either departure or arrival location) must not be null + */ + SOURCE_MUST_NOT_BE_NULL = 2 [deprecated = true]; + /* + * Transportation mode must not be null. + */ + TRANSPORTATION_MUST_NOT_BE_NULL = 3 [deprecated = true]; + /* + * Source (either departure or arrival location) must not be null + */ + SOURCE_NOT_IN_GEOMETRY = 4 [deprecated = true]; + + /* + * Transportation mode unrecognized. + */ + UNRECOGNIZED_TRANSPORTATION_MODE = 5 [deprecated = true]; + + /* + * The travel time limit is too low to process this request. + */ + TRAVEL_TIME_LIMIT_TOO_LOW = 6 [deprecated = true]; + + /* + * The travel time limit is too high to process this request. + */ + TRAVEL_TIME_LIMIT_TOO_HIGH = 7 [deprecated = true]; + + /* + * User id not set. + */ + AUTH_ERROR_NO_USER_ID = 8 [deprecated = true]; + + /* + * Message sent to wrong queue - transportation mode cannot be handled. + */ + SERVICE_MISMATCH_WRONG_TRANSPORTATION_MODE = 9 [deprecated = true]; + + /* + * Source is in a area that doesn't have any points that can be out of + * search e.g a lake, mountains or other desolate areas. + */ + SOURCE_OUT_OF_REACH = 10 [deprecated = true]; + + /* + * The interleaved deltas array should have (lat/lon) deltas and have an + * even number of elements + */ + INTERLEAVED_DELTAS_INVALID_COORDINATE_PAIRS = 11 [deprecated = true]; + + /* + * Public transport requests do not support returning distances for + * returned points. + */ + DISTANCE_PROPERTY_NOT_SUPPORTED = 12 [deprecated = true]; + + /* + * ManyToOne and OneToMany cannot be sent at the same time + */ + BOTH_MANY_TO_ONE_AND_ONE_TO_MANY_CANNOT_BE_SENT = 13 [deprecated = true]; + + /* + * ManyToOne or OneToMany cannot be null + */ + ONE_TO_MANY_OR_MANY_TO_ONE_MUST_NOT_BE_NULL = 14 [deprecated = true]; + /* + * Invalid proto request + */ + INVALID_PROTO_REQUEST = 15 [deprecated = true]; + + /* + * The user has gone over their request limit rate + */ + TOO_MANY_REQUESTS = 16 [deprecated = true]; + + /* + * Parking time penalty is over the overall travel time limit for the journey + */ + PARKING_TIME_OVER_TRAVEL_TIME_LIMIT = 17 [deprecated = true]; + + /* + * Walk to station penalty must be > 0 + */ + WALK_TO_STATION_MUST_BE_POSITIVE = 18 [deprecated = true]; + + /* + * Walk to station limit cannot be greater than the global travel time + * limit for the journey. + */ + WALK_TO_STATION_OVER_TRAVEL_TIME_LIMIT = 19 [deprecated = true]; + + /* + * Walk to station limit cannot be greater than 1800 + */ + WALK_TO_STATION_TOO_HIGH = 20 [deprecated = true]; + + /* + * Drive to station penalty must be > 0 + */ + DRIVE_TO_STATION_MUST_BE_POSITIVE = 21 [deprecated = true]; + + /* + * Drive to station limit cannot be greater than the global travel time + * limit for the journey. + */ + DRIVE_TO_STATION_OVER_TRAVEL_TIME_LIMIT = 22 [deprecated = true]; + + /* + * Drive to station limit cannot be greater than the global travel time + * limit for the journey. + */ + DRIVE_TO_STATION_TOO_HIGH = 23 [deprecated = true]; + + /* + * Transportation details do not match mode. See + * * RequestsCommon.PublicTransportDetails + * * RequestsCommon.DrivingAndPublicTransportDetails + */ + TRANSPORTATION_DETAILS_MISMATCH = 24 [deprecated = true]; } Error error = 1 [deprecated = true]; Properties properties = 2; -} \ No newline at end of file +} diff --git a/pyproject.toml b/pyproject.toml index 7b61c3a..7cdbb99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,8 +56,10 @@ zip-safe = false include-package-data = true py-modules = [ "RequestsCommon_pb2", - "TimeFilterFastRequest_pb2", - "TimeFilterFastResponse_pb2" + "TimeFilterFastRequest_pb2", + "TimeFilterFastResponse_pb2", + "GeohashFastRequest_pb2", + "GeohashFastResponse_pb2", ] packages = { find = {} } diff --git a/scripts/generate_async_client.py b/scripts/generate_async_client.py index a5b21e3..e5c7e98 100755 --- a/scripts/generate_async_client.py +++ b/scripts/generate_async_client.py @@ -61,7 +61,7 @@ def generate_async_client(content: str) -> str: # 4. Add await to API calls content = re.sub( - r"(\s+)return (self\._api_call_(?:post|get|proto)\()", + r"(\s+)return (self\._api_call_(?:post|get|proto|geohash_proto)\()", r"\1return await \2", content, ) diff --git a/tests/geohash_fast_proto_test.py b/tests/geohash_fast_proto_test.py new file mode 100644 index 0000000..1f76a88 --- /dev/null +++ b/tests/geohash_fast_proto_test.py @@ -0,0 +1,152 @@ +import pytest + +from traveltimepy.requests.common import Coordinates +from traveltimepy.async_client import AsyncClient +from traveltimepy.client import Client +from traveltimepy.requests.time_filter_proto import ( + ProtoDrivingAndPublicTransportWithDetails, + ProtoTransportation, + ProtoCountry, + ProtoPublicTransportWithDetails, + RequestType, +) +from traveltimepy.requests.geohash_fast_proto import ProtoCellProperty + + +@pytest.mark.asyncio +async def test_one_to_many(async_client: AsyncClient): + response = await async_client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoTransportation.DRIVING_FERRY, + travel_time=900, + request_type=RequestType.ONE_TO_MANY, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + assert len(response.mean_travel_times) == len(response.ids) + + +@pytest.mark.asyncio +async def test_many_to_one(async_client: AsyncClient): + response = await async_client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoTransportation.DRIVING_FERRY, + travel_time=900, + request_type=RequestType.MANY_TO_ONE, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + assert len(response.mean_travel_times) == len(response.ids) + + +@pytest.mark.asyncio +async def test_all_properties(async_client: AsyncClient): + response = await async_client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoTransportation.DRIVING_FERRY, + travel_time=900, + request_type=RequestType.ONE_TO_MANY, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ + ProtoCellProperty.MIN, + ProtoCellProperty.MAX, + ProtoCellProperty.MEAN, + ], + ) + assert len(response.ids) > 0 + assert len(response.min_travel_times) == len(response.ids) + assert len(response.max_travel_times) == len(response.ids) + assert len(response.mean_travel_times) == len(response.ids) + + +@pytest.mark.asyncio +async def test_one_to_many_pt_with_params(async_client: AsyncClient): + response = await async_client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoPublicTransportWithDetails(walking_time_to_station=900), + travel_time=900, + request_type=RequestType.ONE_TO_MANY, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + + +@pytest.mark.asyncio +async def test_many_to_one_pt_with_params(async_client: AsyncClient): + response = await async_client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoPublicTransportWithDetails(walking_time_to_station=900), + travel_time=900, + request_type=RequestType.MANY_TO_ONE, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + + +@pytest.mark.asyncio +async def test_one_to_many_driving_and_pt_with_params(async_client: AsyncClient): + response = await async_client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoDrivingAndPublicTransportWithDetails( + walking_time_to_station=900, driving_time_to_station=900, parking_time=300 + ), + travel_time=900, + request_type=RequestType.ONE_TO_MANY, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + + +@pytest.mark.asyncio +async def test_many_to_one_driving_and_pt_with_params(async_client: AsyncClient): + response = await async_client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoDrivingAndPublicTransportWithDetails( + walking_time_to_station=900, driving_time_to_station=900, parking_time=300 + ), + travel_time=900, + request_type=RequestType.MANY_TO_ONE, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + + +def test_one_to_many_sync(client: Client): + response = client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoTransportation.DRIVING_FERRY, + travel_time=900, + request_type=RequestType.ONE_TO_MANY, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + assert len(response.mean_travel_times) == len(response.ids) + + +def test_many_to_one_sync(client: Client): + response = client.geohash_fast_proto( + origin_coordinate=Coordinates(lat=51.425709, lng=-0.122061), + transportation=ProtoTransportation.DRIVING_FERRY, + travel_time=900, + request_type=RequestType.MANY_TO_ONE, + country=ProtoCountry.UNITED_KINGDOM, + resolution=6, + properties=[ProtoCellProperty.MEAN], + ) + assert len(response.ids) > 0 + assert len(response.mean_travel_times) == len(response.ids) diff --git a/traveltimepy/async_base_client.py b/traveltimepy/async_base_client.py index 554c298..816c669 100644 --- a/traveltimepy/async_base_client.py +++ b/traveltimepy/async_base_client.py @@ -15,25 +15,29 @@ try: import TimeFilterFastResponse_pb2 # type: ignore + import GeohashFastResponse_pb2 # type: ignore PROTOBUF_AVAILABLE = True except ImportError: PROTOBUF_AVAILABLE = False TimeFilterFastResponse_pb2 = None # type: ignore + GeohashFastResponse_pb2 = None # type: ignore from traveltimepy.accept_type import AcceptType from traveltimepy.base_client import BaseClient, __version__ from traveltimepy.errors import ( TravelTimeJsonError, - TravelTimeProtoError, TravelTimeServerError, ) from traveltimepy.requests.request import TravelTimeRequest from traveltimepy.requests.time_filter_proto import ( TimeFilterFastProtoRequest, - ProtoTransportation, +) +from traveltimepy.requests.geohash_fast_proto import ( + GeohashFastProtoRequest, ) from traveltimepy.responses.error import ResponseError from traveltimepy.responses.time_filter_proto import TimeFilterProtoResponse +from traveltimepy.responses.geohash_fast_proto import GeohashFastProtoResponse T = TypeVar("T", bound=BaseModel) @@ -188,10 +192,7 @@ async def _api_call_proto( async def _make_proto_request(): session = await self._get_session() async with self.async_limiter: - if isinstance(req.transportation, ProtoTransportation): - transportation_mode = req.transportation.value.name - else: - transportation_mode = req.transportation.TYPE.value.name + transportation_mode = self._get_transportation_mode(req.transportation) async with session.post( url=f"https://{self._proto_host}/api/v3/{req.country.value}/time-filter/fast/{transportation_mode}", @@ -201,21 +202,7 @@ async def _make_proto_request(): ) as response: content = await response.read() if response.status != 200: - if response.status >= 500: - raise TravelTimeServerError("Internal server error") - else: - raise TravelTimeProtoError( - status_code=response.status, - error_code=response.headers.get( - "X-ERROR-CODE", "Unknown" - ), - error_details=response.headers.get( - "X-ERROR-DETAILS", "No details provided" - ), - error_message=response.headers.get( - "X-ERROR-MESSAGE", "No message provided" - ), - ) + self._handle_proto_error(response.status, response.headers) else: response_body = ( TimeFilterFastResponse_pb2.TimeFilterFastResponse() # type: ignore @@ -228,6 +215,50 @@ async def _make_proto_request(): return await _make_proto_request() + async def _api_call_geohash_proto( + self, req: GeohashFastProtoRequest + ) -> GeohashFastProtoResponse: + if not PROTOBUF_AVAILABLE: + raise ImportError( + "protobuf is required for proto API calls. " + "Install it with: pip install 'traveltimepy[proto]'" + ) + + @retry( + retry=retry_if_exception_type(TravelTimeServerError), + stop=stop_after_attempt( + self.retry_attempts + 1 + ), # First attempt is not a retry, that's why `+1` + wait=wait_none(), # No wait between retries + ) + async def _make_geohash_proto_request(): + session = await self._get_session() + async with self.async_limiter: + transportation_mode = self._get_transportation_mode(req.transportation) + + async with session.post( + url=f"https://{self._proto_host}/api/v3/{req.country.value}/geohash/fast/{transportation_mode}", + headers=self._get_proto_headers(), + data=req.get_request().SerializeToString(), + auth=BasicAuth(self.app_id, self.api_key), + ) as response: + content = await response.read() + if response.status != 200: + self._handle_proto_error(response.status, response.headers) + else: + response_body = ( + GeohashFastResponse_pb2.GeohashFastResponse() # type: ignore + ) + response_body.ParseFromString(content) + return GeohashFastProtoResponse( + ids=response_body.cells.ids[:], + min_travel_times=response_body.cells.minTravelTimes[:], + max_travel_times=response_body.cells.maxTravelTimes[:], + mean_travel_times=response_body.cells.meanTravelTimes[:], + ) + + return await _make_geohash_proto_request() + async def _handle_response( self, response: ClientResponse, response_class: Type[T] ) -> T: diff --git a/traveltimepy/async_client.py b/traveltimepy/async_client.py index b85a515..fbe48d8 100644 --- a/traveltimepy/async_client.py +++ b/traveltimepy/async_client.py @@ -82,6 +82,11 @@ RequestType, ProtoCountry, ) +from traveltimepy.requests.geohash_fast_proto import ( + GeohashFastProtoRequest, + GeohashFastProtoTransportation, + ProtoCellProperty, +) from traveltimepy.requests.time_map import ( TimeMapDepartureSearch, TimeMapArrivalSearch, @@ -108,6 +113,7 @@ TimeFilterFastResponse, ) from traveltimepy.responses.time_filter_proto import TimeFilterProtoResponse +from traveltimepy.responses.geohash_fast_proto import GeohashFastProtoResponse from traveltimepy.responses.time_map import TimeMapResponse from traveltimepy.responses.time_map_wkt import TimeMapWKTResponse from traveltimepy.responses.zones import ( @@ -220,6 +226,45 @@ async def time_filter_fast_proto( ) ) + async def geohash_fast_proto( + self, + origin_coordinate: Coordinates, + transportation: GeohashFastProtoTransportation, + travel_time: int, + request_type: RequestType, + country: ProtoCountry, + resolution: int, + properties: List[ProtoCellProperty], + ) -> GeohashFastProtoResponse: + """Calculate travel times to geohash cells using Protocol Buffers. + + High-performance endpoint using protobuf format for geohash-based + travel time calculations. + + Args: + origin_coordinate: Single origin coordinate (lat/lng) + transportation: Transportation mode + travel_time: Maximum journey time in seconds + request_type: Type of request calculation + country: Specific country for the calculation + resolution: Geohash resolution level + properties: Statistical properties to calculate (min, max, mean) + + Returns: + GeohashFastProtoResponse: Response with geohash cell IDs and travel time statistics. + """ + return await self._api_call_geohash_proto( + GeohashFastProtoRequest( + origin_coordinate, + transportation, + travel_time, + request_type, + country, + resolution, + properties, + ) + ) + async def map_info(self) -> List[Map]: res: MapInfoResponse = await self._api_call_get( MapInfoResponse, "map-info", AcceptType.JSON, None @@ -590,7 +635,7 @@ async def h3_fast( """Calculate travel times to H3 cells within travel time catchment areas. High-performance endpoint that returns min/max/mean travel times for H3 hexagonal - cells based on arrival searches. + cells based on arrival searches with support for unions and intersections. Args: arrival_searches: Search configurations with arrival points and transportation methods. diff --git a/traveltimepy/base_client.py b/traveltimepy/base_client.py index 59c01e9..887fbf6 100644 --- a/traveltimepy/base_client.py +++ b/traveltimepy/base_client.py @@ -1,15 +1,21 @@ from abc import ABC, abstractmethod from importlib.metadata import version, PackageNotFoundError -from typing import Optional, Dict, TypeVar, Type, Union, Coroutine, Any +from typing import Optional, Dict, Mapping, TypeVar, Type, Union, Coroutine, Any from pydantic import BaseModel from traveltimepy.accept_type import AcceptType +from traveltimepy.errors import TravelTimeProtoError, TravelTimeServerError from traveltimepy.requests.request import TravelTimeRequest from traveltimepy.requests.time_filter_proto import ( TimeFilterFastProtoRequest, + ProtoTransportation, +) +from traveltimepy.requests.geohash_fast_proto import ( + GeohashFastProtoRequest, ) from traveltimepy.responses.time_filter_proto import TimeFilterProtoResponse +from traveltimepy.responses.geohash_fast_proto import GeohashFastProtoResponse T = TypeVar("T", bound=BaseModel) @@ -63,6 +69,27 @@ def _get_proto_headers(self) -> Dict[str, str]: "User-Agent": f"Travel Time Python SDK {__version__}", } + @staticmethod + def _get_transportation_mode( + transportation: Union[ProtoTransportation, Any], + ) -> str: + if isinstance(transportation, ProtoTransportation): + return transportation.value.name + else: + return transportation.TYPE.value.name + + @staticmethod + def _handle_proto_error(status_code: int, headers: Mapping[str, str]) -> None: + if status_code >= 500: + raise TravelTimeServerError("Internal server error") + else: + raise TravelTimeProtoError( + status_code=status_code, + error_code=headers.get("X-ERROR-CODE", "Unknown"), + error_details=headers.get("X-ERROR-DETAILS", "No details provided"), + error_message=headers.get("X-ERROR-MESSAGE", "No message provided"), + ) + @abstractmethod def _api_call_post( self, @@ -88,3 +115,9 @@ def _api_call_proto( self, req: TimeFilterFastProtoRequest ) -> Union[TimeFilterProtoResponse, Coroutine[Any, Any, TimeFilterProtoResponse]]: pass + + @abstractmethod + def _api_call_geohash_proto( + self, req: GeohashFastProtoRequest + ) -> Union[GeohashFastProtoResponse, Coroutine[Any, Any, GeohashFastProtoResponse]]: + pass diff --git a/traveltimepy/client.py b/traveltimepy/client.py index d8b9823..9471f44 100644 --- a/traveltimepy/client.py +++ b/traveltimepy/client.py @@ -78,6 +78,11 @@ RequestType, ProtoCountry, ) +from traveltimepy.requests.geohash_fast_proto import ( + GeohashFastProtoRequest, + GeohashFastProtoTransportation, + ProtoCellProperty, +) from traveltimepy.requests.time_map import ( TimeMapDepartureSearch, TimeMapArrivalSearch, @@ -104,6 +109,7 @@ TimeFilterFastResponse, ) from traveltimepy.responses.time_filter_proto import TimeFilterProtoResponse +from traveltimepy.responses.geohash_fast_proto import GeohashFastProtoResponse from traveltimepy.responses.time_map import TimeMapResponse from traveltimepy.responses.time_map_wkt import TimeMapWKTResponse from traveltimepy.responses.zones import ( @@ -216,6 +222,45 @@ def time_filter_fast_proto( ) ) + def geohash_fast_proto( + self, + origin_coordinate: Coordinates, + transportation: GeohashFastProtoTransportation, + travel_time: int, + request_type: RequestType, + country: ProtoCountry, + resolution: int, + properties: List[ProtoCellProperty], + ) -> GeohashFastProtoResponse: + """Calculate travel times to geohash cells using Protocol Buffers. + + High-performance endpoint using protobuf format for geohash-based + travel time calculations. + + Args: + origin_coordinate: Single origin coordinate (lat/lng) + transportation: Transportation mode + travel_time: Maximum journey time in seconds + request_type: Type of request calculation + country: Specific country for the calculation + resolution: Geohash resolution level + properties: Statistical properties to calculate (min, max, mean) + + Returns: + GeohashFastProtoResponse: Response with geohash cell IDs and travel time statistics. + """ + return self._api_call_geohash_proto( + GeohashFastProtoRequest( + origin_coordinate, + transportation, + travel_time, + request_type, + country, + resolution, + properties, + ) + ) + def map_info(self) -> List[Map]: res: MapInfoResponse = self._api_call_get( MapInfoResponse, "map-info", AcceptType.JSON, None diff --git a/traveltimepy/requests/geohash_fast_proto.py b/traveltimepy/requests/geohash_fast_proto.py new file mode 100644 index 0000000..12071bc --- /dev/null +++ b/traveltimepy/requests/geohash_fast_proto.py @@ -0,0 +1,118 @@ +from enum import Enum +from typing import List, Union + +try: + import RequestsCommon_pb2 # type: ignore + import GeohashFastRequest_pb2 # type: ignore + + PROTOBUF_AVAILABLE = True +except ImportError: + PROTOBUF_AVAILABLE = False + RequestsCommon_pb2 = None # type: ignore + GeohashFastRequest_pb2 = None # type: ignore + +from traveltimepy.requests.common import Coordinates +from traveltimepy.requests.time_filter_proto import ( + ProtoTransportation, + ProtoPublicTransportWithDetails, + ProtoDrivingAndPublicTransportWithDetails, + RequestType, + ProtoCountry, +) + + +class ProtoCellProperty(Enum): + MEAN = 0 + MIN = 1 + MAX = 2 + + +GeohashFastProtoTransportation = Union[ + ProtoTransportation, + ProtoPublicTransportWithDetails, + ProtoDrivingAndPublicTransportWithDetails, +] + + +class GeohashFastProtoRequest: + originCoordinate: Coordinates + transportation: GeohashFastProtoTransportation + travelTime: int + requestType: RequestType + country: ProtoCountry + resolution: int + properties: List[ProtoCellProperty] + + def __init__( + self, + origin_coordinate: Coordinates, + transportation: GeohashFastProtoTransportation, + travel_time: int, + request_type: RequestType, + country: ProtoCountry, + resolution: int, + properties: List[ProtoCellProperty], + ): + self.originCoordinate = origin_coordinate + self.transportation = transportation + self.travelTime = travel_time + self.requestType = request_type + self.country = country + self.resolution = resolution + self.properties = properties + + def get_request(self) -> "GeohashFastRequest_pb2.GeohashFastRequest": # type: ignore + if not PROTOBUF_AVAILABLE: + raise ImportError( + "protobuf is required for GeohashFastProtoRequest. " + "Install it with: pip install 'traveltimepy[proto]'" + ) + request = GeohashFastRequest_pb2.GeohashFastRequest() # type: ignore + + if self.requestType == RequestType.ONE_TO_MANY: + req = request.oneToManyRequest + req.departureLocation.lat = self.originCoordinate.lat + req.departureLocation.lng = self.originCoordinate.lng + else: + req = request.manyToOneRequest + req.arrivalLocation.lat = self.originCoordinate.lat + req.arrivalLocation.lng = self.originCoordinate.lng + + # Set transportation type + if isinstance(self.transportation, ProtoTransportation): + req.transportation.type = self.transportation.value.code + else: + req.transportation.type = self.transportation.TYPE.value.code + + if isinstance(self.transportation, ProtoPublicTransportWithDetails): + if self.transportation.walking_time_to_station is not None: + req.transportation.publicTransport.walkingTimeToStation.value = ( + self.transportation.walking_time_to_station + ) + + elif isinstance( + self.transportation, ProtoDrivingAndPublicTransportWithDetails + ): + if self.transportation.walking_time_to_station is not None: + req.transportation.drivingAndPublicTransport.walkingTimeToStation.value = ( + self.transportation.walking_time_to_station + ) + + if self.transportation.driving_time_to_station is not None: + req.transportation.drivingAndPublicTransport.drivingTimeToStation.value = ( + self.transportation.driving_time_to_station + ) + + if self.transportation.parking_time is not None: + req.transportation.drivingAndPublicTransport.parkingTime.value = ( + self.transportation.parking_time + ) + + req.travelTime = self.travelTime + req.arrivalTimePeriod = RequestsCommon_pb2.TimePeriod.WEEKDAY_MORNING # type: ignore + req.resolution = self.resolution + + for prop in self.properties: + req.properties.append(prop.value) + + return request diff --git a/traveltimepy/responses/geohash_fast_proto.py b/traveltimepy/responses/geohash_fast_proto.py new file mode 100644 index 0000000..4d5fe95 --- /dev/null +++ b/traveltimepy/responses/geohash_fast_proto.py @@ -0,0 +1,18 @@ +from typing import List + +from pydantic import BaseModel + + +class GeohashFastProtoResponse(BaseModel): + """ + Attributes: + ids: List of geohash cell IDs. + min_travel_times: List of minimum travel times in seconds for each cell. + max_travel_times: List of maximum travel times in seconds for each cell. + mean_travel_times: List of mean travel times in seconds for each cell. + """ + + ids: List[str] + min_travel_times: List[int] + max_travel_times: List[int] + mean_travel_times: List[int] diff --git a/traveltimepy/sync_base_client.py b/traveltimepy/sync_base_client.py index 081dad5..50f621b 100644 --- a/traveltimepy/sync_base_client.py +++ b/traveltimepy/sync_base_client.py @@ -16,25 +16,29 @@ try: import TimeFilterFastResponse_pb2 # type: ignore + import GeohashFastResponse_pb2 # type: ignore PROTOBUF_AVAILABLE = True except ImportError: PROTOBUF_AVAILABLE = False TimeFilterFastResponse_pb2 = None # type: ignore + GeohashFastResponse_pb2 = None # type: ignore from traveltimepy.accept_type import AcceptType from traveltimepy.base_client import BaseClient, __version__ from traveltimepy.errors import ( TravelTimeJsonError, - TravelTimeProtoError, TravelTimeServerError, ) from traveltimepy.requests.request import TravelTimeRequest from traveltimepy.requests.time_filter_proto import ( TimeFilterFastProtoRequest, - ProtoTransportation, +) +from traveltimepy.requests.geohash_fast_proto import ( + GeohashFastProtoRequest, ) from traveltimepy.responses.error import ResponseError from traveltimepy.responses.time_filter_proto import TimeFilterProtoResponse +from traveltimepy.responses.geohash_fast_proto import GeohashFastProtoResponse T = TypeVar("T", bound=BaseModel) @@ -228,10 +232,7 @@ def _api_call_proto( wait=wait_none(), # No wait between retries ) def _make_proto_request(): - if isinstance(req.transportation, ProtoTransportation): - transportation_mode = req.transportation.value.name - else: - transportation_mode = req.transportation.TYPE.value.name + transportation_mode = self._get_transportation_mode(req.transportation) url = f"https://{self._proto_host}/api/v3/{req.country.value}/time-filter/fast/{transportation_mode}" headers = self._get_proto_headers() @@ -248,19 +249,7 @@ def _make_proto_request(): ) if response.status_code != 200: - if response.status_code >= 500: - raise TravelTimeServerError("Internal server error") - else: - raise TravelTimeProtoError( - status_code=response.status_code, - error_code=response.headers.get("X-ERROR-CODE", "Unknown"), - error_details=response.headers.get( - "X-ERROR-DETAILS", "No details provided" - ), - error_message=response.headers.get( - "X-ERROR-MESSAGE", "No message provided" - ), - ) + self._handle_proto_error(response.status_code, response.headers) else: response_body = TimeFilterFastResponse_pb2.TimeFilterFastResponse() # type: ignore response_body.ParseFromString(response.content) @@ -271,6 +260,53 @@ def _make_proto_request(): return _make_proto_request() + def _api_call_geohash_proto( + self, req: GeohashFastProtoRequest + ) -> GeohashFastProtoResponse: + if not PROTOBUF_AVAILABLE: + raise ImportError( + "protobuf is required for proto API calls. " + "Install it with: pip install 'traveltimepy[proto]'" + ) + + @retry( + retry=retry_if_exception_type(TravelTimeServerError), + stop=stop_after_attempt( + self.retry_attempts + 1 + ), # First attempt is not a retry, that's why `+1` + wait=wait_none(), # No wait between retries + ) + def _make_geohash_proto_request(): + transportation_mode = self._get_transportation_mode(req.transportation) + + url = f"https://{self._proto_host}/api/v3/{req.country.value}/geohash/fast/{transportation_mode}" + headers = self._get_proto_headers() + auth = HTTPBasicAuth(self.app_id, self.api_key) + data = req.get_request().SerializeToString() + + response = self._session.post( + url=url, + headers=headers, + data=data, + auth=auth, + timeout=self.timeout, + verify=self.use_ssl, + ) + + if response.status_code != 200: + self._handle_proto_error(response.status_code, response.headers) + else: + response_body = GeohashFastResponse_pb2.GeohashFastResponse() # type: ignore + response_body.ParseFromString(response.content) + return GeohashFastProtoResponse( + ids=response_body.cells.ids[:], + min_travel_times=response_body.cells.minTravelTimes[:], + max_travel_times=response_body.cells.maxTravelTimes[:], + mean_travel_times=response_body.cells.meanTravelTimes[:], + ) + + return _make_geohash_proto_request() + def _handle_response( self, response: requests.Response, response_class: Type[T] ) -> T: