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