diff --git a/README.md b/README.md index cd36e04..d02746f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Features - Forward geocoding of single addresses or in batches (up to 10,000 lookups). - Reverse geocoding of coordinates (single or batch). - Append additional data fields (e.g. congressional districts, timezone, census data). +- Distance calculations (single origin to multiple destinations, distance matrices). +- Async distance matrix jobs for large calculations. - Automatic parsing of address components. - Simple exception handling for authentication, data, and server errors. @@ -21,6 +23,8 @@ Install via pip: Usage ----- +> Don't have an API key yet? Sign up at [https://dash.geocod.io](https://dash.geocod.io) to get an API key. The first 2,500 lookups per day are free. + ### Geocoding ```python @@ -28,6 +32,7 @@ from geocodio import Geocodio # Initialize the client with your API key client = Geocodio("YOUR_API_KEY") +# client = Geocodio("YOUR_API_KEY", hostname="api-hipaa.geocod.io") # optionally overwrite the API hostname # Single forward geocode response = client.geocode("1600 Pennsylvania Ave, Washington, DC") @@ -46,12 +51,408 @@ for result in batch_response.results: rev = client.reverse("38.9002898,-76.9990361") print(rev.results[0].formatted_address) -# Append additional fields -data = client.geocode( - "1600 Pennsylvania Ave, Washington, DC", +# Reverse with tuple coordinates +rev = client.reverse((38.9002898, -76.9990361)) +``` + +> Note: You can read more about accuracy scores, accuracy types, input formats and more at https://www.geocod.io/docs/ + +### Batch geocoding + +To batch geocode, simply pass a list of addresses or coordinates instead of a single string: + +```python +response = client.geocode([ + "1109 N Highland St, Arlington VA", + "525 University Ave, Toronto, ON, Canada", + "4410 S Highway 17 92, Casselberry FL", + "15000 NE 24th Street, Redmond WA", + "17015 Walnut Grove Drive, Morgan Hill CA" +]) + +response = client.reverse([ + "35.9746000,-77.9658000", + "32.8793700,-96.6303900", + "33.8337100,-117.8362320", + "35.4171240,-80.6784760" +]) + +# Optionally supply a custom key that will be returned along with results +response = client.geocode({ + "MyId1": "1109 N Highland St, Arlington VA", + "MyId2": "525 University Ave, Toronto, ON, Canada", + "MyId3": "4410 S Highway 17 92, Casselberry FL", + "MyId4": "15000 NE 24th Street, Redmond WA", + "MyId5": "17015 Walnut Grove Drive, Morgan Hill CA" +}) +``` + +### Field appends + +Geocodio allows you to append additional data points such as congressional districts, census codes, timezone, ACS survey results and [much more](https://www.geocod.io/docs/#fields). + +To request additional fields, simply supply them as a list: + +```python +response = client.geocode( + [ + "1109 N Highland St, Arlington VA", + "525 University Ave, Toronto, ON, Canada" + ], fields=["cd", "timezone"] ) -print(data.results[0].fields.timezone.name if data.results[0].fields.timezone else "No timezone data") + +response = client.reverse("38.9002898,-76.9990361", fields=["census2010"]) +``` + +### Address components + +For forward geocoding requests it is possible to supply [individual address components](https://www.geocod.io/docs/#single-address) instead of a full address string: + +```python +response = client.geocode({ + "street": "1109 N Highland St", + "city": "Arlington", + "state": "VA", + "postal_code": "22201" +}) + +response = client.geocode([ + { + "street": "1109 N Highland St", + "city": "Arlington", + "state": "VA" + }, + { + "street": "525 University Ave", + "city": "Toronto", + "state": "ON", + "country": "Canada" + } +]) +``` + +### Limit results + +Optionally limit the number of maximum geocoding results: + +```python +# Only get the first result +response = client.geocode("1109 N Highland St, Arlington, VA", limit=1) + +# Return up to 5 geocoding results +response = client.reverse("38.9002898,-76.9990361", fields=["timezone"], limit=5) +``` + +### Distance calculations + +Calculate distances from a single origin to multiple destinations, or compute full distance matrices. + +#### Coordinate format with custom IDs + +You can add custom identifiers to coordinates using the `lat,lng,id` format. The ID will be returned in the response, making it easy to match results back to your data: + +```python +from geocodio import Coordinate + +# String format with ID +"37.7749,-122.4194,warehouse_1" + +# Tuple format with ID +(37.7749, -122.4194, "warehouse_1") + +# Using the Coordinate class +Coordinate(37.7749, -122.4194, "warehouse_1") + +# The ID is returned in the response: +# DistanceDestination( +# query="37.7749,-122.4194,warehouse_1", +# location=(37.7749, -122.4194), +# id="warehouse_1", +# distance_miles=3.2, +# distance_km=5.1 +# ) +``` + +#### Distance mode and units + +The SDK provides constants for type-safe distance configuration: + +```python +from geocodio import ( + DISTANCE_MODE_STRAIGHTLINE, # Default - great-circle (as the crow flies) + DISTANCE_MODE_DRIVING, # Road network routing with duration + DISTANCE_MODE_HAVERSINE, # Alias for Straightline (backward compat) + DISTANCE_UNITS_MILES, # Default + DISTANCE_UNITS_KM, + DISTANCE_ORDER_BY_DISTANCE, # Default + DISTANCE_ORDER_BY_DURATION, + DISTANCE_SORT_ASC, # Default + DISTANCE_SORT_DESC, +) +``` + +> **Note:** The default mode is `straightline` (great-circle distance). Use `DISTANCE_MODE_DRIVING` if you need road network routing with duration estimates. + +#### Add distance to geocoding requests + +You can add distance calculations to existing geocode or reverse geocode requests. Each geocoded result will include distance data to each destination. + +```python +from geocodio import ( + Geocodio, + DISTANCE_MODE_DRIVING, + DISTANCE_UNITS_MILES, + DISTANCE_ORDER_BY_DISTANCE, + DISTANCE_SORT_ASC, +) + +client = Geocodio("YOUR_API_KEY") + +# Geocode an address and calculate distances to store locations +response = client.geocode( + "1600 Pennsylvania Ave NW, Washington DC", + destinations=[ + "38.9072,-77.0369,store_dc", + "39.2904,-76.6122,store_baltimore", + "39.9526,-75.1652,store_philly" + ], + distance_mode=DISTANCE_MODE_DRIVING, + distance_units=DISTANCE_UNITS_MILES +) + +# Reverse geocode with distances +response = client.reverse( + "38.8977,-77.0365", + destinations=["38.9072,-77.0369,capitol", "38.8895,-77.0353,monument"], + distance_mode=DISTANCE_MODE_STRAIGHTLINE +) + +# With filtering - find nearest 3 stores within 50 miles +response = client.geocode( + "1600 Pennsylvania Ave NW, Washington DC", + destinations=[ + "38.9072,-77.0369,store_1", + "39.2904,-76.6122,store_2", + "39.9526,-75.1652,store_3", + "40.7128,-74.0060,store_4" + ], + distance_mode=DISTANCE_MODE_DRIVING, + distance_max_results=3, + distance_max_distance=50.0, + distance_order_by=DISTANCE_ORDER_BY_DISTANCE, + distance_sort_order=DISTANCE_SORT_ASC +) +``` + +#### Single origin to multiple destinations + +```python +from geocodio import ( + Geocodio, + Coordinate, + DISTANCE_MODE_DRIVING, + DISTANCE_UNITS_KM, + DISTANCE_ORDER_BY_DISTANCE, + DISTANCE_SORT_ASC, +) + +client = Geocodio("YOUR_API_KEY") + +# Calculate distances from one origin to multiple destinations +response = client.distance( + origin="37.7749,-122.4194,headquarters", # Origin with ID + destinations=[ + "37.7849,-122.4094,customer_a", + "37.7949,-122.3994,customer_b", + "37.8049,-122.4294,customer_c" + ] +) + +print(response.origin.id) # "headquarters" +for dest in response.destinations: + print(f"{dest.id}: {dest.distance_miles} miles") + +# Use driving mode for road network routing (includes duration) +response = client.distance( + origin="37.7749,-122.4194", + destinations=["37.7849,-122.4094"], + mode=DISTANCE_MODE_DRIVING +) +print(response.destinations[0].duration_seconds) # e.g., 180 + +# With all filtering and sorting options +response = client.distance( + origin="37.7749,-122.4194,warehouse", + destinations=[ + "37.7849,-122.4094,store_1", + "37.7949,-122.3994,store_2", + "37.8049,-122.4294,store_3" + ], + mode=DISTANCE_MODE_DRIVING, + units=DISTANCE_UNITS_KM, + max_results=2, + max_distance=10.0, + order_by=DISTANCE_ORDER_BY_DISTANCE, + sort_order=DISTANCE_SORT_ASC +) + +# Using Coordinate class +origin = Coordinate(37.7749, -122.4194, "warehouse") +destinations = [ + Coordinate(37.7849, -122.4094, "store_1"), + Coordinate(37.7949, -122.3994, "store_2") +] +response = client.distance(origin=origin, destinations=destinations) + +# Tuple format for coordinates (with or without ID) +response = client.distance( + origin=(37.7749, -122.4194), # Without ID + destinations=[(37.7849, -122.4094, "dest_1")] # With ID as third element +) +``` + +#### Distance matrix (multiple origins × destinations) + +```python +from geocodio import Geocodio, Coordinate, DISTANCE_MODE_DRIVING, DISTANCE_UNITS_KM + +client = Geocodio("YOUR_API_KEY") + +# Calculate full distance matrix with custom IDs +response = client.distance_matrix( + origins=[ + "37.7749,-122.4194,warehouse_sf", + "37.8049,-122.4294,warehouse_oak" + ], + destinations=[ + "37.7849,-122.4094,customer_1", + "37.7949,-122.3994,customer_2" + ] +) + +for result in response.results: + print(f"From {result.origin.id}:") + for dest in result.destinations: + print(f" To {dest.id}: {dest.distance_miles} miles") + +# With driving mode and kilometers +response = client.distance_matrix( + origins=["37.7749,-122.4194"], + destinations=["37.7849,-122.4094"], + mode=DISTANCE_MODE_DRIVING, + units=DISTANCE_UNITS_KM +) + +# Using Coordinate objects +origins = [ + Coordinate(37.7749, -122.4194, "warehouse_sf"), + Coordinate(37.8049, -122.4294, "warehouse_oak") +] +destinations = [ + Coordinate(37.7849, -122.4094, "customer_1"), + Coordinate(37.7949, -122.3994, "customer_2") +] +response = client.distance_matrix(origins=origins, destinations=destinations) +``` + +#### Nearest mode (find closest destinations) + +```python +# Find up to 2 nearest destinations from each origin +response = client.distance_matrix( + origins=["37.7749,-122.4194"], + destinations=["37.7849,-122.4094", "37.7949,-122.3994", "37.8049,-122.4294"], + max_results=2 +) + +# Filter by maximum distance (in miles or km depending on units) +response = client.distance_matrix( + origins=["37.7749,-122.4194"], + destinations=[...], + max_distance=2.0 +) + +# Filter by minimum and maximum distance +response = client.distance_matrix( + origins=["37.7749,-122.4194"], + destinations=[...], + min_distance=1.0, + max_distance=10.0 +) + +# Filter by duration (seconds, driving mode only) +response = client.distance_matrix( + origins=["37.7749,-122.4194"], + destinations=[...], + mode=DISTANCE_MODE_DRIVING, + max_duration=300, # 5 minutes + min_duration=60 # 1 minute minimum +) + +# Sort by duration descending +response = client.distance_matrix( + origins=["37.7749,-122.4194"], + destinations=[...], + mode=DISTANCE_MODE_DRIVING, + max_results=5, + order_by=DISTANCE_ORDER_BY_DURATION, + sort_order=DISTANCE_SORT_DESC +) +``` + +#### Async distance matrix jobs + +For large distance matrix calculations, use async jobs that process in the background. + +```python +from geocodio import Geocodio, DISTANCE_MODE_DRIVING, DISTANCE_UNITS_MILES + +client = Geocodio("YOUR_API_KEY") + +# Create a new distance matrix job +job = client.create_distance_matrix_job( + name="My Distance Calculation", + origins=["37.7749,-122.4194", "37.8049,-122.4294"], + destinations=["37.7849,-122.4094", "37.7949,-122.3994"], + mode=DISTANCE_MODE_DRIVING, + units=DISTANCE_UNITS_MILES, + callback_url="https://example.com/webhook" # Optional +) + +print(job.id) # Job identifier +print(job.status) # "ENQUEUED" +print(job.total_calculations) # 4 + +# Or use list IDs from previously uploaded lists +job = client.create_distance_matrix_job( + name="Distance from List", + origins=12345, # List ID + destinations=67890, # List ID + mode=DISTANCE_MODE_STRAIGHTLINE +) + +# Check job status +status = client.distance_matrix_job_status(job.id) +print(status.status) # "ENQUEUED", "PROCESSING", "COMPLETED", or "FAILED" +print(status.progress) # 0-100 + +# List all jobs (paginated) +jobs = client.distance_matrix_jobs() +jobs = client.distance_matrix_jobs(page=2) # Page 2 + +# Get results when complete (same format as distance_matrix response) +results = client.get_distance_matrix_job_results(job.id) +for result in results.results: + print(f"From {result.origin.id}:") + for dest in result.destinations: + print(f" To {dest.id}: {dest.distance_miles} miles") + +# Or download to a file for very large results +client.download_distance_matrix_job(job.id, "results.json") + +# Delete a job +client.delete_distance_matrix_job(job.id) ``` ### List API @@ -61,7 +462,6 @@ The List API allows you to manage lists of addresses or coordinates for batch pr ```python from geocodio import Geocodio -# Initialize the client with your API key client = Geocodio("YOUR_API_KEY") # Get all lists @@ -129,67 +529,37 @@ response = client.geocode("1600 Pennsylvania Ave, Washington, DC") print(response.results[0].formatted_address) ``` +Testing +------- + +```bash +$ pip install -e ".[dev]" +$ pytest +``` + Documentation ------------- Full documentation is available at . -Contributing ------------- +Changelog +--------- -Contributions are welcome! Please open issues and pull requests on GitHub. +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. -Issues: +Security +-------- + +If you discover any security related issues, please email security@geocod.io instead of using the issue tracker. License ------- This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. -Development Installation ------------------------ - -1. Clone the repository: - ```bash - git clone https://github.com/geocodio/geocodio-library-python.git - cd geocodio-library-python - ``` - -2. Create and activate a virtual environment: - ```bash - python -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate - ``` - -3. Install development dependencies: - ```bash - pip install -e . - pip install -r requirements-dev.txt - ``` - -CI & Publishing ---------------- - -- CI runs unit tests and linting on every push. E2E tests run if `GEOCODIO_API_KEY` is set as a secret. -- PyPI publishing workflow supports both TestPyPI and PyPI. See `.github/workflows/publish.yml`. -- Use `test_pypi_release.py` for local packaging and dry-run upload. - -### Testing GitHub Actions Workflows - -The project includes tests for GitHub Actions workflows using `act` for local development: - -```bash -# Test all workflows (requires act and Docker) -pytest tests/test_workflows.py - -# Test specific workflow -pytest tests/test_workflows.py::test_ci_workflow -pytest tests/test_workflows.py::test_publish_workflow -``` +Contributing +------------ -**Prerequisites:** -- Install [act](https://github.com/nektos/act) for local GitHub Actions testing -- Docker must be running -- For publish workflow tests: Set `TEST_PYPI_API_TOKEN` environment variable +Contributions are welcome! Please open issues and pull requests on GitHub. -**Note:** Workflow tests are automatically skipped in CI environments. +Issues: diff --git a/src/geocodio/__init__.py b/src/geocodio/__init__.py index 2edbb7c..49a0ddf 100644 --- a/src/geocodio/__init__.py +++ b/src/geocodio/__init__.py @@ -6,4 +6,50 @@ from ._version import __version__ from .client import Geocodio -__all__ = ["Geocodio", "__version__"] +# Distance API exports +from .distance import ( + Coordinate, + DISTANCE_MODE_STRAIGHTLINE, + DISTANCE_MODE_DRIVING, + DISTANCE_MODE_HAVERSINE, + DISTANCE_UNITS_MILES, + DISTANCE_UNITS_KM, + DISTANCE_ORDER_BY_DISTANCE, + DISTANCE_ORDER_BY_DURATION, + DISTANCE_SORT_ASC, + DISTANCE_SORT_DESC, +) +from .models import ( + DistanceResponse, + DistanceMatrixResponse, + DistanceDestination, + DistanceOrigin, + DistanceJobResponse, + DistanceMatrixResult, +) + +__all__ = [ + "Geocodio", + "__version__", + # Distance types + "Coordinate", + "DistanceResponse", + "DistanceMatrixResponse", + "DistanceDestination", + "DistanceOrigin", + "DistanceJobResponse", + "DistanceMatrixResult", + # Distance mode constants + "DISTANCE_MODE_STRAIGHTLINE", + "DISTANCE_MODE_DRIVING", + "DISTANCE_MODE_HAVERSINE", + # Distance unit constants + "DISTANCE_UNITS_MILES", + "DISTANCE_UNITS_KM", + # Distance order by constants + "DISTANCE_ORDER_BY_DISTANCE", + "DISTANCE_ORDER_BY_DURATION", + # Distance sort constants + "DISTANCE_SORT_ASC", + "DISTANCE_SORT_DESC", +] diff --git a/src/geocodio/client.py b/src/geocodio/client.py index 1e0c37e..e38fde5 100644 --- a/src/geocodio/client.py +++ b/src/geocodio/client.py @@ -23,7 +23,21 @@ CensusData, ACSSurveyData, StateLegislativeDistrict, SchoolDistrict, Demographics, Economics, Families, Housing, Social, FederalRiding, ProvincialRiding, StatisticsCanadaData, ListResponse, PaginatedResponse, - ZIP4Data, FFIECData + ZIP4Data, FFIECData, + DistanceResponse, DistanceMatrixResponse, DistanceJobResponse, +) +from geocodio.distance import ( + Coordinate, + DISTANCE_MODE_STRAIGHTLINE, + DISTANCE_MODE_DRIVING, + DISTANCE_MODE_HAVERSINE, + DISTANCE_UNITS_MILES, + DISTANCE_UNITS_KM, + DISTANCE_ORDER_BY_DISTANCE, + DISTANCE_ORDER_BY_DURATION, + DISTANCE_SORT_ASC, + DISTANCE_SORT_DESC, + normalize_distance_mode, ) from geocodio.exceptions import InvalidRequestError, AuthenticationError, GeocodioServerError, BadRequestError @@ -57,6 +71,7 @@ def __init__( single_timeout: Optional[float] = None, batch_timeout: Optional[float] = None, list_timeout: Optional[float] = None, + verify_ssl: bool = True, ): self.api_key: str = api_key or os.getenv("GEOCODIO_API_KEY", "") if not self.api_key: @@ -67,7 +82,7 @@ def __init__( self.single_timeout = single_timeout or self.DEFAULT_SINGLE_TIMEOUT self.batch_timeout = batch_timeout or self.DEFAULT_BATCH_TIMEOUT self.list_timeout = list_timeout or self.LIST_API_TIMEOUT - self._http = httpx.Client(base_url=f"https://{self.hostname}") + self._http = httpx.Client(base_url=f"https://{self.hostname}", verify=verify_ssl) # ────────────────────────────────────────────────────────────────────────── # Public methods @@ -80,8 +95,19 @@ def geocode( fields: Optional[List[str]] = None, limit: Optional[int] = None, country: Optional[str] = None, + # Distance parameters + destinations: Optional[List[Union[str, Tuple[float, float], "Coordinate"]]] = None, + distance_mode: Optional[str] = None, + distance_units: Optional[str] = None, + distance_max_results: Optional[int] = None, + distance_max_distance: Optional[float] = None, + distance_max_duration: Optional[int] = None, + distance_min_distance: Optional[float] = None, + distance_min_duration: Optional[int] = None, + distance_order_by: Optional[str] = None, + distance_sort_order: Optional[str] = None, ) -> GeocodingResponse: - params: Dict[str, Union[str, int]] = {} + params: Dict[str, Union[str, int, List[str]]] = {} if fields: params["fields"] = ",".join(fields) if limit: @@ -89,6 +115,32 @@ def geocode( if country: params["country"] = country + # Add distance parameters if destinations provided + if destinations: + dest_strs = [ + self._coordinate_to_string(self._normalize_coordinate(d)) + for d in destinations + ] + params["destinations[]"] = dest_strs + if distance_mode: + params["distance_mode"] = normalize_distance_mode(distance_mode) + if distance_units: + params["distance_units"] = distance_units + if distance_max_results is not None: + params["distance_max_results"] = distance_max_results + if distance_max_distance is not None: + params["distance_max_distance"] = distance_max_distance + if distance_max_duration is not None: + params["distance_max_duration"] = distance_max_duration + if distance_min_distance is not None: + params["distance_min_distance"] = distance_min_distance + if distance_min_duration is not None: + params["distance_min_duration"] = distance_min_duration + if distance_order_by: + params["distance_order_by"] = distance_order_by + if distance_sort_order: + params["distance_sort"] = distance_sort_order + endpoint: str data: Union[List, Dict] | None @@ -134,13 +186,50 @@ def reverse( coordinate: Union[str, Tuple[float, float], List[Union[str, Tuple[float, float]]]], fields: Optional[List[str]] = None, limit: Optional[int] = None, + # Distance parameters + destinations: Optional[List[Union[str, Tuple[float, float], "Coordinate"]]] = None, + distance_mode: Optional[str] = None, + distance_units: Optional[str] = None, + distance_max_results: Optional[int] = None, + distance_max_distance: Optional[float] = None, + distance_max_duration: Optional[int] = None, + distance_min_distance: Optional[float] = None, + distance_min_duration: Optional[int] = None, + distance_order_by: Optional[str] = None, + distance_sort_order: Optional[str] = None, ) -> GeocodingResponse: - params: Dict[str, Union[str, int]] = {} + params: Dict[str, Union[str, int, List[str]]] = {} if fields: params["fields"] = ",".join(fields) if limit: params["limit"] = int(limit) + # Add distance parameters if destinations provided + if destinations: + dest_strs = [ + self._coordinate_to_string(self._normalize_coordinate(d)) + for d in destinations + ] + params["destinations[]"] = dest_strs + if distance_mode: + params["distance_mode"] = normalize_distance_mode(distance_mode) + if distance_units: + params["distance_units"] = distance_units + if distance_max_results is not None: + params["distance_max_results"] = distance_max_results + if distance_max_distance is not None: + params["distance_max_distance"] = distance_max_distance + if distance_max_duration is not None: + params["distance_max_duration"] = distance_max_duration + if distance_min_distance is not None: + params["distance_min_distance"] = distance_min_distance + if distance_min_duration is not None: + params["distance_min_duration"] = distance_min_duration + if distance_order_by: + params["distance_order_by"] = distance_order_by + if distance_sort_order: + params["distance_sort"] = distance_sort_order + endpoint: str data: Union[List[str], None] @@ -713,3 +802,417 @@ def download(self, list_id: str, filename: Optional[str] = None) -> str | bytes: raise GeocodioServerError(f"Failed to save list: {e}") else: # return the bytes content directly return response.content + + # ────────────────────────────────────────────────────────────────────────── + # Distance API methods + # ────────────────────────────────────────────────────────────────────────── + + def _normalize_coordinate( + self, + coord: Union[str, Tuple[float, float], Dict, "Coordinate"], + ) -> "Coordinate": + """Convert various input formats to Coordinate object.""" + return Coordinate.from_input(coord) + + def _coordinate_to_string(self, coord: "Coordinate") -> str: + """Convert Coordinate to string for GET requests: 'lat,lng' or 'lat,lng,id'.""" + return coord.to_string() + + def _coordinate_to_dict(self, coord: "Coordinate") -> Dict: + """Convert Coordinate to dict for POST requests.""" + return coord.to_dict() + + def distance( + self, + origin: Union[str, Tuple[float, float], "Coordinate"], + destinations: List[Union[str, Tuple[float, float], "Coordinate"]], + mode: str = DISTANCE_MODE_STRAIGHTLINE, + units: str = DISTANCE_UNITS_MILES, + max_results: Optional[int] = None, + max_distance: Optional[float] = None, + max_duration: Optional[int] = None, + min_distance: Optional[float] = None, + min_duration: Optional[int] = None, + order_by: str = DISTANCE_ORDER_BY_DISTANCE, + sort_order: str = DISTANCE_SORT_ASC, + ) -> DistanceResponse: + """ + Calculate distance from single origin to multiple destinations. + + Uses GET request with coordinates as query parameters. + + Args: + origin: The origin coordinate (string, tuple, or Coordinate). + destinations: List of destination coordinates. + mode: Distance calculation mode ('straightline' or 'driving'). + units: Distance units ('miles' or 'kilometers'). + max_results: Maximum number of results to return. + max_distance: Maximum distance filter. + max_duration: Maximum duration filter (seconds, driving mode only). + min_distance: Minimum distance filter. + min_duration: Minimum duration filter (seconds, driving mode only). + order_by: Sort results by 'distance' or 'duration'. + sort_order: Sort direction ('asc' or 'desc'). + + Returns: + DistanceResponse with origin and calculated destinations. + + Example: + >>> response = client.distance( + ... origin="38.8977,-77.0365,white_house", + ... destinations=["38.9072,-77.0369,capitol", "38.8895,-77.0353,monument"], + ... mode="straightline", + ... units="miles" + ... ) + >>> print(response.destinations[0].distance_miles) + """ + endpoint = f"{self.BASE_PATH}/distance" + + # Normalize and convert origin to string + origin_coord = self._normalize_coordinate(origin) + origin_str = self._coordinate_to_string(origin_coord) + + # Normalize and convert destinations to strings + dest_strs = [ + self._coordinate_to_string(self._normalize_coordinate(d)) + for d in destinations + ] + + # Build params + params: Dict[str, Union[str, int, float, List[str]]] = { + "origin": origin_str, + "destinations[]": dest_strs, + "mode": normalize_distance_mode(mode), + "units": units, + } + + # Add optional filter parameters + if max_results is not None: + params["max_results"] = max_results + if max_distance is not None: + params["max_distance"] = max_distance + if max_duration is not None: + params["max_duration"] = max_duration + if min_distance is not None: + params["min_distance"] = min_distance + if min_duration is not None: + params["min_duration"] = min_duration + if order_by != DISTANCE_ORDER_BY_DISTANCE: + params["order_by"] = order_by + if sort_order != DISTANCE_SORT_ASC: + params["sort"] = sort_order + + response = self._request("GET", endpoint, params, timeout=self.single_timeout) + return DistanceResponse.from_api(response.json()) + + def distance_matrix( + self, + origins: List[Union[str, Tuple[float, float], "Coordinate"]], + destinations: List[Union[str, Tuple[float, float], "Coordinate"]], + mode: str = DISTANCE_MODE_STRAIGHTLINE, + units: str = DISTANCE_UNITS_MILES, + max_results: Optional[int] = None, + max_distance: Optional[float] = None, + max_duration: Optional[int] = None, + min_distance: Optional[float] = None, + min_duration: Optional[int] = None, + order_by: str = DISTANCE_ORDER_BY_DISTANCE, + sort_order: str = DISTANCE_SORT_ASC, + ) -> DistanceMatrixResponse: + """ + Calculate distance matrix (multiple origins × destinations). + + Uses POST request with coordinates as objects in JSON body. + + Args: + origins: List of origin coordinates. + destinations: List of destination coordinates. + mode: Distance calculation mode ('straightline' or 'driving'). + units: Distance units ('miles' or 'kilometers'). + max_results: Maximum number of results to return per origin. + max_distance: Maximum distance filter. + max_duration: Maximum duration filter (seconds, driving mode only). + min_distance: Minimum distance filter. + min_duration: Minimum duration filter (seconds, driving mode only). + order_by: Sort results by 'distance' or 'duration'. + sort_order: Sort direction ('asc' or 'desc'). + + Returns: + DistanceMatrixResponse with results for each origin. + + Example: + >>> response = client.distance_matrix( + ... origins=[(38.8977, -77.0365), (38.9072, -77.0369)], + ... destinations=[(38.8895, -77.0353), (39.2904, -76.6122)], + ... mode="driving" + ... ) + >>> print(response.results[0].destinations[0].distance_miles) + """ + endpoint = f"{self.BASE_PATH}/distance-matrix" + + # Normalize and convert origins to dicts for POST + origin_dicts = [ + self._coordinate_to_dict(self._normalize_coordinate(o)) + for o in origins + ] + + # Normalize and convert destinations to dicts for POST + dest_dicts = [ + self._coordinate_to_dict(self._normalize_coordinate(d)) + for d in destinations + ] + + # Build request body + body: Dict[str, Union[str, int, float, List[Dict]]] = { + "origins": origin_dicts, + "destinations": dest_dicts, + "mode": normalize_distance_mode(mode), + "units": units, + } + + # Add optional filter parameters + if max_results is not None: + body["max_results"] = max_results + if max_distance is not None: + body["max_distance"] = max_distance + if max_duration is not None: + body["max_duration"] = max_duration + if min_distance is not None: + body["min_distance"] = min_distance + if min_duration is not None: + body["min_duration"] = min_duration + if order_by != DISTANCE_ORDER_BY_DISTANCE: + body["order_by"] = order_by + if sort_order != DISTANCE_SORT_ASC: + body["sort"] = sort_order + + response = self._request("POST", endpoint, json=body, timeout=self.batch_timeout) + return DistanceMatrixResponse.from_api(response.json()) + + def create_distance_matrix_job( + self, + name: str, + origins: Union[List[Union[str, Tuple[float, float], "Coordinate"]], int], + destinations: Union[List[Union[str, Tuple[float, float], "Coordinate"]], int], + mode: str = DISTANCE_MODE_STRAIGHTLINE, + units: str = DISTANCE_UNITS_MILES, + callback_url: Optional[str] = None, + max_results: Optional[int] = None, + max_distance: Optional[float] = None, + max_duration: Optional[int] = None, + min_distance: Optional[float] = None, + min_duration: Optional[int] = None, + order_by: str = DISTANCE_ORDER_BY_DISTANCE, + sort_order: str = DISTANCE_SORT_ASC, + ) -> DistanceJobResponse: + """ + Create an async distance matrix job for large calculations. + + Args: + name: User-defined name for the job. + origins: List of coordinates OR integer list ID. + destinations: List of coordinates OR integer list ID. + mode: Distance calculation mode ('straightline' or 'driving'). + units: Distance units ('miles' or 'kilometers'). + callback_url: Optional URL to call when processing completes. + max_results: Maximum number of results to return per origin. + max_distance: Maximum distance filter. + max_duration: Maximum duration filter (seconds, driving mode only). + min_distance: Minimum distance filter. + min_duration: Minimum duration filter (seconds, driving mode only). + order_by: Sort results by 'distance' or 'duration'. + sort_order: Sort direction ('asc' or 'desc'). + + Returns: + DistanceJobResponse with job ID and status. + + Example: + >>> job = client.create_distance_matrix_job( + ... name="My Calculation", + ... origins=[(38.8977, -77.0365), (38.9072, -77.0369)], + ... destinations=[(38.8895, -77.0353)], + ... mode="driving" + ... ) + >>> print(job.id, job.status) + """ + endpoint = f"{self.BASE_PATH}/distance-jobs" + + # Handle origins - either list of coordinates or list ID + if isinstance(origins, int): + origins_data = origins + else: + origins_data = [ + self._coordinate_to_dict(self._normalize_coordinate(o)) + for o in origins + ] + + # Handle destinations - either list of coordinates or list ID + if isinstance(destinations, int): + destinations_data = destinations + else: + destinations_data = [ + self._coordinate_to_dict(self._normalize_coordinate(d)) + for d in destinations + ] + + # Build request body + body: Dict[str, Union[str, int, float, List[Dict]]] = { + "name": name, + "origins": origins_data, + "destinations": destinations_data, + "mode": normalize_distance_mode(mode), + "units": units, + } + + # Add optional parameters + if callback_url: + body["callback_url"] = callback_url + if max_results is not None: + body["max_results"] = max_results + if max_distance is not None: + body["max_distance"] = max_distance + if max_duration is not None: + body["max_duration"] = max_duration + if min_distance is not None: + body["min_distance"] = min_distance + if min_duration is not None: + body["min_duration"] = min_duration + if order_by != DISTANCE_ORDER_BY_DISTANCE: + body["order_by"] = order_by + if sort_order != DISTANCE_SORT_ASC: + body["sort"] = sort_order + + response = self._request("POST", endpoint, json=body, timeout=self.list_timeout) + return DistanceJobResponse.from_api(response.json()) + + def distance_matrix_job_status(self, job_id: Union[str, int]) -> DistanceJobResponse: + """ + Get the status of a distance matrix job. + + Args: + job_id: The job ID (integer or string). + + Returns: + DistanceJobResponse with current status and progress. + + Example: + >>> status = client.distance_matrix_job_status(123) + >>> print(status.status, status.progress) + """ + endpoint = f"{self.BASE_PATH}/distance-jobs/{job_id}" + response = self._request("GET", endpoint, timeout=self.list_timeout) + return DistanceJobResponse.from_api(response.json()) + + def distance_matrix_jobs(self, page: int = 1) -> PaginatedResponse: + """ + List all distance matrix jobs. + + Args: + page: Page number for pagination. + + Returns: + PaginatedResponse containing list of DistanceJobResponse objects. + """ + endpoint = f"{self.BASE_PATH}/distance-jobs" + params: Dict[str, int] = {} + if page > 1: + params["page"] = page + + response = self._request("GET", endpoint, params, timeout=self.list_timeout) + pagination_info = response.json() + + job_responses = [ + DistanceJobResponse.from_api(job_data) + for job_data in pagination_info.get("data", []) + ] + + # Reuse PaginatedResponse but with job data + return PaginatedResponse( + data=job_responses, # type: ignore + current_page=pagination_info.get("current_page", 1), + from_=pagination_info.get("from", 0), + to=pagination_info.get("to", 0), + path=pagination_info.get("path", ""), + per_page=pagination_info.get("per_page", 10), + first_page_url=pagination_info.get("first_page_url"), + next_page_url=pagination_info.get("next_page_url"), + prev_page_url=pagination_info.get("prev_page_url"), + ) + + def get_distance_matrix_job_results( + self, job_id: Union[str, int] + ) -> DistanceMatrixResponse: + """ + Download and parse distance matrix job results. + + Args: + job_id: The job ID (integer or string). + + Returns: + DistanceMatrixResponse with all calculated distances. + + Raises: + GeocodioServerError: If the job is not complete or failed. + + Example: + >>> results = client.get_distance_matrix_job_results(123) + >>> for result in results.results: + ... print(result.origin.id, result.destinations[0].distance_miles) + """ + endpoint = f"{self.BASE_PATH}/distance-jobs/{job_id}/download" + response = self._request("GET", endpoint, timeout=self.list_timeout) + + # Check if response is JSON (success) or error + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + return DistanceMatrixResponse.from_api(response.json()) + else: + raise GeocodioServerError( + f"Unexpected response format: {content_type}. " + f"Job may not be complete." + ) + + def download_distance_matrix_job( + self, job_id: Union[str, int], filename: str + ) -> str: + """ + Download distance matrix job results to a file. + + Args: + job_id: The job ID (integer or string). + filename: Path to save the results file. + + Returns: + The absolute path to the saved file. + + Raises: + GeocodioServerError: If the job is not complete or download fails. + """ + endpoint = f"{self.BASE_PATH}/distance-jobs/{job_id}/download" + response = self._request("GET", endpoint, timeout=self.list_timeout) + + # Get absolute path + if not os.path.isabs(filename): + filename = os.path.abspath(filename) + + # Ensure directory exists + os.makedirs(os.path.dirname(filename), exist_ok=True) + + try: + with open(filename, "wb") as f: + f.write(response.content) + logger.info(f"Distance job {job_id} downloaded to {filename}") + return filename + except IOError as e: + logger.error(f"Failed to save distance job {job_id} to {filename}: {e}") + raise GeocodioServerError(f"Failed to save distance job: {e}") + + def delete_distance_matrix_job(self, job_id: Union[str, int]) -> None: + """ + Delete a distance matrix job. + + Args: + job_id: The job ID (integer or string). + """ + endpoint = f"{self.BASE_PATH}/distance-jobs/{job_id}" + self._request("DELETE", endpoint, timeout=self.list_timeout) diff --git a/src/geocodio/distance.py b/src/geocodio/distance.py new file mode 100644 index 0000000..ef3f316 --- /dev/null +++ b/src/geocodio/distance.py @@ -0,0 +1,270 @@ +""" +src/geocodio/distance.py +Distance API types and utilities for the Geocodio Python client. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple, Union + + +# ────────────────────────────────────────────────────────────────────────────── +# Distance Mode Constants +# ────────────────────────────────────────────────────────────────────────────── + +DISTANCE_MODE_STRAIGHTLINE = "straightline" +DISTANCE_MODE_DRIVING = "driving" +DISTANCE_MODE_HAVERSINE = "haversine" # Alias for straightline (backward compat) + +# ────────────────────────────────────────────────────────────────────────────── +# Distance Units Constants +# ────────────────────────────────────────────────────────────────────────────── + +DISTANCE_UNITS_MILES = "miles" +DISTANCE_UNITS_KM = "km" + +# ────────────────────────────────────────────────────────────────────────────── +# Order By Constants +# ────────────────────────────────────────────────────────────────────────────── + +DISTANCE_ORDER_BY_DISTANCE = "distance" +DISTANCE_ORDER_BY_DURATION = "duration" + +# ────────────────────────────────────────────────────────────────────────────── +# Sort Order Constants +# ────────────────────────────────────────────────────────────────────────────── + +DISTANCE_SORT_ASC = "asc" +DISTANCE_SORT_DESC = "desc" + + +# ────────────────────────────────────────────────────────────────────────────── +# Coordinate Class +# ────────────────────────────────────────────────────────────────────────────── + +@dataclass(frozen=True, slots=True) +class Coordinate: + """ + Represents a geographic coordinate with optional identifier. + + Attributes: + lat: Latitude (-90 to 90) + lng: Longitude (-180 to 180) + id: Optional identifier for the coordinate + + Examples: + >>> Coordinate(38.8977, -77.0365) + Coordinate(lat=38.8977, lng=-77.0365, id=None) + + >>> Coordinate(38.8977, -77.0365, "white_house") + Coordinate(lat=38.8977, lng=-77.0365, id='white_house') + + >>> Coordinate.from_input("38.8977,-77.0365,white_house") + Coordinate(lat=38.8977, lng=-77.0365, id='white_house') + + >>> Coordinate.from_input((38.8977, -77.0365)) + Coordinate(lat=38.8977, lng=-77.0365, id=None) + """ + + lat: float + lng: float + id: Optional[str] = None + + def __post_init__(self): + """Validate coordinate ranges.""" + if not -90 <= self.lat <= 90: + raise ValueError(f"Latitude must be between -90 and 90, got {self.lat}") + if not -180 <= self.lng <= 180: + raise ValueError(f"Longitude must be between -180 and 180, got {self.lng}") + + @classmethod + def from_input( + cls, + input_value: Union[ + "Coordinate", + str, + Tuple[float, float], + Tuple[float, float, str], + List[Union[float, str]], + Dict[str, Any], + ], + ) -> "Coordinate": + """ + Create a Coordinate from various input formats. + + Supported formats: + - Coordinate object (returned as-is) + - String: "lat,lng" or "lat,lng,id" + - Tuple: (lat, lng) or (lat, lng, id) + - List: [lat, lng] or [lat, lng, id] + - Dict: {"lat": ..., "lng": ..., "id": ...} + + Args: + input_value: The coordinate input in any supported format. + + Returns: + A Coordinate instance. + + Raises: + ValueError: If the input format is invalid or values are out of range. + """ + if isinstance(input_value, Coordinate): + return input_value + + if isinstance(input_value, str): + return cls._from_string(input_value) + + if isinstance(input_value, (tuple, list)): + return cls._from_sequence(input_value) + + if isinstance(input_value, dict): + return cls._from_dict(input_value) + + raise ValueError( + f"Cannot convert {type(input_value).__name__} to Coordinate. " + f"Expected string, tuple, list, dict, or Coordinate." + ) + + @classmethod + def _from_string(cls, value: str) -> "Coordinate": + """Parse coordinate from string format 'lat,lng' or 'lat,lng,id'.""" + parts = [p.strip() for p in value.split(",")] + + if len(parts) < 2: + raise ValueError( + f"Invalid coordinate string '{value}'. " + f"Expected format: 'lat,lng' or 'lat,lng,id'" + ) + + try: + lat = float(parts[0]) + lng = float(parts[1]) + except ValueError as e: + raise ValueError( + f"Invalid coordinate values in '{value}'. " + f"Latitude and longitude must be numbers." + ) from e + + coord_id = parts[2] if len(parts) > 2 else None + return cls(lat=lat, lng=lng, id=coord_id) + + @classmethod + def _from_sequence( + cls, value: Union[Tuple, List] + ) -> "Coordinate": + """Parse coordinate from tuple or list: [lat, lng] or [lat, lng, id].""" + if len(value) < 2: + raise ValueError( + f"Coordinate sequence must have at least 2 elements (lat, lng), " + f"got {len(value)}" + ) + + try: + lat = float(value[0]) + lng = float(value[1]) + except (ValueError, TypeError) as e: + raise ValueError( + f"Invalid coordinate values. Latitude and longitude must be numbers." + ) from e + + coord_id = str(value[2]) if len(value) > 2 else None + return cls(lat=lat, lng=lng, id=coord_id) + + @classmethod + def _from_dict(cls, value: Dict[str, Any]) -> "Coordinate": + """Parse coordinate from dict: {'lat': ..., 'lng': ..., 'id': ...}.""" + if "lat" not in value or "lng" not in value: + raise ValueError( + f"Coordinate dict must have 'lat' and 'lng' keys. " + f"Got keys: {list(value.keys())}" + ) + + try: + lat = float(value["lat"]) + lng = float(value["lng"]) + except (ValueError, TypeError) as e: + raise ValueError( + f"Invalid coordinate values. Latitude and longitude must be numbers." + ) from e + + coord_id = str(value["id"]) if "id" in value and value["id"] is not None else None + return cls(lat=lat, lng=lng, id=coord_id) + + def to_string(self) -> str: + """ + Convert to string format for GET requests. + + Returns: + 'lat,lng' or 'lat,lng,id' if id is set. + + Examples: + >>> Coordinate(38.8977, -77.0365).to_string() + '38.8977,-77.0365' + + >>> Coordinate(38.8977, -77.0365, "white_house").to_string() + '38.8977,-77.0365,white_house' + """ + result = f"{self.lat},{self.lng}" + if self.id: + result += f",{self.id}" + return result + + def to_dict(self) -> Dict[str, Any]: + """ + Convert to dict format for POST requests. + + Returns: + {'lat': ..., 'lng': ...} or {'lat': ..., 'lng': ..., 'id': ...} if id is set. + + Examples: + >>> Coordinate(38.8977, -77.0365).to_dict() + {'lat': 38.8977, 'lng': -77.0365} + + >>> Coordinate(38.8977, -77.0365, "white_house").to_dict() + {'lat': 38.8977, 'lng': -77.0365, 'id': 'white_house'} + """ + result: Dict[str, Any] = {"lat": self.lat, "lng": self.lng} + if self.id: + result["id"] = self.id + return result + + def __str__(self) -> str: + """Return string representation for display.""" + return self.to_string() + + +def normalize_distance_mode(mode: str) -> str: + """ + Normalize distance mode, mapping haversine to straightline. + + Args: + mode: The distance mode. + + Returns: + The normalized mode (haversine -> straightline). + """ + if mode == DISTANCE_MODE_HAVERSINE: + return DISTANCE_MODE_STRAIGHTLINE + return mode + + +__all__ = [ + # Mode constants + "DISTANCE_MODE_STRAIGHTLINE", + "DISTANCE_MODE_DRIVING", + "DISTANCE_MODE_HAVERSINE", + # Units constants + "DISTANCE_UNITS_MILES", + "DISTANCE_UNITS_KM", + # Order by constants + "DISTANCE_ORDER_BY_DISTANCE", + "DISTANCE_ORDER_BY_DURATION", + # Sort constants + "DISTANCE_SORT_ASC", + "DISTANCE_SORT_DESC", + # Classes + "Coordinate", + # Functions + "normalize_distance_mode", +] diff --git a/src/geocodio/models.py b/src/geocodio/models.py index 0be4da7..3e5ba3c 100644 --- a/src/geocodio/models.py +++ b/src/geocodio/models.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, List, Optional, Dict, TypeVar, Type +from typing import Any, List, Optional, Dict, Tuple, TypeVar, Type import httpx @@ -395,6 +395,261 @@ def __getattr__(self, name: str): raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") +# ────────────────────────────────────────────────────────────────────────────── +# Distance API models +# ────────────────────────────────────────────────────────────────────────────── + + +@dataclass(slots=True, frozen=True) +class DistanceDestination(ApiModelMixin): + """ + A destination with calculated distance from the origin. + + Attributes: + query: The original query string for this destination. + location: The [lat, lng] coordinates as a tuple. + distance_miles: Distance from origin in miles. + distance_km: Distance from origin in kilometers. + id: Optional identifier for this destination. + duration_seconds: Travel time in seconds (only when mode='driving'). + extras: Additional fields from the API response. + """ + + query: str + location: Tuple[float, float] + distance_miles: float + distance_km: float + id: Optional[str] = None + duration_seconds: Optional[int] = None + extras: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_api(cls, data: Dict[str, Any]) -> "DistanceDestination": + """Create from API response data.""" + location = data.get("location", [0.0, 0.0]) + if isinstance(location, dict): + location = (location.get("lat", 0.0), location.get("lng", 0.0)) + elif isinstance(location, list): + location = tuple(location) if len(location) >= 2 else (0.0, 0.0) + + known_fields = { + "query", "location", "distance_miles", "distance_km", + "id", "duration_seconds" + } + extras = {k: v for k, v in data.items() if k not in known_fields} + + return cls( + query=data.get("query", ""), + location=location, + distance_miles=data.get("distance_miles", 0.0), + distance_km=data.get("distance_km", 0.0), + id=data.get("id"), + duration_seconds=data.get("duration_seconds"), + extras=extras, + ) + + +@dataclass(slots=True, frozen=True) +class DistanceOrigin(ApiModelMixin): + """ + An origin point in distance response. + + Attributes: + query: The original query string for this origin. + location: The [lat, lng] coordinates as a tuple. + id: Optional identifier for this origin. + extras: Additional fields from the API response. + """ + + query: str + location: Tuple[float, float] + id: Optional[str] = None + extras: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_api(cls, data: Dict[str, Any]) -> "DistanceOrigin": + """Create from API response data.""" + location = data.get("location", [0.0, 0.0]) + if isinstance(location, dict): + location = (location.get("lat", 0.0), location.get("lng", 0.0)) + elif isinstance(location, list): + location = tuple(location) if len(location) >= 2 else (0.0, 0.0) + + known_fields = {"query", "location", "id"} + extras = {k: v for k, v in data.items() if k not in known_fields} + + return cls( + query=data.get("query", ""), + location=location, + id=data.get("id"), + extras=extras, + ) + + +@dataclass(slots=True, frozen=True) +class DistanceResponse: + """ + Response from single origin distance calculation (GET /distance). + + Attributes: + origin: The origin point with coordinates. + mode: The distance calculation mode used ('straightline' or 'driving'). + destinations: List of destinations with calculated distances. + """ + + origin: DistanceOrigin + mode: str + destinations: List[DistanceDestination] + + @classmethod + def from_api(cls, data: Dict[str, Any]) -> "DistanceResponse": + """Create from API response data.""" + origin = DistanceOrigin.from_api(data.get("origin", {})) + destinations = [ + DistanceDestination.from_api(dest) + for dest in data.get("destinations", []) + ] + return cls( + origin=origin, + mode=data.get("mode", ""), + destinations=destinations, + ) + + +@dataclass(slots=True, frozen=True) +class DistanceMatrixResult: + """ + A single origin result in distance matrix response. + + Attributes: + origin: The origin point with coordinates. + destinations: List of destinations with calculated distances. + """ + + origin: DistanceOrigin + destinations: List[DistanceDestination] + + @classmethod + def from_api(cls, data: Dict[str, Any]) -> "DistanceMatrixResult": + """Create from API response data.""" + origin = DistanceOrigin.from_api(data.get("origin", {})) + destinations = [ + DistanceDestination.from_api(dest) + for dest in data.get("destinations", []) + ] + return cls(origin=origin, destinations=destinations) + + +@dataclass(slots=True, frozen=True) +class DistanceMatrixResponse: + """ + Response from distance matrix calculation (POST /distance-matrix). + + Attributes: + mode: The distance calculation mode used ('straightline' or 'driving'). + results: List of results, one per origin. + """ + + mode: str + results: List[DistanceMatrixResult] + + @classmethod + def from_api(cls, data: Dict[str, Any]) -> "DistanceMatrixResponse": + """Create from API response data.""" + results = [ + DistanceMatrixResult.from_api(result) + for result in data.get("results", []) + ] + return cls( + mode=data.get("mode", ""), + results=results, + ) + + +@dataclass(slots=True, frozen=True) +class DistanceJobStatus: + """ + Status information for a distance matrix job. + + Attributes: + state: Current state (e.g., 'ENQUEUED', 'PROCESSING', 'COMPLETED', 'FAILED'). + progress: Completion percentage (0-100). + message: Optional status message. + """ + + state: str + progress: int = 0 + message: Optional[str] = None + + @classmethod + def from_api(cls, data: Dict[str, Any]) -> "DistanceJobStatus": + """Create from API response data.""" + if isinstance(data, str): + return cls(state=data) + return cls( + state=data.get("state", data.get("status", "")), + progress=data.get("progress", 0), + message=data.get("message"), + ) + + +@dataclass(slots=True, frozen=True) +class DistanceJobResponse: + """ + Response from creating a distance matrix job. + + Attributes: + id: The job ID. + identifier: Unique string identifier for the job. + status: Current status of the job. + name: User-provided name for the job. + created_at: Timestamp when the job was created. + origins_count: Number of origin coordinates. + destinations_count: Number of destination coordinates. + total_calculations: Total number of distance calculations. + download_url: URL to download results (when completed). + calculations_completed: Number of completed calculations. + """ + + id: int + identifier: str + status: str + name: str + created_at: str + origins_count: int + destinations_count: int + total_calculations: int + download_url: Optional[str] = None + calculations_completed: Optional[int] = None + progress: Optional[int] = None + + @classmethod + def from_api(cls, data: Dict[str, Any]) -> "DistanceJobResponse": + """Create from API response data.""" + # Handle nested "data" key for status responses + if "data" in data and isinstance(data["data"], dict): + data = data["data"] + + # Status can be a string or dict + status = data.get("status", "") + if isinstance(status, dict): + status = status.get("state", "") + + return cls( + id=data.get("id", 0), + identifier=data.get("identifier", ""), + status=status, + name=data.get("name", ""), + created_at=data.get("created_at", ""), + origins_count=data.get("origins_count", 0), + destinations_count=data.get("destinations_count", 0), + total_calculations=data.get("total_calculations", 0), + download_url=data.get("download_url"), + calculations_completed=data.get("calculations_completed"), + progress=data.get("progress"), + ) + + # ────────────────────────────────────────────────────────────────────────────── # Main result objects # ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/e2e/test_distance.py b/tests/e2e/test_distance.py new file mode 100644 index 0000000..e6f8791 --- /dev/null +++ b/tests/e2e/test_distance.py @@ -0,0 +1,232 @@ +""" +End-to-end tests for the Distance API. + +These tests require a valid GEOCODIO_API_KEY environment variable. +They make real API calls to the Geocodio service. +""" + +import os +import pytest +from geocodio import ( + Geocodio, + Coordinate, + DISTANCE_MODE_STRAIGHTLINE, + DISTANCE_MODE_DRIVING, + DISTANCE_UNITS_MILES, + DISTANCE_UNITS_KM, + DistanceResponse, + DistanceMatrixResponse, +) + + +# Skip all tests if no API key is available +pytestmark = pytest.mark.skipif( + not os.getenv("GEOCODIO_API_KEY"), + reason="GEOCODIO_API_KEY environment variable not set" +) + + +@pytest.fixture +def client(): + """Create a Geocodio client with real API key.""" + api_key = os.getenv("GEOCODIO_API_KEY") + return Geocodio(api_key=api_key) + + +# ────────────────────────────────────────────────────────────────────────────── +# Distance Method E2E Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestDistanceE2E: + """End-to-end tests for distance() method.""" + + def test_distance_basic(self, client): + """Test basic distance calculation with real API.""" + response = client.distance( + origin="38.8977,-77.0365", # White House + destinations=[ + "38.8895,-77.0353", # Washington Monument + "38.9072,-77.0369" # Capitol Building + ], + mode=DISTANCE_MODE_STRAIGHTLINE, + units=DISTANCE_UNITS_MILES + ) + + assert isinstance(response, DistanceResponse) + assert response.mode == "straightline" + assert len(response.destinations) == 2 + + # Check distances are reasonable (should be < 2 miles) + for dest in response.destinations: + assert dest.distance_miles > 0 + assert dest.distance_miles < 2 + assert dest.distance_km > 0 + + def test_distance_with_ids(self, client): + """Test distance with coordinate IDs.""" + response = client.distance( + origin=Coordinate(38.8977, -77.0365, "white_house"), + destinations=[ + Coordinate(38.8895, -77.0353, "monument"), + Coordinate(38.9072, -77.0369, "capitol") + ] + ) + + assert isinstance(response, DistanceResponse) + assert response.origin.id == "white_house" + # IDs in destinations are optional in response + + def test_distance_driving_mode(self, client): + """Test distance with driving mode returns duration.""" + response = client.distance( + origin="38.8977,-77.0365", + destinations=["38.8895,-77.0353"], + mode=DISTANCE_MODE_DRIVING + ) + + assert response.mode == "driving" + # Driving mode should include duration + for dest in response.destinations: + assert dest.duration_seconds is not None + assert dest.duration_seconds > 0 + + def test_distance_kilometers(self, client): + """Test distance in kilometers.""" + response = client.distance( + origin="38.8977,-77.0365", + destinations=["38.8895,-77.0353"], + units=DISTANCE_UNITS_KM + ) + + assert isinstance(response, DistanceResponse) + # Both miles and km should be in response + assert response.destinations[0].distance_miles > 0 + assert response.destinations[0].distance_km > 0 + + +# ────────────────────────────────────────────────────────────────────────────── +# Distance Matrix E2E Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestDistanceMatrixE2E: + """End-to-end tests for distance_matrix() method.""" + + def test_distance_matrix_basic(self, client): + """Test basic distance matrix calculation.""" + response = client.distance_matrix( + origins=[ + (38.8977, -77.0365), # White House + (38.9072, -77.0369) # Capitol + ], + destinations=[ + (38.8895, -77.0353), # Washington Monument + (38.8816, -77.0364) # Jefferson Memorial + ] + ) + + assert isinstance(response, DistanceMatrixResponse) + assert len(response.results) == 2 # Two origins + + for result in response.results: + assert len(result.destinations) == 2 # Two destinations per origin + for dest in result.destinations: + assert dest.distance_miles > 0 + assert dest.distance_km > 0 + + def test_distance_matrix_with_ids(self, client): + """Test distance matrix preserves IDs.""" + response = client.distance_matrix( + origins=[ + Coordinate(38.8977, -77.0365, "origin1"), + Coordinate(38.9072, -77.0369, "origin2") + ], + destinations=[ + Coordinate(38.8895, -77.0353, "dest1") + ] + ) + + assert isinstance(response, DistanceMatrixResponse) + # Check IDs are preserved + assert response.results[0].origin.id == "origin1" + assert response.results[1].origin.id == "origin2" + + +# ────────────────────────────────────────────────────────────────────────────── +# Geocode with Distance E2E Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestGeocodeWithDistanceE2E: + """End-to-end tests for geocode() with distance parameters.""" + + def test_geocode_with_destinations(self, client): + """Test geocode with distance to destinations.""" + response = client.geocode( + "1600 Pennsylvania Ave NW, Washington DC", + destinations=["38.8895,-77.0353"] # Washington Monument + ) + + assert len(response.results) >= 1 + # Note: The response structure for geocode+distance may vary + # This test mainly verifies the request is accepted + + def test_reverse_with_destinations(self, client): + """Test reverse geocode with distance to destinations.""" + response = client.reverse( + (38.8977, -77.0365), # White House coordinates + destinations=["38.8895,-77.0353"] # Washington Monument + ) + + assert len(response.results) >= 1 + + +# ────────────────────────────────────────────────────────────────────────────── +# Coordinate Class E2E Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestCoordinateE2E: + """End-to-end tests verifying Coordinate class works with real API.""" + + def test_all_coordinate_formats(self, client): + """Test that all coordinate formats work with the API.""" + # String format + response1 = client.distance( + origin="38.8977,-77.0365", + destinations=["38.8895,-77.0353"] + ) + assert isinstance(response1, DistanceResponse) + + # Tuple format + response2 = client.distance( + origin=(38.8977, -77.0365), + destinations=[(38.8895, -77.0353)] + ) + assert isinstance(response2, DistanceResponse) + + # Coordinate object format + response3 = client.distance( + origin=Coordinate(38.8977, -77.0365), + destinations=[Coordinate(38.8895, -77.0353)] + ) + assert isinstance(response3, DistanceResponse) + + # Mixed formats + response4 = client.distance( + origin=Coordinate(38.8977, -77.0365, "white_house"), + destinations=[ + "38.8895,-77.0353,monument", + (38.9072, -77.0369) + ] + ) + assert isinstance(response4, DistanceResponse) + + # All responses should have similar distances (within tolerance) + dist1 = response1.destinations[0].distance_miles + dist2 = response2.destinations[0].distance_miles + dist3 = response3.destinations[0].distance_miles + + assert abs(dist1 - dist2) < 0.01 + assert abs(dist2 - dist3) < 0.01 diff --git a/tests/unit/test_distance.py b/tests/unit/test_distance.py new file mode 100644 index 0000000..99f09e8 --- /dev/null +++ b/tests/unit/test_distance.py @@ -0,0 +1,774 @@ +""" +Unit tests for the Distance API implementation. +""" + +import json +import pytest +import httpx + +from geocodio import ( + Geocodio, + Coordinate, + DISTANCE_MODE_STRAIGHTLINE, + DISTANCE_MODE_DRIVING, + DISTANCE_MODE_HAVERSINE, + DISTANCE_UNITS_MILES, + DISTANCE_UNITS_KM, + DISTANCE_ORDER_BY_DISTANCE, + DISTANCE_ORDER_BY_DURATION, + DISTANCE_SORT_ASC, + DISTANCE_SORT_DESC, + DistanceResponse, + DistanceMatrixResponse, + DistanceJobResponse, +) + + +# ────────────────────────────────────────────────────────────────────────────── +# Coordinate Class Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestCoordinate: + """Tests for the Coordinate class.""" + + def test_create_basic(self): + """Test basic coordinate creation.""" + coord = Coordinate(38.8977, -77.0365) + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id is None + + def test_create_with_id(self): + """Test coordinate creation with id.""" + coord = Coordinate(38.8977, -77.0365, "white_house") + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id == "white_house" + + def test_validation_lat_too_high(self): + """Test latitude validation - too high.""" + with pytest.raises(ValueError, match="Latitude must be between"): + Coordinate(91.0, -77.0365) + + def test_validation_lat_too_low(self): + """Test latitude validation - too low.""" + with pytest.raises(ValueError, match="Latitude must be between"): + Coordinate(-91.0, -77.0365) + + def test_validation_lng_too_high(self): + """Test longitude validation - too high.""" + with pytest.raises(ValueError, match="Longitude must be between"): + Coordinate(38.8977, 181.0) + + def test_validation_lng_too_low(self): + """Test longitude validation - too low.""" + with pytest.raises(ValueError, match="Longitude must be between"): + Coordinate(38.8977, -181.0) + + def test_from_string_basic(self): + """Test creating coordinate from string 'lat,lng'.""" + coord = Coordinate.from_input("38.8977,-77.0365") + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id is None + + def test_from_string_with_id(self): + """Test creating coordinate from string 'lat,lng,id'.""" + coord = Coordinate.from_input("38.8977,-77.0365,white_house") + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id == "white_house" + + def test_from_string_with_spaces(self): + """Test creating coordinate from string with spaces.""" + coord = Coordinate.from_input(" 38.8977 , -77.0365 , white_house ") + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id == "white_house" + + def test_from_tuple(self): + """Test creating coordinate from tuple (lat, lng).""" + coord = Coordinate.from_input((38.8977, -77.0365)) + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id is None + + def test_from_tuple_with_id(self): + """Test creating coordinate from tuple (lat, lng, id).""" + coord = Coordinate.from_input((38.8977, -77.0365, "white_house")) + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id == "white_house" + + def test_from_list(self): + """Test creating coordinate from list [lat, lng].""" + coord = Coordinate.from_input([38.8977, -77.0365]) + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + + def test_from_dict(self): + """Test creating coordinate from dict.""" + coord = Coordinate.from_input({"lat": 38.8977, "lng": -77.0365}) + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id is None + + def test_from_dict_with_id(self): + """Test creating coordinate from dict with id.""" + coord = Coordinate.from_input({"lat": 38.8977, "lng": -77.0365, "id": "white_house"}) + assert coord.lat == 38.8977 + assert coord.lng == -77.0365 + assert coord.id == "white_house" + + def test_from_coordinate(self): + """Test creating coordinate from another Coordinate object.""" + original = Coordinate(38.8977, -77.0365, "white_house") + coord = Coordinate.from_input(original) + assert coord is original + + def test_to_string_basic(self): + """Test converting coordinate to string.""" + coord = Coordinate(38.8977, -77.0365) + assert coord.to_string() == "38.8977,-77.0365" + + def test_to_string_with_id(self): + """Test converting coordinate with id to string.""" + coord = Coordinate(38.8977, -77.0365, "white_house") + assert coord.to_string() == "38.8977,-77.0365,white_house" + + def test_to_dict_basic(self): + """Test converting coordinate to dict.""" + coord = Coordinate(38.8977, -77.0365) + assert coord.to_dict() == {"lat": 38.8977, "lng": -77.0365} + + def test_to_dict_with_id(self): + """Test converting coordinate with id to dict.""" + coord = Coordinate(38.8977, -77.0365, "white_house") + assert coord.to_dict() == {"lat": 38.8977, "lng": -77.0365, "id": "white_house"} + + def test_str(self): + """Test string representation.""" + coord = Coordinate(38.8977, -77.0365, "white_house") + assert str(coord) == "38.8977,-77.0365,white_house" + + +# ────────────────────────────────────────────────────────────────────────────── +# Distance Method Tests +# ────────────────────────────────────────────────────────────────────────────── + + +def sample_distance_response(): + """Sample response for distance endpoint.""" + return { + "origin": { + "query": "38.8977,-77.0365,white_house", + "location": [38.8977, -77.0365], + "id": "white_house" + }, + "mode": "straightline", + "destinations": [ + { + "query": "38.9072,-77.0369,capitol", + "location": [38.9072, -77.0369], + "id": "capitol", + "distance_miles": 0.7, + "distance_km": 1.1 + }, + { + "query": "38.8895,-77.0353,monument", + "location": [38.8895, -77.0353], + "id": "monument", + "distance_miles": 0.6, + "distance_km": 0.9 + } + ] + } + + +def sample_distance_driving_response(): + """Sample response for distance endpoint with driving mode.""" + return { + "origin": { + "query": "38.8977,-77.0365", + "location": [38.8977, -77.0365] + }, + "mode": "driving", + "destinations": [ + { + "query": "38.9072,-77.0369", + "location": [38.9072, -77.0369], + "distance_miles": 1.2, + "distance_km": 1.9, + "duration_seconds": 294 + } + ] + } + + +class TestDistance: + """Tests for the distance() method.""" + + def test_distance_basic(self, client, httpx_mock): + """Test basic distance calculation.""" + def response_callback(request): + assert request.method == "GET" + assert "/v1.9/distance" in str(request.url) + return httpx.Response(200, json=sample_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + response = client.distance( + origin="38.8977,-77.0365,white_house", + destinations=["38.9072,-77.0369,capitol", "38.8895,-77.0353,monument"] + ) + + assert isinstance(response, DistanceResponse) + assert response.origin.id == "white_house" + assert response.mode == "straightline" + assert len(response.destinations) == 2 + assert response.destinations[0].id == "capitol" + assert response.destinations[0].distance_miles == 0.7 + + def test_distance_with_coordinate_objects(self, client, httpx_mock): + """Test distance with Coordinate objects.""" + httpx_mock.add_callback( + callback=lambda request: httpx.Response(200, json=sample_distance_response()) + ) + + origin = Coordinate(38.8977, -77.0365, "white_house") + destinations = [ + Coordinate(38.9072, -77.0369, "capitol"), + Coordinate(38.8895, -77.0353, "monument") + ] + + response = client.distance(origin=origin, destinations=destinations) + + assert isinstance(response, DistanceResponse) + assert len(response.destinations) == 2 + + def test_distance_with_tuples(self, client, httpx_mock): + """Test distance with tuple coordinates.""" + httpx_mock.add_callback( + callback=lambda request: httpx.Response(200, json=sample_distance_response()) + ) + + response = client.distance( + origin=(38.8977, -77.0365), + destinations=[(38.9072, -77.0369), (38.8895, -77.0353)] + ) + + assert isinstance(response, DistanceResponse) + + def test_distance_driving_mode(self, client, httpx_mock): + """Test distance with driving mode returns duration.""" + def response_callback(request): + assert "mode=driving" in str(request.url) + return httpx.Response(200, json=sample_distance_driving_response()) + + httpx_mock.add_callback(callback=response_callback) + + response = client.distance( + origin="38.8977,-77.0365", + destinations=["38.9072,-77.0369"], + mode=DISTANCE_MODE_DRIVING + ) + + assert response.mode == "driving" + assert response.destinations[0].duration_seconds == 294 + + def test_distance_haversine_mapped_to_straightline(self, client, httpx_mock): + """Test that haversine mode is mapped to straightline.""" + def response_callback(request): + assert "mode=straightline" in str(request.url) + return httpx.Response(200, json=sample_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.distance( + origin="38.8977,-77.0365", + destinations=["38.9072,-77.0369"], + mode=DISTANCE_MODE_HAVERSINE + ) + + def test_distance_with_filters(self, client, httpx_mock): + """Test distance with filter parameters.""" + def response_callback(request): + url_str = str(request.url) + assert "max_results=5" in url_str + assert "max_distance=10" in url_str # Changed from 10.0 due to URL encoding + return httpx.Response(200, json=sample_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.distance( + origin="38.8977,-77.0365", + destinations=["38.9072,-77.0369"], + max_results=5, + max_distance=10.0 + ) + + def test_distance_with_sorting(self, client, httpx_mock): + """Test distance with sorting parameters.""" + def response_callback(request): + url_str = str(request.url) + assert "order_by=duration" in url_str + assert "sort=desc" in url_str + return httpx.Response(200, json=sample_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.distance( + origin="38.8977,-77.0365", + destinations=["38.9072,-77.0369"], + order_by=DISTANCE_ORDER_BY_DURATION, + sort_order=DISTANCE_SORT_DESC + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Distance Matrix Method Tests +# ────────────────────────────────────────────────────────────────────────────── + + +def sample_distance_matrix_response(): + """Sample response for distance-matrix endpoint.""" + return { + "mode": "straightline", + "results": [ + { + "origin": { + "query": "38.8977,-77.0365", + "location": [38.8977, -77.0365], + "id": "origin1" + }, + "destinations": [ + { + "query": "38.8895,-77.0353", + "location": [38.8895, -77.0353], + "id": "dest1", + "distance_miles": 1.5, + "distance_km": 2.5 + } + ] + }, + { + "origin": { + "query": "38.9072,-77.0369", + "location": [38.9072, -77.0369], + "id": "origin2" + }, + "destinations": [ + { + "query": "38.8895,-77.0353", + "location": [38.8895, -77.0353], + "id": "dest1", + "distance_miles": 1.3, + "distance_km": 2.1 + } + ] + } + ] + } + + +class TestDistanceMatrix: + """Tests for the distance_matrix() method.""" + + def test_distance_matrix_basic(self, client, httpx_mock): + """Test basic distance matrix calculation.""" + def response_callback(request): + assert request.method == "POST" + body = json.loads(request.content) + assert "origins" in body + assert "destinations" in body + assert body["mode"] == "straightline" + return httpx.Response(200, json=sample_distance_matrix_response()) + + httpx_mock.add_callback(callback=response_callback) + + response = client.distance_matrix( + origins=[ + (38.8977, -77.0365, "origin1"), + (38.9072, -77.0369, "origin2") + ], + destinations=[ + (38.8895, -77.0353, "dest1") + ] + ) + + assert isinstance(response, DistanceMatrixResponse) + assert response.mode == "straightline" + assert len(response.results) == 2 + assert response.results[0].origin.id == "origin1" + assert response.results[0].destinations[0].distance_miles == 1.5 + + def test_distance_matrix_uses_object_format(self, client, httpx_mock): + """Test that distance_matrix uses object format in POST body.""" + def response_callback(request): + body = json.loads(request.content) + # Origins and destinations should be dicts, not strings + assert isinstance(body["origins"][0], dict) + assert "lat" in body["origins"][0] + assert "lng" in body["origins"][0] + return httpx.Response(200, json=sample_distance_matrix_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.distance_matrix( + origins=["38.8977,-77.0365,origin1"], + destinations=["38.8895,-77.0353,dest1"] + ) + + def test_distance_matrix_preserves_ids(self, client, httpx_mock): + """Test that IDs are preserved in request.""" + def response_callback(request): + body = json.loads(request.content) + assert body["origins"][0]["id"] == "origin1" + assert body["destinations"][0]["id"] == "dest1" + return httpx.Response(200, json=sample_distance_matrix_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.distance_matrix( + origins=[Coordinate(38.8977, -77.0365, "origin1")], + destinations=[Coordinate(38.8895, -77.0353, "dest1")] + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Distance Job Method Tests +# ────────────────────────────────────────────────────────────────────────────── + + +def sample_job_create_response(): + """Sample response for creating a distance job.""" + return { + "id": 123, + "identifier": "abc123def456", + "status": "ENQUEUED", + "name": "My Job", + "created_at": "2025-01-15T12:00:00.000000Z", + "origins_count": 2, + "destinations_count": 2, + "total_calculations": 4 + } + + +def sample_job_status_response(): + """Sample response for job status.""" + return { + "data": { + "id": 123, + "identifier": "abc123def456", + "name": "My Job", + "status": "COMPLETED", + "progress": 100, + "download_url": "https://api.geocod.io/v1.9/distance-jobs/123/download", + "total_calculations": 4, + "calculations_completed": 4, + "origins_count": 2, + "destinations_count": 2, + "created_at": "2025-01-15T12:00:00.000000Z" + } + } + + +def sample_jobs_list_response(): + """Sample response for listing jobs.""" + return { + "data": [ + { + "id": 123, + "identifier": "abc123", + "status": "COMPLETED", + "name": "Job 1", + "created_at": "2025-01-15T12:00:00.000000Z", + "origins_count": 2, + "destinations_count": 2, + "total_calculations": 4 + }, + { + "id": 124, + "identifier": "def456", + "status": "PROCESSING", + "name": "Job 2", + "created_at": "2025-01-15T13:00:00.000000Z", + "origins_count": 3, + "destinations_count": 3, + "total_calculations": 9 + } + ], + "current_page": 1, + "from": 1, + "to": 2, + "path": "/v1.9/distance-jobs", + "per_page": 10 + } + + +class TestDistanceJobs: + """Tests for distance job methods.""" + + def test_create_job_with_coordinates(self, client, httpx_mock): + """Test creating a distance job with coordinate lists.""" + def response_callback(request): + assert request.method == "POST" + body = json.loads(request.content) + assert body["name"] == "My Job" + assert isinstance(body["origins"], list) + assert isinstance(body["destinations"], list) + return httpx.Response(200, json=sample_job_create_response()) + + httpx_mock.add_callback(callback=response_callback) + + response = client.create_distance_matrix_job( + name="My Job", + origins=[(38.8977, -77.0365), (38.9072, -77.0369)], + destinations=[(38.8895, -77.0353), (39.2904, -76.6122)] + ) + + assert isinstance(response, DistanceJobResponse) + assert response.id == 123 + assert response.status == "ENQUEUED" + assert response.total_calculations == 4 + + def test_create_job_with_list_ids(self, client, httpx_mock): + """Test creating a distance job with list IDs.""" + def response_callback(request): + body = json.loads(request.content) + assert body["origins"] == 12345 + assert body["destinations"] == 67890 + return httpx.Response(200, json=sample_job_create_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.create_distance_matrix_job( + name="My Job", + origins=12345, + destinations=67890 + ) + + def test_create_job_with_callback_url(self, client, httpx_mock): + """Test creating a job with callback URL.""" + def response_callback(request): + body = json.loads(request.content) + assert body["callback_url"] == "https://example.com/webhook" + return httpx.Response(200, json=sample_job_create_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.create_distance_matrix_job( + name="My Job", + origins=[(38.8977, -77.0365)], + destinations=[(38.8895, -77.0353)], + callback_url="https://example.com/webhook" + ) + + def test_job_status(self, client, httpx_mock): + """Test getting job status.""" + httpx_mock.add_callback( + callback=lambda request: httpx.Response(200, json=sample_job_status_response()) + ) + + response = client.distance_matrix_job_status(123) + + assert response.id == 123 + assert response.status == "COMPLETED" + assert response.progress == 100 + + def test_list_jobs(self, client, httpx_mock): + """Test listing jobs.""" + httpx_mock.add_callback( + callback=lambda request: httpx.Response(200, json=sample_jobs_list_response()) + ) + + response = client.distance_matrix_jobs() + + assert response.current_page == 1 + assert len(response.data) == 2 + + def test_get_job_results(self, client, httpx_mock): + """Test downloading job results.""" + httpx_mock.add_callback( + callback=lambda request: httpx.Response( + 200, + json=sample_distance_matrix_response(), + headers={"content-type": "application/json"} + ) + ) + + response = client.get_distance_matrix_job_results(123) + + assert isinstance(response, DistanceMatrixResponse) + assert len(response.results) == 2 + + def test_delete_job(self, client, httpx_mock): + """Test deleting a job.""" + def response_callback(request): + assert request.method == "DELETE" + return httpx.Response(204) + + httpx_mock.add_callback(callback=response_callback) + + # Should not raise + client.delete_distance_matrix_job(123) + + +# ────────────────────────────────────────────────────────────────────────────── +# Geocode with Distance Tests +# ────────────────────────────────────────────────────────────────────────────── + + +def sample_geocode_with_distance_response(): + """Sample geocode response with distance data.""" + return { + "results": [{ + "address_components": { + "number": "1600", + "street": "Pennsylvania", + "suffix": "Ave", + "city": "Washington", + "state": "DC", + "zip": "20500" + }, + "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500", + "location": {"lat": 38.8977, "lng": -77.0365}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "DC", + "destinations": [ + { + "query": "38.9072,-77.0369", + "location": [38.9072, -77.0369], + "distance_miles": 0.7, + "distance_km": 1.1 + } + ] + }] + } + + +class TestGeocodeWithDistance: + """Tests for geocode() with distance parameters.""" + + def test_geocode_with_destinations(self, client, httpx_mock): + """Test geocode with destination parameter.""" + def response_callback(request): + url_str = str(request.url) + assert "/v1.9/geocode" in url_str + assert "destinations%5B%5D" in url_str or "destinations[]" in url_str.replace("%5B", "[").replace("%5D", "]") + return httpx.Response(200, json=sample_geocode_with_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + response = client.geocode( + "1600 Pennsylvania Ave NW, Washington DC", + destinations=["38.9072,-77.0369"] + ) + + assert len(response.results) == 1 + + def test_geocode_with_distance_mode(self, client, httpx_mock): + """Test geocode with distance mode parameter.""" + def response_callback(request): + url_str = str(request.url) + assert "/v1.9/geocode" in url_str + assert "distance_mode=driving" in url_str + return httpx.Response(200, json=sample_geocode_with_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.geocode( + "1600 Pennsylvania Ave NW, Washington DC", + destinations=["38.9072,-77.0369"], + distance_mode=DISTANCE_MODE_DRIVING + ) + + def test_geocode_with_distance_units(self, client, httpx_mock): + """Test geocode with distance units parameter.""" + def response_callback(request): + url_str = str(request.url) + assert "/v1.9/geocode" in url_str + assert "distance_units=km" in url_str + return httpx.Response(200, json=sample_geocode_with_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.geocode( + "1600 Pennsylvania Ave NW, Washington DC", + destinations=["38.9072,-77.0369"], + distance_units=DISTANCE_UNITS_KM + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Reverse with Distance Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestReverseWithDistance: + """Tests for reverse() with distance parameters.""" + + def test_reverse_with_destinations(self, client, httpx_mock): + """Test reverse geocode with destination parameter.""" + def response_callback(request): + url_str = str(request.url) + assert "/v1.9/reverse" in url_str + assert "destinations%5B%5D" in url_str or "destinations[]" in url_str.replace("%5B", "[").replace("%5D", "]") + return httpx.Response(200, json=sample_geocode_with_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + response = client.reverse( + "38.8977,-77.0365", + destinations=["38.9072,-77.0369"] + ) + + assert len(response.results) == 1 + + def test_reverse_with_distance_mode(self, client, httpx_mock): + """Test reverse geocode with distance mode parameter.""" + def response_callback(request): + url_str = str(request.url) + assert "/v1.9/reverse" in url_str + assert "distance_mode=straightline" in url_str + return httpx.Response(200, json=sample_geocode_with_distance_response()) + + httpx_mock.add_callback(callback=response_callback) + + client.reverse( + (38.8977, -77.0365), + destinations=[(38.9072, -77.0369)], + distance_mode=DISTANCE_MODE_STRAIGHTLINE + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Constants Tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestConstants: + """Tests for distance constants.""" + + def test_mode_constants(self): + """Test distance mode constants.""" + assert DISTANCE_MODE_STRAIGHTLINE == "straightline" + assert DISTANCE_MODE_DRIVING == "driving" + assert DISTANCE_MODE_HAVERSINE == "haversine" + + def test_units_constants(self): + """Test distance units constants.""" + assert DISTANCE_UNITS_MILES == "miles" + assert DISTANCE_UNITS_KM == "km" + + def test_order_by_constants(self): + """Test order by constants.""" + assert DISTANCE_ORDER_BY_DISTANCE == "distance" + assert DISTANCE_ORDER_BY_DURATION == "duration" + + def test_sort_constants(self): + """Test sort constants.""" + assert DISTANCE_SORT_ASC == "asc" + assert DISTANCE_SORT_DESC == "desc"