From 07cf8e0f217be9769b7510b52aae5d50687b377b Mon Sep 17 00:00:00 2001 From: Rahul singh Date: Wed, 29 Apr 2026 12:25:41 +0530 Subject: [PATCH] feat: add advanced cache/batch features, expand hard test coverage, and refresh README Added two advanced fork features for production workflows: Pluggable response cache with TTL + LRU behavior, cache stats, and safe key normalization. Concurrent batch executor with ordered results, per-item exception handling, and configurable workers. Integrated cache behavior into client request flow with safe defaults and no breaking API changes. Added large advanced test coverage (including edge cases, concurrency, error paths, and integration-style scenarios) across convert, cache, client, maps, exceptions, and batch functionality. Modernized and stabilized quality gates: All tests passing. Lint checks passing. Rewrote README in a more professional, detailed format: clear feature explanations, architecture/behavior notes, usage examples, development workflow and quality summary. --- .gitignore | 15 + README.md | 418 ++++++++++++--- coverage.xml | 849 ------------------------------ googlemaps/__init__.py | 14 +- googlemaps/addressvalidation.py | 26 +- googlemaps/batch.py | 160 ++++++ googlemaps/cache.py | 184 +++++++ googlemaps/client.py | 292 +++++----- googlemaps/convert.py | 82 ++- googlemaps/directions.py | 31 +- googlemaps/distance_matrix.py | 26 +- googlemaps/elevation.py | 10 +- googlemaps/exceptions.py | 16 +- googlemaps/geocoding.py | 18 +- googlemaps/geolocation.py | 26 +- googlemaps/maps.py | 62 ++- googlemaps/places.py | 102 ++-- googlemaps/roads.py | 59 ++- googlemaps/timezone.py | 8 +- pyproject.toml | 116 ++++ setup.cfg | 6 +- setup.py | 53 +- tests/__init__.py | 5 +- tests/test_addressvalidation.py | 10 +- tests/test_batch.py | 207 ++++++++ tests/test_batch_advanced.py | 279 ++++++++++ tests/test_cache.py | 262 +++++++++ tests/test_cache_advanced.py | 352 +++++++++++++ tests/test_client.py | 30 +- tests/test_client_advanced.py | 408 ++++++++++++++ tests/test_convert.py | 6 +- tests/test_convert_advanced.py | 377 +++++++++++++ tests/test_directions.py | 16 +- tests/test_distance_matrix.py | 17 +- tests/test_elevation.py | 3 +- tests/test_exceptions_advanced.py | 132 +++++ tests/test_geocoding.py | 17 +- tests/test_geolocation.py | 4 +- tests/test_maps.py | 25 +- tests/test_maps_advanced.py | 224 ++++++++ tests/test_places.py | 20 +- tests/test_roads.py | 9 +- tests/test_timezone.py | 5 +- text.py | 19 - 44 files changed, 3557 insertions(+), 1443 deletions(-) delete mode 100644 coverage.xml create mode 100644 googlemaps/batch.py create mode 100644 googlemaps/cache.py create mode 100644 pyproject.toml create mode 100644 tests/test_batch.py create mode 100644 tests/test_batch_advanced.py create mode 100644 tests/test_cache.py create mode 100644 tests/test_cache_advanced.py create mode 100644 tests/test_client_advanced.py create mode 100644 tests/test_convert_advanced.py create mode 100644 tests/test_exceptions_advanced.py create mode 100644 tests/test_maps_advanced.py delete mode 100644 text.py 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