diff --git a/README.md b/README.md
index 03f1859..0852189 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,11 @@ gmaps geocode
[--lang=CODE] [--raw]
gmaps revgeocode | --at=LAT,LNG [--raw]
gmaps route --from=ORIGIN --to=DEST [--mode=drive|walk|bicycle|transit]
[--lang=CODE] [--raw]
+gmaps matrix --from=A [--from=B ...] --to=C [--to=D ...]
+ [--mode=drive|walk|bicycle|transit] [--raw]
+gmaps tz --at=LAT,LNG [--time=EPOCH] [--raw]
+gmaps weather | --at=LAT,LNG [--days=N | --hours=N]
+ [--units=metric|imperial] [--lang=CODE] [--raw]
```
### `search` — Places Text Search
@@ -111,6 +116,38 @@ gmaps route --from=45.53,-73.61 --to=placeid:ChIJDbdkHFQayUwR7-8fITgxTmU --mode=
Distance and duration only — no polyline, no turn-by-turn (keeps output small
and the request in the cheapest SKU).
+### `matrix` — many origins × many destinations
+
+Repeat `--from` and `--to` (up to 10 each; billed per origin×destination
+element). Same waypoint forms as `route`.
+
+```bash
+gmaps matrix --from="Home St, Rosemère" --from="Work Ave" --to="YUL Airport" --to="Downtown"
+```
+
+### `tz` — time zone at a coordinate
+
+```bash
+gmaps tz --at=45.5017,-73.5673
+# {"ok":true,"data":{"timezone_id":"America/Toronto","utc_offset_s":-14400,...}}
+```
+
+`--time=EPOCH` evaluates DST for a specific moment (defaults to now).
+
+### `weather` — current conditions or forecast
+
+Takes a place name (geocoded first, one extra Essentials call) or `--at=LAT,LNG`.
+
+```bash
+gmaps weather "Rosemère QC" # current conditions
+gmaps weather --at=43.07,11.68 --days=5 # daily forecast (1-10)
+gmaps weather "Pienza" --hours=12 --units=metric
+```
+
+> Note: the Time Zone and Weather services document only `?key=` query-param
+> auth, so for those two calls the key is in the request URL (over TLS). It is
+> still scrubbed from every output stream.
+
## Output & agent contract
Success goes to **stdout** as compact JSON; errors go to **stderr**. The shape is
@@ -145,9 +182,12 @@ upstream JSON instead (still limited to the fields in the mask).
internal retries, so the retry policy is entirely the caller's. The HTTP timeout
is a fixed 10 seconds and there is no `--timeout` flag.
-The resolved API key is sent only in the `X-Goog-Api-Key` request header. It
-never appears in argv, a URL, stdout, or stderr — including error paths, where
-the key is scrubbed from any message before it is printed.
+For Places, Geocoding, and Routes calls the resolved API key is sent only in
+the `X-Goog-Api-Key` request header. The Time Zone and Weather services
+document only `?key=` auth, so for `tz` and `weather` the key rides the
+request URL (over TLS). In every case it never appears in argv, stdout, or
+stderr — including error paths, where both the raw and URL-encoded key forms
+are scrubbed from any message before it is printed.
## API key setup
@@ -158,21 +198,26 @@ export GMAPS_API_KEY="AIza..."
```
Create a key with `gcloud` and restrict it to exactly the APIs this tool uses —
-Places (New), Routes, and Geocoding — so a leaked key cannot reach anything else:
+Places (New), Routes, Geocoding, Time Zone, and Weather — so a leaked key
+cannot reach anything else:
```bash
-# Enable the three backends
+# Enable the five backends
gcloud services enable \
places.googleapis.com \
routes.googleapis.com \
- geocoding-backend.googleapis.com
+ geocoding-backend.googleapis.com \
+ timezone-backend.googleapis.com \
+ weather.googleapis.com
# Create an API-restricted key (prints keyString once — capture it safely)
gcloud services api-keys create \
--display-name="gmaps-cli" \
--api-target=service=places.googleapis.com \
--api-target=service=routes.googleapis.com \
- --api-target=service=geocoding-backend.googleapis.com
+ --api-target=service=geocoding-backend.googleapis.com \
+ --api-target=service=timezone-backend.googleapis.com \
+ --api-target=service=weather.googleapis.com
# Read the key string back later if needed
gcloud services api-keys get-key-string KEY_ID --format='value(keyString)'
@@ -196,6 +241,9 @@ field-mask override flag.
| `place` | Places Enterprise | full contact + opening hours for one chosen place |
| `place --reviews` | Enterprise + Atmosphere | adds editorial summary + review snippets |
| `route` | Routes Essentials | distance + duration only; never sets `routingPreference` |
+| `matrix` | Routes Essentials | billed per origin×destination element; never traffic-aware |
+| `tz` | Time Zone Essentials | single lookup |
+| `weather` | Weather Essentials | current or forecast; place form adds one Geocoding call |
| `geocode`, `revgeocode` | Geocoding Essentials | no field mask required |
Practical guidance for agents: start with `search` (Pro), only add `--detailed`
diff --git a/SKILL.md b/SKILL.md
index 54cee9a..b10b729 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -1,6 +1,6 @@
---
name: gmaps
-description: "Google Maps: search, ratings, hours, routes (gmaps CLI)."
+description: "Google Maps places, routes, matrix, weather, time zones (CLI)."
---
# gmaps — Google Maps lookups from the shell
@@ -8,8 +8,9 @@ description: "Google Maps: search, ratings, hours, routes (gmaps CLI)."
## When to Use
Use `gmaps` whenever you need real-world place data: finding businesses or POIs,
-ratings and opening hours, converting addresses to coordinates (or back), or
-travel time/distance between two points. Prefer it over scraping or guessing —
+ratings and opening hours, converting addresses to coordinates (or back),
+travel time/distance (one pair via `route`, many via `matrix`), the time zone
+at a location (`tz`), or weather — current conditions or forecast (`weather`). Prefer it over scraping or guessing —
results come from the Google Maps Platform.
## Prerequisites
@@ -39,6 +40,10 @@ gmaps place ChIJDbdkHFQayUwR7-8fITgxTmU --reviews # + editorial summary, 3 revi
gmaps geocode "Piazza del Campo, Siena"
gmaps revgeocode --at=45.5017,-73.5673
gmaps route --from="Rosemère, QC" --to="Trudeau Airport" --mode=drive
+gmaps matrix --from="Home" --from="Work" --to="Airport" # many-to-many times
+gmaps tz --at=45.5017,-73.5673 # IANA tz + UTC offset
+gmaps weather "Rosemère QC" # current conditions
+gmaps weather --at=43.07,11.68 --days=5 # daily forecast
```
`route` waypoints accept an address, `LAT,LNG`, or `placeid:`. Modes:
diff --git a/gmaps.py b/gmaps.py
index 210c232..e1c7d31 100755
--- a/gmaps.py
+++ b/gmaps.py
@@ -1,11 +1,15 @@
#!/usr/bin/python3 -I
"""gmaps — a zero-dependency Google Maps CLI for AI agents and humans.
-Wraps the Places API (New), Geocoding v4, and Routes API in a single stdlib-only
-Python file. Output is a compact JSON envelope on stdout; errors are a JSON
-envelope on stderr with stable exit codes. The API key is read only from the
-``GMAPS_API_KEY`` environment variable and sent via the ``X-Goog-Api-Key``
-header — it never appears in argv, a URL, or any output stream.
+Wraps the Places API (New), Geocoding v4, Routes API, Time Zone API, and
+Weather API in a single stdlib-only Python file. Output is a compact JSON
+envelope on stdout; errors are a JSON envelope on stderr with stable exit
+codes. The API key is read only from the ``GMAPS_API_KEY`` environment
+variable and sent via the ``X-Goog-Api-Key`` header where supported (Places,
+Geocoding, Routes); the Time Zone and Weather services only document the
+``?key=`` query-parameter form, so for those two the key rides the request URL
+— it still never appears in argv or any output stream (all output is scrubbed
+at the emit boundary).
Design notes:
@@ -22,6 +26,7 @@
import os
import socket
import sys
+import time
import urllib.error
import urllib.parse
import urllib.request
@@ -35,6 +40,9 @@
GEOCODE_FWD_BASE = "https://geocode.googleapis.com/v4/geocode/address/"
GEOCODE_REV_BASE = "https://geocode.googleapis.com/v4/geocode/location/"
ROUTES_URL = "https://routes.googleapis.com/directions/v2:computeRoutes"
+MATRIX_URL = "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix"
+TIMEZONE_URL = "https://maps.googleapis.com/maps/api/timezone/json"
+WEATHER_BASE = "https://weather.googleapis.com/v1/"
TIMEOUT = 10 # seconds; kept below any supervising process's kill timeout.
@@ -66,9 +74,14 @@
"place": _PLACE_BASE,
"place_reviews": _PLACE_BASE + "," + _PLACE_REVIEW_EXTRA,
"route": "routes.distanceMeters,routes.duration,routes.description",
+ # MUST include `status`: without it every matrix element reads as OK.
+ "matrix": "originIndex,destinationIndex,status,condition,distanceMeters,duration",
}
DEFAULT_LIMIT = 8
+WEATHER_MAX_DAYS = 10 # API maximum
+WEATHER_MAX_HOURS = 24 # forecast/hours pageSize maxes at 24; no pagination
+MATRIX_MAX_SIDE = 10 # 10x10 = 100 elements; API cap is 625 (100 for transit)
SEARCH_DEFAULT_RADIUS = 5000
NEARBY_DEFAULT_RADIUS = 1500
MAX_RADIUS = 50000
@@ -111,6 +124,9 @@ def redact(text, secret):
"""
if secret and len(secret) >= 4:
text = text.replace(secret, "***")
+ encoded = urllib.parse.quote_plus(secret)
+ if encoded != secret:
+ text = text.replace(encoded, "***")
return text
@@ -218,7 +234,10 @@ def _call(transport, method, url, headers, body_obj=None):
)
if status == 200:
try:
- return json.loads(raw.decode("utf-8"))
+ parsed = json.loads(raw.decode("utf-8"))
+ if not isinstance(parsed, (dict, list)):
+ raise ValueError("non-container JSON body")
+ return parsed
except (ValueError, UnicodeDecodeError):
raise GmapsError(
"upstream",
@@ -363,6 +382,73 @@ def _trim_geocode(result):
return out
+def _cond_text(container):
+ return ((container.get("weatherCondition") or {}).get("description") or {}).get(
+ "text"
+ )
+
+
+def _trim_weather_current(resp):
+ out = {}
+ _set(out, "condition", _cond_text(resp))
+ _set(out, "temp", (resp.get("temperature") or {}).get("degrees"))
+ _set(out, "feels_like", (resp.get("feelsLikeTemperature") or {}).get("degrees"))
+ _set(out, "humidity_pct", resp.get("relativeHumidity"))
+ wind = resp.get("wind") or {}
+ _set(out, "wind_kmh", (wind.get("speed") or {}).get("value"))
+ _set(out, "wind_dir", (wind.get("direction") or {}).get("cardinal"))
+ precip = ((resp.get("precipitation") or {}).get("probability")) or {}
+ _set(out, "precip_prob_pct", precip.get("percent"))
+ _set(out, "uv_index", resp.get("uvIndex"))
+ _set(out, "cloud_pct", resp.get("cloudCover"))
+ return out
+
+
+def _weather_date(display_date):
+ y = display_date.get("year")
+ m = display_date.get("month")
+ d = display_date.get("day")
+ if y is None or m is None or d is None:
+ return None
+ return f"{y:04d}-{m:02d}-{d:02d}"
+
+
+def _trim_weather_day(day):
+ out = {}
+ _set(out, "date", _weather_date(day.get("displayDate") or {}))
+ daytime = day.get("daytimeForecast") or {}
+ night = day.get("nighttimeForecast") or {}
+ _set(out, "condition_day", _cond_text(daytime))
+ _set(out, "condition_night", _cond_text(night))
+ _set(out, "max", (day.get("maxTemperature") or {}).get("degrees"))
+ _set(out, "min", (day.get("minTemperature") or {}).get("degrees"))
+ _set(
+ out,
+ "precip_prob_pct",
+ (((daytime.get("precipitation") or {}).get("probability")) or {}).get(
+ "percent"
+ ),
+ )
+ return out
+
+
+def _trim_weather_hour(hour):
+ out = {}
+ _set(out, "time", hour.get("interval", {}).get("startTime"))
+ _set(
+ out,
+ "condition",
+ hour.get("weatherCondition", {}).get("description", {}).get("text"),
+ )
+ _set(out, "temp", hour.get("temperature", {}).get("degrees"))
+ _set(
+ out,
+ "precip_prob_pct",
+ hour.get("precipitation", {}).get("probability", {}).get("percent"),
+ )
+ return out
+
+
def _duration_seconds(value):
if isinstance(value, str) and value.endswith("s"):
try:
@@ -497,6 +583,229 @@ def cmd_route(args, key, transport):
return [_trim_route(r) for r in resp.get("routes", [])]
+# Time Zone API is a legacy web service: HTTP 200 with an embedded status enum.
+_TZ_STATUS_ERRORS = {
+ "ZERO_RESULTS": (
+ "not_found",
+ "No time zone for this location",
+ False,
+ 4,
+ "Coordinates may be over open water.",
+ ),
+ "REQUEST_DENIED": (
+ "auth",
+ "Time Zone API request denied",
+ False,
+ 3,
+ "Check that the API key permits the Time Zone API.",
+ ),
+ "INVALID_REQUEST": (
+ "usage",
+ "Invalid Time Zone API request",
+ False,
+ 2,
+ "Check the --at coordinates.",
+ ),
+ "OVER_QUERY_LIMIT": (
+ "rate_limited",
+ "Time Zone API quota exceeded",
+ True,
+ 5,
+ "Retry later.",
+ ),
+ "OVER_DAILY_LIMIT": (
+ "rate_limited",
+ "Time Zone API daily quota exceeded",
+ True,
+ 5,
+ "Retry tomorrow or raise the quota cap.",
+ ),
+}
+
+
+def cmd_tz(args, key, transport):
+ if not args.at:
+ raise _usage(
+ "tz requires --at=LAT,LNG",
+ "Example: gmaps tz --at=45.63,-73.78",
+ )
+ _parse_latlng(args.at, "--at")
+ timestamp = args.time if args.time is not None else int(time.time())
+ url = (
+ TIMEZONE_URL
+ + "?"
+ + urllib.parse.urlencode(
+ {"location": args.at, "timestamp": timestamp, "key": key}
+ )
+ )
+ resp = _call(transport, "GET", url, {}, None)
+ if not isinstance(resp, dict):
+ raise GmapsError(
+ "upstream", "Malformed Time Zone API response", True, "Retry shortly.", 5
+ )
+ status = resp.get("status")
+ if status != "OK":
+ code, message, retryable, exit_code, hint = _TZ_STATUS_ERRORS.get(
+ status,
+ (
+ "upstream",
+ f"Time Zone API returned status {status}",
+ True,
+ 5,
+ "Retry shortly.",
+ ),
+ )
+ raise GmapsError(code, message, retryable, hint, exit_code)
+ if args.raw:
+ return resp
+ raw_offset = resp.get("rawOffset", 0)
+ dst_offset = resp.get("dstOffset", 0)
+ return {
+ "timezone_id": resp.get("timeZoneId"),
+ "timezone_name": resp.get("timeZoneName"),
+ "raw_offset_s": raw_offset,
+ "dst_offset_s": dst_offset,
+ "utc_offset_s": raw_offset + dst_offset,
+ }
+
+
+def _resolve_location(args, key, transport):
+ """LAT,LNG strings for --at, else forward-geocode the positional place."""
+ if args.at:
+ lat, lng = _parse_latlng(args.at, "--at")
+ raw_lat, raw_lng = args.at.split(",", 1)
+ return raw_lat.strip(), raw_lng.strip()
+ if args.place:
+ url = GEOCODE_FWD_BASE + urllib.parse.quote(args.place, safe="")
+ resp = _call(transport, "GET", url, {"X-Goog-Api-Key": key}, None)
+ results = resp.get("results", [])
+ if not results:
+ raise GmapsError(
+ "not_found",
+ f"Could not geocode {args.place!r}",
+ False,
+ "Try a more specific place name or pass --at=LAT,LNG.",
+ 4,
+ )
+ loc = results[0].get("location") or {}
+ lat, lng = loc.get("latitude"), loc.get("longitude")
+ if lat is None or lng is None:
+ raise GmapsError(
+ "not_found",
+ f"Geocode result for {args.place!r} has no coordinates",
+ False,
+ "Try a more specific place name or pass --at=LAT,LNG.",
+ 4,
+ )
+ return str(lat), str(lng)
+ raise _usage(
+ "weather requires a place or --at=LAT,LNG",
+ 'Examples: gmaps weather "Rosemère QC" · gmaps weather --at=45.63,-73.78',
+ )
+
+
+def cmd_weather(args, key, transport):
+ if args.days is not None and args.hours is not None:
+ raise _usage(
+ "--days and --hours are mutually exclusive",
+ "Pick one forecast granularity.",
+ )
+ if args.days is not None and not 1 <= args.days <= WEATHER_MAX_DAYS:
+ raise _usage(
+ f"--days must be 1-{WEATHER_MAX_DAYS}",
+ "Example: gmaps weather --at=45.63,-73.78 --days=3",
+ )
+ if args.hours is not None and not 1 <= args.hours <= WEATHER_MAX_HOURS:
+ raise _usage(
+ f"--hours must be 1-{WEATHER_MAX_HOURS}",
+ "Example: gmaps weather --at=45.63,-73.78 --hours=6",
+ )
+ lat, lng = _resolve_location(args, key, transport)
+ params = {"key": key, "location.latitude": lat, "location.longitude": lng}
+ if args.days is not None:
+ endpoint = "forecast/days:lookup"
+ params["days"] = args.days
+ params["pageSize"] = args.days
+ elif args.hours is not None:
+ endpoint = "forecast/hours:lookup"
+ params["hours"] = args.hours
+ params["pageSize"] = args.hours
+ else:
+ endpoint = "currentConditions:lookup"
+ if args.units:
+ params["unitsSystem"] = args.units.upper()
+ if args.lang:
+ params["languageCode"] = args.lang
+ url = WEATHER_BASE + endpoint + "?" + urllib.parse.urlencode(params)
+ resp = _call(transport, "GET", url, {}, None)
+ if not isinstance(resp, dict):
+ raise GmapsError(
+ "upstream", "Malformed Weather API response", True, "Retry shortly.", 5
+ )
+ if args.raw:
+ return resp
+ if args.days is not None:
+ return [_trim_weather_day(d) for d in resp.get("forecastDays", [])]
+ if args.hours is not None:
+ return [_trim_weather_hour(h) for h in resp.get("forecastHours", [])]
+ return _trim_weather_current(resp)
+
+
+def cmd_matrix(args, key, transport):
+ origins = args.origins or []
+ dests = args.dests or []
+ if (
+ not 1 <= len(origins) <= MATRIX_MAX_SIDE
+ or not 1 <= len(dests) <= MATRIX_MAX_SIDE
+ ):
+ raise _usage(
+ f"matrix needs 1-{MATRIX_MAX_SIDE} --from and 1-{MATRIX_MAX_SIDE} --to",
+ 'Example: gmaps matrix --from="Home St" --from="Work Ave" --to="Airport"',
+ )
+ body = {
+ "origins": [{"waypoint": _waypoint(v)} for v in origins],
+ "destinations": [{"waypoint": _waypoint(v)} for v in dests],
+ "travelMode": args.mode.upper(),
+ }
+ headers = {"X-Goog-Api-Key": key, "X-Goog-FieldMask": FIELD_MASKS["matrix"]}
+ resp = _call(transport, "POST", MATRIX_URL, headers, body)
+ if not isinstance(resp, list):
+ detail = None
+ if isinstance(resp, dict):
+ err = resp.get("error")
+ if isinstance(err, dict):
+ detail = err.get("message")
+ raise GmapsError(
+ "upstream",
+ detail or "Unexpected Route Matrix response",
+ True,
+ "Retry shortly.",
+ 5,
+ )
+ if args.raw:
+ return resp
+ elements = sorted(
+ resp,
+ key=lambda e: (e.get("originIndex", 0), e.get("destinationIndex", 0)),
+ )
+ out = []
+ for el in elements:
+ row = {}
+ oi, di = el.get("originIndex", 0), el.get("destinationIndex", 0)
+ if oi < len(origins):
+ row["from"] = origins[oi]
+ if di < len(dests):
+ row["to"] = dests[di]
+ _set(row, "distance_m", el.get("distanceMeters"))
+ _set(row, "duration_s", _duration_seconds(el.get("duration")))
+ _set(row, "condition", el.get("condition"))
+ el_status = el.get("status") or {}
+ if el_status.get("code"):
+ _set(row, "error", el_status.get("message") or "element failed")
+ out.append(row)
+ return out
+
+
# --------------------------------------------------------------------------- #
# Argument parser
# --------------------------------------------------------------------------- #
@@ -573,6 +882,33 @@ def build_parser():
rtp.add_argument("--raw", action="store_true")
rtp.set_defaults(func=cmd_route)
+ tzp = _add_subparser(sub, "tz", "Time zone at a coordinate")
+ tzp.add_argument("--at", metavar="LAT,LNG")
+ tzp.add_argument("--time", type=int, metavar="EPOCH")
+ tzp.add_argument("--raw", action="store_true")
+ tzp.set_defaults(func=cmd_tz)
+
+ wp = _add_subparser(sub, "weather", "Current conditions or forecast")
+ wp.add_argument("place", nargs="?", metavar="PLACE")
+ wp.add_argument("--at", metavar="LAT,LNG")
+ wp.add_argument("--days", type=int, metavar="N")
+ wp.add_argument("--hours", type=int, metavar="N")
+ wp.add_argument("--units", choices=["metric", "imperial"])
+ wp.add_argument("--lang", metavar="CODE")
+ wp.add_argument("--raw", action="store_true")
+ wp.set_defaults(func=cmd_weather)
+
+ mp = _add_subparser(
+ sub, "matrix", "Distance/time for many origin-destination pairs"
+ )
+ mp.add_argument("--from", dest="origins", action="append", metavar="ORIGIN")
+ mp.add_argument("--to", dest="dests", action="append", metavar="DEST")
+ mp.add_argument(
+ "--mode", choices=["drive", "walk", "bicycle", "transit"], default="drive"
+ )
+ mp.add_argument("--raw", action="store_true")
+ mp.set_defaults(func=cmd_matrix)
+
return parser
diff --git a/tests/test_gmaps.py b/tests/test_gmaps.py
index 7c926a1..7ef310d 100644
--- a/tests/test_gmaps.py
+++ b/tests/test_gmaps.py
@@ -103,6 +103,9 @@ def test_field_mask_table_is_verbatim():
"editorialSummary,reviews"
),
"route": "routes.distanceMeters,routes.duration,routes.description",
+ "matrix": (
+ "originIndex,destinationIndex,status,condition,distanceMeters,duration"
+ ),
}
diff --git a/tests/test_v02.py b/tests/test_v02.py
new file mode 100644
index 0000000..22b0d49
--- /dev/null
+++ b/tests/test_v02.py
@@ -0,0 +1,510 @@
+"""v0.2 subcommands: tz (Time Zone API), weather (Weather API), matrix (Route Matrix).
+
+Same hermetic style as test_gmaps.py — injected transport, exact request
+assertions, trim shapes, exit codes, key-redaction.
+"""
+
+import json
+import urllib.parse
+
+import pytest
+
+import gmaps
+
+API_KEY = "TESTKEY-abc123-super-secret"
+
+
+@pytest.fixture(autouse=True)
+def _set_key(monkeypatch):
+ monkeypatch.setenv("GMAPS_API_KEY", API_KEY)
+
+
+class Recorder:
+ def __init__(self, status=200, payload=None, raw=None):
+ self.calls = []
+ self.status = status
+ self.payload = payload if payload is not None else {}
+ self.raw = raw
+
+ def __call__(self, method, url, headers, body):
+ self.calls.append(
+ {"method": method, "url": url, "headers": dict(headers), "body": body}
+ )
+ if self.raw is not None:
+ return self.status, self.raw
+ return self.status, json.dumps(self.payload).encode("utf-8")
+
+ @property
+ def last(self):
+ return self.calls[-1]
+
+ def body_json(self):
+ b = self.last["body"]
+ return json.loads(b.decode("utf-8")) if b else None
+
+
+class Seq:
+ """Transport returning a different canned response per call, in order."""
+
+ def __init__(self, responses):
+ self.calls = []
+ self.responses = list(responses)
+
+ def __call__(self, method, url, headers, body):
+ self.calls.append(
+ {"method": method, "url": url, "headers": dict(headers), "body": body}
+ )
+ status, payload = self.responses.pop(0)
+ return status, json.dumps(payload).encode("utf-8")
+
+
+def qs(url):
+ parts = urllib.parse.urlparse(url)
+ return parts, urllib.parse.parse_qs(parts.query)
+
+
+def out_json(capsys):
+ out = capsys.readouterr()
+ return json.loads(out.out), out
+
+
+TZ_OK = {
+ "dstOffset": 3600,
+ "rawOffset": -18000,
+ "status": "OK",
+ "timeZoneId": "America/Toronto",
+ "timeZoneName": "Eastern Daylight Time",
+}
+
+
+# --------------------------------------------------------------------------- #
+# tz
+# --------------------------------------------------------------------------- #
+
+
+def test_tz_request_url_and_trim(capsys):
+ rec = Recorder(payload=TZ_OK)
+ code = gmaps.main(["tz", "--at=45.5,-73.6", "--time=1710000000"], transport=rec)
+ assert code == 0
+ assert rec.last["method"] == "GET"
+ parts, q = qs(rec.last["url"])
+ assert parts.netloc == "maps.googleapis.com"
+ assert parts.path == "/maps/api/timezone/json"
+ assert q["location"] == ["45.5,-73.6"]
+ assert q["timestamp"] == ["1710000000"]
+ assert q["key"] == [API_KEY]
+ # legacy web service: key is in the URL, never in a header
+ assert "X-Goog-Api-Key" not in rec.last["headers"]
+ data, _ = out_json(capsys)
+ assert data == {
+ "ok": True,
+ "data": {
+ "timezone_id": "America/Toronto",
+ "timezone_name": "Eastern Daylight Time",
+ "raw_offset_s": -18000,
+ "dst_offset_s": 3600,
+ "utc_offset_s": -14400,
+ },
+ }
+
+
+def test_tz_defaults_timestamp_to_now(monkeypatch):
+ rec = Recorder(payload=TZ_OK)
+ monkeypatch.setattr(gmaps.time, "time", lambda: 1710000123.7)
+ assert gmaps.main(["tz", "--at=45.5,-73.6"], transport=rec) == 0
+ _, q = qs(rec.last["url"])
+ assert q["timestamp"] == ["1710000123"]
+
+
+def test_tz_requires_at():
+ assert gmaps.main(["tz"], transport=Recorder(payload=TZ_OK)) == 2
+
+
+@pytest.mark.parametrize(
+ "status,exit_code",
+ [
+ ("ZERO_RESULTS", 4),
+ ("REQUEST_DENIED", 3),
+ ("OVER_QUERY_LIMIT", 5),
+ ("OVER_DAILY_LIMIT", 5),
+ ("INVALID_REQUEST", 2),
+ ("UNKNOWN_ERROR", 5),
+ ],
+)
+def test_tz_embedded_status_map(status, exit_code, capsys):
+ rec = Recorder(payload={"status": status})
+ assert gmaps.main(["tz", "--at=0.0,0.0"], transport=rec) == exit_code
+ _, out = out_json_err(capsys)
+ assert API_KEY not in out.out + out.err
+
+
+def out_json_err(capsys):
+ out = capsys.readouterr()
+ return json.loads(out.err), out
+
+
+def test_tz_retryable_flag_on_over_limit(capsys):
+ rec = Recorder(payload={"status": "OVER_QUERY_LIMIT"})
+ gmaps.main(["tz", "--at=1,1"], transport=rec)
+ err, _ = out_json_err(capsys)
+ assert err["ok"] is False
+ assert err["error"]["retryable"] is True
+
+
+def test_tz_raw(capsys):
+ rec = Recorder(payload=TZ_OK)
+ assert gmaps.main(["tz", "--at=1,1", "--raw"], transport=rec) == 0
+ data, _ = out_json(capsys)
+ assert data["data"] == TZ_OK
+
+
+# --------------------------------------------------------------------------- #
+# weather
+# --------------------------------------------------------------------------- #
+
+WEATHER_CURRENT = {
+ "weatherCondition": {"description": {"text": "Sunny"}, "type": "CLEAR"},
+ "temperature": {"degrees": 24.1, "unit": "CELSIUS"},
+ "feelsLikeTemperature": {"degrees": 26.0, "unit": "CELSIUS"},
+ "relativeHumidity": 40,
+ "uvIndex": 6,
+ "cloudCover": 10,
+ "precipitation": {"probability": {"percent": 5, "type": "RAIN"}},
+ "wind": {
+ "direction": {"degrees": 300, "cardinal": "NORTH_NORTHWEST"},
+ "speed": {"value": 12, "unit": "KILOMETERS_PER_HOUR"},
+ "gust": {"value": 20, "unit": "KILOMETERS_PER_HOUR"},
+ },
+}
+
+GEOCODE_ONE = {
+ "results": [
+ {
+ "placeId": "ChIJgeo",
+ "formattedAddress": "Rosemère, QC, Canada",
+ "location": {"latitude": 45.6389, "longitude": -73.785},
+ }
+ ]
+}
+
+
+def test_weather_current_by_at(capsys):
+ rec = Recorder(payload=WEATHER_CURRENT)
+ assert gmaps.main(["weather", "--at=45.5,-73.6"], transport=rec) == 0
+ assert len(rec.calls) == 1
+ parts, q = qs(rec.last["url"])
+ assert parts.netloc == "weather.googleapis.com"
+ assert parts.path == "/v1/currentConditions:lookup"
+ assert q["location.latitude"] == ["45.5"]
+ assert q["location.longitude"] == ["-73.6"]
+ assert q["key"] == [API_KEY]
+ data, _ = out_json(capsys)
+ d = data["data"]
+ assert d["condition"] == "Sunny"
+ assert d["temp"] == 24.1
+ assert d["feels_like"] == 26.0
+ assert d["humidity_pct"] == 40
+ assert d["wind_kmh"] == 12
+ assert d["wind_dir"] == "NORTH_NORTHWEST"
+ assert d["precip_prob_pct"] == 5
+ assert d["uv_index"] == 6
+
+
+def test_weather_days_forecast_url():
+ rec = Recorder(payload={"forecastDays": []})
+ assert gmaps.main(["weather", "--at=45.5,-73.6", "--days=3"], transport=rec) == 0
+ parts, q = qs(rec.last["url"])
+ assert parts.path == "/v1/forecast/days:lookup"
+ assert q["days"] == ["3"]
+ assert q["pageSize"] == ["3"]
+
+
+def test_weather_hours_forecast_url():
+ rec = Recorder(payload={"forecastHours": []})
+ assert gmaps.main(["weather", "--at=45.5,-73.6", "--hours=6"], transport=rec) == 0
+ parts, q = qs(rec.last["url"])
+ assert parts.path == "/v1/forecast/hours:lookup"
+ assert q["hours"] == ["6"]
+ assert q["pageSize"] == ["6"]
+
+
+def test_weather_units_and_lang_params():
+ rec = Recorder(payload=WEATHER_CURRENT)
+ gmaps.main(["weather", "--at=1,1", "--units=imperial", "--lang=fr"], transport=rec)
+ _, q = qs(rec.last["url"])
+ assert q["unitsSystem"] == ["IMPERIAL"]
+ assert q["languageCode"] == ["fr"]
+
+
+def test_weather_place_positional_geocodes_first():
+ seq = Seq([(200, GEOCODE_ONE), (200, WEATHER_CURRENT)])
+ assert gmaps.main(["weather", "Rosemère QC"], transport=seq) == 0
+ assert len(seq.calls) == 2
+ g_parts, _ = qs(seq.calls[0]["url"])
+ assert g_parts.netloc == "geocode.googleapis.com"
+ assert "Rosem%C3%A8re%20QC" in seq.calls[0]["url"]
+ _, q = qs(seq.calls[1]["url"])
+ assert q["location.latitude"] == ["45.6389"]
+ assert q["location.longitude"] == ["-73.785"]
+
+
+def test_weather_place_not_found():
+ seq = Seq([(200, {"results": []})])
+ assert gmaps.main(["weather", "Nowhereville"], transport=seq) == 4
+
+
+@pytest.mark.parametrize(
+ "argv",
+ [
+ ["weather"], # no location at all
+ ["weather", "--at=1,1", "--days=2", "--hours=2"], # both forecasts
+ ["weather", "--at=1,1", "--days=0"],
+ ["weather", "--at=1,1", "--days=11"],
+ ["weather", "--at=1,1", "--hours=0"],
+ ["weather", "--at=1,1", "--hours=25"],
+ ],
+)
+def test_weather_usage_errors(argv):
+ assert gmaps.main(argv, transport=Recorder(payload=WEATHER_CURRENT)) == 2
+
+
+def test_weather_days_trim(capsys):
+ payload = {
+ "forecastDays": [
+ {
+ "displayDate": {"year": 2026, "month": 7, "day": 24},
+ "maxTemperature": {"degrees": 28.0, "unit": "CELSIUS"},
+ "minTemperature": {"degrees": 17.0, "unit": "CELSIUS"},
+ "daytimeForecast": {
+ "weatherCondition": {"description": {"text": "Partly sunny"}},
+ "precipitation": {"probability": {"percent": 20}},
+ },
+ "nighttimeForecast": {
+ "weatherCondition": {"description": {"text": "Clear"}},
+ "precipitation": {"probability": {"percent": 5}},
+ },
+ }
+ ]
+ }
+ rec = Recorder(payload=payload)
+ assert gmaps.main(["weather", "--at=1,1", "--days=1"], transport=rec) == 0
+ data, _ = out_json(capsys)
+ assert data["data"] == [
+ {
+ "date": "2026-07-24",
+ "condition_day": "Partly sunny",
+ "condition_night": "Clear",
+ "max": 28.0,
+ "min": 17.0,
+ "precip_prob_pct": 20,
+ }
+ ]
+
+
+def test_weather_key_never_in_output(capsys):
+ rec = Recorder(status=403, raw=b'{"error": {"message": "denied"}}')
+ assert gmaps.main(["weather", "--at=1,1"], transport=rec) == 3
+ out = capsys.readouterr()
+ assert API_KEY not in out.out + out.err
+
+
+# --------------------------------------------------------------------------- #
+# matrix
+# --------------------------------------------------------------------------- #
+
+MATRIX_RESP = [
+ {
+ "originIndex": 1,
+ "destinationIndex": 0,
+ "status": {},
+ "condition": "ROUTE_EXISTS",
+ "distanceMeters": 1200,
+ "duration": "300s",
+ },
+ {
+ "originIndex": 0,
+ "destinationIndex": 0,
+ "status": {},
+ "condition": "ROUTE_EXISTS",
+ "distanceMeters": 4500,
+ "duration": "780s",
+ },
+]
+
+
+def matrix_rec():
+ return Recorder(raw=json.dumps(MATRIX_RESP).encode("utf-8"))
+
+
+def test_matrix_request_shape():
+ rec = matrix_rec()
+ code = gmaps.main(
+ [
+ "matrix",
+ "--from=Rosemère QC",
+ "--from=45.5,-73.6",
+ "--to=placeid:ChIJxyz",
+ "--mode=walk",
+ ],
+ transport=rec,
+ )
+ assert code == 0
+ assert rec.last["method"] == "POST"
+ assert (
+ rec.last["url"]
+ == "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix"
+ )
+ assert rec.last["headers"]["X-Goog-Api-Key"] == API_KEY
+ assert (
+ rec.last["headers"]["X-Goog-FieldMask"]
+ == "originIndex,destinationIndex,status,condition,distanceMeters,duration"
+ )
+ body = rec.body_json()
+ assert body["origins"] == [
+ {"waypoint": {"address": "Rosemère QC"}},
+ {"waypoint": {"location": {"latLng": {"latitude": 45.5, "longitude": -73.6}}}},
+ ]
+ assert body["destinations"] == [{"waypoint": {"placeId": "ChIJxyz"}}]
+ assert body["travelMode"] == "WALK"
+ assert "routingPreference" not in body
+
+
+def test_matrix_trim_sorted_by_indexes(capsys):
+ rec = matrix_rec()
+ gmaps.main(["matrix", "--from=A", "--from=B", "--to=C"], transport=rec)
+ data, _ = out_json(capsys)
+ assert data["data"] == [
+ {
+ "from": "A",
+ "to": "C",
+ "distance_m": 4500,
+ "duration_s": 780,
+ "condition": "ROUTE_EXISTS",
+ },
+ {
+ "from": "B",
+ "to": "C",
+ "distance_m": 1200,
+ "duration_s": 300,
+ "condition": "ROUTE_EXISTS",
+ },
+ ]
+
+
+@pytest.mark.parametrize(
+ "argv",
+ [
+ ["matrix", "--to=C"], # no origins
+ ["matrix", "--from=A"], # no destinations
+ ["matrix"] + [f"--from=o{i}" for i in range(11)] + ["--to=C"], # >10
+ ["matrix", "--from=A"] + [f"--to=d{i}" for i in range(11)], # >10
+ ],
+)
+def test_matrix_usage_errors(argv):
+ assert gmaps.main(argv, transport=matrix_rec()) == 2
+
+
+def test_matrix_default_mode_is_drive():
+ rec = matrix_rec()
+ gmaps.main(["matrix", "--from=A", "--to=B"], transport=rec)
+ assert rec.body_json()["travelMode"] == "DRIVE"
+
+
+# --------------------------------------------------------------------------- #
+# Review fixes (adversarial review of feat/v0.2)
+# --------------------------------------------------------------------------- #
+
+
+def test_weather_hours_cap_is_24():
+ # forecast/hours pageSize maxes at 24 and the CLI never paginates
+ assert gmaps.WEATHER_MAX_HOURS == 24
+
+
+def test_matrix_dict_response_is_upstream_error(capsys):
+ rec = Recorder(payload={"error": {"message": "boom"}})
+ assert gmaps.main(["matrix", "--from=A", "--to=B"], transport=rec) == 5
+ err = json.loads(capsys.readouterr().err)
+ assert err["ok"] is False
+ assert err["error"]["retryable"] is True
+
+
+def test_tz_non_dict_response_is_upstream_error(capsys):
+ rec = Recorder(raw=b"[1,2]")
+ assert gmaps.main(["tz", "--at=1,1"], transport=rec) == 5
+ json.loads(capsys.readouterr().err) # a JSON envelope, not a traceback
+
+
+def test_weather_non_dict_response_is_upstream_error(capsys):
+ rec = Recorder(raw=b"[]")
+ assert gmaps.main(["weather", "--at=1,1"], transport=rec) == 5
+ json.loads(capsys.readouterr().err)
+
+
+def test_matrix_element_error_status_surfaced(capsys):
+ resp = [
+ {
+ "originIndex": 0,
+ "destinationIndex": 0,
+ "status": {"code": 3, "message": "Origin cannot be geocoded"},
+ "condition": "ROUTE_NOT_FOUND",
+ }
+ ]
+ rec = Recorder(raw=json.dumps(resp).encode("utf-8"))
+ assert gmaps.main(["matrix", "--from=A", "--to=B"], transport=rec) == 0
+ data = json.loads(capsys.readouterr().out)["data"]
+ assert data[0]["error"] == "Origin cannot be geocoded"
+ assert data[0]["condition"] == "ROUTE_NOT_FOUND"
+
+
+def test_weather_current_null_fields_no_crash(capsys):
+ rec = Recorder(
+ payload={
+ "temperature": None,
+ "weatherCondition": None,
+ "wind": None,
+ "precipitation": None,
+ "feelsLikeTemperature": None,
+ }
+ )
+ assert gmaps.main(["weather", "--at=1,1"], transport=rec) == 0
+ assert json.loads(capsys.readouterr().out)["ok"] is True
+
+
+def test_weather_day_null_fields_no_crash():
+ rec = Recorder(
+ payload={
+ "forecastDays": [
+ {
+ "displayDate": None,
+ "daytimeForecast": None,
+ "nighttimeForecast": None,
+ "maxTemperature": None,
+ "minTemperature": None,
+ }
+ ]
+ }
+ )
+ assert gmaps.main(["weather", "--at=1,1", "--days=1"], transport=rec) == 0
+
+
+URLKEY = "TESTKEY+abc/123==" # urlencodes to TESTKEY%2Babc%2F123%3D%3D
+
+
+def test_urlencoded_key_scrubbed_from_error_output(capsys, monkeypatch):
+ # The headline v0.2 risk: the key rides the URL for tz/weather. If Google
+ # ever echoes request params in an error message, BOTH the raw and the
+ # percent-encoded key forms must be scrubbed.
+ monkeypatch.setenv("GMAPS_API_KEY", URLKEY)
+ enc = urllib.parse.quote_plus(URLKEY)
+ raw = json.dumps({"error": {"message": f"bad request for ?key={enc}"}})
+ rec = Recorder(status=400, raw=raw.encode("utf-8"))
+ assert gmaps.main(["tz", "--at=1,1"], transport=rec) == 4
+ out = capsys.readouterr()
+ assert URLKEY not in out.out + out.err
+ assert enc not in out.out + out.err
+ assert "***" in out.err
+
+
+def test_weather_geocode_result_missing_location_is_not_found():
+ seq = Seq([(200, {"results": [{"placeId": "x"}]})])
+ assert gmaps.main(["weather", "Rosemère"], transport=seq) == 4