Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,22 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Setup Python
uses: "actions/setup-python@v3"
with:
python-version: "${{ matrix.python-version }}"

- name: Install dependencies
run: ./.github/scripts/install.sh

- name: Run tests
env:
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
run: |
python3 -m nox --session "tests-${{ matrix.python-version }}"
python3 -m nox -e distribution
Expand Down
100 changes: 90 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,40 @@ contribute, please read contribute.
- Python 3.5 or later.
- A Google Maps API key.

## API Keys
## API Keys & Setup

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).
Each request to Google Maps Platform Web Services requires an API key.

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.
### 1. Generating an API Key

**Important:** This key should be kept secret on your server.
1. Go to the [Google Cloud Console Credentials](https://console.cloud.google.com/apis/credentials) page.
2. Select your Google Cloud project (or create a new project).
3. Click **+ Create Credentials** at the top and select **API key**.
4. Copy the generated API key.

### 2. Enabling Required APIs

Google Maps Platform Web Services are modular. Enable the specific APIs your project needs in the [Google Cloud API Library](https://console.cloud.google.com/apis/library):

- **Places API (New)** (`places.googleapis.com`)
- **Routes API (v2)** (`routes.googleapis.com`)
- **Address Validation API** (`addressvalidation.googleapis.com`)
- **Geocoding API** (`geocoding-backend.googleapis.com`)
- **Isochrones API** (`isochrones.googleapis.com`)
- **Environmental APIs**: Solar (`solar.googleapis.com`), Air Quality (`airquality.googleapis.com`), Pollen (`pollen.googleapis.com`)

### 3. Key Restrictions & Security Best Practices

> ⚠️ **Important Security Rule:** Never hardcode secret API keys directly into public repositories or client-side code.

- **API Restrictions**: In Cloud Console, select your API Key -> **API restrictions** -> choose **Restrict key** -> select only the specific APIs your backend application requires.
- **Application Restrictions**: Limit key usage by server IP address (`IP addresses` restriction) for backend environments.
- **Environment Variables**: Store your key in an environment variable:
```bash
export GOOGLE_MAPS_API_KEY="AIzaSy..."
```

For full setup documentation, visit [Get Started with Google Maps Platform](https://developers.google.com/maps/gmp-get-started).

## Installation

Expand Down Expand Up @@ -108,14 +134,68 @@ Automatically retry when intermittent failures occur. That is, when any of the r
are returned from the API.


## Building the Project
## Testing

The project contains unit tests (with mock HTTP responses) and live integration tests (against Google Maps Platform production servers).

### 1. Running Unit Tests (Offline / Mocked)

Run the fast unit test suite using `pytest`:

```bash
# Run all unit tests
pytest

# Run tests with coverage report
pytest --cov=googlemaps --cov-report=term-missing
```

### 2. Running Live Integration Tests

The live integration suite in `tests/test_integration.py` tests actual API requests against Google Maps Platform production endpoints.

# Installing nox
$ pip install nox
#### Running Locally
Set your API key in your shell environment:

# Running tests
$ nox
```bash
export GOOGLE_MAPS_API_KEY="YOUR_ACTUAL_API_KEY"
pytest tests/test_integration.py
```

#### Running automatically in GitHub Actions CI
To run live integration tests on GitHub Actions:
1. Open your repository on GitHub.
2. Go to **Settings** -> **Secrets and variables** -> **Actions**.
3. Click **New repository secret**.
4. Name: `GOOGLE_MAPS_API_KEY`
5. Value: *your restricted Google Maps Platform API key*.

The `.github/workflows/test.yml` workflow automatically passes `${{ secrets.GOOGLE_MAPS_API_KEY }}` into test runs across Python versions 3.9 through 3.13.

*(Note: If `GOOGLE_MAPS_API_KEY` secret or environment variable is absent, live integration tests are automatically skipped).*

### 3. Running Executable CUJ Samples

Run any of the real-world CUJ sample applications:

```bash
python samples/real_estate_insights.py
python samples/delivery_route_planner.py
python samples/travel_discovery_assistant.py
python samples/isochrone_reachability_cuj.py
```

---

## Building the Project

```bash
# Installing nox
$ pip install nox

# Running full test matrices across Python versions
$ nox
```

# Generating documentation
$ nox -e docs
Expand Down
66 changes: 66 additions & 0 deletions googlemaps/airquality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 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
#
# 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.

"""Performs requests to the Google Maps Air Quality API."""

from googlemaps import convert
from googlemaps import exceptions

_AIRQUALITY_BASE_URL = "https://airquality.googleapis.com"


def _airquality_extract(response):
if response.status_code != 200:
try:
body = response.json()
if "error" in body:
raise exceptions.ApiError(
body["error"].get("status", response.status_code),
body["error"].get("message"),
)
except ValueError:
pass
raise exceptions.HTTPError(response.status_code)
return response.json()


def air_quality_current_conditions(
client,
location,
extra_computations=None,
language_code=None,
uaqi_color_palette=None,
):
lat, lng = convert.normalize_lat_lng(location)
params = {
"location": {
"latitude": lat,
"longitude": lng,
}
}

if extra_computations:
params["extraComputations"] = convert.as_list(extra_computations)
if language_code:
params["languageCode"] = language_code
if uaqi_color_palette:
params["uaqiColorPalette"] = uaqi_color_palette

return client._request(
"/v1/currentConditions:lookup",
{},
base_url=_AIRQUALITY_BASE_URL,
extract_body=_airquality_extract,
post_json=params,
)
32 changes: 32 additions & 0 deletions googlemaps/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,15 @@ def _request(self, url, params, first_request_time=None, retry_counter=0,
# Default to the client-level self.requests_kwargs, with method-level
# requests_kwargs arg overriding.
requests_kwargs = requests_kwargs or {}

# Safely merge headers to preserve default headers (e.g. User-Agent)
client_headers = self.requests_kwargs.get("headers", {})
request_headers = requests_kwargs.get("headers", {})
merged_headers = dict(client_headers, **request_headers)

final_requests_kwargs = dict(self.requests_kwargs, **requests_kwargs)
if merged_headers:
final_requests_kwargs["headers"] = merged_headers

# Determine GET/POST.
requests_method = self.session.get
Expand Down Expand Up @@ -428,6 +436,18 @@ def _generate_auth_url(self, path, params, accepts_clientid):
from googlemaps.places import places_autocomplete_query
from googlemaps.maps import static_map
from googlemaps.addressvalidation import addressvalidation
from googlemaps.routes import compute_routes, compute_route_matrix
from googlemaps.solar import find_closest_building_insights, get_solar_data_layers
from googlemaps.airquality import air_quality_current_conditions
from googlemaps.pollen import pollen_forecast
from googlemaps.places_v1 import (
places_search_text,
places_search_nearby,
place_v1,
places_autocomplete_v1,
place_photo_v1,
)
from googlemaps.isochrones import generate_isochrones

def make_api_method(func):
"""
Expand Down Expand Up @@ -472,6 +492,18 @@ def wrapper(*args, **kwargs):
Client.places_autocomplete_query = make_api_method(places_autocomplete_query)
Client.static_map = make_api_method(static_map)
Client.addressvalidation = make_api_method(addressvalidation)
Client.compute_routes = make_api_method(compute_routes)
Client.compute_route_matrix = make_api_method(compute_route_matrix)
Client.find_closest_building_insights = make_api_method(find_closest_building_insights)
Client.get_solar_data_layers = make_api_method(get_solar_data_layers)
Client.air_quality_current_conditions = make_api_method(air_quality_current_conditions)
Client.pollen_forecast = make_api_method(pollen_forecast)
Client.places_search_text = make_api_method(places_search_text)
Client.places_search_nearby = make_api_method(places_search_nearby)
Client.place_v1 = make_api_method(place_v1)
Client.places_autocomplete_v1 = make_api_method(places_autocomplete_v1)
Client.place_photo_v1 = make_api_method(place_photo_v1)
Client.generate_isochrones = make_api_method(generate_isochrones)


def sign_hmac(secret, payload):
Expand Down
94 changes: 94 additions & 0 deletions googlemaps/isochrones.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# 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
#
# 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.

"""Performs requests to the Google Maps Isochrones API."""

from googlemaps import exceptions

_ISOCHRONES_BASE_URL = "https://isochrones.googleapis.com"


def _isochrones_extract(response):
if response.status_code != 200:
try:
body = response.json()
if "error" in body:
raise exceptions.ApiError(
body["error"].get("status", response.status_code),
body["error"].get("message"),
)
except ValueError:
pass
raise exceptions.HTTPError(response.status_code)
return response.json()


def _format_latlng(location):
if isinstance(location, dict):
if "latitude" in location:
return {"latitude": location["latitude"], "longitude": location["longitude"]}
elif "lat" in location:
return {"latitude": location["lat"], "longitude": location["lng"]}
elif isinstance(location, (tuple, list)):
return {"latitude": location[0], "longitude": location[1]}
elif isinstance(location, str) and "," in location:
parts = [float(p.strip()) for p in location.split(",")]
return {"latitude": parts[0], "longitude": parts[1]}
raise ValueError("location must be a tuple (lat, lng), string 'lat,lng', or dict with latitude/longitude")


def generate_isochrones(
client,
location,
travel_mode="DRIVE",
travel_direction="FROM",
duration_seconds=900,
routing_preference=None,
):
"""Performs a request to the Google Maps Isochrones API to calculate area reachability.

:param location: The origin location as a dict or lat/lng tuple.
:type location: dict or tuple

:param travel_mode: Mode of travel. Supported: "DRIVE", "WALK", "BICYCLE", "TRANSIT".
:type travel_mode: string

:param travel_direction: Direction of travel: "FROM" (outbound) or "TO" (inbound). Default "FROM".
:type travel_direction: string

:param duration_seconds: Travel duration limit in seconds (e.g. 900 for 15 min). Default 900.
:type duration_seconds: int

:param routing_preference: Routing preference ("TRAFFIC_AWARE" or "TRAFFIC_UNAWARE").
:type routing_preference: string
"""
params = {
"location": _format_latlng(location),
"travelMode": travel_mode,
"travelDirection": travel_direction,
}

if duration_seconds is not None:
params["travelDuration"] = "%ds" % duration_seconds

if routing_preference:
params["routingPreference"] = routing_preference

return client._request(
"/v1/isochrones:generate",
{},
base_url=_ISOCHRONES_BASE_URL,
extract_body=_isochrones_extract,
post_json=params,
)
Loading
Loading