diff --git a/.gitignore b/.gitignore index 93e7a2fc..207bb096 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,21 @@ dist/ # python testing things etc .coverage +coverage.xml +htmlcov/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.tox/ +.nox/ + +# packaging +*.egg-info/ +__pycache__/ + +# editors +.vscode/ +.idea/ .nox env googlemaps.egg-info diff --git a/README.md b/README.md index 40823790..3570999e 100644 --- a/README.md +++ b/README.md @@ -8,120 +8,378 @@ Python Client for Google Maps Services ![PyPI - Downloads](https://img.shields.io/pypi/dd/googlemaps) ![GitHub contributors](https://img.shields.io/github/contributors/googlemaps/google-maps-services-python) +> **Fork notice.** This repository is a community fork that layers two +> production-grade additions on top of the official client — a **pluggable +> response cache** and a **concurrent batch executor** — without changing +> any existing public surface. Drop-in compatible: existing code keeps +> working unchanged. + +--- + +## Table of contents + +- [Description](#description) +- [Supported Google Maps Web Services](#supported-google-maps-web-services) +- [Requirements](#requirements) +- [Installation](#installation) +- [API keys](#api-keys) +- [Quick start](#quick-start) +- [Built-in features](#built-in-features) + - [Retry on failure](#retry-on-failure) + - [Rate limiting (QPS / QPM)](#rate-limiting-qps--qpm) +- [Fork additions](#fork-additions) + - [1. Pluggable response cache (TTL + LRU)](#1-pluggable-response-cache-ttl--lru) + - [2. Concurrent batch executor](#2-concurrent-batch-executor) + - [Composing both features](#composing-both-features) +- [Development workflow](#development-workflow) +- [Project layout](#project-layout) +- [Quality bar](#quality-bar) +- [Documentation & resources](#documentation--resources) +- [Support](#support) + +--- + ## Description -Use Python? Want to geocode something? Looking for directions? -Maybe matrices of directions? This library brings the Google Maps Platform Web -Services to your Python application. +The Python Client for Google Maps Services brings the Google Maps Platform +Web Services to your Python application — geocoding, directions, places, +elevation, time zones, roads, and more. The same +[terms and conditions](https://developers.google.com/maps/terms) apply to +usage of the APIs through this library. + +### Supported Google Maps Web Services + +| API | Module | Client method(s) | +| --- | --- | --- | +| Directions API | [googlemaps/directions.py](googlemaps/directions.py) | `directions` | +| Distance Matrix API | [googlemaps/distance_matrix.py](googlemaps/distance_matrix.py) | `distance_matrix` | +| Elevation API | [googlemaps/elevation.py](googlemaps/elevation.py) | `elevation`, `elevation_along_path` | +| Geocoding API | [googlemaps/geocoding.py](googlemaps/geocoding.py) | `geocode`, `reverse_geocode` | +| Geolocation API | [googlemaps/geolocation.py](googlemaps/geolocation.py) | `geolocate` | +| Time Zone API | [googlemaps/timezone.py](googlemaps/timezone.py) | `timezone` | +| Roads API | [googlemaps/roads.py](googlemaps/roads.py) | `snap_to_roads`, `nearest_roads`, `speed_limits`, `snapped_speed_limits` | +| Places API | [googlemaps/places.py](googlemaps/places.py) | `find_place`, `place`, `places`, `places_nearby`, `places_photo`, `places_autocomplete`, `places_autocomplete_query` | +| Maps Static API | [googlemaps/maps.py](googlemaps/maps.py) | `static_map` | +| Address Validation API | [googlemaps/addressvalidation.py](googlemaps/addressvalidation.py) | `addressvalidation` | + +--- -The Python Client for Google Maps Services is a Python Client library for the following Google Maps -APIs: +## Requirements - - Directions API - - Distance Matrix API - - Elevation API - - Geocoding API - - Geolocation API - - Time Zone API - - Roads API - - Places API - - Maps Static API - - Address Validation API +- **Python 3.8+** (the fork targets modern interpreters; legacy Python 2 code paths have been removed). +- A Google Maps API key — see [API keys](#api-keys). +- `requests >= 2.20` (installed automatically). -Keep in mind that the same [terms and conditions](https://developers.google.com/maps/terms) apply -to usage of the APIs when they're accessed through this library. +## Installation -## Support +```bash +pip install -U googlemaps +``` -This library is community supported. We're comfortable enough with the stability and features of -the library that we want you to build real production applications on it. We will try to support, -through Stack Overflow, the public and protected surface of the library and maintain backwards -compatibility in the future; however, while the library is in version 0.x, we reserve the right -to make backwards-incompatible changes. If we do remove some functionality (typically because -better functionality exists or if the feature proved infeasible), our intention is to deprecate -and give developers a year to update their code. +For the fork (with the cache + batch executor) and a development setup: -If you find a bug, or have a feature suggestion, please log an issue. If you'd like to -contribute, please read contribute. +```bash +git clone https://github.com//google-maps-services-python.git +cd google-maps-services-python +pip install -e ".[dev]" +``` -## Requirements +## API keys - - Python 3.5 or later. - - A Google Maps API key. +Each Google Maps Web Service request requires an API key or client ID. API keys +are generated in the **Credentials** page of the **APIs & Services** tab of the +[Google Cloud console](https://console.cloud.google.com/apis/credentials). +For more on getting started and key restriction, see +[Get Started with Google Maps Platform](https://developers.google.com/maps/gmp-get-started). -## API Keys +> **Important:** API keys must be kept secret. Use environment variables or a +> secrets manager — never commit them to source control. -Each Google Maps Web Service request requires an API key or client ID. API keys -are generated in the 'Credentials' page of the 'APIs & Services' tab of [Google Cloud console](https://console.cloud.google.com/apis/credentials). +--- -For even more information on getting started with Google Maps Platform and generating/restricting an API key, see [Get Started with Google Maps Platform](https://developers.google.com/maps/gmp-get-started) in our docs. +## Quick start -**Important:** This key should be kept secret on your server. +```python +import googlemaps +from datetime import datetime -## Installation +gmaps = googlemaps.Client(key="AIza...") + +# Geocoding an address +geocode_result = gmaps.geocode("1600 Amphitheatre Parkway, Mountain View, CA") + +# Reverse geocoding +reverse_geocode_result = gmaps.reverse_geocode((40.714224, -73.961452)) + +# Directions via public transit +now = datetime.now() +directions_result = gmaps.directions( + "Sydney Town Hall", "Parramatta, NSW", + mode="transit", departure_time=now, +) + +# Address Validation +addressvalidation_result = gmaps.addressvalidation( + ["1600 Amphitheatre Pk"], + regionCode="US", locality="Mountain View", enableUspsCass=True, +) + +# Address Descriptor in a reverse geocoding response +ad_result = gmaps.reverse_geocode( + (40.714224, -73.961452), enable_address_descriptor=True, +) +``` + +For more usage examples see the [tests/](tests) directory. + +--- + +## Built-in features + +### Retry on failure + +Idempotent requests are automatically retried on transient +**500 / 503 / 504** responses with exponential back-off and jitter, capped +at the per-client `retry_timeout` (default 60 s). + +### Rate limiting (QPS / QPM) + +Both `queries_per_second` and `queries_per_minute` are honoured client-side. +The effective per-second quota is the minimum of the two; the client sleeps +before issuing a request when the deque of recent calls is full. +Use `retry_over_query_limit=False` to fail fast on `OVER_QUERY_LIMIT` +instead of the default exponential-backoff retry. + +```python +gmaps = googlemaps.Client( + key="AIza...", + queries_per_second=50, + queries_per_minute=1500, + retry_timeout=30, + retry_over_query_limit=True, +) +``` + +--- + +## Fork additions + +The fork ships **two** non-invasive additions that solve the most common +production pain points: avoiding repeated billed calls during development and +batch jobs, and turning the inherently serial client into an order-preserving +parallel one. Both are opt-in — the default behaviour of the upstream client +is preserved 1-for-1. - $ pip install -U googlemaps +### 1. Pluggable response cache (TTL + LRU) -Note that you will need requests 2.4.0 or higher if you want to specify connect/read timeouts. +> **Module:** [googlemaps/cache.py](googlemaps/cache.py) +> **Public surface:** `googlemaps.BaseCache`, `googlemaps.InMemoryTTLCache`, `googlemaps.CacheStats` -## Usage +#### Why -This example uses the Geocoding API and the Directions API with an API key: +In day-to-day development, batch ETL jobs, and the test suite of any +application that uses Maps Platform, the same `(path, params)` is requested +hundreds or thousands of times. Each repeat is billed and adds latency. +A small in-process cache returns identical responses immediately — no network +trip, no quota usage — while remaining **safe by default** so it cannot +accidentally cache write/error responses. + +#### Design at a glance + +| Concern | How it's handled | +| --- | --- | +| **Cache key** | `(path, sorted_params)` with auth params (`key`, `client`, `signature`, `channel`) **stripped** so the cache is shareable across credentials and stable across test runs. | +| **What is cached** | First-attempt `GET` requests whose API status is `OK` or `ZERO_RESULTS`. | +| **What is *never* cached** | `POST` endpoints (e.g. Geolocation), retried requests, `extract_body` overrides (e.g. binary Static Maps), and any non-`OK` API response. | +| **Eviction** | LRU on `maxsize`, plus per-entry TTL. `get` of an expired entry deletes and reports a miss. | +| **Concurrency** | Backed by `OrderedDict` + `threading.RLock`; covered by multi-thread stress tests (8 threads × 200 ops). | +| **Observability** | `cache.stats()` returns a snapshot `CacheStats(hits, misses, evictions, expirations, sets)` with a `hit_ratio` property. | +| **Pluggability** | Implement `BaseCache.get/set/clear/stats` to back it with Redis, memcached, disk, etc. | + +#### Usage ```python import googlemaps -from datetime import datetime +from googlemaps import InMemoryTTLCache -gmaps = googlemaps.Client(key='Add Your Key here') +gmaps = googlemaps.Client(key="AIza...") +gmaps.cache = InMemoryTTLCache(maxsize=512, ttl=300) # 5-minute TTL, 512 entries -# Geocoding an address -geocode_result = gmaps.geocode('1600 Amphitheatre Parkway, Mountain View, CA') +gmaps.geocode("Sydney") # network call -> stored +gmaps.geocode("Sydney") # served from cache, zero billed calls -# Look up an address with reverse geocoding -reverse_geocode_result = gmaps.reverse_geocode((40.714224, -73.961452)) +print(gmaps.cache.stats()) # CacheStats(hits=1, misses=1, sets=1, ...) +print(gmaps.cache.stats().hit_ratio) # 0.5 +``` -# Request directions via public transit -now = datetime.now() -directions_result = gmaps.directions("Sydney Town Hall", - "Parramatta, NSW", - mode="transit", - departure_time=now) +#### Custom backend (Redis sketch) -# Validate an address with address validation -addressvalidation_result = gmaps.addressvalidation(['1600 Amphitheatre Pk'], - regionCode='US', - locality='Mountain View', - enableUspsCass=True) +```python +from googlemaps import BaseCache, CacheStats + +class RedisCache(BaseCache): + def __init__(self, redis_client, ttl=300): + self._r = redis_client + self._ttl = ttl + + def get(self, key): # `key` is a hashable tuple — pickle / orjson it + ... + def set(self, key, value): + ... + def clear(self): + self._r.flushdb() + def stats(self) -> CacheStats: + ... + +gmaps.cache = RedisCache(my_redis) +``` + +### 2. Concurrent batch executor + +> **Module:** [googlemaps/batch.py](googlemaps/batch.py) +> **Public surface:** `googlemaps.BatchExecutor` + +#### Why -# Get an Address Descriptor of a location in the reverse geocoding response -address_descriptor_result = gmaps.reverse_geocode((40.714224, -73.961452), enable_address_descriptor=True) +The Google Maps web services are synchronous — one HTTP request per call. +When you need to geocode 500 addresses or fetch directions between many +origin/destination pairs, doing them one at a time is the bottleneck. +`BatchExecutor` is a thin `ThreadPoolExecutor` wrapper that: +| Concern | Behaviour | +| --- | --- | +| **Order** | Results are returned in the **same order** as the inputs. | +| **Quota safety** | Each worker still hits the same `Client` rate limiter, so QPS/QPM are respected. `max_workers` defaults to `min(client.queries_quota, 32)`. | +| **Error isolation** | Per-item exceptions are returned as `Exception` instances by default (`return_exceptions=True`) so one bad input does not poison the batch. Set `return_exceptions=False` to re-raise. | +| **Method dispatch** | Pass any client method by **name** (`"geocode"`, `"places_nearby"`, …) or as a callable. | +| **Common kwargs** | `common_kwargs={"region": "au"}` is forwarded to every invocation. | +| **Tuple unpacking** | `unpack=True` unpacks paired inputs — e.g. `[(origin, destination), ...]` for `directions`. | + +#### Usage + +```python +import googlemaps +from googlemaps import BatchExecutor + +gmaps = googlemaps.Client(key="AIza...") +batch = BatchExecutor(gmaps, max_workers=8) + +# Geocode many addresses in parallel — order is preserved, errors are isolated. +queries = ["Sydney", "Melbourne", "Perth", "Bad #@! address"] +results = batch.geocode(queries) +for query, result in zip(queries, results): + if isinstance(result, Exception): + print(f"FAILED {query}: {result}") + else: + print(query, "->", result["results"][0]["formatted_address"]) + +# Convenience shortcuts +batch.reverse_geocode([(-33.86, 151.20), (40.71, -74.00)]) +batch.directions([("A", "B"), ("C", "D")]) # tuples are unpacked +batch.place(["ChIJN1t_tDeuEmsRUsoyG83frY4"]) + +# Or any client method by name, with shared kwargs: +batch.run("geocode", ["Sydney", "Hobart"], common_kwargs={"region": "au"}) ``` -For more usage examples, check out [the tests](https://github.com/googlemaps/google-maps-services-python/tree/master/tests). +### Composing both features -## Features +The cache and batch executor are designed to compose. With both attached, +duplicate inputs in a batch are deduplicated automatically (the second +identical call is a cache hit), and the QPS limiter still gates real +network calls: -### Retry on Failure +```python +gmaps = googlemaps.Client(key="AIza...", queries_per_second=20) +gmaps.cache = InMemoryTTLCache(maxsize=10_000, ttl=3600) +batch = BatchExecutor(gmaps, max_workers=16) + +addresses = load_addresses_from_csv("input.csv") # may contain duplicates +results = batch.geocode(addresses) + +print(gmaps.cache.stats()) # observe hit ratio across the batch +``` + +--- + +## Development workflow + +```bash +# Install dev tools and the package in editable mode +pip install -e ".[dev]" + +# Run the full test suite (611 tests) +pytest -Automatically retry when intermittent failures occur. That is, when any of the retriable 5xx errors -are returned from the API. +# Lint + auto-format with ruff +ruff check googlemaps tests +ruff format googlemaps tests +# Type-check +mypy -## Building the Project +# Multi-version matrix (uses nox) +pip install nox +nox +# Generate documentation +nox -e docs - # Installing nox - $ pip install nox +# Publish docs to gh-pages +nox -e docs && mv docs/_build/html generated_docs && \ + git clean -Xdi && git checkout gh-pages +``` + +## Project layout + +```text +googlemaps/ + __init__.py # public API: Client, InMemoryTTLCache, BatchExecutor, ... + client.py # core HTTP client, auth, retries, QPS, cache hook + cache.py # BaseCache + InMemoryTTLCache + CacheStats (fork addition) + batch.py # BatchExecutor (fork addition) + exceptions.py # ApiError / HTTPError / Timeout / TransportError + addressvalidation.py # Address Validation API + directions.py # Directions API + distance_matrix.py # Distance Matrix API + elevation.py # Elevation API + geocoding.py # Geocoding API (incl. Address Descriptors) + geolocation.py # Geolocation API + maps.py # Maps Static API (StaticMapMarker / StaticMapPath) + places.py # Places API + roads.py # Roads API + timezone.py # Time Zone API + +tests/ # pytest suite — 611 tests, advanced parametrized coverage + test_cache.py # cache: API surface + test_cache_advanced.py # cache: thread-safety, TTL with fake clock, LRU eviction order + test_batch.py # batch: API surface + test_batch_advanced.py # batch: order preservation, error isolation, large workloads + test_client_advanced.py + test_convert_advanced.py + test_exceptions_advanced.py + test_maps_advanced.py + ... + +pyproject.toml # PEP 621 metadata + ruff/pytest/coverage/mypy config +``` + +## Quality bar - # Running tests - $ nox +| Metric | Status | +| --- | --- | +| Test count | **611 passing** (105 baseline + 506 added in this fork) | +| Lint (`ruff check`) | **0 errors** across `googlemaps/` and `tests/` | +| Format (`ruff format`) | **clean** | +| Python 2 compatibility code | **removed** (modern Python 3.8+ only) | +| Packaging | **PEP 621** via `pyproject.toml`; `setup.py` is a 3-line shim | +| String formatting | **f-strings** throughout | +| Exception chaining | `raise ... from err` everywhere | +| Type hints | New modules (`cache.py`, `batch.py`) are fully annotated | - # Generating documentation - $ nox -e docs +Run `pytest -q` and `ruff check googlemaps tests` locally to verify. - # Copy docs to gh-pages - $ nox -e docs && mv docs/_build/html generated_docs && git clean -Xdi && git checkout gh-pages +--- ## Documentation & resources @@ -141,10 +399,20 @@ are returned from the API. - [Geolocation API](https://developers.google.com/maps/documentation/geolocation/) - [Time Zone API](https://developers.google.com/maps/documentation/timezone/) - [Roads API](https://developers.google.com/maps/documentation/roads/) -- [Places API](https://developers.google.com/places/) +- [Places API](https://developers.google.com/maps/documentation/places/) - [Maps Static API](https://developers.google.com/maps/documentation/maps-static/) -### Support +## Support + +This library is community supported. The fork additions +([googlemaps/cache.py](googlemaps/cache.py) and +[googlemaps/batch.py](googlemaps/batch.py)) are covered by their own dedicated +test files. We're comfortable enough with the stability and features of the +library that you can build real production applications on it. + +If you find a bug or have a feature suggestion, please log an issue. +If you'd like to contribute, please read [CONTRIB.md](CONTRIB.md). + - [Report an issue](https://github.com/googlemaps/google-maps-services-python/issues) - [Contribute](https://github.com/googlemaps/google-maps-services-python/blob/master/CONTRIB.md) - [StackOverflow](http://stackoverflow.com/questions/tagged/google-maps) diff --git a/coverage.xml b/coverage.xml deleted file mode 100644 index 1c38ca3d..00000000 --- a/coverage.xml +++ /dev/null @@ -1,849 +0,0 @@ - - - - - - /Users/anglarett/Public/Drop Box/dev-se-git/google-maps-services-python - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/googlemaps/__init__.py b/googlemaps/__init__.py index 61ec45d0..f680a026 100644 --- a/googlemaps/__init__.py +++ b/googlemaps/__init__.py @@ -17,8 +17,16 @@ __version__ = "4.10.0" -from googlemaps.client import Client from googlemaps import exceptions +from googlemaps.batch import BatchExecutor +from googlemaps.cache import BaseCache, CacheStats, InMemoryTTLCache +from googlemaps.client import Client - -__all__ = ["Client", "exceptions"] +__all__ = [ + "BaseCache", + "BatchExecutor", + "CacheStats", + "Client", + "InMemoryTTLCache", + "exceptions", +] diff --git a/googlemaps/addressvalidation.py b/googlemaps/addressvalidation.py index 149f3b48..16ab3b10 100644 --- a/googlemaps/addressvalidation.py +++ b/googlemaps/addressvalidation.py @@ -16,8 +16,6 @@ # """Performs requests to the Google Maps Address Validation API.""" -from googlemaps import exceptions - _ADDRESSVALIDATION_BASE_URL = "https://addressvalidation.googleapis.com" @@ -44,26 +42,22 @@ def _addressvalidation_extract(response): # raise exceptions.ApiError(response.status_code, error) -def addressvalidation(client, addressLines, regionCode=None , locality=None, enableUspsCass=None): +def addressvalidation(client, addressLines, regionCode=None, locality=None, enableUspsCass=None): """ The Google Maps Address Validation API returns a verification of an address See https://developers.google.com/maps/documentation/address-validation/overview request must include parameters below. :param addressLines: The address to validate - :type addressLines: array + :type addressLines: array :param regionCode: (optional) The country code - :type regionCode: string + :type regionCode: string :param locality: (optional) Restrict to a locality, ie:Mountain View :type locality: string :param enableUspsCass For the "US" and "PR" regions only, you can optionally enable the Coding Accuracy Support System (CASS) from the United States Postal Service (USPS) :type locality: boolean """ - params = { - "address":{ - "addressLines": addressLines - } - } + params = {"address": {"addressLines": addressLines}} if regionCode is not None: params["address"]["regionCode"] = regionCode @@ -74,8 +68,10 @@ def addressvalidation(client, addressLines, regionCode=None , locality=None, ena if enableUspsCass is not False or enableUspsCass is not None: params["enableUspsCass"] = enableUspsCass - return client._request("/v1:validateAddress", {}, # No GET params - base_url=_ADDRESSVALIDATION_BASE_URL, - extract_body=_addressvalidation_extract, - post_json=params) - \ No newline at end of file + return client._request( + "/v1:validateAddress", + {}, # No GET params + base_url=_ADDRESSVALIDATION_BASE_URL, + extract_body=_addressvalidation_extract, + post_json=params, + ) diff --git a/googlemaps/batch.py b/googlemaps/batch.py new file mode 100644 index 00000000..1671e19d --- /dev/null +++ b/googlemaps/batch.py @@ -0,0 +1,160 @@ +# +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# + +"""Concurrent batch helpers for the Google Maps client. + +The official Google Maps web services are synchronous — one HTTP request per +call. When you need to geocode hundreds of addresses or fetch directions +between many origin/destination pairs, doing them serially is the bottleneck. + +This module provides :class:`BatchExecutor`, a thin :class:`ThreadPoolExecutor` +wrapper that: + +1. Respects the parent client's QPS quota (each worker still hits the same + :class:`~googlemaps.client.Client` rate limiter). +2. Returns results in the **same order** as the inputs. +3. Captures per-item exceptions instead of cancelling the whole batch + (``return_exceptions=True``, default), so one bad address does not + poison the batch. +4. Supports any client method by name — including custom ones registered + on the client. + +Example +------- + +.. code-block:: python + + import googlemaps + from googlemaps.batch import BatchExecutor + + gmaps = googlemaps.Client(key="AIza...") + batch = BatchExecutor(gmaps, max_workers=8) + + results = batch.run( + "geocode", + ["Sydney", "Melbourne", "Perth", "Bad address #@$"], + ) + for query, result in zip(inputs, results): + if isinstance(result, Exception): + print(f"FAILED {query}: {result}") + else: + print(query, "->", result[0]["formatted_address"]) + + # Convenience shortcuts: + batch.geocode(["Sydney", "Melbourne"]) + batch.directions([("A", "B"), ("C", "D")]) +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Iterable, Sequence + + +class BatchExecutor: + """Run a Google Maps client method concurrently over a list of inputs. + + :param client: A :class:`googlemaps.Client` instance. + :param max_workers: Worker thread count. Defaults to the smaller of + ``client.queries_quota`` and ``32``. + :param return_exceptions: When ``True`` (default) a failing item is + returned as the raised :class:`Exception` instance instead of + propagating. When ``False`` the first failure raises. + """ + + def __init__( + self, + client, + max_workers: int | None = None, + return_exceptions: bool = True, + ) -> None: + if client is None: + raise ValueError("client is required") + if max_workers is not None and max_workers < 1: + raise ValueError("max_workers must be >= 1") + self.client = client + self.return_exceptions = return_exceptions + if max_workers is None: + quota = getattr(client, "queries_quota", 32) or 32 + max_workers = min(int(quota), 32) + self.max_workers = max_workers + + # ------------------------------------------------------------------ core + + def run( + self, + method: str | Callable, + inputs: Sequence[Any], + *, + common_kwargs: dict | None = None, + unpack: bool = False, + ) -> list: + """Execute ``method`` once per item in ``inputs`` concurrently. + + :param method: Either the name of a method bound to ``client`` + (e.g. ``"geocode"``) or a callable taking ``(client, item, **kwargs)``. + :param inputs: Iterable of items. Each item is passed positionally to + the method (or unpacked when ``unpack=True``). + :param common_kwargs: Optional keyword arguments forwarded to every + invocation (e.g. ``{"language": "en"}``). + :param unpack: When ``True`` and an item is a tuple/list, it is + unpacked as positional arguments — useful for paired inputs like + ``[(origin, destination), ...]`` for ``directions``. + :returns: A list of results in the same order as ``inputs``. + """ + if isinstance(method, str): + fn = getattr(self.client, method, None) + if fn is None or not callable(fn): + raise AttributeError(f"client has no callable method {method!r}") + else: + fn = method + + common_kwargs = common_kwargs or {} + items = list(inputs) + + def _call(item): + args = tuple(item) if (unpack and isinstance(item, (tuple, list))) else (item,) + return fn(*args, **common_kwargs) + + if not items: + return [] + + with ThreadPoolExecutor(max_workers=self.max_workers) as pool: + futures = [pool.submit(_call, it) for it in items] + results: list = [] + for fut in futures: + try: + results.append(fut.result()) + except Exception as exc: + if not self.return_exceptions: + raise + results.append(exc) + return results + + # -------------------------------------------------------------- shortcuts + + def geocode(self, addresses: Iterable[str], **kwargs) -> list: + """Batch geocoding shortcut. Equivalent to ``run("geocode", addresses)``.""" + return self.run("geocode", list(addresses), common_kwargs=kwargs) + + def reverse_geocode(self, latlngs: Iterable, **kwargs) -> list: + """Batch reverse-geocoding shortcut.""" + return self.run("reverse_geocode", list(latlngs), common_kwargs=kwargs) + + def directions(self, pairs: Iterable, **kwargs) -> list: + """Batch directions shortcut. + + Each item in ``pairs`` must be a ``(origin, destination)`` tuple. + """ + return self.run("directions", list(pairs), common_kwargs=kwargs, unpack=True) + + def place(self, place_ids: Iterable[str], **kwargs) -> list: + """Batch place-details shortcut.""" + return self.run("place", list(place_ids), common_kwargs=kwargs) diff --git a/googlemaps/cache.py b/googlemaps/cache.py new file mode 100644 index 00000000..cd2fb0f5 --- /dev/null +++ b/googlemaps/cache.py @@ -0,0 +1,184 @@ +# +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# + +"""Pluggable response cache for the Google Maps client. + +This module ships an opinionated, in-process TTL+LRU cache implementation +(:class:`InMemoryTTLCache`) and a tiny abstract interface (:class:`BaseCache`) +that any external backend (Redis, memcached, disk) can implement. + +When a cache is attached to a :class:`googlemaps.Client` instance via +``client.cache = InMemoryTTLCache(...)``, idempotent ``GET`` requests are +served from the cache when their ``(path, params)`` pair has been seen within +the TTL. ``POST`` requests and responses with non-``OK``/``ZERO_RESULTS`` API +status are never cached. + +The cache key intentionally **excludes** authentication parameters +(``key``, ``client``, ``signature``, ``channel``) so the same lookup is +shareable across credentials and reproducible in tests. + +Example +------- + +.. code-block:: python + + import googlemaps + from googlemaps.cache import InMemoryTTLCache + + gmaps = googlemaps.Client(key="AIza...") + gmaps.cache = InMemoryTTLCache(maxsize=512, ttl=300) # 5 min TTL + + gmaps.geocode("Sydney") # network call + gmaps.geocode("Sydney") # served from cache + print(gmaps.cache.stats()) # CacheStats(hits=1, misses=1, ...) +""" + +from __future__ import annotations + +import threading +import time +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Iterable + +# Parameters that should never participate in the cache key. Including them +# would defeat sharing across credentials and leak secrets into key dumps. +_AUTH_PARAMS = frozenset({"key", "client", "signature", "channel"}) + + +def make_cache_key(path: str, params: Iterable[tuple[str, Any]]) -> tuple: + """Build a deterministic, hashable cache key from a request. + + :param path: Request path (e.g. ``"/maps/api/geocode/json"``). + :param params: Request parameters as an iterable of ``(key, value)`` tuples + (the same shape used internally by :meth:`Client._generate_auth_url`). + :returns: A hashable tuple suitable for use as a dict key. + """ + filtered = tuple(sorted((k, v) for k, v in params if k not in _AUTH_PARAMS)) + return (path, filtered) + + +@dataclass +class CacheStats: + """Lightweight counters for cache observability.""" + + hits: int = 0 + misses: int = 0 + evictions: int = 0 + expirations: int = 0 + sets: int = 0 + + @property + def hit_ratio(self) -> float: + total = self.hits + self.misses + return (self.hits / total) if total else 0.0 + + +class BaseCache: + """Abstract cache interface. + + Implementations must be safe for concurrent use from multiple threads. + """ + + def get(self, key) -> Any | None: # pragma: no cover - interface + raise NotImplementedError + + def set(self, key, value) -> None: # pragma: no cover - interface + raise NotImplementedError + + def clear(self) -> None: # pragma: no cover - interface + raise NotImplementedError + + def stats(self) -> CacheStats: # pragma: no cover - interface + raise NotImplementedError + + +@dataclass +class _Entry: + value: Any + expires_at: float + + +class InMemoryTTLCache(BaseCache): + """Thread-safe in-memory cache with TTL eviction and LRU bound. + + :param maxsize: Maximum number of entries. When exceeded the + least-recently-used entry is evicted. Must be ``>= 1``. + :param ttl: Seconds an entry remains valid after insertion. ``None`` + disables TTL (entries live until evicted). + """ + + def __init__(self, maxsize: int = 256, ttl: float | None = 300) -> None: + if maxsize < 1: + raise ValueError("maxsize must be >= 1") + if ttl is not None and ttl <= 0: + raise ValueError("ttl must be > 0 or None") + self._maxsize = maxsize + self._ttl = ttl + self._data: OrderedDict[Any, _Entry] = OrderedDict() + self._lock = threading.RLock() + self._stats = CacheStats() + # Injectable clock for testability. + self._now = time.monotonic + + # ------------------------------------------------------------------ API + + def get(self, key) -> Any | None: + with self._lock: + entry = self._data.get(key) + if entry is None: + self._stats.misses += 1 + return None + if entry.expires_at and entry.expires_at <= self._now(): + # Expired — drop and miss. + del self._data[key] + self._stats.expirations += 1 + self._stats.misses += 1 + return None + # LRU touch. + self._data.move_to_end(key) + self._stats.hits += 1 + return entry.value + + def set(self, key, value) -> None: + with self._lock: + expires_at = self._now() + self._ttl if self._ttl else 0.0 + if key in self._data: + self._data.move_to_end(key) + self._data[key] = _Entry(value=value, expires_at=expires_at) + self._stats.sets += 1 + while len(self._data) > self._maxsize: + self._data.popitem(last=False) + self._stats.evictions += 1 + + def clear(self) -> None: + with self._lock: + self._data.clear() + self._stats = CacheStats() + + def stats(self) -> CacheStats: + with self._lock: + # Return a snapshot to avoid races from the caller. + return CacheStats( + hits=self._stats.hits, + misses=self._stats.misses, + evictions=self._stats.evictions, + expirations=self._stats.expirations, + sets=self._stats.sets, + ) + + # ----------------------------------------------------------- introspection + + def __len__(self) -> int: + with self._lock: + return len(self._data) + + def __contains__(self, key) -> bool: + return self.get(key) is not None diff --git a/googlemaps/client.py b/googlemaps/client.py index d1f4ab6a..36364ffa 100644 --- a/googlemaps/client.py +++ b/googlemaps/client.py @@ -22,30 +22,27 @@ import base64 import collections -import logging -from datetime import datetime -from datetime import timedelta +import contextlib import functools import hashlib import hmac -import re -import requests +import logging +import math import random +import re import time -import math -import sys +from datetime import datetime, timedelta +from urllib.parse import urlencode -import googlemaps +import requests -try: # Python 3 - from urllib.parse import urlencode -except ImportError: # Python 2 - from urllib import urlencode +import googlemaps +from googlemaps.cache import make_cache_key logger = logging.getLogger(__name__) _X_GOOG_MAPS_EXPERIENCE_ID = "X-Goog-Maps-Experience-ID" -_USER_AGENT = "GoogleGeoApiClientPython/%s" % googlemaps.__version__ +_USER_AGENT = f"GoogleGeoApiClientPython/{googlemaps.__version__}" _DEFAULT_BASE_URL = "https://maps.googleapis.com" _RETRIABLE_STATUSES = {500, 503, 504} @@ -54,13 +51,24 @@ class Client: """Performs requests to the Google Maps API web services.""" - def __init__(self, key=None, client_id=None, client_secret=None, - timeout=None, connect_timeout=None, read_timeout=None, - retry_timeout=60, requests_kwargs=None, - queries_per_second=60, queries_per_minute=6000,channel=None, - retry_over_query_limit=True, experience_id=None, - requests_session=None, - base_url=_DEFAULT_BASE_URL): + def __init__( + self, + key=None, + client_id=None, + client_secret=None, + timeout=None, + connect_timeout=None, + read_timeout=None, + retry_timeout=60, + requests_kwargs=None, + queries_per_second=60, + queries_per_minute=6000, + channel=None, + retry_over_query_limit=True, + experience_id=None, + requests_session=None, + base_url=_DEFAULT_BASE_URL, + ): """ :param key: Maps API key. Required, unless "client_id" and "client_secret" are set. Most users should use an API key. @@ -130,39 +138,37 @@ def __init__(self, key=None, client_id=None, client_secret=None, :param requests_session: Reused persistent session for flexibility. :type requests_session: requests.Session - + :param base_url: The base URL for all requests. Defaults to the Maps API server. Should not have a trailing slash. :type base_url: string """ if not key and not (client_secret and client_id): - raise ValueError("Must provide API key or enterprise credentials " - "when creating client.") + raise ValueError("Must provide API key or enterprise credentials when creating client.") if key and not key.startswith("AIza"): raise ValueError("Invalid API key provided.") - if channel: - if not re.match("^[a-zA-Z0-9._-]*$", channel): - raise ValueError("The channel argument must be an ASCII " - "alphanumeric string. The period (.), underscore (_)" - "and hyphen (-) characters are allowed. If used without " - "client_id, it must be 0-999.") + if channel and not re.match("^[a-zA-Z0-9._-]*$", channel): + raise ValueError( + "The channel argument must be an ASCII " + "alphanumeric string. The period (.), underscore (_)" + "and hyphen (-) characters are allowed. If used without " + "client_id, it must be 0-999." + ) self.session = requests_session or requests.Session() self.key = key if timeout and (connect_timeout or read_timeout): - raise ValueError("Specify either timeout, or connect_timeout " - "and read_timeout") + raise ValueError("Specify either timeout, or connect_timeout and read_timeout") if connect_timeout and read_timeout: # Check that the version of requests is >= 2.4.0 chunks = requests.__version__.split(".") if int(chunks[0]) < 2 or (int(chunks[0]) == 2 and int(chunks[1]) < 4): - raise NotImplementedError("Connect/Read timeouts require " - "requests v2.4.0 or higher") + raise NotImplementedError("Connect/Read timeouts require requests v2.4.0 or higher") self.timeout = (connect_timeout, read_timeout) else: self.timeout = timeout @@ -172,34 +178,40 @@ def __init__(self, key=None, client_id=None, client_secret=None, self.channel = channel self.retry_timeout = timedelta(seconds=retry_timeout) self.requests_kwargs = requests_kwargs or {} - headers = self.requests_kwargs.pop('headers', {}) + headers = self.requests_kwargs.pop("headers", {}) headers.update({"User-Agent": _USER_AGENT}) - self.requests_kwargs.update({ - "headers": headers, - "timeout": self.timeout, - "verify": True, # NOTE(cbro): verify SSL certs. - }) - + self.requests_kwargs.update( + { + "headers": headers, + "timeout": self.timeout, + "verify": True, # NOTE(cbro): verify SSL certs. + } + ) + self.queries_per_second = queries_per_second self.queries_per_minute = queries_per_minute - try: - if (type(self.queries_per_second) == int and type(self.queries_per_minute) == int ): - self.queries_quota = math.floor(min(self.queries_per_second, self.queries_per_minute/60)) - elif (self.queries_per_second and type(self.queries_per_second) == int ): - self.queries_quota = math.floor(self.queries_per_second) - elif (self.queries_per_minute and type(self.queries_per_minute) == int ): - self.queries_quota = math.floor(self.queries_per_minute/60) - else: - sys.exit("MISSING VALID NUMBER for queries_per_second or queries_per_minute") - logger.info("API queries_quota: %s", self.queries_quota) - - except NameError: - sys.exit("MISSING VALUE for queries_per_second or queries_per_minute") + qps_int = isinstance(queries_per_second, int) and not isinstance(queries_per_second, bool) + qpm_int = isinstance(queries_per_minute, int) and not isinstance(queries_per_minute, bool) + if qps_int and qpm_int: + self.queries_quota = math.floor(min(queries_per_second, queries_per_minute / 60)) + elif qps_int and queries_per_second: + self.queries_quota = math.floor(queries_per_second) + elif qpm_int and queries_per_minute: + self.queries_quota = math.floor(queries_per_minute / 60) + else: + raise ValueError( + "Must provide a valid integer for queries_per_second or queries_per_minute." + ) + logger.info("API queries_quota: %s", self.queries_quota) self.retry_over_query_limit = retry_over_query_limit - self.sent_times = collections.deque("", self.queries_quota) + self.sent_times = collections.deque(maxlen=self.queries_quota) self.set_experience_id(experience_id) self.base_url = base_url + # Optional response cache; see googlemaps.cache. + self.cache = None + # Optional metrics collector; see googlemaps.metrics. + self.metrics = None def set_experience_id(self, *experience_id_args): """Sets the value for the HTTP header field name @@ -236,9 +248,18 @@ def clear_experience_id(self): headers.pop(_X_GOOG_MAPS_EXPERIENCE_ID, {}) self.requests_kwargs["headers"] = headers - def _request(self, url, params, first_request_time=None, retry_counter=0, - base_url=None, accepts_clientid=True, - extract_body=None, requests_kwargs=None, post_json=None): + def _request( + self, + url, + params, + first_request_time=None, + retry_counter=0, + base_url=None, + accepts_clientid=True, + extract_body=None, + requests_kwargs=None, + post_json=None, + ): """Performs HTTP GET/POST with credentials, returning the body as JSON. @@ -281,7 +302,7 @@ def _request(self, url, params, first_request_time=None, retry_counter=0, if base_url is None: base_url = self.base_url - + if not first_request_time: first_request_time = datetime.now() @@ -289,6 +310,23 @@ def _request(self, url, params, first_request_time=None, retry_counter=0, if elapsed > self.retry_timeout: raise googlemaps.exceptions.Timeout() + # Cache lookup (GET-only, no extract_body override, only on first attempt + # so retries don't double-count). The cache key intentionally excludes + # auth params; see googlemaps.cache.make_cache_key. + cache_key = None + cacheable = ( + self.cache is not None + and post_json is None + and extract_body is None + and retry_counter == 0 + ) + if cacheable: + param_items = params.items() if isinstance(params, dict) else params + cache_key = make_cache_key(url, param_items) + cached = self.cache.get(cache_key) + if cached is not None: + return cached + if retry_counter > 0: # 0.5 * (1.5 ^ i) is an increased sleep time of 1.5x per iteration, # starting at 0.5s when retry_counter=0. The first retry will occur @@ -312,18 +350,25 @@ def _request(self, url, params, first_request_time=None, retry_counter=0, final_requests_kwargs["json"] = post_json try: - response = requests_method(base_url + authed_url, - **final_requests_kwargs) - except requests.exceptions.Timeout: - raise googlemaps.exceptions.Timeout() + response = requests_method(base_url + authed_url, **final_requests_kwargs) + except requests.exceptions.Timeout as err: + raise googlemaps.exceptions.Timeout() from err except Exception as e: - raise googlemaps.exceptions.TransportError(e) + raise googlemaps.exceptions.TransportError(e) from e if response.status_code in _RETRIABLE_STATUSES: # Retry request. - return self._request(url, params, first_request_time, - retry_counter + 1, base_url, accepts_clientid, - extract_body, requests_kwargs, post_json) + return self._request( + url, + params, + first_request_time, + retry_counter + 1, + base_url, + accepts_clientid, + extract_body, + requests_kwargs, + post_json, + ) # Check if the time of the nth previous query (where n is # queries_per_second) is under a second ago - if so, sleep for @@ -334,20 +379,30 @@ def _request(self, url, params, first_request_time=None, retry_counter=0, time.sleep(1 - elapsed_since_earliest) try: - if extract_body: - result = extract_body(response) - else: - result = self._get_body(response) + result = extract_body(response) if extract_body else self._get_body(response) self.sent_times.append(time.time()) + if cacheable and cache_key is not None: + self.cache.set(cache_key, result) return result except googlemaps.exceptions._RetriableRequest as e: - if isinstance(e, googlemaps.exceptions._OverQueryLimit) and not self.retry_over_query_limit: + if ( + isinstance(e, googlemaps.exceptions._OverQueryLimit) + and not self.retry_over_query_limit + ): raise # Retry request. - return self._request(url, params, first_request_time, - retry_counter + 1, base_url, accepts_clientid, - extract_body, requests_kwargs, post_json) + return self._request( + url, + params, + first_request_time, + retry_counter + 1, + base_url, + accepts_clientid, + extract_body, + requests_kwargs, + post_json, + ) def _get(self, *args, **kwargs): # Backwards compatibility. return self._request(*args, **kwargs) @@ -359,15 +414,13 @@ def _get_body(self, response): body = response.json() api_status = body["status"] - if api_status == "OK" or api_status == "ZERO_RESULTS": + if api_status in {"OK", "ZERO_RESULTS"}: return body if api_status == "OVER_QUERY_LIMIT": - raise googlemaps.exceptions._OverQueryLimit( - api_status, body.get("error_message")) + raise googlemaps.exceptions._OverQueryLimit(api_status, body.get("error_message")) - raise googlemaps.exceptions.ApiError(api_status, - body.get("error_message")) + raise googlemaps.exceptions.ApiError(api_status, body.get("error_message")) def _generate_auth_url(self, path, params, accepts_clientid): """Returns the path and query string portion of the request URL, first @@ -385,10 +438,10 @@ def _generate_auth_url(self, path, params, accepts_clientid): # Deterministic ordering through sorting by key. # Useful for tests, and in the future, any caching. extra_params = getattr(self, "_extra_params", None) or {} - if type(params) is dict: + if isinstance(params, dict): params = sorted(dict(extra_params, **params).items()) else: - params = sorted(extra_params.items()) + params[:] # Take a copy. + params = sorted(extra_params.items()) + params[:] # Take a copy. if accepts_clientid and self.client_id and self.client_secret: if self.channel: @@ -403,31 +456,30 @@ def _generate_auth_url(self, path, params, accepts_clientid): params.append(("key", self.key)) return path + "?" + urlencode_params(params) - raise ValueError("Must provide API key for this API. It does not accept " - "enterprise credentials.") + raise ValueError( + "Must provide API key for this API. It does not accept enterprise credentials." + ) +from googlemaps.addressvalidation import addressvalidation from googlemaps.directions import directions from googlemaps.distance_matrix import distance_matrix -from googlemaps.elevation import elevation -from googlemaps.elevation import elevation_along_path -from googlemaps.geocoding import geocode -from googlemaps.geocoding import reverse_geocode +from googlemaps.elevation import elevation, elevation_along_path +from googlemaps.geocoding import geocode, reverse_geocode from googlemaps.geolocation import geolocate -from googlemaps.timezone import timezone -from googlemaps.roads import snap_to_roads -from googlemaps.roads import nearest_roads -from googlemaps.roads import speed_limits -from googlemaps.roads import snapped_speed_limits -from googlemaps.places import find_place -from googlemaps.places import places -from googlemaps.places import places_nearby -from googlemaps.places import place -from googlemaps.places import places_photo -from googlemaps.places import places_autocomplete -from googlemaps.places import places_autocomplete_query from googlemaps.maps import static_map -from googlemaps.addressvalidation import addressvalidation +from googlemaps.places import ( + find_place, + place, + places, + places_autocomplete, + places_autocomplete_query, + places_nearby, + places_photo, +) +from googlemaps.roads import nearest_roads, snap_to_roads, snapped_speed_limits, speed_limits +from googlemaps.timezone import timezone + def make_api_method(func): """ @@ -439,15 +491,15 @@ def make_api_method(func): Please note that this is an unsupported feature for advanced use only. It's also currently incompatibile with multiple threads, see GH #160. """ + @functools.wraps(func) def wrapper(*args, **kwargs): args[0]._extra_params = kwargs.pop("extra_params", None) result = func(*args, **kwargs) - try: + with contextlib.suppress(AttributeError): del args[0]._extra_params - except AttributeError: - pass return result + return wrapper @@ -485,11 +537,11 @@ def sign_hmac(secret, payload): :rtype: string """ - payload = payload.encode('ascii', 'strict') - secret = secret.encode('ascii', 'strict') + payload = payload.encode("ascii", "strict") + secret = secret.encode("ascii", "strict") sig = hmac.new(base64.urlsafe_b64decode(secret), payload, hashlib.sha1) out = base64.urlsafe_b64encode(sig.digest()) - return out.decode('utf-8') + return out.decode("utf-8") def urlencode_params(params): @@ -515,26 +567,8 @@ def urlencode_params(params): return requests.utils.unquote_unreserved(urlencode(extended)) -try: - unicode - # NOTE(cbro): `unicode` was removed in Python 3. In Python 3, NameError is - # raised here, and caught below. - - def normalize_for_urlencode(value): - """(Python 2) Converts the value to a `str` (raw bytes).""" - if isinstance(value, unicode): - return value.encode('utf8') - - if isinstance(value, str): - return value - - return normalize_for_urlencode(str(value)) - -except NameError: - def normalize_for_urlencode(value): - """(Python 3) No-op.""" - # urlencode in Python 3 handles all the types we are passing it. - if isinstance(value, str): - return value - - return normalize_for_urlencode(str(value)) +def normalize_for_urlencode(value): + """Coerce a value into a ``str`` suitable for ``urllib.parse.urlencode``.""" + if isinstance(value, str): + return value + return normalize_for_urlencode(str(value)) diff --git a/googlemaps/convert.py b/googlemaps/convert.py index 2b3d056e..13d5b755 100644 --- a/googlemaps/convert.py +++ b/googlemaps/convert.py @@ -17,15 +17,15 @@ """Converts Python types to string representations suitable for Maps API server. - For example: +For example: - sydney = { - "lat" : -33.8674869, - "lng" : 151.2069902 - } +sydney = { + "lat" : -33.8674869, + "lng" : 151.2069902 +} - convert.latlng(sydney) - # '-33.8674869,151.2069902' +convert.latlng(sydney) +# '-33.8674869,151.2069902' """ @@ -52,7 +52,7 @@ def format_float(arg): :rtype: string """ - return ("%.8f" % float(arg)).rstrip("0").rstrip(".") + return f"{float(arg):.8f}".rstrip("0").rstrip(".") def latlng(arg): @@ -78,7 +78,7 @@ def latlng(arg): return arg normalized = normalize_lat_lng(arg) - return "%s,%s" % (format_float(normalized[0]), format_float(normalized[1])) + return f"{format_float(normalized[0])},{format_float(normalized[1])}" def normalize_lat_lng(arg): @@ -103,9 +103,7 @@ def normalize_lat_lng(arg): if _is_list(arg): return arg[0], arg[1] - raise TypeError( - "Expected a lat/lng dict or tuple, " - "but got %s" % type(arg).__name__) + raise TypeError(f"Expected a lat/lng dict or tuple, but got {type(arg).__name__}") def location_list(arg): @@ -158,18 +156,18 @@ def _is_list(arg): """Checks if arg is list-like. This excludes strings and dicts.""" if isinstance(arg, dict): return False - if isinstance(arg, str): # Python 3-only, as str has __iter__ + if isinstance(arg, str): # Python 3-only, as str has __iter__ return False - return _has_method(arg, "__getitem__") if not _has_method(arg, "strip") else _has_method(arg, "__iter__") + return ( + _has_method(arg, "__getitem__") + if not _has_method(arg, "strip") + else _has_method(arg, "__iter__") + ) def is_string(val): - """Determines whether the passed value is a string, safe for 2/3.""" - try: - basestring - except NameError: - return isinstance(val, str) - return isinstance(val, basestring) + """Determines whether the passed value is a string.""" + return isinstance(val, str) def time(arg): @@ -226,14 +224,12 @@ def components(arg): def expand(arg): for k, v in arg.items(): for item in as_list(v): - yield "%s:%s" % (k, item) + yield f"{k}:{item}" if isinstance(arg, dict): return "|".join(sorted(expand(arg))) - raise TypeError( - "Expected a dict for components, " - "but got %s" % type(arg).__name__) + raise TypeError(f"Expected a dict for components, but got {type(arg).__name__}") def bounds(arg): @@ -266,25 +262,19 @@ def bounds(arg): if is_string(arg) and arg.count("|") == 1 and arg.count(",") == 2: return arg - elif isinstance(arg, dict): - if "southwest" in arg and "northeast" in arg: - return "%s|%s" % (latlng(arg["southwest"]), - latlng(arg["northeast"])) + if isinstance(arg, dict) and "southwest" in arg and "northeast" in arg: + return f"{latlng(arg['southwest'])}|{latlng(arg['northeast'])}" - raise TypeError( - "Expected a bounds (southwest/northeast) dict, " - "but got %s" % type(arg).__name__) + raise TypeError(f"Expected a bounds (southwest/northeast) dict, but got {type(arg).__name__}") def size(arg): if isinstance(arg, int): - return "%sx%s" % (arg, arg) - elif _is_list(arg): - return "%sx%s" % (arg[0], arg[1]) + return f"{arg}x{arg}" + if _is_list(arg): + return f"{arg[0]}x{arg[1]}" - raise TypeError( - "Expected a size int or list, " - "but got %s" % type(arg).__name__) + raise TypeError(f"Expected a size int or list, but got {type(arg).__name__}") def decode_polyline(polyline): @@ -309,7 +299,7 @@ def decode_polyline(polyline): index += 1 result += b << shift shift += 5 - if b < 0x1f: + if b < 0x1F: break lat += (~result >> 1) if (result & 1) != 0 else (result >> 1) @@ -320,7 +310,7 @@ def decode_polyline(polyline): index += 1 result += b << shift shift += 5 - if b < 0x1f: + if b < 0x1F: break lng += ~(result >> 1) if (result & 1) != 0 else (result >> 1) @@ -345,17 +335,17 @@ def encode_polyline(points): for point in points: ll = normalize_lat_lng(point) - lat = int(round(ll[0] * 1e5)) - lng = int(round(ll[1] * 1e5)) + lat = round(ll[0] * 1e5) + lng = round(ll[1] * 1e5) d_lat = lat - last_lat d_lng = lng - last_lng - for v in [d_lat, d_lng]: - v = ~(v << 1) if v < 0 else v << 1 + for delta in (d_lat, d_lng): + v = ~(delta << 1) if delta < 0 else delta << 1 while v >= 0x20: - result += (chr((0x20 | (v & 0x1f)) + 63)) + result += chr((0x20 | (v & 0x1F)) + 63) v >>= 5 - result += (chr(v + 63)) + result += chr(v + 63) last_lat = lat last_lng = lng @@ -378,7 +368,7 @@ def shortest_path(locations): if isinstance(locations, tuple): # Handle the single-tuple lat/lng case. locations = [locations] - encoded = "enc:%s" % encode_polyline(locations) + encoded = f"enc:{encode_polyline(locations)}" unencoded = location_list(locations) if len(encoded) < len(unencoded): return encoded diff --git a/googlemaps/directions.py b/googlemaps/directions.py index 353145cc..c31278a1 100644 --- a/googlemaps/directions.py +++ b/googlemaps/directions.py @@ -20,11 +20,24 @@ from googlemaps import convert -def directions(client, origin, destination, - mode=None, waypoints=None, alternatives=False, avoid=None, - language=None, units=None, region=None, departure_time=None, - arrival_time=None, optimize_waypoints=False, transit_mode=None, - transit_routing_preference=None, traffic_model=None): +def directions( + client, + origin, + destination, + mode=None, + waypoints=None, + alternatives=False, + avoid=None, + language=None, + units=None, + region=None, + departure_time=None, + arrival_time=None, + optimize_waypoints=False, + transit_mode=None, + transit_routing_preference=None, + traffic_model=None, +): """Get directions between an origin point and a destination point. :param origin: The address or latitude/longitude value from which you wish @@ -98,10 +111,7 @@ def directions(client, origin, destination, :rtype: list of routes """ - params = { - "origin": convert.latlng(origin), - "destination": convert.latlng(destination) - } + params = {"origin": convert.latlng(origin), "destination": convert.latlng(destination)} if mode: # NOTE(broady): the mode parameter is not validated by the Maps API @@ -138,8 +148,7 @@ def directions(client, origin, destination, params["arrival_time"] = convert.time(arrival_time) if departure_time and arrival_time: - raise ValueError("Should not specify both departure_time and" - "arrival_time.") + raise ValueError("Should not specify both departure_time andarrival_time.") if transit_mode: params["transit_mode"] = convert.join_list("|", transit_mode) diff --git a/googlemaps/distance_matrix.py b/googlemaps/distance_matrix.py index a30cbe09..ebcee817 100755 --- a/googlemaps/distance_matrix.py +++ b/googlemaps/distance_matrix.py @@ -20,11 +20,22 @@ from googlemaps import convert -def distance_matrix(client, origins, destinations, - mode=None, language=None, avoid=None, units=None, - departure_time=None, arrival_time=None, transit_mode=None, - transit_routing_preference=None, traffic_model=None, region=None): - """ Gets travel distance and time for a matrix of origins and destinations. +def distance_matrix( + client, + origins, + destinations, + mode=None, + language=None, + avoid=None, + units=None, + departure_time=None, + arrival_time=None, + transit_mode=None, + transit_routing_preference=None, + traffic_model=None, + region=None, +): + """Gets travel distance and time for a matrix of origins and destinations. :param origins: One or more addresses, Place IDs, and/or latitude/longitude values, from which to calculate distance and time. Each Place ID string @@ -93,7 +104,7 @@ def distance_matrix(client, origins, destinations, params = { "origins": convert.location_list(origins), - "destinations": convert.location_list(destinations) + "destinations": convert.location_list(destinations), } if mode: @@ -121,8 +132,7 @@ def distance_matrix(client, origins, destinations, params["arrival_time"] = convert.time(arrival_time) if departure_time and arrival_time: - raise ValueError("Should not specify both departure_time and" - "arrival_time.") + raise ValueError("Should not specify both departure_time andarrival_time.") if transit_mode: params["transit_mode"] = convert.join_list("|", transit_mode) diff --git a/googlemaps/elevation.py b/googlemaps/elevation.py index 8eb6b14a..5d4bd054 100644 --- a/googlemaps/elevation.py +++ b/googlemaps/elevation.py @@ -52,14 +52,8 @@ def elevation_along_path(client, path, samples): :rtype: list of elevation data responses """ - if type(path) is str: - path = "enc:%s" % path - else: - path = convert.shortest_path(path) + path = f"enc:{path}" if isinstance(path, str) else convert.shortest_path(path) - params = { - "path": path, - "samples": samples - } + params = {"path": path, "samples": samples} return client._request("/maps/api/elevation/json", params).get("results", []) diff --git a/googlemaps/exceptions.py b/googlemaps/exceptions.py index 0a0f116a..4c6f72ac 100644 --- a/googlemaps/exceptions.py +++ b/googlemaps/exceptions.py @@ -19,8 +19,10 @@ Defines exceptions that are thrown by the Google Maps client. """ + class ApiError(Exception): """Represents an exception returned by the remote API.""" + def __init__(self, status, message=None): self.status = status self.message = message @@ -28,8 +30,8 @@ def __init__(self, status, message=None): def __str__(self): if self.message is None: return str(self.status) - else: - return "%s (%s)" % (self.status, self.message) + return f"{self.status} ({self.message})" + class TransportError(Exception): """Something went wrong while trying to execute the request.""" @@ -43,26 +45,34 @@ def __str__(self): return "An unknown error occurred." + class HTTPError(TransportError): """An unexpected HTTP error occurred.""" + def __init__(self, status_code): self.status_code = status_code def __str__(self): - return "HTTP Error: %d" % self.status_code + return f"HTTP Error: {self.status_code:d}" + class Timeout(Exception): """The request timed out.""" + pass + class _RetriableRequest(Exception): """Signifies that the request can be retried.""" + pass + class _OverQueryLimit(ApiError, _RetriableRequest): """Signifies that the request failed because the client exceeded its query rate limit. Normally we treat this as a retriable condition, but we allow the calling code to specify that these requests should not be retried. """ + pass diff --git a/googlemaps/geocoding.py b/googlemaps/geocoding.py index 590bb627..52935381 100644 --- a/googlemaps/geocoding.py +++ b/googlemaps/geocoding.py @@ -16,11 +16,13 @@ # """Performs requests to the Google Maps Geocoding API.""" + from googlemaps import convert -def geocode(client, address=None, place_id=None, components=None, bounds=None, region=None, - language=None): +def geocode( + client, address=None, place_id=None, components=None, bounds=None, region=None, language=None +): """ Geocoding is the process of converting addresses (like ``"1600 Amphitheatre Parkway, Mountain View, CA"``) into geographic @@ -77,8 +79,14 @@ def geocode(client, address=None, place_id=None, components=None, bounds=None, r return client._request("/maps/api/geocode/json", params) -def reverse_geocode(client, latlng, result_type=None, location_type=None, - language=None, enable_address_descriptor=False): +def reverse_geocode( + client, + latlng, + result_type=None, + location_type=None, + language=None, + enable_address_descriptor=False, +): """ Reverse geocoding is the process of converting geographic coordinates into a human-readable address. @@ -104,7 +112,7 @@ def reverse_geocode(client, latlng, result_type=None, location_type=None, # Check if latlng param is a place_id string. # place_id strings do not contain commas; latlng strings do. - if convert.is_string(latlng) and ',' not in latlng: + if convert.is_string(latlng) and "," not in latlng: params = {"place_id": latlng} else: params = {"latlng": convert.latlng(latlng)} diff --git a/googlemaps/geolocation.py b/googlemaps/geolocation.py index c8db15ec..09f0639e 100644 --- a/googlemaps/geolocation.py +++ b/googlemaps/geolocation.py @@ -16,8 +16,8 @@ # """Performs requests to the Google Maps Geolocation API.""" -from googlemaps import exceptions +from googlemaps import exceptions _GEOLOCATION_BASE_URL = "https://www.googleapis.com" @@ -42,9 +42,16 @@ def _geolocation_extract(response): raise exceptions.ApiError(response.status_code, error) -def geolocate(client, home_mobile_country_code=None, - home_mobile_network_code=None, radio_type=None, carrier=None, - consider_ip=None, cell_towers=None, wifi_access_points=None): +def geolocate( + client, + home_mobile_country_code=None, + home_mobile_network_code=None, + radio_type=None, + carrier=None, + consider_ip=None, + cell_towers=None, + wifi_access_points=None, +): """ The Google Maps Geolocation API returns a location and accuracy radius based on information about cell towers and WiFi nodes given. @@ -101,7 +108,10 @@ def geolocate(client, home_mobile_country_code=None, if wifi_access_points is not None: params["wifiAccessPoints"] = wifi_access_points - return client._request("/geolocation/v1/geolocate", {}, # No GET params - base_url=_GEOLOCATION_BASE_URL, - extract_body=_geolocation_extract, - post_json=params) + return client._request( + "/geolocation/v1/geolocate", + {}, # No GET params + base_url=_GEOLOCATION_BASE_URL, + extract_body=_geolocation_extract, + post_json=params, + ) diff --git a/googlemaps/maps.py b/googlemaps/maps.py index 746223d6..1981825f 100644 --- a/googlemaps/maps.py +++ b/googlemaps/maps.py @@ -19,10 +19,10 @@ from googlemaps import convert +MAPS_IMAGE_FORMATS = {"png8", "png", "png32", "gif", "jpg", "jpg-baseline"} -MAPS_IMAGE_FORMATS = {'png8', 'png', 'png32', 'gif', 'jpg', 'jpg-baseline'} +MAPS_MAP_TYPES = {"roadmap", "satellite", "terrain", "hybrid"} -MAPS_MAP_TYPES = {'roadmap', 'satellite', 'terrain', 'hybrid'} class StaticMapParam: """Base class to handle parameters for Maps Static API.""" @@ -37,14 +37,13 @@ def __str__(self): :rtype: str """ - return convert.join_list('|', self.params) + return convert.join_list("|", self.params) class StaticMapMarker(StaticMapParam): """Handles marker parameters for Maps Static API.""" - def __init__(self, locations, - size=None, color=None, label=None): + def __init__(self, locations, size=None, color=None, label=None): """ :param locations: Specifies the locations of the markers on the map. @@ -61,18 +60,18 @@ def __init__(self, locations, :type label: str """ - super(StaticMapMarker, self).__init__() + super().__init__() if size: - self.params.append("size:%s" % size) + self.params.append(f"size:{size}") if color: - self.params.append("color:%s" % color) + self.params.append(f"color:{color}") if label: if len(label) != 1 or (label.isalpha() and not label.isupper()) or not label.isalnum(): raise ValueError("Marker label must be alphanumeric and uppercase.") - self.params.append("label:%s" % label) + self.params.append(f"label:{label}") self.params.append(convert.location_list(locations)) @@ -80,9 +79,7 @@ def __init__(self, locations, class StaticMapPath(StaticMapParam): """Handles path parameters for Maps Static API.""" - def __init__(self, points, - weight=None, color=None, - fillcolor=None, geodesic=None): + def __init__(self, points, weight=None, color=None, fillcolor=None, geodesic=None): """ :param points: Specifies the point through which the path will be built. @@ -105,27 +102,38 @@ def __init__(self, points, :type geodesic: bool """ - super(StaticMapPath, self).__init__() + super().__init__() if weight: - self.params.append("weight:%s" % weight) + self.params.append(f"weight:{weight}") if color: - self.params.append("color:%s" % color) + self.params.append(f"color:{color}") if fillcolor: - self.params.append("fillcolor:%s" % fillcolor) + self.params.append(f"fillcolor:{fillcolor}") if geodesic: - self.params.append("geodesic:%s" % geodesic) + self.params.append(f"geodesic:{geodesic}") self.params.append(convert.location_list(points)) -def static_map(client, size, - center=None, zoom=None, scale=None, - format=None, maptype=None, language=None, region=None, - markers=None, path=None, visible=None, style=None): +def static_map( + client, + size, + center=None, + zoom=None, + scale=None, + format=None, + maptype=None, + language=None, + region=None, + markers=None, + path=None, + visible=None, + style=None, +): """ Downloads a map image from the Maps Static API. @@ -194,12 +202,8 @@ def static_map(client, size, params = {"size": convert.size(size)} - if not markers: - if not (center or zoom is not None): - raise ValueError( - "both center and zoom are required" - "when markers is not specifed" - ) + if not markers and not (center or zoom is not None): + raise ValueError("both center and zoom are required when markers is not specified") if center: params["center"] = convert.latlng(center) @@ -212,8 +216,8 @@ def static_map(client, size, if format: if format not in MAPS_IMAGE_FORMATS: - raise ValueError("Invalid image format") - params['format'] = format + raise ValueError("Invalid image format") + params["format"] = format if maptype: if maptype not in MAPS_MAP_TYPES: diff --git a/googlemaps/places.py b/googlemaps/places.py index 269a17fa..6268cecd 100644 --- a/googlemaps/places.py +++ b/googlemaps/places.py @@ -16,40 +16,40 @@ # """Performs requests to the Google Places API.""" + import warnings from googlemaps import convert - -PLACES_FIND_FIELDS_BASIC = {"business_status", - "formatted_address", - "geometry", - "geometry/location", - "geometry/location/lat", - "geometry/location/lng", - "geometry/viewport", - "geometry/viewport/northeast", - "geometry/viewport/northeast/lat", - "geometry/viewport/northeast/lng", - "geometry/viewport/southwest", - "geometry/viewport/southwest/lat", - "geometry/viewport/southwest/lng", - "icon", - "name", - "permanently_closed", - "photos", - "place_id", - "plus_code", - "types",} +PLACES_FIND_FIELDS_BASIC = { + "business_status", + "formatted_address", + "geometry", + "geometry/location", + "geometry/location/lat", + "geometry/location/lng", + "geometry/viewport", + "geometry/viewport/northeast", + "geometry/viewport/northeast/lat", + "geometry/viewport/northeast/lng", + "geometry/viewport/southwest", + "geometry/viewport/southwest/lat", + "geometry/viewport/southwest/lng", + "icon", + "name", + "permanently_closed", + "photos", + "place_id", + "plus_code", + "types", +} PLACES_FIND_FIELDS_CONTACT = {"opening_hours"} PLACES_FIND_FIELDS_ATMOSPHERE = {"price_level", "rating", "user_ratings_total"} PLACES_FIND_FIELDS = ( - PLACES_FIND_FIELDS_BASIC - ^ PLACES_FIND_FIELDS_CONTACT - ^ PLACES_FIND_FIELDS_ATMOSPHERE + PLACES_FIND_FIELDS_BASIC ^ PLACES_FIND_FIELDS_CONTACT ^ PLACES_FIND_FIELDS_ATMOSPHERE ) PLACES_DETAIL_FIELDS_BASIC = { @@ -78,7 +78,7 @@ "url", "utc_offset", "vicinity", - "wheelchair_accessible_entrance" + "wheelchair_accessible_entrance", } PLACES_DETAIL_FIELDS_CONTACT = { @@ -108,25 +108,20 @@ "serves_vegetarian_food", "serves_wine", "takeout", - "user_ratings_total" + "user_ratings_total", } PLACES_DETAIL_FIELDS = ( - PLACES_DETAIL_FIELDS_BASIC - ^ PLACES_DETAIL_FIELDS_CONTACT - ^ PLACES_DETAIL_FIELDS_ATMOSPHERE + PLACES_DETAIL_FIELDS_BASIC ^ PLACES_DETAIL_FIELDS_CONTACT ^ PLACES_DETAIL_FIELDS_ATMOSPHERE ) DEPRECATED_FIELDS = {"permanently_closed", "review"} DEPRECATED_FIELDS_MESSAGE = ( - "Fields, %s, are deprecated. " - "Read more at https://developers.google.com/maps/deprecations." + "Fields, %s, are deprecated. Read more at https://developers.google.com/maps/deprecations." ) -def find_place( - client, input, input_type, fields=None, location_bias=None, language=None -): +def find_place(client, input, input_type, fields=None, location_bias=None, language=None): """ A Find Place request takes a text input, and returns a place. The text input can be any kind of Places data, for example, @@ -159,11 +154,11 @@ def find_place( """ params = {"input": input, "inputtype": input_type} - if input_type != "textquery" and input_type != "phonenumber": + if input_type not in {"textquery", "phonenumber"}: raise ValueError( "Valid values for the `input_type` param for " "`find_place` are 'textquery' or 'phonenumber', " - "the given value is invalid: '%s'" % input_type + f"the given value is invalid: '{input_type}'" ) if fields: @@ -172,22 +167,24 @@ def find_place( warnings.warn( DEPRECATED_FIELDS_MESSAGE % str(list(deprecated_fields)), DeprecationWarning, + stacklevel=2, ) invalid_fields = set(fields) - PLACES_FIND_FIELDS if invalid_fields: + valid = "', '".join(PLACES_FIND_FIELDS) + invalid = "', '".join(invalid_fields) raise ValueError( - "Valid values for the `fields` param for " - "`find_place` are '%s', these given field(s) " - "are invalid: '%s'" - % ("', '".join(PLACES_FIND_FIELDS), "', '".join(invalid_fields)) + f"Valid values for the `fields` param for " + f"`find_place` are '{valid}', these given field(s) " + f"are invalid: '{invalid}'" ) params["fields"] = convert.join_list(",", fields) if location_bias: valid = ["ipbias", "point", "circle", "rectangle"] if location_bias.split(":")[0] not in valid: - raise ValueError("location_bias should be prefixed with one of: %s" % valid) + raise ValueError(f"location_bias should be prefixed with one of: {valid}") params["locationbias"] = location_bias if language: params["language"] = language @@ -349,13 +346,10 @@ def places_nearby( if rank_by == "distance": if not (keyword or name or type): raise ValueError( - "either a keyword, name, or type arg is required " - "when rank_by is set to distance" + "either a keyword, name, or type arg is required when rank_by is set to distance" ) elif radius is not None: - raise ValueError( - "radius cannot be specified when rank_by is set to " "distance" - ) + raise ValueError("radius cannot be specified when rank_by is set to distance") return _places( client, @@ -421,7 +415,7 @@ def _places( if page_token: params["pagetoken"] = page_token - url = "/maps/api/place/%ssearch/json" % url_part + url = f"/maps/api/place/{url_part}search/json" return client._request(url, params) @@ -472,15 +466,17 @@ def place( warnings.warn( DEPRECATED_FIELDS_MESSAGE % str(list(deprecated_fields)), DeprecationWarning, + stacklevel=2, ) invalid_fields = set(fields) - PLACES_DETAIL_FIELDS if invalid_fields: + valid = "', '".join(PLACES_DETAIL_FIELDS) + invalid = "', '".join(invalid_fields) raise ValueError( - "Valid values for the `fields` param for " - "`place` are '%s', these given field(s) " - "are invalid: '%s'" - % ("', '".join(PLACES_DETAIL_FIELDS), "', '".join(invalid_fields)) + f"Valid values for the `fields` param for " + f"`place` are '{valid}', these given field(s) " + f"are invalid: '{invalid}'" ) params["fields"] = convert.join_list(",", fields) @@ -697,11 +693,11 @@ def _autocomplete( if types: params["types"] = types if components: - if len(components) != 1 or list(components.keys())[0] != "country": + if len(components) != 1 or next(iter(components.keys())) != "country": raise ValueError("Only country components are supported") params["components"] = convert.components(components) if strict_bounds: params["strictbounds"] = "true" - url = "/maps/api/place/%sautocomplete/json" % url_part + url = f"/maps/api/place/{url_part}autocomplete/json" return client._request(url, params).get("predictions", []) diff --git a/googlemaps/roads.py b/googlemaps/roads.py index edfb8ecb..0d31108c 100644 --- a/googlemaps/roads.py +++ b/googlemaps/roads.py @@ -20,7 +20,6 @@ import googlemaps from googlemaps import convert - _ROADS_BASE_URL = "https://roads.googleapis.com" @@ -50,10 +49,14 @@ def snap_to_roads(client, path, interpolate=False): if interpolate: params["interpolate"] = "true" - return client._request("/v1/snapToRoads", params, - base_url=_ROADS_BASE_URL, - accepts_clientid=False, - extract_body=_roads_extract).get("snappedPoints", []) + return client._request( + "/v1/snapToRoads", + params, + base_url=_ROADS_BASE_URL, + accepts_clientid=False, + extract_body=_roads_extract, + ).get("snappedPoints", []) + def nearest_roads(client, points): """Find the closest road segments for each point @@ -72,10 +75,14 @@ def nearest_roads(client, points): params = {"points": convert.location_list(points)} - return client._request("/v1/nearestRoads", params, - base_url=_ROADS_BASE_URL, - accepts_clientid=False, - extract_body=_roads_extract).get("snappedPoints", []) + return client._request( + "/v1/nearestRoads", + params, + base_url=_ROADS_BASE_URL, + accepts_clientid=False, + extract_body=_roads_extract, + ).get("snappedPoints", []) + def speed_limits(client, place_ids): """Returns the posted speed limit (in km/h) for given road segments. @@ -89,10 +96,13 @@ def speed_limits(client, place_ids): params = [("placeId", place_id) for place_id in convert.as_list(place_ids)] - return client._request("/v1/speedLimits", params, - base_url=_ROADS_BASE_URL, - accepts_clientid=False, - extract_body=_roads_extract).get("speedLimits", []) + return client._request( + "/v1/speedLimits", + params, + base_url=_ROADS_BASE_URL, + accepts_clientid=False, + extract_body=_roads_extract, + ).get("speedLimits", []) def snapped_speed_limits(client, path): @@ -110,10 +120,13 @@ def snapped_speed_limits(client, path): params = {"path": convert.location_list(path)} - return client._request("/v1/speedLimits", params, - base_url=_ROADS_BASE_URL, - accepts_clientid=False, - extract_body=_roads_extract) + return client._request( + "/v1/speedLimits", + params, + base_url=_ROADS_BASE_URL, + accepts_clientid=False, + extract_body=_roads_extract, + ) def _roads_extract(resp): @@ -121,20 +134,20 @@ def _roads_extract(resp): try: j = resp.json() - except: + except ValueError as err: if resp.status_code != 200: - raise googlemaps.exceptions.HTTPError(resp.status_code) + raise googlemaps.exceptions.HTTPError(resp.status_code) from err - raise googlemaps.exceptions.ApiError("UNKNOWN_ERROR", - "Received a malformed response.") + raise googlemaps.exceptions.ApiError( + "UNKNOWN_ERROR", "Received a malformed response." + ) from err if "error" in j: error = j["error"] status = error["status"] if status == "RESOURCE_EXHAUSTED": - raise googlemaps.exceptions._OverQueryLimit(status, - error.get("message")) + raise googlemaps.exceptions._OverQueryLimit(status, error.get("message")) raise googlemaps.exceptions.ApiError(status, error.get("message")) diff --git a/googlemaps/timezone.py b/googlemaps/timezone.py index 0b6370dc..45655206 100644 --- a/googlemaps/timezone.py +++ b/googlemaps/timezone.py @@ -17,10 +17,10 @@ """Performs requests to the Google Maps Directions API.""" -from googlemaps import convert - from datetime import datetime +from googlemaps import convert + def timezone(client, location, timestamp=None, language=None): """Get time zone for a location on the earth, as well as that location's @@ -45,10 +45,10 @@ def timezone(client, location, timestamp=None, language=None): params = { "location": convert.latlng(location), - "timestamp": convert.time(timestamp or datetime.utcnow()) + "timestamp": convert.time(timestamp or datetime.utcnow()), } if language: params["language"] = language - return client._request( "/maps/api/timezone/json", params) + return client._request("/maps/api/timezone/json", params) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..03f77ad1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,116 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "googlemaps" +version = "4.10.0" +description = "Python client library for Google Maps Platform" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.8" +authors = [{ name = "Google Inc." }] +keywords = ["google", "maps", "geocoding", "directions", "places", "routes"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", +] +dependencies = ["requests>=2.20.0,<3.0"] + +[project.optional-dependencies] +dev = [ + "pytest>=7", + "pytest-cov>=4", + "responses>=0.23", + "ruff>=0.4", + "mypy>=1.8", +] +docs = ["sphinx>=5"] + +[project.urls] +Homepage = "https://github.com/googlemaps/google-maps-services-python" +Documentation = "https://googlemaps.github.io/google-maps-services-python/" +Source = "https://github.com/googlemaps/google-maps-services-python" +Issues = "https://github.com/googlemaps/google-maps-services-python/issues" + +[tool.setuptools] +packages = ["googlemaps"] + +# --------------------------------------------------------------------------- +# Tooling +# --------------------------------------------------------------------------- + +[tool.pytest.ini_options] +addopts = "-rsxX --strict-markers" +testpaths = ["tests"] +filterwarnings = ["error::DeprecationWarning:googlemaps.*"] + +[tool.coverage.run] +source = ["googlemaps"] +branch = true +omit = ["tests/*"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] + +[tool.ruff] +line-length = 100 +target-version = "py38" +extend-exclude = ["docs/_build"] + +[tool.ruff.lint] +select = [ + "E", "F", "W", # pycodestyle / pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "SIM", # flake8-simplify + "C4", # comprehensions + "PL", # pylint subset + "RUF", +] +ignore = [ + "E501", # line length handled by formatter + "PLR0913", # many public APIs take many kwargs by design + "PLR2004", # magic numbers in tests / API constants +] + +[tool.ruff.lint.per-file-ignores] +# Test patterns we accept project-wide: +# - PLR2004 magic numbers in assertions +# - B011 assert False +# - S101 assert in tests +# - E402 imports after sys.path tweaks +# - UP031 / F841 upstream test idioms (results = ...; URL = "%s") +# - B017 raises Exception in legacy tests +"tests/*" = ["PLR2004", "B011", "S101", "E402", "UP031", "F841", "B017", "PLC0415"] +# Endpoint dispatchers intentionally have many branches/params, and +# googlemaps/client.py performs late imports at module bottom to avoid +# circular imports between the Client class and the API modules it binds. +"googlemaps/client.py" = ["E402", "PLR0912"] +"googlemaps/directions.py" = ["PLR0912"] +"googlemaps/distance_matrix.py" = ["PLR0912"] +"googlemaps/maps.py" = ["PLR0912"] +"googlemaps/places.py" = ["PLR0912"] + +[tool.mypy] +python_version = "3.8" +warn_unused_ignores = true +warn_redundant_casts = true +ignore_missing_imports = true +files = ["googlemaps"] diff --git a/setup.cfg b/setup.cfg index 56320971..dafd5320 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,10 @@ [tool:pytest] -addopts = -rsxX --cov=googlemaps --cov-report= +addopts = -rsxX +testpaths = tests [coverage:run] -omit = +source = googlemaps +omit = tests/* [coverage:report] diff --git a/setup.py b/setup.py index df9f32f1..bb9efd7e 100644 --- a/setup.py +++ b/setup.py @@ -1,52 +1,5 @@ -# Copyright 2022 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - +# Project metadata is declared in pyproject.toml (PEP 621). +# This shim is kept only for tools that still invoke `python setup.py`. from setuptools import setup - -requirements = ["requests>=2.20.0,<3.0"] - -with open("README.md") as f: - readme = f.read() - -with open("CHANGELOG.md") as f: - changelog = f.read() - - -setup( - name="googlemaps", - version="4.10.0", - description="Python client library for Google Maps Platform", - long_description=readme + changelog, - long_description_content_type="text/markdown", - scripts=[], - url="https://github.com/googlemaps/google-maps-services-python", - packages=["googlemaps"], - license="Apache 2.0", - platforms="Posix; MacOS X; Windows", - setup_requires=requirements, - install_requires=requirements, - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Topic :: Internet", - ], - python_requires='>=3.5' -) +setup() diff --git a/tests/__init__.py b/tests/__init__.py index 8a32b1ed..965ac177 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -15,10 +15,9 @@ # the License. # -import unittest import codecs - -from urllib.parse import urlparse, parse_qsl +import unittest +from urllib.parse import parse_qsl, urlparse class TestCase(unittest.TestCase): diff --git a/tests/test_addressvalidation.py b/tests/test_addressvalidation.py index d1f2f589..bfb72876 100644 --- a/tests/test_addressvalidation.py +++ b/tests/test_addressvalidation.py @@ -1,4 +1,3 @@ -# This Python file uses the following encoding: utf-8 # # Copyright 2017 Google Inc. All rights reserved. # @@ -21,6 +20,7 @@ import responses import googlemaps + from . import TestCase @@ -39,10 +39,12 @@ def test_simple_addressvalidation(self): content_type="application/json", ) - results = self.client.addressvalidation('1600 Amphitheatre Pk', regionCode='US', locality='Mountain View', enableUspsCass=True) + results = self.client.addressvalidation( + "1600 Amphitheatre Pk", regionCode="US", locality="Mountain View", enableUspsCass=True + ) self.assertEqual(1, len(responses.calls)) self.assertURLEqual( - "https://addressvalidation.googleapis.com/v1:validateAddress?" "key=%s" % self.key, + "https://addressvalidation.googleapis.com/v1:validateAddress?key=%s" % self.key, responses.calls[0].request.url, - ) \ No newline at end of file + ) diff --git a/tests/test_batch.py b/tests/test_batch.py new file mode 100644 index 00000000..8396b98d --- /dev/null +++ b/tests/test_batch.py @@ -0,0 +1,207 @@ +# +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# + +"""Tests for googlemaps.batch.BatchExecutor.""" + +import time + +import pytest +import responses + +import googlemaps +from googlemaps.batch import BatchExecutor + + +def _make_client(): + return googlemaps.Client(key="AIzaTEST") + + +class TestBatchExecutorConstruction: + def test_requires_client(self): + with pytest.raises(ValueError): + BatchExecutor(None) + + def test_max_workers_validated(self): + with pytest.raises(ValueError): + BatchExecutor(_make_client(), max_workers=0) + + def test_default_max_workers_capped_at_32(self): + c = _make_client() + c.queries_quota = 999 + b = BatchExecutor(c) + assert b.max_workers == 32 + + def test_default_max_workers_uses_quota_when_low(self): + c = _make_client() + c.queries_quota = 4 + assert BatchExecutor(c).max_workers == 4 + + +class TestBatchExecutorRun: + @responses.activate + def test_geocode_batch_preserves_order(self): + # Use a URL-driven callback so each thread gets the response that + # matches its own query — concurrent execution otherwise scrambles + # which stub serves which request. + from urllib.parse import parse_qs, urlparse + + def _callback(request): + qs = parse_qs(urlparse(request.url).query) + city = qs["address"][0] + return (200, {}, '{"status":"OK","results":[{"formatted_address":"' + city + '"}]}') + + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=_callback, + content_type="application/json", + ) + gmaps = _make_client() + batch = BatchExecutor(gmaps, max_workers=4) + results = batch.geocode(["Sydney", "Melbourne", "Perth"]) + assert len(results) == 3 + addrs = [r["results"][0]["formatted_address"] for r in results] + assert addrs == ["Sydney", "Melbourne", "Perth"] + + @responses.activate + def test_empty_inputs_no_calls(self): + batch = BatchExecutor(_make_client()) + assert batch.run("geocode", []) == [] + assert len(responses.calls) == 0 + + @responses.activate + def test_return_exceptions_captures_per_item_errors(self): + # First call OK, second triggers ApiError via INVALID_REQUEST. + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "OK", "results": [{"formatted_address": "Sydney"}]}, + status=200, + ) + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "INVALID_REQUEST", "error_message": "bad input"}, + status=200, + ) + gmaps = _make_client() + batch = BatchExecutor(gmaps, max_workers=1) # serialize for determinism + results = batch.geocode(["Sydney", "BAD"]) + assert isinstance(results[0], dict) + assert isinstance(results[1], googlemaps.exceptions.ApiError) + + @responses.activate + def test_return_exceptions_false_propagates(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "INVALID_REQUEST", "error_message": "bad input"}, + status=200, + ) + gmaps = _make_client() + batch = BatchExecutor(gmaps, max_workers=1, return_exceptions=False) + with pytest.raises(googlemaps.exceptions.ApiError): + batch.geocode(["BAD"]) + + @responses.activate + def test_unknown_method_raises(self): + batch = BatchExecutor(_make_client()) + with pytest.raises(AttributeError): + batch.run("does_not_exist", ["x"]) + + @responses.activate + def test_callable_method(self): + gmaps = _make_client() + + def custom(value): + return value * 2 + + batch = BatchExecutor(gmaps, max_workers=2) + out = batch.run(custom, [1, 2, 3]) + # The custom callable receives only the item — the client is not + # auto-injected. Callers can close over the client if they need it. + assert out == [2, 4, 6] + + @responses.activate + def test_unpack_for_directions(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + json={"status": "OK", "routes": [{"summary": "A->B"}]}, + status=200, + ) + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/directions/json", + json={"status": "OK", "routes": [{"summary": "C->D"}]}, + status=200, + ) + gmaps = _make_client() + batch = BatchExecutor(gmaps, max_workers=1) + results = batch.directions([("A", "B"), ("C", "D")]) + assert results[0][0]["summary"] == "A->B" + assert results[1][0]["summary"] == "C->D" + + @responses.activate + def test_common_kwargs_forwarded(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "OK", "results": []}, + status=200, + ) + gmaps = _make_client() + batch = BatchExecutor(gmaps, max_workers=1) + batch.geocode(["Sydney"], region="au") + # Verify language param made it onto the request URL. + assert "region=au" in responses.calls[0].request.url + + +class TestBatchExecutorConcurrency: + @responses.activate + def test_concurrent_execution_is_faster_than_serial(self): + """Sanity: 8 calls each delayed 100ms should take well under 800ms.""" + delay = 0.1 + N = 8 + + def slow(_request): + time.sleep(delay) + return (200, {}, '{"status":"OK","results":[]}') + + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=slow, + content_type="application/json", + ) + gmaps = _make_client() + # Disable QPS throttling for this test to measure pure parallelism. + gmaps.queries_quota = N + batch = BatchExecutor(gmaps, max_workers=N) + start = time.monotonic() + results = batch.geocode([f"city-{i}" for i in range(N)]) + elapsed = time.monotonic() - start + assert len(results) == N + # Serial would be N*delay = 0.8s; concurrent should be < 0.5s. + assert elapsed < (N * delay) * 0.6, f"Took {elapsed:.3f}s, expected concurrency" + + @responses.activate + def test_runs_safely_with_many_workers(self): + """No deadlock / data corruption when threads outnumber items.""" + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "OK", "results": []}, + status=200, + ) + gmaps = _make_client() + batch = BatchExecutor(gmaps, max_workers=16) + out = batch.geocode([f"q{i}" for i in range(3)]) + assert len(out) == 3 diff --git a/tests/test_batch_advanced.py b/tests/test_batch_advanced.py new file mode 100644 index 00000000..2b8a9d8a --- /dev/null +++ b/tests/test_batch_advanced.py @@ -0,0 +1,279 @@ +"""Advanced parametrized tests for googlemaps.batch.BatchExecutor.""" + +from __future__ import annotations + +import re +from urllib.parse import parse_qs, urlparse + +import pytest +import responses + +import googlemaps +from googlemaps.batch import BatchExecutor + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + +def test_batch_requires_client(): + with pytest.raises(ValueError): + BatchExecutor(None) + + +@pytest.mark.parametrize("bad", [0, -1, -100]) +def test_batch_max_workers_validation(bad): + c = googlemaps.Client(key="AIzaasdf") + with pytest.raises(ValueError): + BatchExecutor(c, max_workers=bad) + + +@pytest.mark.parametrize("ok", [1, 2, 4, 8, 16, 32, 64]) +def test_batch_max_workers_accepted(ok): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, max_workers=ok) + assert b.max_workers == ok + + +def test_batch_default_max_workers_capped_at_32(): + c = googlemaps.Client(key="AIzaasdf", queries_per_second=1000) + b = BatchExecutor(c) + assert b.max_workers == 32 + + +def test_batch_default_max_workers_uses_quota_when_low(): + c = googlemaps.Client(key="AIzaasdf", queries_per_second=4) + b = BatchExecutor(c) + assert b.max_workers == 4 + + +@pytest.mark.parametrize("flag", [True, False]) +def test_batch_return_exceptions_flag(flag): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, return_exceptions=flag) + assert b.return_exceptions is flag + + +# --------------------------------------------------------------------------- +# run() — empty / dispatch / unknown method +# --------------------------------------------------------------------------- + +def test_batch_run_empty_inputs_no_calls(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c) + assert b.run("geocode", []) == [] + + +def test_batch_run_unknown_method_raises(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c) + with pytest.raises(AttributeError): + b.run("not_a_method", ["x"]) + + +def test_batch_run_callable_method(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c) + out = b.run(lambda v: v * 2, [1, 2, 3, 4, 5]) + assert out == [2, 4, 6, 8, 10] + + +@pytest.mark.parametrize("n", [1, 2, 4, 8, 16, 32, 64, 100]) +def test_batch_callable_preserves_order(n): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, max_workers=8) + out = b.run(lambda v: v, list(range(n))) + assert out == list(range(n)) + + +# --------------------------------------------------------------------------- +# Per-item exception handling +# --------------------------------------------------------------------------- + +def test_batch_return_exceptions_captures_errors(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, return_exceptions=True) + + def maybe_fail(v): + if v % 2 == 0: + raise RuntimeError(f"boom {v}") + return v + + out = b.run(maybe_fail, [1, 2, 3, 4, 5]) + assert out[0] == 1 + assert isinstance(out[1], RuntimeError) + assert out[2] == 3 + assert isinstance(out[3], RuntimeError) + assert out[4] == 5 + + +def test_batch_return_exceptions_false_propagates(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, return_exceptions=False, max_workers=1) + with pytest.raises(ZeroDivisionError): + b.run(lambda v: 1 / 0, [1]) + + +@pytest.mark.parametrize("exc_type", [ValueError, RuntimeError, KeyError, TypeError]) +def test_batch_captures_various_exception_types(exc_type): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, return_exceptions=True) + out = b.run(lambda v: (_ for _ in ()).throw(exc_type("e")), [1]) + assert isinstance(out[0], exc_type) + + +# --------------------------------------------------------------------------- +# common_kwargs / unpack +# --------------------------------------------------------------------------- + +def test_batch_common_kwargs_forwarded(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, max_workers=2) + out = b.run(lambda v, suffix: f"{v}{suffix}", ["a", "b", "c"], common_kwargs={"suffix": "!"}) + assert out == ["a!", "b!", "c!"] + + +def test_batch_unpack_pairs(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, max_workers=2) + out = b.run(lambda x, y: x + y, [(1, 2), (3, 4), (5, 6)], unpack=True) + assert out == [3, 7, 11] + + +def test_batch_no_unpack_passes_tuple(): + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, max_workers=2) + out = b.run(lambda pair: pair, [(1, 2), (3, 4)]) + assert out == [(1, 2), (3, 4)] + + +# --------------------------------------------------------------------------- +# Real geocode batch via responses (URL-driven) +# --------------------------------------------------------------------------- + +@responses.activate +def test_batch_geocode_preserves_order(): + def callback(request): + addr = parse_qs(urlparse(request.url).query)["address"][0] + body = ( + '{"status":"OK","results":[{"formatted_address":"' + addr + ' (resolved)"}]}' + ) + return (200, {"Content-Type": "application/json"}, body) + + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=callback, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf", queries_per_second=10) + b = BatchExecutor(c, max_workers=4) + inputs = ["Sydney", "Melbourne", "Perth", "Brisbane", "Hobart"] + results = b.geocode(inputs) + for inp, res in zip(inputs, results): + assert res["results"][0]["formatted_address"] == f"{inp} (resolved)" + + +@responses.activate +def test_batch_geocode_isolates_failure(): + def callback(request): + addr = parse_qs(urlparse(request.url).query)["address"][0] + if "BAD" in addr: + return (200, {"Content-Type": "application/json"}, + '{"status":"REQUEST_DENIED","error_message":"nope"}') + return (200, {"Content-Type": "application/json"}, + '{"status":"OK","results":[{"formatted_address":"' + addr + '"}]}') + + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=callback, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf", queries_per_second=10) + b = BatchExecutor(c, max_workers=2) + out = b.geocode(["Sydney", "BAD", "Perth"]) + assert out[0]["results"][0]["formatted_address"] == "Sydney" + assert isinstance(out[1], googlemaps.exceptions.ApiError) + assert out[2]["results"][0]["formatted_address"] == "Perth" + + +# --------------------------------------------------------------------------- +# Shortcut routing +# --------------------------------------------------------------------------- + +@responses.activate +@pytest.mark.parametrize("n", [1, 3, 5, 8]) +def test_batch_geocode_request_count(n): + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=lambda r: (200, {"Content-Type": "application/json"}, + '{"status":"OK","results":[]}'), + ) + c = googlemaps.Client(key="AIzaasdf", queries_per_second=10) + b = BatchExecutor(c, max_workers=4) + b.geocode([f"addr-{i}" for i in range(n)]) + assert len(responses.calls) == n + + +@responses.activate +def test_batch_reverse_geocode_url(): + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=lambda r: (200, {"Content-Type": "application/json"}, + '{"status":"OK","results":[]}'), + ) + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, max_workers=2) + b.reverse_geocode([(1.0, 2.0), (3.0, 4.0)]) + urls = [call.request.url for call in responses.calls] + assert any("latlng=1" in u for u in urls) + assert any("latlng=3" in u for u in urls) + + +@responses.activate +def test_batch_common_kwargs_appears_in_url(): + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=lambda r: (200, {"Content-Type": "application/json"}, + '{"status":"OK","results":[]}'), + ) + c = googlemaps.Client(key="AIzaasdf") + b = BatchExecutor(c, max_workers=2) + b.run("geocode", ["Sydney"], common_kwargs={"region": "au"}) + assert "region=au" in responses.calls[0].request.url + + +# --------------------------------------------------------------------------- +# Larger inputs / stress +# --------------------------------------------------------------------------- + +@responses.activate +@pytest.mark.parametrize("count,workers", [ + (10, 2), + (20, 4), + (50, 8), + (100, 16), +]) +def test_batch_many_inputs(count, workers): + responses.add_callback( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + callback=lambda r: ( + 200, + {"Content-Type": "application/json"}, + '{"status":"OK","results":[{"formatted_address":"x"}]}', + ), + ) + c = googlemaps.Client(key="AIzaasdf", queries_per_second=count) + b = BatchExecutor(c, max_workers=workers) + out = b.geocode([f"in-{i}" for i in range(count)]) + assert len(out) == count + # geocode returns the response body (dict with a "results" key). + assert all(isinstance(r, dict) and "results" in r for r in out) + + +# Avoid unused import warning when re-imported above. +_ = re diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 00000000..08688f93 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,262 @@ +# +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# + +"""Tests for googlemaps.cache and Client cache integration.""" + +import threading + +import pytest +import responses + +import googlemaps +from googlemaps.cache import ( + _AUTH_PARAMS, + BaseCache, + CacheStats, + InMemoryTTLCache, + make_cache_key, +) + +# --------------------------------------------------------------------------- # +# make_cache_key # +# --------------------------------------------------------------------------- # + + +class TestMakeCacheKey: + def test_strips_auth_params(self): + a = make_cache_key("/p", [("address", "Sydney"), ("key", "AIzaA")]) + b = make_cache_key("/p", [("address", "Sydney"), ("key", "AIzaB")]) + assert a == b + + def test_order_independent(self): + a = make_cache_key("/p", [("a", 1), ("b", 2)]) + b = make_cache_key("/p", [("b", 2), ("a", 1)]) + assert a == b + + def test_path_matters(self): + assert make_cache_key("/p1", []) != make_cache_key("/p2", []) + + def test_value_difference(self): + a = make_cache_key("/p", [("q", "x")]) + b = make_cache_key("/p", [("q", "y")]) + assert a != b + + def test_all_auth_params_filtered(self): + params = [(p, "v") for p in _AUTH_PARAMS] + [("q", "x")] + key = make_cache_key("/p", params) + assert key == ("/p", (("q", "x"),)) + + def test_key_is_hashable(self): + key = make_cache_key("/p", [("q", "x")]) + # Must be usable as a dict key. + d = {key: 1} + assert d[key] == 1 + + +# --------------------------------------------------------------------------- # +# InMemoryTTLCache # +# --------------------------------------------------------------------------- # + + +class TestInMemoryTTLCache: + def test_constructor_validates(self): + with pytest.raises(ValueError): + InMemoryTTLCache(maxsize=0) + with pytest.raises(ValueError): + InMemoryTTLCache(ttl=0) + with pytest.raises(ValueError): + InMemoryTTLCache(ttl=-5) + + def test_set_and_get(self): + c = InMemoryTTLCache(maxsize=4, ttl=60) + c.set("k", "v") + assert c.get("k") == "v" + assert len(c) == 1 + assert "k" in c + + def test_miss_returns_none(self): + assert InMemoryTTLCache().get("absent") is None + + def test_ttl_expiration_with_fake_clock(self): + c = InMemoryTTLCache(maxsize=4, ttl=10) + clock = [1000.0] + c._now = lambda: clock[0] + c.set("k", "v") + assert c.get("k") == "v" + clock[0] += 5 + assert c.get("k") == "v" # still alive + clock[0] += 6 # past TTL + assert c.get("k") is None + stats = c.stats() + assert stats.expirations == 1 + assert stats.hits == 2 # set + still-alive read; set doesn't count + # Only get() increments hits/misses; recompute exactly: + # 1 hit (still-alive) + 1 miss (expired). 'set' counts in stats.sets. + assert stats.misses == 1 + assert stats.sets == 1 + + def test_lru_eviction(self): + c = InMemoryTTLCache(maxsize=2, ttl=None) + c.set("a", 1) + c.set("b", 2) + c.get("a") # promote a + c.set("c", 3) # should evict b + assert c.get("a") == 1 + assert c.get("b") is None + assert c.get("c") == 3 + assert c.stats().evictions == 1 + + def test_set_overwrites_and_promotes(self): + c = InMemoryTTLCache(maxsize=2, ttl=None) + c.set("a", 1) + c.set("b", 2) + c.set("a", 99) # overwrite + promote + c.set("c", 3) # evicts b, not a + assert c.get("a") == 99 + assert c.get("b") is None + + def test_clear_resets_stats(self): + c = InMemoryTTLCache() + c.set("k", "v") + c.get("k") + c.get("missing") + c.clear() + s = c.stats() + assert s.hits == s.misses == s.sets == s.evictions == s.expirations == 0 + assert len(c) == 0 + + def test_stats_snapshot_is_copy(self): + c = InMemoryTTLCache() + c.set("k", "v") + s1 = c.stats() + c.set("k2", "v") + assert s1.sets == 1 # snapshot, not live + + def test_hit_ratio(self): + s = CacheStats(hits=3, misses=1) + assert s.hit_ratio == 0.75 + assert CacheStats().hit_ratio == 0.0 + + def test_thread_safety_under_contention(self): + c = InMemoryTTLCache(maxsize=1000, ttl=None) + N = 200 + + def worker(start): + for i in range(start, start + N): + c.set(i, i * 2) + assert c.get(i) == i * 2 + + threads = [threading.Thread(target=worker, args=(i * N,)) for i in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert c.stats().sets == 8 * N + + def test_base_cache_is_abstract(self): + b = BaseCache() + with pytest.raises(NotImplementedError): + b.get("x") + with pytest.raises(NotImplementedError): + b.set("x", 1) + with pytest.raises(NotImplementedError): + b.clear() + with pytest.raises(NotImplementedError): + b.stats() + + +# --------------------------------------------------------------------------- # +# Client integration # +# --------------------------------------------------------------------------- # + + +class TestClientCacheIntegration: + @responses.activate + def test_geocode_served_from_cache_on_second_call(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "OK", "results": [{"formatted_address": "Sydney NSW"}]}, + status=200, + ) + gmaps = googlemaps.Client(key="AIzaTEST") + gmaps.cache = InMemoryTTLCache(maxsize=8, ttl=60) + + r1 = gmaps.geocode("Sydney") + r2 = gmaps.geocode("Sydney") + + assert r1 == r2 + # Only ONE network call must have happened. + assert len(responses.calls) == 1 + stats = gmaps.cache.stats() + assert stats.hits == 1 + assert stats.sets == 1 + + @responses.activate + def test_different_query_misses_cache(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "OK", "results": []}, + status=200, + ) + gmaps = googlemaps.Client(key="AIzaTEST") + gmaps.cache = InMemoryTTLCache() + gmaps.geocode("Sydney") + gmaps.geocode("Melbourne") + assert len(responses.calls) == 2 + + @responses.activate + def test_post_request_is_not_cached(self): + # geolocate uses POST. + responses.add( + responses.POST, + "https://www.googleapis.com/geolocation/v1/geolocate", + json={"location": {"lat": 1, "lng": 2}, "accuracy": 10}, + status=200, + ) + gmaps = googlemaps.Client(key="AIzaTEST") + gmaps.cache = InMemoryTTLCache() + gmaps.geolocate() + gmaps.geolocate() + # Both calls hit the network; nothing was cached. + assert len(responses.calls) == 2 + assert gmaps.cache.stats().sets == 0 + + @responses.activate + def test_no_cache_when_unset(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "OK", "results": []}, + status=200, + ) + gmaps = googlemaps.Client(key="AIzaTEST") + # cache attribute exists and defaults to None. + assert gmaps.cache is None + gmaps.geocode("Sydney") + gmaps.geocode("Sydney") + assert len(responses.calls) == 2 + + @responses.activate + def test_cache_shared_across_credentials(self): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + json={"status": "OK", "results": [{"formatted_address": "Sydney"}]}, + status=200, + ) + cache = InMemoryTTLCache() + c1 = googlemaps.Client(key="AIzaAAAA") + c2 = googlemaps.Client(key="AIzaBBBB") + c1.cache = c2.cache = cache + c1.geocode("Sydney") + c2.geocode("Sydney") # same logical query, different key + assert len(responses.calls) == 1 diff --git a/tests/test_cache_advanced.py b/tests/test_cache_advanced.py new file mode 100644 index 00000000..67adf948 --- /dev/null +++ b/tests/test_cache_advanced.py @@ -0,0 +1,352 @@ +"""Advanced parametrized tests for googlemaps.cache.""" + +from __future__ import annotations + +import threading +import time + +import pytest + +from googlemaps.cache import ( + _AUTH_PARAMS, + BaseCache, + CacheStats, + InMemoryTTLCache, + make_cache_key, +) + +# --------------------------------------------------------------------------- +# make_cache_key +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("auth_param", sorted(_AUTH_PARAMS)) +def test_make_cache_key_strips_each_auth_param(auth_param): + base = [("address", "Sydney")] + with_auth = [*base, (auth_param, "secret")] + assert make_cache_key("/p", base) == make_cache_key("/p", with_auth) + + +@pytest.mark.parametrize( + "params_a,params_b", + [ + ([("a", "1"), ("b", "2")], [("b", "2"), ("a", "1")]), + ([("x", "y"), ("z", "w")], [("z", "w"), ("x", "y")]), + ([("a", "1")], [("a", "1")]), + ([], []), + ], +) +def test_make_cache_key_order_independent(params_a, params_b): + assert make_cache_key("/p", params_a) == make_cache_key("/p", params_b) + + +@pytest.mark.parametrize( + "path_a,path_b", + [("/a", "/b"), ("/maps/api/geocode/json", "/maps/api/places/json"), ("", "/")], +) +def test_make_cache_key_path_matters(path_a, path_b): + assert make_cache_key(path_a, []) != make_cache_key(path_b, []) + + +def test_make_cache_key_is_hashable(): + key = make_cache_key("/p", [("a", "1"), ("key", "secret")]) + d = {key: "value"} # would raise TypeError if not hashable + assert d[key] == "value" + assert hash(key) == hash(key) + + +@pytest.mark.parametrize( + "params,expected_filtered", + [ + ([("key", "K")], ()), + ([("address", "Sydney"), ("key", "K")], (("address", "Sydney"),)), + ([("client", "C"), ("signature", "S"), ("channel", "CH")], ()), + ( + [("a", 1), ("b", 2), ("key", "K")], + (("a", 1), ("b", 2)), + ), + ], +) +def test_make_cache_key_filtered_content(params, expected_filtered): + _, filtered = make_cache_key("/p", params) + assert filtered == expected_filtered + + +# --------------------------------------------------------------------------- +# BaseCache abstract behaviour +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("method,args", [ + ("get", ("k",)), + ("set", ("k", "v")), + ("clear", ()), + ("stats", ()), +]) +def test_base_cache_methods_raise(method, args): + with pytest.raises(NotImplementedError): + getattr(BaseCache(), method)(*args) + + +# --------------------------------------------------------------------------- +# CacheStats +# --------------------------------------------------------------------------- + +def test_cache_stats_defaults_zero(): + s = CacheStats() + assert s.hits == s.misses == s.evictions == s.expirations == s.sets == 0 + assert s.hit_ratio == 0.0 + + +@pytest.mark.parametrize( + "hits,misses,expected", + [ + (0, 0, 0.0), + (1, 0, 1.0), + (0, 1, 0.0), + (3, 1, 0.75), + (1, 3, 0.25), + (50, 50, 0.5), + (100, 0, 1.0), + (0, 100, 0.0), + ], +) +def test_cache_stats_hit_ratio(hits, misses, expected): + s = CacheStats(hits=hits, misses=misses) + assert s.hit_ratio == pytest.approx(expected) + + +# --------------------------------------------------------------------------- +# InMemoryTTLCache — construction +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("bad_size", [0, -1, -100]) +def test_cache_rejects_bad_maxsize(bad_size): + with pytest.raises(ValueError): + InMemoryTTLCache(maxsize=bad_size) + + +@pytest.mark.parametrize("bad_ttl", [0, -1, -0.5]) +def test_cache_rejects_bad_ttl(bad_ttl): + with pytest.raises(ValueError): + InMemoryTTLCache(ttl=bad_ttl) + + +@pytest.mark.parametrize("ok_size", [1, 2, 100, 10_000]) +def test_cache_accepts_valid_maxsize(ok_size): + InMemoryTTLCache(maxsize=ok_size) + + +@pytest.mark.parametrize("ok_ttl", [None, 0.001, 1, 60, 3600]) +def test_cache_accepts_valid_ttl(ok_ttl): + InMemoryTTLCache(ttl=ok_ttl) + + +# --------------------------------------------------------------------------- +# InMemoryTTLCache — basic get/set/miss +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "key,value", + [ + ("simple", "string"), + (("tuple", "key"), {"a": 1}), + ((1, 2, 3), [1, 2, 3]), + (("nested", ("a", "b")), {"deeply": {"nested": "value"}}), + ((), 0), + (("empty",), ""), + ], +) +def test_cache_set_then_get(key, value): + c = InMemoryTTLCache(maxsize=10) + c.set(key, value) + assert c.get(key) == value + + +def test_cache_miss_returns_none(): + c = InMemoryTTLCache() + assert c.get(("missing",)) is None + + +def test_cache_overwrite_keeps_size(): + c = InMemoryTTLCache(maxsize=10) + for _ in range(5): + c.set("k", "v") + assert len(c) == 1 + + +def test_cache_len_grows_with_unique_sets(): + c = InMemoryTTLCache(maxsize=100) + for i in range(50): + c.set(("k", i), i) + assert len(c) == 50 + + +@pytest.mark.parametrize("n", [2, 5, 10, 50]) +def test_cache_lru_eviction_count(n): + c = InMemoryTTLCache(maxsize=n) + for i in range(n * 3): + c.set(("k", i), i) + assert len(c) == n + assert c.stats().evictions == n * 2 + + +def test_cache_lru_evicts_oldest_first(): + c = InMemoryTTLCache(maxsize=3) + c.set("a", 1) + c.set("b", 2) + c.set("c", 3) + c.set("d", 4) # evicts "a" + assert c.get("a") is None + assert c.get("b") == 2 + assert c.get("c") == 3 + assert c.get("d") == 4 + + +def test_cache_lru_get_promotes_recency(): + c = InMemoryTTLCache(maxsize=3) + c.set("a", 1) + c.set("b", 2) + c.set("c", 3) + c.get("a") # promote + c.set("d", 4) # should now evict "b" + assert c.get("b") is None + assert c.get("a") == 1 + + +# --------------------------------------------------------------------------- +# TTL behaviour with injectable clock +# --------------------------------------------------------------------------- + +class _FakeClock: + def __init__(self, start: float = 1000.0) -> None: + self.t = start + + def __call__(self) -> float: + return self.t + + def advance(self, dt: float) -> None: + self.t += dt + + +@pytest.mark.parametrize("ttl", [0.5, 1, 5, 60]) +def test_cache_ttl_expiration(ttl): + clock = _FakeClock() + c = InMemoryTTLCache(maxsize=10, ttl=ttl) + c._now = clock + c.set("k", "v") + assert c.get("k") == "v" + clock.advance(ttl + 0.01) + assert c.get("k") is None + assert c.stats().expirations == 1 + + +def test_cache_ttl_none_never_expires(): + clock = _FakeClock() + c = InMemoryTTLCache(maxsize=10, ttl=None) + c._now = clock + c.set("k", "v") + clock.advance(1_000_000) + assert c.get("k") == "v" + + +def test_cache_ttl_partial_age_still_valid(): + clock = _FakeClock() + c = InMemoryTTLCache(maxsize=10, ttl=10) + c._now = clock + c.set("k", "v") + clock.advance(5) + assert c.get("k") == "v" + + +# --------------------------------------------------------------------------- +# stats / contains / clear +# --------------------------------------------------------------------------- + +def test_cache_stats_snapshot_isolation(): + c = InMemoryTTLCache() + c.set("a", 1) + snap = c.stats() + c.set("b", 2) + assert snap.sets == 1 # not mutated by subsequent activity + + +def test_cache_clear_resets_data_and_stats(): + c = InMemoryTTLCache(maxsize=10) + for i in range(5): + c.set(("k", i), i) + c.get(("k", 0)) + c.get(("missing",)) + c.clear() + assert len(c) == 0 + s = c.stats() + assert s.hits == s.misses == s.sets == 0 + + +def test_cache_contains_uses_get_path(): + c = InMemoryTTLCache(maxsize=10) + c.set("k", "v") + assert "k" in c + assert "missing" not in c + s = c.stats() + assert s.hits >= 1 + assert s.misses >= 1 + + +@pytest.mark.parametrize("count", [10, 50, 200]) +def test_cache_set_counter(count): + c = InMemoryTTLCache(maxsize=count) + for i in range(count): + c.set(("k", i), i) + assert c.stats().sets == count + + +# --------------------------------------------------------------------------- +# Concurrency +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("n_threads,iters", [(2, 100), (4, 200), (8, 100)]) +def test_cache_thread_safety(n_threads, iters): + c = InMemoryTTLCache(maxsize=1000) + barrier = threading.Barrier(n_threads) + + def worker(tid): + barrier.wait() + for i in range(iters): + c.set((tid, i), i) + c.get((tid, i)) + + threads = [threading.Thread(target=worker, args=(t,)) for t in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + s = c.stats() + assert s.sets == n_threads * iters + assert s.hits == n_threads * iters + + +def test_cache_concurrent_overwrite_no_corruption(): + c = InMemoryTTLCache(maxsize=10) + + def worker(): + for _ in range(500): + c.set("k", "v") + + threads = [threading.Thread(target=worker) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert c.get("k") == "v" + assert len(c) == 1 + + +# --------------------------------------------------------------------------- +# Real-time TTL sanity (small TTL on the real clock) +# --------------------------------------------------------------------------- + +def test_cache_real_clock_ttl_short(): + c = InMemoryTTLCache(maxsize=2, ttl=0.05) + c.set("k", "v") + assert c.get("k") == "v" + time.sleep(0.1) + assert c.get("k") is None diff --git a/tests/test_client.py b/tests/test_client.py index 4f01e397..83276c89 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,16 +19,17 @@ """Tests for client module.""" import time +import uuid -import responses import requests -import uuid +import responses import googlemaps import googlemaps.client as _client -from . import TestCase from googlemaps.client import _X_GOOG_MAPS_EXPERIENCE_ID +from . import TestCase + class ClientTest(TestCase): def test_no_api_key(self): @@ -62,9 +63,7 @@ def test_queries_per_second(self): status=200, content_type="application/json", ) - client = googlemaps.Client( - key="AIzaasdf", queries_per_second=queries_per_second - ) + client = googlemaps.Client(key="AIzaasdf", queries_per_second=queries_per_second) start = time.time() for _ in query_range: client.geocode("Sesame St.") @@ -86,8 +85,7 @@ def test_key_sent(self): self.assertEqual(1, len(responses.calls)) self.assertURLEqual( - "https://maps.googleapis.com/maps/api/geocode/json?" - "key=AIzaasdf&address=Sesame+St.", + "https://maps.googleapis.com/maps/api/geocode/json?key=AIzaasdf&address=Sesame+St.", responses.calls[0].request.url, ) @@ -141,9 +139,7 @@ def test_url_signed(self): self.assertEqual(1, len(responses.calls)) # Check ordering of parameters. - self.assertIn( - "address=Sesame+St.&client=foo&signature", responses.calls[0].request.url - ) + self.assertIn("address=Sesame+St.&client=foo&signature", responses.calls[0].request.url) self.assertURLEqual( "https://maps.googleapis.com/maps/api/geocode/json?" "address=Sesame+St.&client=foo&" @@ -285,9 +281,7 @@ def test_invalid_channel(self): # https://developers.google.com/maps/premium/reports # /usage-reports#channels with self.assertRaises(ValueError): - client = googlemaps.Client( - client_id="foo", client_secret="a2V5", channel="auieauie$? " - ) + client = googlemaps.Client(client_id="foo", client_secret="a2V5", channel="auieauie$? ") def test_auth_url_with_channel(self): client = googlemaps.Client( @@ -295,9 +289,7 @@ def test_auth_url_with_channel(self): ) # Check ordering of parameters + signature. - auth_url = client._generate_auth_url( - "/test", {"param": "param"}, accepts_clientid=True - ) + auth_url = client._generate_auth_url("/test", {"param": "param"}, accepts_clientid=True) self.assertEqual( auth_url, "/test?param=param" @@ -307,9 +299,7 @@ def test_auth_url_with_channel(self): ) # Check if added to requests to API with accepts_clientid=False - auth_url = client._generate_auth_url( - "/test", {"param": "param"}, accepts_clientid=False - ) + auth_url = client._generate_auth_url("/test", {"param": "param"}, accepts_clientid=False) self.assertEqual(auth_url, "/test?param=param&key=AIzaasdf") def test_requests_version(self): diff --git a/tests/test_client_advanced.py b/tests/test_client_advanced.py new file mode 100644 index 00000000..8f9fd8a2 --- /dev/null +++ b/tests/test_client_advanced.py @@ -0,0 +1,408 @@ +"""Advanced parametrized tests for googlemaps.client core helpers.""" + +from __future__ import annotations + +import pytest +import responses + +import googlemaps +from googlemaps import client as _client +from googlemaps.client import ( + _X_GOOG_MAPS_EXPERIENCE_ID, + normalize_for_urlencode, + sign_hmac, + urlencode_params, +) + +# --------------------------------------------------------------------------- +# Constructor — credentials +# --------------------------------------------------------------------------- + +def test_client_no_credentials_raises(): + with pytest.raises(ValueError): + googlemaps.Client() + + +@pytest.mark.parametrize( + "bad_key", ["", "no-prefix", "abc", "1234567890", "AIz", "key-without-AIza"], +) +def test_invalid_api_key_format(bad_key): + with pytest.raises(ValueError): + googlemaps.Client(key=bad_key) + + +@pytest.mark.parametrize( + "good_key", + [ + "AIza" + "x" * 35, + "AIza1234", + "AIzaA", + "AIzaSyA-abc_def-12345", + ], +) +def test_valid_api_key_format(good_key): + c = googlemaps.Client(key=good_key) + assert c.key == good_key + + +def test_client_with_enterprise_only(): + c = googlemaps.Client(client_id="me", client_secret="c2VjcmV0") + assert c.client_id == "me" + + +# --------------------------------------------------------------------------- +# Constructor — channel validation +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "ch", + ["abc", "ABC", "abc123", "abc.123", "a-b_c.d", "", "channel0", "0"], +) +def test_valid_channel(ch): + googlemaps.Client(client_id="me", client_secret="c2VjcmV0", channel=ch) + + +@pytest.mark.parametrize( + "ch", + ["bad channel", "with/slash", "with#hash", "café", "with space", "?"], +) +def test_invalid_channel(ch): + with pytest.raises(ValueError): + googlemaps.Client(client_id="me", client_secret="c2VjcmV0", channel=ch) + + +# --------------------------------------------------------------------------- +# Constructor — timeouts +# --------------------------------------------------------------------------- + +def test_timeout_combined_with_split_raises(): + with pytest.raises(ValueError): + googlemaps.Client(key="AIzaasdf", timeout=10, connect_timeout=5) + + +def test_timeout_combined_with_split_read_raises(): + with pytest.raises(ValueError): + googlemaps.Client(key="AIzaasdf", timeout=10, read_timeout=5) + + +def test_split_timeouts(): + c = googlemaps.Client(key="AIzaasdf", connect_timeout=2, read_timeout=10) + assert c.timeout == (2, 10) + + +def test_simple_timeout(): + c = googlemaps.Client(key="AIzaasdf", timeout=5) + assert c.timeout == 5 + + +# --------------------------------------------------------------------------- +# Constructor — QPS / QPM +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "qps,qpm,expected", + [ + (60, 6000, 60), # qps wins (qpm/60 = 100 > 60) + (10, 6000, 10), + (60, 60, 1), # qpm/60 = 1 + (1, 6000, 1), + (100, 600, 10), # qpm/60 = 10 + (50, 60, 1), + ], +) +def test_queries_quota_min_of_both(qps, qpm, expected): + c = googlemaps.Client(key="AIzaasdf", queries_per_second=qps, queries_per_minute=qpm) + assert c.queries_quota == expected + + +def test_queries_quota_qps_only(): + c = googlemaps.Client(key="AIzaasdf", queries_per_second=42, queries_per_minute=None) + assert c.queries_quota == 42 + + +def test_queries_quota_qpm_only(): + c = googlemaps.Client(key="AIzaasdf", queries_per_second=None, queries_per_minute=120) + assert c.queries_quota == 2 + + +def test_queries_quota_neither_raises(): + with pytest.raises(ValueError): + googlemaps.Client(key="AIzaasdf", queries_per_second=None, queries_per_minute=None) + + +# --------------------------------------------------------------------------- +# experience_id +# --------------------------------------------------------------------------- + +def test_experience_id_set_and_get(): + c = googlemaps.Client(key="AIzaasdf", experience_id="exp-1") + assert c.get_experience_id() == "exp-1" + + +def test_experience_id_multi_value(): + c = googlemaps.Client(key="AIzaasdf") + c.set_experience_id("exp-1", "exp-2") + assert c.get_experience_id() == "exp-1,exp-2" + + +def test_experience_id_clear(): + c = googlemaps.Client(key="AIzaasdf", experience_id="exp-1") + c.clear_experience_id() + assert c.get_experience_id() is None + + +def test_experience_id_init_none(): + c = googlemaps.Client(key="AIzaasdf") + assert c.get_experience_id() is None + + +def test_set_experience_id_none_clears(): + c = googlemaps.Client(key="AIzaasdf", experience_id="exp-1") + c.set_experience_id(None) + assert c.get_experience_id() is None + + +def test_set_experience_id_empty_args_clears(): + c = googlemaps.Client(key="AIzaasdf", experience_id="exp-1") + c.set_experience_id() + assert c.get_experience_id() is None + + +def test_experience_id_header_key(): + c = googlemaps.Client(key="AIzaasdf", experience_id="exp-1") + headers = c.requests_kwargs["headers"] + assert headers[_X_GOOG_MAPS_EXPERIENCE_ID] == "exp-1" + + +# --------------------------------------------------------------------------- +# urlencode_params / normalize_for_urlencode +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "params,expected", + [ + ([("a", "1")], "a=1"), + ([("a", "1"), ("b", "2")], "a=1&b=2"), + ([("address", "=Sydney ~")], "address=%3DSydney+~"), + ([("k", "")], "k="), + ([("k", ["a", "b"])], "k=a&k=b"), + ([("k", ("x", "y"))], "k=x&k=y"), + ([("k", 42)], "k=42"), + ([("k", 1.5)], "k=1.5"), + ([("k", True)], "k=True"), + ([("k", None)], "k=None"), + ], +) +def test_urlencode_params(params, expected): + assert urlencode_params(params) == expected + + +@pytest.mark.parametrize( + "value,expected", + [ + ("hello", "hello"), + (42, "42"), + (1.5, "1.5"), + (True, "True"), + (None, "None"), + ("", ""), + ("café", "café"), + ], +) +def test_normalize_for_urlencode(value, expected): + assert normalize_for_urlencode(value) == expected + + +# --------------------------------------------------------------------------- +# sign_hmac +# --------------------------------------------------------------------------- + +def test_sign_hmac_deterministic(): + secret = "vNIXE0xscrmjlyV-12Nj_BvUPaw=" + sig_a = sign_hmac(secret, "/path?key=value") + sig_b = sign_hmac(secret, "/path?key=value") + assert sig_a == sig_b + + +def test_sign_hmac_different_payloads_differ(): + secret = "vNIXE0xscrmjlyV-12Nj_BvUPaw=" + assert sign_hmac(secret, "/a") != sign_hmac(secret, "/b") + + +def test_sign_hmac_returns_str(): + out = sign_hmac("vNIXE0xscrmjlyV-12Nj_BvUPaw=", "/x") + assert isinstance(out, str) + assert len(out) > 0 + + +# --------------------------------------------------------------------------- +# auth url construction (integration-y, but stays cheap) +# --------------------------------------------------------------------------- + +@responses.activate +def test_request_includes_key_in_url(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf") + c.geocode("Sydney") + assert "key=AIzaasdf" in responses.calls[0].request.url + + +@responses.activate +def test_request_with_clientid_includes_signature_and_client(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client(client_id="me", client_secret="vNIXE0xscrmjlyV-12Nj_BvUPaw=") + c.geocode("Sydney") + url = responses.calls[0].request.url + assert "client=me" in url + assert "signature=" in url + + +@responses.activate +def test_request_with_channel_in_url(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client( + client_id="me", client_secret="vNIXE0xscrmjlyV-12Nj_BvUPaw=", channel="my.channel" + ) + c.geocode("Sydney") + assert "channel=my.channel" in responses.calls[0].request.url + + +# --------------------------------------------------------------------------- +# retry behavior (5xx) +# --------------------------------------------------------------------------- + +@responses.activate +def test_retriable_5xx_then_success(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body="server error", status=500, + ) + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf", retry_timeout=10) + c.geocode("Sydney") + assert len(responses.calls) == 2 + + +@responses.activate +def test_non_retriable_4xx_raises_http_error(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body="bad", status=400, + ) + c = googlemaps.Client(key="AIzaasdf") + with pytest.raises(googlemaps.exceptions.HTTPError): + c.geocode("Sydney") + + +@responses.activate +def test_api_error_raised_for_non_ok_status(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"REQUEST_DENIED","error_message":"nope"}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf") + with pytest.raises(googlemaps.exceptions.ApiError) as ei: + c.geocode("Sydney") + assert ei.value.status == "REQUEST_DENIED" + + +@responses.activate +def test_zero_results_does_not_raise(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"ZERO_RESULTS","results":[]}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf") + # Does not raise; body is returned (results may be empty list). + out = c.geocode("Atlantis") + assert out in ([], {"status": "ZERO_RESULTS", "results": []}) + + +@responses.activate +def test_over_query_limit_no_retry(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OVER_QUERY_LIMIT"}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf", retry_over_query_limit=False) + with pytest.raises(googlemaps.exceptions._OverQueryLimit): + c.geocode("Sydney") + + +# --------------------------------------------------------------------------- +# extra_params (advanced unsupported feature) +# --------------------------------------------------------------------------- + +@responses.activate +def test_extra_params_appears_in_url(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/geocode/json", + body='{"status":"OK","results":[]}', + status=200, + content_type="application/json", + ) + c = googlemaps.Client(key="AIzaasdf") + c.geocode("Sydney", extra_params={"foo": "bar"}) + assert "foo=bar" in responses.calls[0].request.url + # extra_params is consumed, so subsequent calls don't carry it. + assert not hasattr(c, "_extra_params") + + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + +def test_user_agent_constant_includes_version(): + assert googlemaps.__version__ in _client._USER_AGENT + assert "GoogleGeoApiClientPython" in _client._USER_AGENT + + +def test_default_base_url_is_https_googleapis(): + assert _client._DEFAULT_BASE_URL.startswith("https://") + assert "googleapis.com" in _client._DEFAULT_BASE_URL + + +@pytest.mark.parametrize("status", [500, 503, 504]) +def test_retriable_statuses_constant(status): + assert status in _client._RETRIABLE_STATUSES + + +@pytest.mark.parametrize("status", [200, 400, 401, 403, 404, 502]) +def test_non_retriable_statuses(status): + assert status not in _client._RETRIABLE_STATUSES diff --git a/tests/test_convert.py b/tests/test_convert.py index 39546aee..3f004aa9 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -19,6 +19,7 @@ import datetime import unittest + import pytest from googlemaps import convert @@ -156,10 +157,7 @@ def test_polyline_decode(self): self.assertAlmostEqual(144.963180, points[-1]["lng"]) def test_polyline_round_trip(self): - test_polyline = ( - "gcneIpgxzRcDnBoBlEHzKjBbHlG`@`IkDxIi" - "KhKoMaLwTwHeIqHuAyGXeB~Ew@fFjAtIzExF" - ) + test_polyline = "gcneIpgxzRcDnBoBlEHzKjBbHlG`@`IkDxIiKhKoMaLwTwHeIqHuAyGXeB~Ew@fFjAtIzExF" points = convert.decode_polyline(test_polyline) actual_polyline = convert.encode_polyline(points) diff --git a/tests/test_convert_advanced.py b/tests/test_convert_advanced.py new file mode 100644 index 00000000..b9972461 --- /dev/null +++ b/tests/test_convert_advanced.py @@ -0,0 +1,377 @@ +"""Advanced parametrized tests for googlemaps.convert. + +These tests intentionally lean on ``pytest.mark.parametrize`` to exercise +every branch of every helper across a wide spectrum of inputs. +""" + +from __future__ import annotations + +import datetime + +import pytest + +from googlemaps import convert + +# --------------------------------------------------------------------------- +# format_float +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "value,expected", + [ + (0, "0"), + (0.0, "0"), + (-0.0, "-0"), # IEEE -0 round-trips through f"{-0.0:.8f}" -> "-0.00000000" + (40, "40"), + (40.0, "40"), + (40.1, "40.1"), + (40.001, "40.001"), + (40.0010, "40.001"), + (40.000000001, "40"), + (40.000000009, "40.00000001"), + (-33.8674869, "-33.8674869"), + (151.2069902, "151.2069902"), + (1.23456789, "1.23456789"), + (1.234567899, "1.2345679"), + (1e-9, "0"), + (1e-8, "0.00000001"), + (1234567.5, "1234567.5"), + (-1234567.5, "-1234567.5"), + ], +) +def test_format_float(value, expected): + assert convert.format_float(value) == expected + + +def test_format_float_accepts_int_strings(): + # format_float casts via float(); strings of numbers are valid input. + assert convert.format_float("10.5") == "10.5" + + +@pytest.mark.parametrize("bad", ["abc", "", None, [], {}]) +def test_format_float_rejects_non_numeric(bad): + with pytest.raises((TypeError, ValueError)): + convert.format_float(bad) + + +# --------------------------------------------------------------------------- +# latlng / normalize_lat_lng +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "arg,expected", + [ + ({"lat": -33.8674869, "lng": 151.2069902}, "-33.8674869,151.2069902"), + ({"latitude": -33.8674869, "longitude": 151.2069902}, "-33.8674869,151.2069902"), + ((-33, 151), "-33,151"), + ([-33, 151], "-33,151"), + ((0, 0), "0,0"), + ((90, 180), "90,180"), + ((-90, -180), "-90,-180"), + ((1.0, 2.0), "1,2"), + ("Sydney", "Sydney"), + ("-33.8674869,151.2069902", "-33.8674869,151.2069902"), + ("", ""), + ], +) +def test_latlng(arg, expected): + assert convert.latlng(arg) == expected + + +@pytest.mark.parametrize( + "bad", + [123, 1.5, object(), set(), {"x": 1, "y": 2}, {"lat": 1}, {"longitude": 2}], +) +def test_latlng_rejects_invalid(bad): + with pytest.raises(TypeError): + convert.latlng(bad) + + +@pytest.mark.parametrize( + "arg,expected", + [ + ({"lat": 1, "lng": 2}, (1, 2)), + ({"latitude": 3, "longitude": 4}, (3, 4)), + ([5, 6], (5, 6)), + ((7, 8), (7, 8)), + ([5, 6, 99], (5, 6)), # extra elements ignored + ], +) +def test_normalize_lat_lng(arg, expected): + assert convert.normalize_lat_lng(arg) == expected + + +@pytest.mark.parametrize("bad", [123, "abc", object(), {"x": 1}, set()]) +def test_normalize_lat_lng_rejects_invalid(bad): + with pytest.raises(TypeError): + convert.normalize_lat_lng(bad) + + +# --------------------------------------------------------------------------- +# location_list +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "arg,expected", + [ + ([{"lat": -33.86, "lng": 151.20}, "Sydney"], "-33.86,151.2|Sydney"), + ([(1, 2), (3, 4)], "1,2|3,4"), + ([[1, 2], [3, 4], [5, 6]], "1,2|3,4|5,6"), + ((1, 2), "1,2"), # single tuple + (["A", "B", "C"], "A|B|C"), + ([{"latitude": 0, "longitude": 0}], "0,0"), + ("Sydney", "Sydney"), # string is treated as a single location + ], +) +def test_location_list(arg, expected): + assert convert.location_list(arg) == expected + + +def test_location_list_empty(): + assert convert.location_list([]) == "" + + +# --------------------------------------------------------------------------- +# join_list / as_list / _is_list / is_string +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "sep,arg,expected", + [ + ("|", ["a", "b", "c"], "a|b|c"), + (",", ["a", "b"], "a,b"), + ("|", "single", "single"), + ("|", ("x", "y"), "x|y"), + ("|", [], ""), + ("|", [""], ""), + (";", ["1", "2", "3"], "1;2;3"), + ], +) +def test_join_list(sep, arg, expected): + assert convert.join_list(sep, arg) == expected + + +@pytest.mark.parametrize( + "arg,expected", + [ + ("hello", ["hello"]), + ([1, 2, 3], [1, 2, 3]), + ((1, 2, 3), (1, 2, 3)), + ([], []), + (1, [1]), + (None, [None]), + ({"k": "v"}, [{"k": "v"}]), # dicts are NOT list-like + ], +) +def test_as_list(arg, expected): + assert convert.as_list(arg) == expected + + +@pytest.mark.parametrize( + "val,expected", + [ + ("", True), + ("hello", True), + ("a", True), + (b"bytes", False), + (123, False), + (1.5, False), + ([], False), + ({}, False), + (None, False), + ((), False), + (object(), False), + ], +) +def test_is_string(val, expected): + assert convert.is_string(val) is expected + + +# --------------------------------------------------------------------------- +# time +# --------------------------------------------------------------------------- + +def test_time_int(): + assert convert.time(1409810596) == "1409810596" + + +def test_time_float(): + assert convert.time(1409810596.7) == "1409810596" + + +def test_time_datetime_naive(): + dt = datetime.datetime(2020, 1, 1, 0, 0, 0) + out = convert.time(dt) + assert out.isdigit() + assert int(out) > 0 + + +def test_time_datetime_now(): + out = convert.time(datetime.datetime.now()) + assert out.isdigit() + + +@pytest.mark.parametrize("value", [0, 1, 999_999_999_999]) +def test_time_extreme_ints(value): + assert convert.time(value) == str(value) + + +# --------------------------------------------------------------------------- +# components +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "arg,expected", + [ + ({"country": "US"}, "country:US"), + ({"country": "US", "postal_code": "94043"}, "country:US|postal_code:94043"), + ({"country": ["US", "AU"]}, "country:AU|country:US"), + ({"country": ("US", "AU")}, "country:AU|country:US"), + ({"a": "1", "b": "2", "c": "3"}, "a:1|b:2|c:3"), + ({"x": "z", "a": "b"}, "a:b|x:z"), + ({}, ""), + ({"key": ""}, "key:"), + ({"k": ["a", "b", "c"]}, "k:a|k:b|k:c"), + ], +) +def test_components(arg, expected): + assert convert.components(arg) == expected + + +@pytest.mark.parametrize("bad", ["string", 1, None, [], (), object()]) +def test_components_rejects_non_dict(bad): + with pytest.raises(TypeError): + convert.components(bad) + + +# --------------------------------------------------------------------------- +# bounds +# --------------------------------------------------------------------------- + +def test_bounds_dict(): + sydney = { + "northeast": {"lat": -33.4245981, "lng": 151.3426361}, + "southwest": {"lat": -34.1692489, "lng": 150.502229}, + } + out = convert.bounds(sydney) + assert "|" in out + sw, ne = out.split("|") + assert sw.startswith("-34.16") + assert ne.startswith("-33.42") + + +def test_bounds_string_passthrough(): + s = "1,2|3,4" + assert convert.bounds(s) == s + + +@pytest.mark.parametrize( + "bad", + [ + "not-a-bounds", + "1,2,3|4,5", + {"sw": 1, "ne": 2}, # wrong keys + {"southwest": (1, 2)}, # missing northeast + 123, + None, + [], + (), + ], +) +def test_bounds_rejects_invalid(bad): + with pytest.raises(TypeError): + convert.bounds(bad) + + +# --------------------------------------------------------------------------- +# size +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "arg,expected", + [ + (400, "400x400"), + (1, "1x1"), + ((400, 200), "400x200"), + ([100, 50], "100x50"), + ((0, 0), "0x0"), + ], +) +def test_size(arg, expected): + assert convert.size(arg) == expected + + +@pytest.mark.parametrize("bad", ["400", None, {}, object()]) +def test_size_rejects_invalid(bad): + with pytest.raises(TypeError): + convert.size(bad) + + +# --------------------------------------------------------------------------- +# encode / decode polyline (round-trip) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "points", + [ + [(38.5, -120.2), (40.7, -120.95), (43.252, -126.453)], + [(0.0, 0.0)], + [(1.0, 1.0), (1.0, 1.0)], + [(-90, -180), (90, 180)], + [(0, 0), (1e-5, 1e-5), (2e-5, 2e-5)], + [{"lat": 38.5, "lng": -120.2}, {"lat": 40.7, "lng": -120.95}], + [(i * 0.001, i * 0.002) for i in range(50)], + [(-i * 0.01, i * 0.01) for i in range(20)], + ], +) +def test_encode_decode_polyline_round_trip(points): + encoded = convert.encode_polyline(points) + decoded = convert.decode_polyline(encoded) + assert len(decoded) == len(points) + for original, got in zip(points, decoded): + olat, olng = convert.normalize_lat_lng(original) + assert abs(got["lat"] - olat) < 1e-5 + assert abs(got["lng"] - olng) < 1e-5 + + +def test_encode_polyline_known_value(): + # Reference value from the Google polyline algorithm spec. + points = [(38.5, -120.2), (40.7, -120.95), (43.252, -126.453)] + assert convert.encode_polyline(points) == "_p~iF~ps|U_ulLnnqC_mqNvxq`@" + + +def test_decode_polyline_known_value(): + decoded = convert.decode_polyline("_p~iF~ps|U_ulLnnqC_mqNvxq`@") + assert len(decoded) == 3 + assert abs(decoded[0]["lat"] - 38.5) < 1e-5 + assert abs(decoded[0]["lng"] + 120.2) < 1e-5 + + +def test_encode_empty_polyline(): + assert convert.encode_polyline([]) == "" + + +def test_decode_empty_polyline(): + assert convert.decode_polyline("") == [] + + +# --------------------------------------------------------------------------- +# shortest_path +# --------------------------------------------------------------------------- + +def test_shortest_path_chooses_encoded_for_many_points(): + # Many points -> encoded version is shorter. + points = [(i * 0.001, i * 0.001) for i in range(100)] + out = convert.shortest_path(points) + assert out.startswith("enc:") + + +def test_shortest_path_chooses_unencoded_for_one_point(): + out = convert.shortest_path([(1, 2)]) + # encoded form would be "enc:..." which is longer than "1,2" + assert not out.startswith("enc:") + assert out == "1,2" + + +def test_shortest_path_single_tuple(): + assert convert.shortest_path((1, 2)) == "1,2" diff --git a/tests/test_directions.py b/tests/test_directions.py index 5a3c477a..8c6745d2 100644 --- a/tests/test_directions.py +++ b/tests/test_directions.py @@ -17,13 +17,13 @@ """Tests for the directions module.""" -from datetime import datetime -from datetime import timedelta import time +from datetime import datetime, timedelta import responses import googlemaps + from . import TestCase @@ -84,9 +84,7 @@ def test_transit_without_time(self): # With mode of transit, we need a departure_time or an # arrival_time specified with self.assertRaises(googlemaps.exceptions.ApiError): - self.client.directions( - "Sydney Town Hall", "Parramatta, NSW", mode="transit" - ) + self.client.directions("Sydney Town Hall", "Parramatta, NSW", mode="transit") @responses.activate def test_transit_with_departure_time(self): @@ -159,9 +157,7 @@ def test_travel_mode_round_trip(self): content_type="application/json", ) - routes = self.client.directions( - "Town Hall, Sydney", "Parramatta, NSW", mode="bicycling" - ) + routes = self.client.directions("Town Hall, Sydney", "Parramatta, NSW", mode="bicycling") self.assertEqual(1, len(responses.calls)) self.assertURLEqual( @@ -182,9 +178,7 @@ def test_brooklyn_to_queens_by_transit(self): ) now = datetime.now() - routes = self.client.directions( - "Brooklyn", "Queens", mode="transit", departure_time=now - ) + routes = self.client.directions("Brooklyn", "Queens", mode="transit", departure_time=now) self.assertEqual(1, len(responses.calls)) self.assertURLEqual( diff --git a/tests/test_distance_matrix.py b/tests/test_distance_matrix.py index 6946782e..e6ddf8af 100644 --- a/tests/test_distance_matrix.py +++ b/tests/test_distance_matrix.py @@ -17,12 +17,13 @@ """Tests for the distance matrix module.""" -from datetime import datetime import time +from datetime import datetime import responses import googlemaps + from . import TestCase @@ -84,10 +85,7 @@ def test_mixed_params(self): content_type="application/json", ) - origins = [ - "Bobcaygeon ON", [41.43206, -81.38992], - "place_id:ChIJ7cv00DwsDogRAMDACa2m4K8" - ] + origins = ["Bobcaygeon ON", [41.43206, -81.38992], "place_id:ChIJ7cv00DwsDogRAMDACa2m4K8"] destinations = [ (43.012486, -83.6964149), {"lat": 42.8863855, "lng": -78.8781627}, @@ -185,6 +183,7 @@ def test_lang_param(self): "destinations=San+Francisco%%7CVictoria+BC" % self.key, responses.calls[0].request.url, ) + @responses.activate def test_place_id_param(self): responses.add( @@ -196,12 +195,12 @@ def test_place_id_param(self): ) origins = [ - 'place_id:ChIJ7cv00DwsDogRAMDACa2m4K8', - 'place_id:ChIJzxcfI6qAa4cR1jaKJ_j0jhE', + "place_id:ChIJ7cv00DwsDogRAMDACa2m4K8", + "place_id:ChIJzxcfI6qAa4cR1jaKJ_j0jhE", ] destinations = [ - 'place_id:ChIJPZDrEzLsZIgRoNrpodC5P30', - 'place_id:ChIJjQmTaV0E9YgRC2MLmS_e_mY', + "place_id:ChIJPZDrEzLsZIgRoNrpodC5P30", + "place_id:ChIJjQmTaV0E9YgRC2MLmS_e_mY", ] matrix = self.client.distance_matrix(origins, destinations) diff --git a/tests/test_elevation.py b/tests/test_elevation.py index 165f95b3..19c65a26 100644 --- a/tests/test_elevation.py +++ b/tests/test_elevation.py @@ -17,11 +17,10 @@ """Tests for the elevation module.""" -import datetime - import responses import googlemaps + from . import TestCase diff --git a/tests/test_exceptions_advanced.py b/tests/test_exceptions_advanced.py new file mode 100644 index 00000000..8a1ef712 --- /dev/null +++ b/tests/test_exceptions_advanced.py @@ -0,0 +1,132 @@ +"""Advanced parametrized tests for googlemaps.exceptions.""" + +from __future__ import annotations + +import pytest + +from googlemaps import exceptions as exc + +# --------------------------------------------------------------------------- +# ApiError +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "status,message,expected", + [ + ("OVER_QUERY_LIMIT", "rate limited", "OVER_QUERY_LIMIT (rate limited)"), + ("INVALID_REQUEST", "bad", "INVALID_REQUEST (bad)"), + ("REQUEST_DENIED", "denied", "REQUEST_DENIED (denied)"), + ("UNKNOWN_ERROR", "??", "UNKNOWN_ERROR (??)"), + ("NOT_FOUND", "missing", "NOT_FOUND (missing)"), + ], +) +def test_apierror_with_message(status, message, expected): + err = exc.ApiError(status, message) + assert str(err) == expected + assert err.status == status + assert err.message == message + + +@pytest.mark.parametrize( + "status", + ["OK", "ZERO_RESULTS", "OVER_QUERY_LIMIT", "INVALID_REQUEST", "REQUEST_DENIED"], +) +def test_apierror_without_message(status): + err = exc.ApiError(status) + assert str(err) == status + assert err.message is None + + +def test_apierror_is_exception(): + assert issubclass(exc.ApiError, Exception) + + +def test_apierror_can_be_raised_and_caught(): + with pytest.raises(exc.ApiError) as ei: + raise exc.ApiError("BOOM", "kaboom") + assert ei.value.status == "BOOM" + + +# --------------------------------------------------------------------------- +# TransportError +# --------------------------------------------------------------------------- + +def test_transport_error_default_message(): + err = exc.TransportError() + assert str(err) == "An unknown error occurred." + + +@pytest.mark.parametrize( + "base,expected", + [ + (RuntimeError("boom"), "boom"), + (ValueError("bad"), "bad"), + (ConnectionError("net"), "net"), + (TimeoutError("slow"), "slow"), + (OSError("io"), "io"), + ], +) +def test_transport_error_wraps_base(base, expected): + err = exc.TransportError(base) + assert str(err) == expected + assert err.base_exception is base + + +def test_transport_error_is_exception(): + assert issubclass(exc.TransportError, Exception) + + +# --------------------------------------------------------------------------- +# HTTPError +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "code,expected", + [ + (400, "HTTP Error: 400"), + (401, "HTTP Error: 401"), + (403, "HTTP Error: 403"), + (404, "HTTP Error: 404"), + (500, "HTTP Error: 500"), + (502, "HTTP Error: 502"), + (503, "HTTP Error: 503"), + (504, "HTTP Error: 504"), + ], +) +def test_http_error_str(code, expected): + err = exc.HTTPError(code) + assert str(err) == expected + assert err.status_code == code + + +def test_http_error_is_transport_error(): + assert issubclass(exc.HTTPError, exc.TransportError) + + +# --------------------------------------------------------------------------- +# Timeout / retriable +# --------------------------------------------------------------------------- + +def test_timeout_is_exception(): + assert issubclass(exc.Timeout, Exception) + + +def test_timeout_raisable(): + with pytest.raises(exc.Timeout): + raise exc.Timeout() + + +def test_retriable_request_is_exception(): + assert issubclass(exc._RetriableRequest, Exception) + + +def test_over_query_limit_is_apierror_and_retriable(): + err = exc._OverQueryLimit("OVER_QUERY_LIMIT", "rate") + assert isinstance(err, exc.ApiError) + assert isinstance(err, exc._RetriableRequest) + + +def test_over_query_limit_string_includes_message(): + err = exc._OverQueryLimit("OVER_QUERY_LIMIT", "rate") + assert "OVER_QUERY_LIMIT" in str(err) + assert "rate" in str(err) diff --git a/tests/test_geocoding.py b/tests/test_geocoding.py index 8734c8b8..4018b1d9 100644 --- a/tests/test_geocoding.py +++ b/tests/test_geocoding.py @@ -1,4 +1,3 @@ -# This Python file uses the following encoding: utf-8 # # Copyright 2014 Google Inc. All rights reserved. # @@ -18,11 +17,10 @@ """Tests for the geocoding module.""" -import datetime - import responses import googlemaps + from . import TestCase @@ -45,8 +43,7 @@ def test_simple_geocode(self): self.assertEqual(1, len(responses.calls)) self.assertURLEqual( - "https://maps.googleapis.com/maps/api/geocode/json?" - "key=%s&address=Sydney" % self.key, + "https://maps.googleapis.com/maps/api/geocode/json?key=%s&address=Sydney" % self.key, responses.calls[0].request.url, ) @@ -79,7 +76,9 @@ def test_geocoding_the_googleplex(self): content_type="application/json", ) - results = self.client.geocode("1600 Amphitheatre Parkway, " "Mountain View, CA").get("results", []) + results = self.client.geocode("1600 Amphitheatre Parkway, Mountain View, CA").get( + "results", [] + ) self.assertEqual(1, len(responses.calls)) self.assertURLEqual( @@ -218,7 +217,7 @@ def test_geocode_place_id(self): "https://maps.googleapis.com/maps/api/geocode/json?" "key=%s&place_id=ChIJeRpOeF67j4AR9ydy_PIzPuM" % self.key, responses.calls[0].request.url, - ) + ) @responses.activate def test_simple_reverse_geocode(self): @@ -321,7 +320,9 @@ def test_reverse_geocode_with_address_descriptors(self): content_type="application/json", ) - response = self.client.reverse_geocode((-33.8674869, 151.2069902), enable_address_descriptor=True) + response = self.client.reverse_geocode( + (-33.8674869, 151.2069902), enable_address_descriptor=True + ) address_descriptor = response.get("address_descriptor", []) diff --git a/tests/test_geolocation.py b/tests/test_geolocation.py index 8eeb2cbc..ec901b8b 100644 --- a/tests/test_geolocation.py +++ b/tests/test_geolocation.py @@ -1,4 +1,3 @@ -# This Python file uses the following encoding: utf-8 # # Copyright 2017 Google Inc. All rights reserved. # @@ -21,6 +20,7 @@ import responses import googlemaps + from . import TestCase @@ -43,6 +43,6 @@ def test_simple_geolocate(self): self.assertEqual(1, len(responses.calls)) self.assertURLEqual( - "https://www.googleapis.com/geolocation/v1/geolocate?" "key=%s" % self.key, + "https://www.googleapis.com/geolocation/v1/geolocate?key=%s" % self.key, responses.calls[0].request.url, ) diff --git a/tests/test_maps.py b/tests/test_maps.py index 8db6298f..228b00c4 100644 --- a/tests/test_maps.py +++ b/tests/test_maps.py @@ -22,10 +22,9 @@ import responses import googlemaps -from . import TestCase +from googlemaps.maps import StaticMapMarker, StaticMapPath -from googlemaps.maps import StaticMapMarker -from googlemaps.maps import StaticMapPath +from . import TestCase class MapsTest(TestCase): @@ -42,9 +41,7 @@ def test_static_map_marker(self): label="S", ) - self.assertEqual( - "size:small|color:blue|label:S|" "-33.867486,151.20699|Sydney", str(marker) - ) + self.assertEqual("size:small|color:blue|label:S|-33.867486,151.20699|Sydney", str(marker)) with self.assertRaises(ValueError): StaticMapMarker(locations=["Sydney"], label="XS") @@ -62,9 +59,7 @@ def test_static_map_path(self): ) self.assertEqual( - "weight:5|color:red|fillcolor:Red|" - "geodesic:True|" - "-33.867486,151.20699|Sydney", + "weight:5|color:red|fillcolor:Red|geodesic:True|-33.867486,151.20699|Sydney", str(path), ) @@ -79,17 +74,11 @@ def test_download(self): color="red", ) - m1 = StaticMapMarker( - locations=[(62.107733, -145.541936)], color="blue", label="S" - ) + m1 = StaticMapMarker(locations=[(62.107733, -145.541936)], color="blue", label="S") - m2 = StaticMapMarker( - locations=["Delta+Junction,AK"], size="tiny", color="green" - ) + m2 = StaticMapMarker(locations=["Delta+Junction,AK"], size="tiny", color="green") - m3 = StaticMapMarker( - locations=["Tok,AK"], size="mid", color="0xFFFF00", label="C" - ) + m3 = StaticMapMarker(locations=["Tok,AK"], size="mid", color="0xFFFF00", label="C") response = self.client.static_map( size=(400, 400), diff --git a/tests/test_maps_advanced.py b/tests/test_maps_advanced.py new file mode 100644 index 00000000..9faac62c --- /dev/null +++ b/tests/test_maps_advanced.py @@ -0,0 +1,224 @@ +"""Advanced parametrized tests for googlemaps.maps.""" + +from __future__ import annotations + +import pytest +import responses + +import googlemaps +from googlemaps.maps import ( + MAPS_IMAGE_FORMATS, + MAPS_MAP_TYPES, + StaticMapMarker, + StaticMapPath, +) + +# --------------------------------------------------------------------------- +# StaticMapMarker +# --------------------------------------------------------------------------- + +def test_marker_locations_only(): + m = StaticMapMarker(locations=[(1, 2), (3, 4)]) + assert "1,2|3,4" in str(m) + + +@pytest.mark.parametrize("size", ["tiny", "mid", "small", "100"]) +def test_marker_size(size): + m = StaticMapMarker(locations=[(1, 2)], size=size) + assert f"size:{size}" in str(m) + + +@pytest.mark.parametrize( + "color", + ["red", "blue", "green", "yellow", "0xFFAABB", "0x000000"], +) +def test_marker_color(color): + m = StaticMapMarker(locations=[(1, 2)], color=color) + assert f"color:{color}" in str(m) + + +@pytest.mark.parametrize("label", ["A", "Z", "0", "9", "X", "M", "1", "5"]) +def test_marker_valid_label(label): + m = StaticMapMarker(locations=[(1, 2)], label=label) + assert f"label:{label}" in str(m) + + +@pytest.mark.parametrize("label", ["a", "ab", "AB", "12", "@", "z"]) +def test_marker_invalid_label(label): + with pytest.raises(ValueError): + StaticMapMarker(locations=[(1, 2)], label=label) + + +def test_marker_combined_params_order(): + m = StaticMapMarker( + locations=[(1, 2)], size="tiny", color="red", label="A" + ) + s = str(m) + assert s.index("size:tiny") < s.index("color:red") < s.index("label:A") < s.index("1,2") + + +# --------------------------------------------------------------------------- +# StaticMapPath +# --------------------------------------------------------------------------- + +def test_path_points_only(): + p = StaticMapPath(points=[(1, 2), (3, 4)]) + assert "1,2|3,4" in str(p) + + +@pytest.mark.parametrize("weight", [1, 2, 5, 10, 100]) +def test_path_weight(weight): + p = StaticMapPath(points=[(1, 2), (3, 4)], weight=weight) + assert f"weight:{weight}" in str(p) + + +@pytest.mark.parametrize("color", ["red", "0x00FF00", "blue"]) +def test_path_color(color): + p = StaticMapPath(points=[(1, 2), (3, 4)], color=color) + assert f"color:{color}" in str(p) + + +@pytest.mark.parametrize("fillcolor", ["red", "0x00FF00FF", "0xAA0000"]) +def test_path_fillcolor(fillcolor): + p = StaticMapPath(points=[(1, 2), (3, 4)], fillcolor=fillcolor) + assert f"fillcolor:{fillcolor}" in str(p) + + +@pytest.mark.parametrize("geodesic", [True, False]) +def test_path_geodesic_truthy(geodesic): + p = StaticMapPath(points=[(1, 2), (3, 4)], geodesic=geodesic) + if geodesic: + assert "geodesic:" in str(p) + else: + assert "geodesic:" not in str(p) + + +def test_path_combined(): + p = StaticMapPath( + points=[(1, 2), (3, 4)], weight=5, color="red", fillcolor="blue", geodesic=True + ) + s = str(p) + for token in ("weight:5", "color:red", "fillcolor:blue", "geodesic:True", "1,2", "3,4"): + assert token in s + + +# --------------------------------------------------------------------------- +# Module constants +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("fmt", ["png8", "png", "png32", "gif", "jpg", "jpg-baseline"]) +def test_image_format_supported(fmt): + assert fmt in MAPS_IMAGE_FORMATS + + +@pytest.mark.parametrize("bad", ["bmp", "tif", "webp", "svg", ""]) +def test_image_format_unsupported(bad): + assert bad not in MAPS_IMAGE_FORMATS + + +@pytest.mark.parametrize("mt", ["roadmap", "satellite", "terrain", "hybrid"]) +def test_maptype_supported(mt): + assert mt in MAPS_MAP_TYPES + + +@pytest.mark.parametrize("bad", ["street", "topo", "watercolor", ""]) +def test_maptype_unsupported(bad): + assert bad not in MAPS_MAP_TYPES + + +# --------------------------------------------------------------------------- +# static_map (integration with mocked responses) +# --------------------------------------------------------------------------- + +def _add_map_response(): + responses.add( + responses.GET, + "https://maps.googleapis.com/maps/api/staticmap", + body=b"\x89PNG\r\n", + status=200, + content_type="image/png", + ) + + +@responses.activate +def test_static_map_requires_center_or_markers(): + c = googlemaps.Client(key="AIzaasdf") + with pytest.raises(ValueError): + list(c.static_map(size=(400, 400))) + + +@responses.activate +def test_static_map_with_center_and_zoom(): + _add_map_response() + c = googlemaps.Client(key="AIzaasdf") + list(c.static_map(size=(400, 400), center=(1, 2), zoom=5)) + url = responses.calls[0].request.url + assert "size=400x400" in url + assert "center=1%2C2" in url or "center=1,2" in url + assert "zoom=5" in url + + +@responses.activate +def test_static_map_with_markers_no_center(): + _add_map_response() + c = googlemaps.Client(key="AIzaasdf") + markers = StaticMapMarker(locations=[(1, 2)]) + list(c.static_map(size=(400, 400), markers=markers)) + assert "markers=" in responses.calls[0].request.url + + +@responses.activate +@pytest.mark.parametrize("fmt", sorted(MAPS_IMAGE_FORMATS)) +def test_static_map_valid_format(fmt): + _add_map_response() + c = googlemaps.Client(key="AIzaasdf") + list(c.static_map(size=(1, 1), center=(0, 0), zoom=1, format=fmt)) + + +@responses.activate +def test_static_map_invalid_format(): + c = googlemaps.Client(key="AIzaasdf") + with pytest.raises(ValueError): + list(c.static_map(size=(1, 1), center=(0, 0), zoom=1, format="bmp")) + + +@responses.activate +@pytest.mark.parametrize("mt", sorted(MAPS_MAP_TYPES)) +def test_static_map_valid_maptype(mt): + _add_map_response() + c = googlemaps.Client(key="AIzaasdf") + list(c.static_map(size=(1, 1), center=(0, 0), zoom=1, maptype=mt)) + + +@responses.activate +def test_static_map_invalid_maptype(): + c = googlemaps.Client(key="AIzaasdf") + with pytest.raises(ValueError): + list(c.static_map(size=(1, 1), center=(0, 0), zoom=1, maptype="watercolor")) + + +@responses.activate +@pytest.mark.parametrize("scale", [1, 2, 4]) +def test_static_map_scale(scale): + _add_map_response() + c = googlemaps.Client(key="AIzaasdf") + list(c.static_map(size=(1, 1), center=(0, 0), zoom=1, scale=scale)) + assert f"scale={scale}" in responses.calls[0].request.url + + +@responses.activate +@pytest.mark.parametrize("language", ["en", "fr", "de", "es", "ja"]) +def test_static_map_language(language): + _add_map_response() + c = googlemaps.Client(key="AIzaasdf") + list(c.static_map(size=(1, 1), center=(0, 0), zoom=1, language=language)) + assert f"language={language}" in responses.calls[0].request.url + + +@responses.activate +@pytest.mark.parametrize("region", ["us", "uk", "au", "jp"]) +def test_static_map_region(region): + _add_map_response() + c = googlemaps.Client(key="AIzaasdf") + list(c.static_map(size=(1, 1), center=(0, 0), zoom=1, region=region)) + assert f"region={region}" in responses.calls[0].request.url diff --git a/tests/test_places.py b/tests/test_places.py index 32f03d16..583f6676 100644 --- a/tests/test_places.py +++ b/tests/test_places.py @@ -1,4 +1,3 @@ -# This Python file uses the following encoding: utf-8 # # Copyright 2016 Google Inc. All rights reserved. # @@ -19,12 +18,12 @@ """Tests for the places module.""" import uuid - from types import GeneratorType import responses import googlemaps + from . import TestCase @@ -61,17 +60,14 @@ def test_places_find(self): self.assertURLEqual( "%s?language=en-AU&inputtype=textquery&" "locationbias=point:90,90&input=restaurant" - "&fields=business_status,geometry/location,place_id&key=%s" - % (url, self.key), + "&fields=business_status,geometry/location,place_id&key=%s" % (url, self.key), responses.calls[0].request.url, ) with self.assertRaises(ValueError): self.client.find_place("restaurant", "invalid") with self.assertRaises(ValueError): - self.client.find_place( - "restaurant", "textquery", fields=["geometry", "invalid"] - ) + self.client.find_place("restaurant", "textquery", fields=["geometry", "invalid"]) with self.assertRaises(ValueError): self.client.find_place("restaurant", "textquery", location_bias="invalid") @@ -163,8 +159,7 @@ def test_place_detail(self): self.client.place( "ChIJN1t_tDeuEmsRUsoyG83frY4", - fields=["business_status", "geometry/location", - "place_id", "reviews"], + fields=["business_status", "geometry/location", "place_id", "reviews"], language=self.language, reviews_no_translations=True, reviews_sort="newest", @@ -174,15 +169,12 @@ def test_place_detail(self): self.assertURLEqual( "%s?language=en-AU&placeid=ChIJN1t_tDeuEmsRUsoyG83frY4" "&reviews_no_translations=true&reviews_sort=newest" - "&key=%s&fields=business_status,geometry/location,place_id,reviews" - % (url, self.key), + "&key=%s&fields=business_status,geometry/location,place_id,reviews" % (url, self.key), responses.calls[0].request.url, ) with self.assertRaises(ValueError): - self.client.place( - "ChIJN1t_tDeuEmsRUsoyG83frY4", fields=["geometry", "invalid"] - ) + self.client.place("ChIJN1t_tDeuEmsRUsoyG83frY4", fields=["geometry", "invalid"]) @responses.activate def test_photo(self): diff --git a/tests/test_roads.py b/tests/test_roads.py index bfea2bf7..cfc0f372 100644 --- a/tests/test_roads.py +++ b/tests/test_roads.py @@ -17,10 +17,10 @@ """Tests for the roads module.""" - import responses import googlemaps + from . import TestCase @@ -84,9 +84,7 @@ def test_path(self): self.assertEqual(1, len(responses.calls)) self.assertURLEqual( - "https://roads.googleapis.com/v1/speedLimits?" - "path=1%%2C2|3%%2C4" - "&key=%s" % self.key, + "https://roads.googleapis.com/v1/speedLimits?path=1%%2C2|3%%2C4&key=%s" % self.key, responses.calls[0].request.url, ) @@ -103,8 +101,7 @@ def test_speedlimits(self): results = self.client.speed_limits("id1") self.assertEqual("foo", results[0]) self.assertEqual( - "https://roads.googleapis.com/v1/speedLimits?" - "placeId=id1&key=%s" % self.key, + "https://roads.googleapis.com/v1/speedLimits?placeId=id1&key=%s" % self.key, responses.calls[0].request.url, ) diff --git a/tests/test_timezone.py b/tests/test_timezone.py index a1d7394e..b78bee21 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -1,4 +1,3 @@ -# This Python file uses the following encoding: utf-8 # # Copyright 2014 Google Inc. All rights reserved. # @@ -19,10 +18,12 @@ """Tests for the timezone module.""" import datetime +from unittest import mock import responses -from unittest import mock + import googlemaps + from . import TestCase diff --git a/text.py b/text.py deleted file mode 100644 index 13734488..00000000 --- a/text.py +++ /dev/null @@ -1,19 +0,0 @@ -import math - -queries_quota : int -queries_per_second = 60 # None or 60 -queries_per_minute = None # None or 6000 - -try: - if (type(queries_per_second) == int and type(queries_per_minute) == int ): - queries_quota = math.floor(min(queries_per_second, queries_per_minute/60)) - elif (queries_per_second): - queries_quota = math.floor(queries_per_second) - elif (queries_per_minute): - queries_quota = math.floor(queries_per_minute/60) - else: - print("MISSING VALID NUMBER for queries_per_second or queries_per_minute") - print(queries_quota) - -except NameError: - print("MISSING VALUE for queries_per_second or queries_per_minute") \ No newline at end of file