diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb5cdb3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install pytest + run: python -m pip install --upgrade pip pytest + - name: Run tests + run: python -m pytest -q diff --git a/README.md b/README.md index 6f87c1b..03f1859 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,234 @@ # gmaps-cli A zero-dependency Google Maps CLI, built for AI agents and humans alike. -Wraps Places API (New), Geocoding v4, and Routes in a single stdlib-only -Python file with agent-friendly JSON output, fixed field masks (predictable -billing SKUs), and stable exit codes. +Wraps **Places API (New)**, **Geocoding v4**, and **Routes** in a single +stdlib-only Python file with agent-friendly JSON output, fixed field masks +(predictable billing SKUs), and stable exit codes. -**Status: work in progress** — implementation landing shortly. +``` +gmaps search "espresso bar" --near=45.53,-73.61 --min-rating=4.3 --limit=5 +gmaps route --from="Montréal, QC" --to="Québec City" --mode=drive +``` + +No SDK, no config file, no telemetry — just `python3` and one file. + +## Install + +The only runtime requirement is Python 3.11+. Pick whichever fits: + +```bash +# Run the single file directly (nothing to install) +curl -fsSL https://raw.githubusercontent.com/gldc/gmaps-cli/main/gmaps.py -o gmaps.py +chmod +x gmaps.py +./gmaps.py search "ramen" + +# Or install the console script with uv / pipx +uv tool install git+https://github.com/gldc/gmaps-cli +pipx install git+https://github.com/gldc/gmaps-cli +``` -## Planned commands +Either way the command reads your key from the `GMAPS_API_KEY` environment +variable (see [API key setup](#api-key-setup)). +## Commands + +Every value flag accepts both `--flag=value` and `--flag value`. There are **no +short flags** and no config file — the endpoints are constants and the key comes +only from the environment. + +``` +gmaps search [--near=LAT,LNG] [--radius=M] [--open-now] [--type=TYPE] + [--min-rating=N] [--limit=N] [--detailed] [--lang=CODE] [--raw] +gmaps nearby --near=LAT,LNG [--radius=M] [--type=TYPE] [--limit=N] + [--rank=popularity|distance] [--detailed] [--lang=CODE] [--raw] +gmaps place [--reviews] [--lang=CODE] [--raw] +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] +``` + +### `search` — Places Text Search + +Free-text place search. Add `--near=LAT,LNG` to bias results toward a point +(`--radius` meters, default 5000, max 50000). `--type` takes a single +[place type](https://developers.google.com/maps/documentation/places/web-service/place-types) +(e.g. `restaurant`). `--min-rating` filters to N+ stars and implies `--detailed`. + +```bash +gmaps search "third-wave coffee" --near=45.53,-73.61 --radius=1500 --open-now +gmaps search "trattoria" --type=restaurant --min-rating=4.4 --limit=5 ``` -gmaps search # Places Text Search (ratings/hours via --detailed) -gmaps nearby --near=LAT,LNG # Places Nearby Search -gmaps place # Place Details -gmaps geocode
# forward geocode -gmaps revgeocode # reverse geocode -gmaps route --from=A --to=B # travel time/distance (drive/walk/bicycle/transit) + +`--detailed` (or `--min-rating`) enriches each result with rating, review count, +price level, open-now, phone, and website. + +### `nearby` — Places Nearby Search + +Everything of a type within a radius of a point. `--near` is **required** +(`--radius` default 1500, max 50000). `--rank=distance` orders by proximity; +`--rank=popularity` (the default upstream behavior) by prominence. + +```bash +gmaps nearby --near=43.77,11.25 --type=restaurant --rank=distance --limit=10 +gmaps nearby --near=45.50,-73.57 --type=pharmacy --detailed +``` + +### `place` — Place Details + +Full detail for one place id (as returned by `search`/`nearby`/`geocode`). +`--reviews` adds an editorial summary and up to three review snippets. + +```bash +gmaps place ChIJDbdkHFQayUwR7-8fITgxTmU +gmaps place ChIJDbdkHFQayUwR7-8fITgxTmU --reviews --lang=it +``` + +### `geocode` / `revgeocode` — address ↔ coordinates + +```bash +gmaps geocode "1 Infinite Loop, Cupertino, CA" +gmaps geocode "Piazza del Campo, Siena" --lang=it +gmaps revgeocode --at=45.5017,-73.5673 +gmaps revgeocode 45.5017,-73.5673 # positional form also works +``` + +Reverse geocode takes coordinates as `--at=LAT,LNG` or as a plain positional. +Prefer the `--at=` form in scripts and agent wrappers — a negative latitude as a +positional looks like a flag to some argv-validating wrappers, and `--at=` is +accepted everywhere. + +### `route` — travel time and distance + +`--from`/`--to` each accept a free-text address, `LAT,LNG`, or `placeid:`. +Default mode is `drive`. + +```bash +gmaps route --from="Rosemère, QC" --to="Trudeau Airport" --mode=drive +gmaps route --from=45.53,-73.61 --to=placeid:ChIJDbdkHFQayUwR7-8fITgxTmU --mode=transit ``` +Distance and duration only — no polyline, no turn-by-turn (keeps output small +and the request in the cheapest SKU). + +## Output & agent contract + +Success goes to **stdout** as compact JSON; errors go to **stderr**. The shape is +stable so an agent can branch on it without scraping prose. + +```jsonc +// stdout, exit 0 +{"ok":true,"data":[ {"name":"...","address":"...","lat":45.5,"lng":-73.6, + "place_id":"ChIJ...","maps_url":"https://...","types":["cafe"]} ]} + +// stderr, non-zero exit +{"ok":false,"error":{"code":"not_found","message":"...","retryable":false, + "hint":"Check that the place id or address is valid."}} +``` + +Places are trimmed to a flat object — `name, address, lat, lng, place_id, +maps_url, types` always, plus `rating, ratings_count, price, open_now, phone, +website, hours` when a detailed mask was used. Pass `--raw` to get the untrimmed +upstream JSON instead (still limited to the fields in the mask). + +### Exit codes + +| code | meaning | `retryable` | +|---|---|---| +| `0` | success (zero results is success with an empty list) | — | +| `2` | usage error (bad flag, malformed `LAT,LNG`, out-of-range value) | `false` | +| `3` | auth / key problem (missing key, Google 401/403) | `false` | +| `4` | not found (bad place id, unresolvable address, Google 400/404) | `false` | +| `5` | upstream / network / timeout (Google 429/5xx, DNS, connect, 10s timeout) | `true` | + +`retryable: true` means the same call may succeed if repeated; the CLI does **no** +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. + +## API key setup + +`gmaps` reads the key from `GMAPS_API_KEY` and nowhere else: + +```bash +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: + +```bash +# Enable the three backends +gcloud services enable \ + places.googleapis.com \ + routes.googleapis.com \ + geocoding-backend.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 + +# Read the key string back later if needed +gcloud services api-keys get-key-string KEY_ID --format='value(keyString)' +``` + +> `api-keys create` echoes the key string in its response — capture it through a +> pipe or `--format`, and never paste it into a shared terminal or commit it. + +## Cost & field-mask philosophy + +The Places and Routes APIs bill per request, and the **most expensive field you +request sets the SKU for the whole call**. `gmaps` fixes one field mask per +command in a single table in the source, so cost is a property of the command you +run — not of a `--fields` flag a caller can accidentally inflate. There is no +field-mask override flag. + +| command | tier | why | +|---|---|---| +| `search`, `nearby` | Places Pro | id, name, address, location, types, maps URL | +| `search --detailed` / `--min-rating`, `nearby --detailed` | Places Enterprise | adds rating, price, open-now, phone, website | +| `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` | +| `geocode`, `revgeocode` | Geocoding Essentials | no field mask required | + +Practical guidance for agents: start with `search` (Pro), only add `--detailed` +when you actually need ratings or hours, and only call `place` for a result the +user has chosen. `--reviews` is the most expensive path — request it explicitly. + +> **Running this under an AI agent?** Ship [`SKILL.md`](SKILL.md) into your +> harness's skills directory — it teaches the command surface, the JSON/exit-code +> contract, and the cost discipline in the standard agent-skill format. + ## Design principles -- **Zero dependencies** — single file, Python 3.11+ stdlib only. -- **Agent-optimized output** — compact JSON envelope `{"ok": true, "data": ...}` on - stdout; errors as `{"ok": false, "error": {code, message, retryable, hint}}` on - stderr; stable exit codes (0 ok · 2 usage · 3 auth · 4 not-found · 5 upstream). -- **Long-form flags only** — no short flags, no config file, no endpoint overrides. -- **Cost-deliberate** — fixed per-command field masks keep requests in the cheapest - Google Maps Platform SKU that answers the question. -- The API key comes only from the `GMAPS_API_KEY` environment variable — never - from argv. +- **Zero dependencies** — a single file, Python 3.11+ stdlib only. +- **Agent-optimized output** — compact JSON envelope on stdout, structured errors + on stderr, stable exit codes, `retryable` on every error. +- **Long-form flags only** — no short flags, no config file, no endpoint or + field-mask overrides. +- **Cost-deliberate** — fixed per-command field masks keep each request in the + cheapest Google Maps Platform SKU that answers the question. +- **Key stays in the environment** — read only from `GMAPS_API_KEY`, sent only as + a request header, scrubbed from every output stream. + +## Development + +```bash +uv sync --group dev # or: python -m pip install pytest +python -m pytest -q +``` + +The test suite is hermetic: it injects a fake transport and asserts the exact +request each command builds (URL, headers, body, field mask) plus the output +trimming, exit-code map, and key redaction — no network access required. CI runs +it on Python 3.11 and 3.13. ## License diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..54cee9a --- /dev/null +++ b/SKILL.md @@ -0,0 +1,70 @@ +--- +name: gmaps +description: "Google Maps: search, ratings, hours, routes (gmaps CLI)." +--- + +# gmaps — Google Maps lookups from the shell + +## 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 — +results come from the Google Maps Platform. + +## Prerequisites + +- `GMAPS_API_KEY` must be set in the environment (the only place the key is read + from). If a command exits `3` with code `auth`, the key is missing or invalid. +- Python 3.11+ (`gmaps` is a single stdlib-only file). + +## How to Run + +- All flags are long-form. Both `--flag=value` and `--flag value` work; prefer + the inline `--flag=value` form — it is unambiguous everywhere, including for + negative coordinates (`--at=-34.6,-58.4`). +- Output is machine-parseable JSON: `{"ok": true, "data": ...}` on stdout, or + `{"ok": false, "error": {code, message, retryable, hint}}` on stderr. +- Branch on exit codes, not text: `0` ok (zero results = `ok` with empty list) · + `2` usage · `3` auth/key · `4` not found · `5` upstream/network (**only 5 is + worth retrying**). + +## Quick Reference + +```bash +gmaps search "trattoria" --near=43.07,11.68 --min-rating=4.3 --limit=5 +gmaps nearby --near=45.50,-73.57 --type=pharmacy --rank=distance +gmaps place ChIJDbdkHFQayUwR7-8fITgxTmU # details for one place id +gmaps place ChIJDbdkHFQayUwR7-8fITgxTmU --reviews # + editorial summary, 3 reviews +gmaps geocode "Piazza del Campo, Siena" +gmaps revgeocode --at=45.5017,-73.5673 +gmaps route --from="Rosemère, QC" --to="Trudeau Airport" --mode=drive +``` + +`route` waypoints accept an address, `LAT,LNG`, or `placeid:`. Modes: +`drive` (default), `walk`, `bicycle`, `transit`. + +## Cost Discipline + +Field masks are fixed per command; the cheap path is: + +1. `gmaps search ""` first — id/name/address/location per result. +2. `gmaps place ` only for the one result you actually chose (adds rating, + hours, phone, website). +3. `--detailed` on search/nearby when you need ratings across the whole list; + `--reviews` only when the user explicitly wants review content. + +## Pitfalls + +- There are **no short flags** (`-o` etc.) — long-form only. +- `--min-rating` implies `--detailed` (the rating must be requested to filter on + it). +- `transit` routes return duration/distance only (no itinerary). +- The CLI never retries; if exit is `5` and `retryable` is `true`, retry + yourself with backoff. + +## Verification + +`gmaps geocode "Eiffel Tower"` should exit `0` and return one result near +`48.858, 2.294`. If it exits `3`, fix `GMAPS_API_KEY`; if `5`, check network and +retry. diff --git a/gmaps.py b/gmaps.py new file mode 100755 index 0000000..210c232 --- /dev/null +++ b/gmaps.py @@ -0,0 +1,624 @@ +#!/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. + +Design notes: + +* Field masks are fixed per subcommand (see ``FIELD_MASKS``). The mask's highest + field determines the billing SKU, so pinning them keeps costs predictable and + makes a cost regression a failing test rather than a surprise invoice. +* The HTTP layer is a single injectable callable ``transport(method, url, + headers, body) -> (status, bytes)`` so the whole surface is testable without + a network. +""" + +import argparse +import json +import os +import socket +import sys +import urllib.error +import urllib.parse +import urllib.request + +# --------------------------------------------------------------------------- # +# Endpoints (hard-coded — there is deliberately no --url override flag). +# --------------------------------------------------------------------------- # +PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText" +PLACES_NEARBY_URL = "https://places.googleapis.com/v1/places:searchNearby" +PLACES_DETAILS_BASE = "https://places.googleapis.com/v1/places/" +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" + +TIMEOUT = 10 # seconds; kept below any supervising process's kill timeout. + +# --------------------------------------------------------------------------- # +# Field masks — one table, the single source of truth for request cost. +# The highest-tier field in a mask sets the SKU, so keep these minimal. +# --------------------------------------------------------------------------- # +_SEARCH_BASE = ( + "places.id,places.displayName,places.formattedAddress," + "places.location,places.types,places.googleMapsUri" +) +_SEARCH_DETAIL_EXTRA = ( + "places.rating,places.userRatingCount,places.priceLevel," + "places.currentOpeningHours.openNow,places.websiteUri," + "places.nationalPhoneNumber" +) +_PLACE_BASE = ( + "id,displayName,formattedAddress,location,types,googleMapsUri," + "rating,userRatingCount,priceLevel,websiteUri,nationalPhoneNumber," + "internationalPhoneNumber,regularOpeningHours,currentOpeningHours" +) +_PLACE_REVIEW_EXTRA = "editorialSummary,reviews" + +FIELD_MASKS = { + "search": _SEARCH_BASE, + "search_detailed": _SEARCH_BASE + "," + _SEARCH_DETAIL_EXTRA, + "nearby": _SEARCH_BASE, + "nearby_detailed": _SEARCH_BASE + "," + _SEARCH_DETAIL_EXTRA, + "place": _PLACE_BASE, + "place_reviews": _PLACE_BASE + "," + _PLACE_REVIEW_EXTRA, + "route": "routes.distanceMeters,routes.duration,routes.description", +} + +DEFAULT_LIMIT = 8 +SEARCH_DEFAULT_RADIUS = 5000 +NEARBY_DEFAULT_RADIUS = 1500 +MAX_RADIUS = 50000 + + +# --------------------------------------------------------------------------- # +# Errors +# --------------------------------------------------------------------------- # +class GmapsError(Exception): + """A structured, renderable error. ``exit_code`` drives the process code.""" + + def __init__(self, code, message, retryable, hint, exit_code): + super().__init__(message) + self.code = code + self.message = message + self.retryable = retryable + self.hint = hint + self.exit_code = exit_code + + +class _UsageError(Exception): + """Raised by the argument parser instead of printing + calling exit().""" + + +class _JsonArgumentParser(argparse.ArgumentParser): + """ArgumentParser that raises instead of writing argparse's own text, so the + JSON envelope is the only thing a caller ever sees on stderr.""" + + def error(self, message): # noqa: D401 - argparse hook + raise _UsageError(message) + + +# --------------------------------------------------------------------------- # +# Output helpers +# --------------------------------------------------------------------------- # +def redact(text, secret): + """Scrub the resolved API key from any text headed to a user stream. + + Scoped to the exact secret so legitimate values (place ids, hashes) survive. + """ + if secret and len(secret) >= 4: + text = text.replace(secret, "***") + return text + + +def _dump(obj): + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def _emit_success(data, key): + sys.stdout.write(redact(_dump({"ok": True, "data": data}), key) + "\n") + + +def _emit_error(exc, key): + body = { + "code": exc.code, + "message": exc.message, + "retryable": exc.retryable, + "hint": exc.hint, + } + sys.stderr.write(redact(_dump({"ok": False, "error": body}), key) + "\n") + return exc.exit_code + + +# --------------------------------------------------------------------------- # +# HTTP +# --------------------------------------------------------------------------- # +def _urlopen_transport(method, url, headers, body): + """Default transport. Returns (status, bytes) for any HTTP response, + including 4xx/5xx; raises URLError/timeout for genuine network failures.""" + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + return resp.getcode(), resp.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + + +def _google_message(raw): + try: + obj = json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError, AttributeError): + return None + err = obj.get("error") if isinstance(obj, dict) else None + if isinstance(err, dict): + msg = err.get("message") + if isinstance(msg, str): + return msg + return None + + +def _classify(status): + """Map an HTTP error status to (code, exit_code, retryable, hint).""" + if status in (401, 403): + return ( + "auth", + 3, + False, + "Verify GMAPS_API_KEY is set and authorized for this API.", + ) + if status in (400, 404): + return ( + "not_found", + 4, + False, + "Check that the place id or address is valid.", + ) + return ( + "upstream", + 5, + True, + "Transient upstream error; the request may be retried.", + ) + + +def _call(transport, method, url, headers, body_obj=None): + hdrs = dict(headers) + data = None + if body_obj is not None: + data = _dump(body_obj).encode("utf-8") + hdrs["Content-Type"] = "application/json" + try: + status, raw = transport(method, url, hdrs, data) + except (TimeoutError, socket.timeout): + raise GmapsError( + "timeout", + "Request to Google Maps timed out", + True, + "Retry shortly.", + 5, + ) + except urllib.error.URLError as exc: + if isinstance(getattr(exc, "reason", None), (TimeoutError, socket.timeout)): + raise GmapsError( + "timeout", + "Request to Google Maps timed out", + True, + "Retry shortly.", + 5, + ) + raise GmapsError( + "network", + "Could not reach Google Maps", + True, + "Check connectivity and retry.", + 5, + ) + if status == 200: + try: + return json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + raise GmapsError( + "upstream", + "Malformed response from Google Maps", + True, + "Retry shortly.", + 5, + ) + code, exit_code, retryable, hint = _classify(status) + message = _google_message(raw) or f"Google Maps returned HTTP {status}" + raise GmapsError(code, message, retryable, hint, exit_code) + + +# --------------------------------------------------------------------------- # +# Validation helpers (raise usage errors → exit 2) +# --------------------------------------------------------------------------- # +def _usage(message, hint): + return GmapsError("usage", message, False, hint, 2) + + +def _parse_latlng(value, flag): + parts = value.split(",") + if len(parts) != 2: + raise _usage(f"{flag} must be LAT,LNG", "Example: --near=45.51,-73.57") + try: + lat = float(parts[0]) + lng = float(parts[1]) + except ValueError: + raise _usage(f"{flag} must be numeric LAT,LNG", "Example: --near=45.51,-73.57") + if not (-90.0 <= lat <= 90.0 and -180.0 <= lng <= 180.0): + raise _usage( + f"{flag} is out of range", + "Latitude -90..90, longitude -180..180.", + ) + return lat, lng + + +def _validate_radius(radius): + if radius < 1 or radius > MAX_RADIUS: + raise _usage( + f"--radius must be between 1 and {MAX_RADIUS} meters", + "Example: --radius=2000", + ) + return radius + + +def _validate_limit(limit): + if limit < 1 or limit > 20: + raise _usage("--limit must be between 1 and 20", "Example: --limit=5") + return limit + + +def _validate_min_rating(rating): + if rating <= 0 or rating > 5: + raise _usage( + "--min-rating must be greater than 0 and at most 5", + "Example: --min-rating=4.0", + ) + return rating + + +def _maybe_latlng(value): + parts = value.split(",") + if len(parts) != 2: + return None + try: + return float(parts[0]), float(parts[1]) + except ValueError: + return None + + +def _waypoint(value): + if value.startswith("placeid:"): + return {"placeId": value[len("placeid:") :]} + coords = _maybe_latlng(value) + if coords is not None: + lat, lng = coords + return {"location": {"latLng": {"latitude": lat, "longitude": lng}}} + return {"address": value} + + +# --------------------------------------------------------------------------- # +# Output trimming (token- and exfil-minimizing projections) +# --------------------------------------------------------------------------- # +def _set(out, key, value): + if value is not None: + out[key] = value + + +def _trim_place(place): + out = {} + _set(out, "name", (place.get("displayName") or {}).get("text")) + _set(out, "address", place.get("formattedAddress")) + loc = place.get("location") or {} + _set(out, "lat", loc.get("latitude")) + _set(out, "lng", loc.get("longitude")) + _set(out, "place_id", place.get("id")) + _set(out, "maps_url", place.get("googleMapsUri")) + _set(out, "types", place.get("types")) + _set(out, "rating", place.get("rating")) + _set(out, "ratings_count", place.get("userRatingCount")) + _set(out, "price", place.get("priceLevel")) + _set(out, "open_now", (place.get("currentOpeningHours") or {}).get("openNow")) + _set( + out, + "phone", + place.get("nationalPhoneNumber") or place.get("internationalPhoneNumber"), + ) + _set(out, "website", place.get("websiteUri")) + _set( + out, + "hours", + (place.get("regularOpeningHours") or {}).get("weekdayDescriptions"), + ) + _set(out, "editorial", (place.get("editorialSummary") or {}).get("text")) + reviews = place.get("reviews") + if reviews is not None: + out["reviews"] = [_trim_review(r) for r in reviews[:3]] + return out + + +def _trim_review(review): + out = {} + _set(out, "author", (review.get("authorAttribution") or {}).get("displayName")) + _set(out, "rating", review.get("rating")) + text = (review.get("text") or {}).get("text") or ( + review.get("originalText") or {} + ).get("text") + _set(out, "text", text) + _set(out, "when", review.get("relativePublishTimeDescription")) + return out + + +def _trim_geocode(result): + out = {} + _set(out, "address", result.get("formattedAddress")) + loc = result.get("location") or {} + _set(out, "lat", loc.get("latitude")) + _set(out, "lng", loc.get("longitude")) + _set(out, "place_id", result.get("placeId")) + _set(out, "granularity", result.get("granularity")) + return out + + +def _duration_seconds(value): + if isinstance(value, str) and value.endswith("s"): + try: + return int(value[:-1]) + except ValueError: + return None + return None + + +def _trim_route(route): + out = {} + _set(out, "distance_m", route.get("distanceMeters")) + duration = route.get("duration") + if duration is not None: + seconds = _duration_seconds(duration) + if seconds is not None: + out["duration_s"] = seconds + else: + out["duration"] = duration + _set(out, "summary", route.get("description")) + return out + + +# --------------------------------------------------------------------------- # +# Subcommand handlers — each returns the ``data`` payload for the envelope. +# --------------------------------------------------------------------------- # +def cmd_search(args, key, transport): + detailed = args.detailed or args.min_rating is not None + mask = FIELD_MASKS["search_detailed" if detailed else "search"] + body = {"textQuery": args.query, "pageSize": _validate_limit(args.limit)} + if args.lang: + body["languageCode"] = args.lang + if args.type: + body["includedType"] = args.type + if args.min_rating is not None: + body["minRating"] = _validate_min_rating(args.min_rating) + if args.open_now: + body["openNow"] = True + radius = _validate_radius( + args.radius if args.radius is not None else SEARCH_DEFAULT_RADIUS + ) + if args.near: + lat, lng = _parse_latlng(args.near, "--near") + body["locationBias"] = { + "circle": {"center": {"latitude": lat, "longitude": lng}, "radius": radius} + } + headers = {"X-Goog-Api-Key": key, "X-Goog-FieldMask": mask} + resp = _call(transport, "POST", PLACES_SEARCH_URL, headers, body) + if args.raw: + return resp + return [_trim_place(p) for p in resp.get("places", [])] + + +def cmd_nearby(args, key, transport): + mask = FIELD_MASKS["nearby_detailed" if args.detailed else "nearby"] + lat, lng = _parse_latlng(args.near, "--near") + radius = _validate_radius( + args.radius if args.radius is not None else NEARBY_DEFAULT_RADIUS + ) + body = { + "locationRestriction": { + "circle": {"center": {"latitude": lat, "longitude": lng}, "radius": radius} + }, + "maxResultCount": _validate_limit(args.limit), + } + if args.type: + body["includedTypes"] = [args.type] + if args.rank: + body["rankPreference"] = ( + "POPULARITY" if args.rank == "popularity" else "DISTANCE" + ) + if args.lang: + body["languageCode"] = args.lang + headers = {"X-Goog-Api-Key": key, "X-Goog-FieldMask": mask} + resp = _call(transport, "POST", PLACES_NEARBY_URL, headers, body) + if args.raw: + return resp + return [_trim_place(p) for p in resp.get("places", [])] + + +def cmd_place(args, key, transport): + mask = FIELD_MASKS["place_reviews" if args.reviews else "place"] + url = PLACES_DETAILS_BASE + urllib.parse.quote(args.place_id, safe="") + if args.lang: + url += "?" + urllib.parse.urlencode({"languageCode": args.lang}) + headers = {"X-Goog-Api-Key": key, "X-Goog-FieldMask": mask} + resp = _call(transport, "GET", url, headers, None) + if args.raw: + return resp + return _trim_place(resp) + + +def cmd_geocode(args, key, transport): + url = GEOCODE_FWD_BASE + urllib.parse.quote(args.address, safe="") + if args.lang: + url += "?" + urllib.parse.urlencode({"languageCode": args.lang}) + headers = {"X-Goog-Api-Key": key} + resp = _call(transport, "GET", url, headers, None) + if args.raw: + return resp + return [_trim_geocode(r) for r in resp.get("results", [])] + + +def cmd_revgeocode(args, key, transport): + coords = args.at_flag if args.at_flag is not None else args.at + if not coords: + raise _usage( + "revgeocode requires LAT,LNG via --at or a positional argument", + "Example: gmaps revgeocode --at=45.51,-73.57", + ) + _parse_latlng(coords, "--at") + url = GEOCODE_REV_BASE + urllib.parse.quote(coords, safe=",-.") + headers = {"X-Goog-Api-Key": key} + resp = _call(transport, "GET", url, headers, None) + if args.raw: + return resp + return [_trim_geocode(r) for r in resp.get("results", [])] + + +def cmd_route(args, key, transport): + body = { + "origin": _waypoint(args.origin), + "destination": _waypoint(args.dest), + "travelMode": args.mode.upper(), + } + if args.lang: + body["languageCode"] = args.lang + headers = {"X-Goog-Api-Key": key, "X-Goog-FieldMask": FIELD_MASKS["route"]} + resp = _call(transport, "POST", ROUTES_URL, headers, body) + if args.raw: + return resp + return [_trim_route(r) for r in resp.get("routes", [])] + + +# --------------------------------------------------------------------------- # +# Argument parser +# --------------------------------------------------------------------------- # +def _add_subparser(sub, name, help_text): + parser = sub.add_parser(name, help=help_text, add_help=False) + parser.add_argument("--help", action="help", help="show this help message and exit") + return parser + + +def build_parser(): + parser = _JsonArgumentParser( + prog="gmaps", + description="Zero-dependency Google Maps CLI (Places New, Geocoding v4, Routes).", + add_help=False, + ) + parser.add_argument("--help", action="help", help="show this help message and exit") + sub = parser.add_subparsers( + dest="command", + required=True, + parser_class=_JsonArgumentParser, + metavar="COMMAND", + ) + + sp = _add_subparser(sub, "search", "Places Text Search") + sp.add_argument("query") + sp.add_argument("--near", metavar="LAT,LNG") + sp.add_argument("--radius", type=int, metavar="M") + sp.add_argument("--open-now", action="store_true") + sp.add_argument("--type", metavar="TYPE") + sp.add_argument("--min-rating", type=float, metavar="N") + sp.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N") + sp.add_argument("--detailed", action="store_true") + sp.add_argument("--lang", metavar="CODE") + sp.add_argument("--raw", action="store_true") + sp.set_defaults(func=cmd_search) + + np = _add_subparser(sub, "nearby", "Places Nearby Search") + np.add_argument("--near", required=True, metavar="LAT,LNG") + np.add_argument("--radius", type=int, metavar="M") + np.add_argument("--type", metavar="TYPE") + np.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N") + np.add_argument("--rank", choices=["popularity", "distance"]) + np.add_argument("--detailed", action="store_true") + np.add_argument("--lang", metavar="CODE") + np.add_argument("--raw", action="store_true") + np.set_defaults(func=cmd_nearby) + + pp = _add_subparser(sub, "place", "Place Details") + pp.add_argument("place_id", metavar="PLACE_ID") + pp.add_argument("--reviews", action="store_true") + pp.add_argument("--lang", metavar="CODE") + pp.add_argument("--raw", action="store_true") + pp.set_defaults(func=cmd_place) + + gp = _add_subparser(sub, "geocode", "Forward geocode an address") + gp.add_argument("address") + gp.add_argument("--lang", metavar="CODE") + gp.add_argument("--raw", action="store_true") + gp.set_defaults(func=cmd_geocode) + + rp = _add_subparser(sub, "revgeocode", "Reverse geocode LAT,LNG") + rp.add_argument("at", nargs="?", metavar="LAT,LNG") + rp.add_argument("--at", dest="at_flag", metavar="LAT,LNG") + rp.add_argument("--raw", action="store_true") + rp.set_defaults(func=cmd_revgeocode) + + rtp = _add_subparser(sub, "route", "Travel time and distance") + rtp.add_argument("--from", dest="origin", required=True, metavar="ORIGIN") + rtp.add_argument("--to", dest="dest", required=True, metavar="DEST") + rtp.add_argument( + "--mode", choices=["drive", "walk", "bicycle", "transit"], default="drive" + ) + rtp.add_argument("--lang", metavar="CODE") + rtp.add_argument("--raw", action="store_true") + rtp.set_defaults(func=cmd_route) + + return parser + + +# --------------------------------------------------------------------------- # +# Entry point +# --------------------------------------------------------------------------- # +def _reconfigure_streams(): + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") + except (AttributeError, ValueError): + pass + + +def main(argv=None, transport=None): + _reconfigure_streams() + if transport is None: + transport = _urlopen_transport + key = os.environ.get("GMAPS_API_KEY") or "" + + parser = build_parser() + try: + args = parser.parse_args(argv) + except _UsageError as exc: + return _emit_error(_usage(str(exc), "Run `gmaps --help` for usage."), key) + + if not key: + return _emit_error( + GmapsError( + "auth", + "GMAPS_API_KEY is not set", + False, + "Set the GMAPS_API_KEY environment variable.", + 3, + ), + key, + ) + + try: + data = args.func(args, key, transport) + except GmapsError as exc: + return _emit_error(exc, key) + + _emit_success(data, key) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ea1479e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "gmaps-cli" +version = "0.1.0" +description = "Zero-dependency Google Maps CLI (Places New, Geocoding v4, Routes) with agent-friendly JSON output." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "Gianluca D'Intino-Conte" }] +keywords = ["google-maps", "places", "geocoding", "routes", "cli", "agent"] +classifiers = [ + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Utilities", +] + +[project.urls] +Homepage = "https://github.com/gldc/gmaps-cli" +Issues = "https://github.com/gldc/gmaps-cli/issues" + +[project.scripts] +gmaps = "gmaps:main" + +[dependency-groups] +dev = ["pytest>=8"] + +[tool.hatch.build.targets.wheel] +only-include = ["gmaps.py"] + +[tool.hatch.build.targets.sdist] +include = ["gmaps.py", "tests/", "README.md", "LICENSE"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/tests/test_gmaps.py b/tests/test_gmaps.py new file mode 100644 index 0000000..7c926a1 --- /dev/null +++ b/tests/test_gmaps.py @@ -0,0 +1,772 @@ +"""Hermetic tests for the gmaps CLI. + +No network: an injected transport records the exact request (method, url, headers, +body) each subcommand builds and returns a canned response. Covers the argparse +surface, the verbatim field-mask table, per-subcommand request shapes, output +trimming, the exit-code map, the JSON envelope, key-redaction, and Unicode. +""" + +import json +import urllib.error + +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: + """Injectable transport: records calls, returns a canned (status, bytes).""" + + def __init__(self, status=200, payload=None, raw=None, exc=None): + self.calls = [] + self.status = status + self.payload = payload if payload is not None else {} + self.raw = raw + self.exc = exc + + def __call__(self, method, url, headers, body): + self.calls.append( + {"method": method, "url": url, "headers": dict(headers), "body": body} + ) + if self.exc is not None: + raise self.exc + 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 + + +def ok(status=200, payload=None): + return Recorder( + status=status, payload=payload if payload is not None else {"places": []} + ) + + +def err(status, message="boom"): + return Recorder( + status=status, raw=json.dumps({"error": {"message": message}}).encode("utf-8") + ) + + +# --------------------------------------------------------------------------- # +# Field-mask table (verbatim, single equality — a cost-regression guard) +# --------------------------------------------------------------------------- # + + +def test_field_mask_table_is_verbatim(): + assert gmaps.FIELD_MASKS == { + "search": ( + "places.id,places.displayName,places.formattedAddress," + "places.location,places.types,places.googleMapsUri" + ), + "search_detailed": ( + "places.id,places.displayName,places.formattedAddress," + "places.location,places.types,places.googleMapsUri," + "places.rating,places.userRatingCount,places.priceLevel," + "places.currentOpeningHours.openNow,places.websiteUri," + "places.nationalPhoneNumber" + ), + "nearby": ( + "places.id,places.displayName,places.formattedAddress," + "places.location,places.types,places.googleMapsUri" + ), + "nearby_detailed": ( + "places.id,places.displayName,places.formattedAddress," + "places.location,places.types,places.googleMapsUri," + "places.rating,places.userRatingCount,places.priceLevel," + "places.currentOpeningHours.openNow,places.websiteUri," + "places.nationalPhoneNumber" + ), + "place": ( + "id,displayName,formattedAddress,location,types,googleMapsUri," + "rating,userRatingCount,priceLevel,websiteUri,nationalPhoneNumber," + "internationalPhoneNumber,regularOpeningHours,currentOpeningHours" + ), + "place_reviews": ( + "id,displayName,formattedAddress,location,types,googleMapsUri," + "rating,userRatingCount,priceLevel,websiteUri,nationalPhoneNumber," + "internationalPhoneNumber,regularOpeningHours,currentOpeningHours," + "editorialSummary,reviews" + ), + "route": "routes.distanceMeters,routes.duration,routes.description", + } + + +def test_nearby_masks_equal_search_masks(): + assert gmaps.FIELD_MASKS["nearby"] == gmaps.FIELD_MASKS["search"] + assert gmaps.FIELD_MASKS["nearby_detailed"] == gmaps.FIELD_MASKS["search_detailed"] + + +# --------------------------------------------------------------------------- # +# search +# --------------------------------------------------------------------------- # + + +def test_search_basic_request(): + rec = ok() + rc = gmaps.main(["search", "coffee"], transport=rec) + assert rc == 0 + assert rec.last["method"] == "POST" + assert rec.last["url"] == "https://places.googleapis.com/v1/places:searchText" + h = rec.last["headers"] + assert h["X-Goog-Api-Key"] == API_KEY + assert h["X-Goog-FieldMask"] == gmaps.FIELD_MASKS["search"] + assert h["Content-Type"] == "application/json" + assert rec.body_json() == {"textQuery": "coffee", "pageSize": 8} + + +def test_search_type_is_singular_string(): + rec = ok() + gmaps.main(["search", "food", "--type=restaurant"], transport=rec) + body = rec.body_json() + assert body["includedType"] == "restaurant" + assert "includedTypes" not in body + + +def test_search_near_builds_location_bias(): + rec = ok() + gmaps.main( + ["search", "coffee", "--near=45.51,-73.57", "--radius=2000"], transport=rec + ) + assert rec.body_json()["locationBias"] == { + "circle": {"center": {"latitude": 45.51, "longitude": -73.57}, "radius": 2000} + } + + +def test_search_near_default_radius_5000(): + rec = ok() + gmaps.main(["search", "coffee", "--near=45.51,-73.57"], transport=rec) + assert rec.body_json()["locationBias"]["circle"]["radius"] == 5000 + + +def test_search_open_now_and_lang(): + rec = ok() + gmaps.main(["search", "coffee", "--open-now", "--lang=fr"], transport=rec) + body = rec.body_json() + assert body["openNow"] is True + assert body["languageCode"] == "fr" + + +def test_search_detailed_uses_detailed_mask(): + rec = ok() + gmaps.main(["search", "coffee", "--detailed"], transport=rec) + assert ( + rec.last["headers"]["X-Goog-FieldMask"] == gmaps.FIELD_MASKS["search_detailed"] + ) + + +def test_search_min_rating_implies_detailed(): + rec = ok() + gmaps.main(["search", "coffee", "--min-rating=4"], transport=rec) + assert ( + rec.last["headers"]["X-Goog-FieldMask"] == gmaps.FIELD_MASKS["search_detailed"] + ) + assert rec.body_json()["minRating"] == 4.0 + + +def test_search_limit_maps_to_pagesize(): + rec = ok() + gmaps.main(["search", "coffee", "--limit=3"], transport=rec) + assert rec.body_json()["pageSize"] == 3 + + +def test_search_trim_output(capsys): + payload = { + "places": [ + { + "id": "P1", + "displayName": {"text": "Café X", "languageCode": "fr"}, + "formattedAddress": "1 rue Principale", + "location": {"latitude": 45.5, "longitude": -73.5}, + "types": ["cafe", "food"], + "googleMapsUri": "https://maps.google.com/?cid=1", + } + ] + } + rec = ok(payload=payload) + gmaps.main(["search", "coffee"], transport=rec) + out = json.loads(capsys.readouterr().out) + assert out["ok"] is True + assert out["data"] == [ + { + "name": "Café X", + "address": "1 rue Principale", + "lat": 45.5, + "lng": -73.5, + "place_id": "P1", + "maps_url": "https://maps.google.com/?cid=1", + "types": ["cafe", "food"], + } + ] + + +def test_search_detailed_trim_includes_ratings(capsys): + payload = { + "places": [ + { + "id": "P1", + "displayName": {"text": "Bistro"}, + "formattedAddress": "2 rue", + "location": {"latitude": 1.0, "longitude": 2.0}, + "types": ["restaurant"], + "googleMapsUri": "u", + "rating": 4.6, + "userRatingCount": 231, + "priceLevel": "PRICE_LEVEL_MODERATE", + "currentOpeningHours": {"openNow": True}, + "websiteUri": "https://bistro.example", + "nationalPhoneNumber": "514-555-0199", + } + ] + } + rec = ok(payload=payload) + gmaps.main(["search", "bistro", "--detailed"], transport=rec) + d = json.loads(capsys.readouterr().out)["data"][0] + assert d["rating"] == 4.6 + assert d["ratings_count"] == 231 + assert d["price"] == "PRICE_LEVEL_MODERATE" + assert d["open_now"] is True + assert d["phone"] == "514-555-0199" + assert d["website"] == "https://bistro.example" + + +def test_search_zero_results_is_ok_empty(capsys): + rec = ok(payload={"places": []}) + rc = gmaps.main(["search", "nothing here"], transport=rec) + assert rc == 0 + assert json.loads(capsys.readouterr().out) == {"ok": True, "data": []} + + +def test_search_zero_results_missing_key(capsys): + rec = ok(payload={}) # no "places" key at all + rc = gmaps.main(["search", "x"], transport=rec) + assert rc == 0 + assert json.loads(capsys.readouterr().out)["data"] == [] + + +# --------------------------------------------------------------------------- # +# nearby +# --------------------------------------------------------------------------- # + + +def test_nearby_basic_request(): + rec = ok() + rc = gmaps.main(["nearby", "--near=45.5,-73.6"], transport=rec) + assert rc == 0 + assert rec.last["method"] == "POST" + assert rec.last["url"] == "https://places.googleapis.com/v1/places:searchNearby" + assert rec.body_json() == { + "locationRestriction": { + "circle": {"center": {"latitude": 45.5, "longitude": -73.6}, "radius": 1500} + }, + "maxResultCount": 8, + } + + +def test_nearby_uses_max_result_count_not_pagesize(): + rec = ok() + gmaps.main(["nearby", "--near=45.5,-73.6", "--limit=5"], transport=rec) + body = rec.body_json() + assert body["maxResultCount"] == 5 + assert "pageSize" not in body + + +def test_nearby_type_is_plural_array(): + rec = ok() + gmaps.main(["nearby", "--near=45.5,-73.6", "--type=restaurant"], transport=rec) + body = rec.body_json() + assert body["includedTypes"] == ["restaurant"] + assert "includedType" not in body + + +def test_nearby_rank_preference_uppercased(): + rec = ok() + gmaps.main(["nearby", "--near=45.5,-73.6", "--rank=distance"], transport=rec) + assert rec.body_json()["rankPreference"] == "DISTANCE" + rec2 = ok() + gmaps.main(["nearby", "--near=45.5,-73.6", "--rank=popularity"], transport=rec2) + assert rec2.body_json()["rankPreference"] == "POPULARITY" + + +def test_nearby_requires_near(): + rc = gmaps.main(["nearby"], transport=ok()) + assert rc == 2 + + +def test_nearby_detailed_mask(): + rec = ok() + gmaps.main(["nearby", "--near=45.5,-73.6", "--detailed"], transport=rec) + assert ( + rec.last["headers"]["X-Goog-FieldMask"] == gmaps.FIELD_MASKS["nearby_detailed"] + ) + + +# --------------------------------------------------------------------------- # +# place +# --------------------------------------------------------------------------- # + + +def test_place_request_get_bare_mask(): + rec = Recorder(payload={"id": "ChIJabc"}) + rc = gmaps.main(["place", "ChIJabc"], transport=rec) + assert rc == 0 + assert rec.last["method"] == "GET" + assert rec.last["url"] == "https://places.googleapis.com/v1/places/ChIJabc" + h = rec.last["headers"] + assert h["X-Goog-Api-Key"] == API_KEY + assert h["X-Goog-FieldMask"] == gmaps.FIELD_MASKS["place"] + assert "Content-Type" not in h + assert rec.last["body"] is None + + +def test_place_lang_query_param(): + rec = Recorder(payload={"id": "ChIJabc"}) + gmaps.main(["place", "ChIJabc", "--lang=fr"], transport=rec) + assert ( + rec.last["url"] + == "https://places.googleapis.com/v1/places/ChIJabc?languageCode=fr" + ) + + +def test_place_reviews_mask_and_trim(capsys): + payload = { + "id": "ChIJabc", + "displayName": {"text": "Trattoria"}, + "location": {"latitude": 43.0, "longitude": 11.0}, + "editorialSummary": {"text": "Cozy Tuscan spot"}, + "regularOpeningHours": {"weekdayDescriptions": ["Mon: 12-22", "Tue: 12-22"]}, + "currentOpeningHours": {"openNow": False}, + "reviews": [ + { + "rating": 5, + "text": {"text": "Superb"}, + "authorAttribution": {"displayName": "Ann"}, + "relativePublishTimeDescription": "a week ago", + }, + { + "rating": 4, + "text": {"text": "Good"}, + "authorAttribution": {"displayName": "Bob"}, + }, + { + "rating": 5, + "text": {"text": "Great"}, + "authorAttribution": {"displayName": "Cy"}, + }, + { + "rating": 3, + "text": {"text": "Meh"}, + "authorAttribution": {"displayName": "Di"}, + }, + ], + } + rec = Recorder(payload=payload) + gmaps.main(["place", "ChIJabc", "--reviews"], transport=rec) + assert rec.last["headers"]["X-Goog-FieldMask"] == gmaps.FIELD_MASKS["place_reviews"] + d = json.loads(capsys.readouterr().out)["data"] + assert d["name"] == "Trattoria" + assert d["editorial"] == "Cozy Tuscan spot" + assert d["hours"] == ["Mon: 12-22", "Tue: 12-22"] + assert d["open_now"] is False + assert len(d["reviews"]) == 3 # capped at 3 + + +def test_place_not_found_exit_4(capsys): + rec = err(404, "Requested entity was not found.") + rc = gmaps.main(["place", "ChIJbad"], transport=rec) + assert rc == 4 + e = json.loads(capsys.readouterr().err)["error"] + assert e["retryable"] is False + + +# --------------------------------------------------------------------------- # +# geocode / revgeocode +# --------------------------------------------------------------------------- # + + +def test_geocode_request_url_encoded(): + rec = Recorder(payload={"results": []}) + rc = gmaps.main(["geocode", "Café René, Montréal"], transport=rec) + assert rc == 0 + assert rec.last["method"] == "GET" + assert rec.last["url"] == ( + "https://geocode.googleapis.com/v4/geocode/address/" + "Caf%C3%A9%20Ren%C3%A9%2C%20Montr%C3%A9al" + ) + assert "X-Goog-FieldMask" not in rec.last["headers"] + assert rec.last["headers"]["X-Goog-Api-Key"] == API_KEY + + +def test_geocode_lang_query_param(): + rec = Recorder(payload={"results": []}) + gmaps.main(["geocode", "Rome", "--lang=it"], transport=rec) + assert rec.last["url"].endswith("/address/Rome?languageCode=it") + + +def test_geocode_trim_output(capsys): + payload = { + "results": [ + { + "formattedAddress": "Rome, Metropolitan City of Rome, Italy", + "placeId": "ChIJRome", + "location": {"latitude": 41.9, "longitude": 12.5}, + "granularity": "GEOMETRIC_CENTER", + } + ] + } + rec = Recorder(payload=payload) + gmaps.main(["geocode", "Rome"], transport=rec) + assert json.loads(capsys.readouterr().out)["data"] == [ + { + "address": "Rome, Metropolitan City of Rome, Italy", + "lat": 41.9, + "lng": 12.5, + "place_id": "ChIJRome", + "granularity": "GEOMETRIC_CENTER", + } + ] + + +def test_geocode_zero_results_ok(capsys): + rec = Recorder(payload={"results": []}) + rc = gmaps.main(["geocode", "asdkjfhaskdjfh"], transport=rec) + assert rc == 0 + assert json.loads(capsys.readouterr().out)["data"] == [] + + +def test_revgeocode_at_flag(): + rec = Recorder(payload={"results": []}) + rc = gmaps.main(["revgeocode", "--at=45.51,-73.57"], transport=rec) + assert rc == 0 + assert rec.last["method"] == "GET" + assert ( + rec.last["url"] + == "https://geocode.googleapis.com/v4/geocode/location/45.51,-73.57" + ) + + +def test_revgeocode_positional(): + rec = Recorder(payload={"results": []}) + gmaps.main(["revgeocode", "45.51,-73.57"], transport=rec) + assert ( + rec.last["url"] + == "https://geocode.googleapis.com/v4/geocode/location/45.51,-73.57" + ) + + +def test_revgeocode_requires_coords(): + rc = gmaps.main(["revgeocode"], transport=Recorder(payload={"results": []})) + assert rc == 2 + + +# --------------------------------------------------------------------------- # +# route +# --------------------------------------------------------------------------- # + + +def test_route_address_waypoints(): + rec = Recorder(payload={"routes": []}) + rc = gmaps.main(["route", "--from=Montreal", "--to=Quebec City"], transport=rec) + assert rc == 0 + assert rec.last["method"] == "POST" + assert ( + rec.last["url"] == "https://routes.googleapis.com/directions/v2:computeRoutes" + ) + body = rec.body_json() + assert body == { + "origin": {"address": "Montreal"}, + "destination": {"address": "Quebec City"}, + "travelMode": "DRIVE", + } + assert "routingPreference" not in body + assert rec.last["headers"]["X-Goog-FieldMask"] == gmaps.FIELD_MASKS["route"] + assert rec.last["headers"]["Content-Type"] == "application/json" + + +def test_route_never_sets_routing_preference(): + rec = Recorder(payload={"routes": []}) + gmaps.main( + ["route", "--from=A", "--to=B", "--mode=transit", "--lang=fr"], transport=rec + ) + assert "routingPreference" not in rec.body_json() + + +def test_route_latlng_and_placeid_waypoints(): + rec = Recorder(payload={"routes": []}) + gmaps.main( + ["route", "--from=45.5,-73.6", "--to=placeid:ChIJxyz", "--mode=walk"], + transport=rec, + ) + body = rec.body_json() + assert body["origin"] == { + "location": {"latLng": {"latitude": 45.5, "longitude": -73.6}} + } + assert body["destination"] == {"placeId": "ChIJxyz"} + assert body["travelMode"] == "WALK" + + +def test_route_mode_mapping(): + for mode, expected in [ + ("drive", "DRIVE"), + ("walk", "WALK"), + ("bicycle", "BICYCLE"), + ("transit", "TRANSIT"), + ]: + rec = Recorder(payload={"routes": []}) + gmaps.main(["route", "--from=A", "--to=B", f"--mode={mode}"], transport=rec) + assert rec.body_json()["travelMode"] == expected + + +def test_route_trim_output(capsys): + payload = { + "routes": [ + { + "distanceMeters": 5300, + "duration": "742s", + "description": "via Autoroute 15", + } + ] + } + rec = Recorder(payload=payload) + gmaps.main(["route", "--from=A", "--to=B"], transport=rec) + assert json.loads(capsys.readouterr().out)["data"] == [ + { + "distance_m": 5300, + "duration_s": 742, + "summary": "via Autoroute 15", + } + ] + + +def test_route_requires_from_and_to(): + assert ( + gmaps.main(["route", "--from=A"], transport=Recorder(payload={"routes": []})) + == 2 + ) + assert ( + gmaps.main(["route", "--to=B"], transport=Recorder(payload={"routes": []})) == 2 + ) + + +# --------------------------------------------------------------------------- # +# envelope + exit codes +# --------------------------------------------------------------------------- # + + +def test_success_envelope(capsys): + rec = ok(payload={"places": []}) + rc = gmaps.main(["search", "x"], transport=rec) + out = json.loads(capsys.readouterr().out) + assert rc == 0 + assert out == {"ok": True, "data": []} + + +def test_error_envelope_shape(capsys): + rec = err(403, "PERMISSION_DENIED") + rc = gmaps.main(["place", "ChIJabc"], transport=rec) + assert rc == 3 + payload = json.loads(capsys.readouterr().err) + assert payload["ok"] is False + assert set(payload["error"].keys()) == {"code", "message", "retryable", "hint"} + assert payload["error"]["retryable"] is False + + +@pytest.mark.parametrize( + "status,expected", + [(401, 3), (403, 3), (400, 4), (404, 4), (429, 5), (500, 5), (502, 5), (503, 5)], +) +def test_http_status_to_exit_code(status, expected): + rc = gmaps.main(["search", "x"], transport=err(status)) + assert rc == expected + + +def test_upstream_5xx_is_retryable(capsys): + rc = gmaps.main(["search", "x"], transport=err(503, "unavailable")) + assert rc == 5 + assert json.loads(capsys.readouterr().err)["error"]["retryable"] is True + + +def test_timeout_exit_5(): + rec = Recorder(exc=TimeoutError("slow")) + assert gmaps.main(["search", "x"], transport=rec) == 5 + + +def test_network_error_exit_5(capsys): + rec = Recorder(exc=urllib.error.URLError("connection refused")) + rc = gmaps.main(["search", "x"], transport=rec) + assert rc == 5 + assert json.loads(capsys.readouterr().err)["error"]["retryable"] is True + + +def test_url_error_wrapping_timeout_exit_5(): + rec = Recorder(exc=urllib.error.URLError(TimeoutError("timed out"))) + assert gmaps.main(["search", "x"], transport=rec) == 5 + + +@pytest.mark.parametrize( + "argv", + [ + [], # no subcommand + ["bogus"], # unknown subcommand + ["search", "x", "--near=not-a-coord"], # malformed latlng + ["search", "x", "--near=200,0"], # latitude out of range + ["search", "x", "--near=45,-73", "--radius=99999"], # radius too big + ["search", "x", "--limit=99"], # limit out of range + ["search", "x", "--limit=0"], # limit too small + ["nearby"], # missing required --near + ["route", "--from=A"], # missing --to + ["search", "x", "--unknown-flag=1"], # unrecognized flag + ], +) +def test_usage_errors_exit_2(argv): + assert gmaps.main(argv, transport=ok()) == 2 + + +def test_usage_error_emits_envelope(capsys): + gmaps.main(["nearby"], transport=ok()) + payload = json.loads(capsys.readouterr().err) + assert payload["ok"] is False + assert payload["error"]["code"] == "usage" + + +# --------------------------------------------------------------------------- # +# key handling +# --------------------------------------------------------------------------- # + + +def test_missing_key_exit_3(monkeypatch, capsys): + monkeypatch.delenv("GMAPS_API_KEY", raising=False) + rc = gmaps.main(["search", "x"], transport=ok()) + assert rc == 3 + assert json.loads(capsys.readouterr().err)["ok"] is False + + +def test_empty_key_exit_3(monkeypatch): + monkeypatch.setenv("GMAPS_API_KEY", "") + assert gmaps.main(["search", "x"], transport=ok()) == 3 + + +def test_key_sent_in_header_only(): + rec = ok() + gmaps.main(["search", "x"], transport=rec) + assert rec.last["headers"]["X-Goog-Api-Key"] == API_KEY + + +def test_key_never_in_success_output(capsys): + payload = { + "places": [ + { + "id": "1", + "displayName": {"text": "X"}, + "formattedAddress": "a", + "location": {"latitude": 1, "longitude": 2}, + "types": ["cafe"], + "googleMapsUri": "u", + } + ] + } + gmaps.main(["search", "x"], transport=ok(payload=payload)) + cap = capsys.readouterr() + assert API_KEY not in cap.out + assert API_KEY not in cap.err + + +def test_key_redacted_even_if_upstream_echoes_it(capsys): + # Simulate Google echoing the key in an error message; it must be scrubbed. + rec = Recorder( + status=403, + raw=json.dumps( + {"error": {"message": f"API key {API_KEY} not authorized"}} + ).encode("utf-8"), + ) + gmaps.main(["search", "x"], transport=rec) + cap = capsys.readouterr() + assert API_KEY not in cap.err + assert "***" in cap.err + + +@pytest.mark.parametrize( + "argv", + [ + ["search", "x"], + ["nearby", "--near=45,-73"], + ["place", "ChIJabc"], + ["geocode", "Rome"], + ["revgeocode", "--at=45,-73"], + ["route", "--from=A", "--to=B"], + ], +) +def test_key_absent_from_all_subcommand_output(argv, capsys): + payloads = {"places": [], "results": [], "routes": []} + gmaps.main(argv, transport=Recorder(payload=payloads)) + cap = capsys.readouterr() + assert API_KEY not in cap.out + assert API_KEY not in cap.err + + +def test_key_not_leaked_via_argparse_error(capsys): + # A caller who wrongly passes the key as an unknown flag must not see it echoed. + gmaps.main(["search", "x", f"--api-key={API_KEY}"], transport=ok()) + assert API_KEY not in capsys.readouterr().err + + +# --------------------------------------------------------------------------- # +# --raw + flag forms + Unicode +# --------------------------------------------------------------------------- # + + +def test_raw_passthrough(capsys): + payload = {"places": [{"id": "1", "displayName": {"text": "X"}, "weirdField": 123}]} + gmaps.main(["search", "x", "--raw"], transport=ok(payload=payload)) + assert json.loads(capsys.readouterr().out) == {"ok": True, "data": payload} + + +def test_flag_equals_and_space_forms_equivalent(): + rec1 = ok() + gmaps.main(["search", "x", "--limit=5"], transport=rec1) + rec2 = ok() + gmaps.main(["search", "x", "--limit", "5"], transport=rec2) + assert rec1.body_json()["pageSize"] == 5 == rec2.body_json()["pageSize"] + + +def test_unicode_query_end_to_end(capsys): + query = "caffè a Montréal, près d'ici" + payload = { + "places": [ + { + "id": "1", + "displayName": {"text": "Caffè Italia"}, + "formattedAddress": "6840 Boul St-Laurent", + "location": {"latitude": 45.5, "longitude": -73.6}, + "types": ["cafe"], + "googleMapsUri": "u", + } + ] + } + rc = gmaps.main(["search", query], transport=ok(payload=payload)) + assert rc == 0 + # Sent verbatim in the request body. + # (recorder body reflects exactly what we serialized) + out = json.loads(capsys.readouterr().out) + assert out["data"][0]["name"] == "Caffè Italia" + + +def test_unicode_query_in_request_body(): + query = "caffè a Montréal" + rec = ok() + gmaps.main(["search", query], transport=rec) + assert rec.body_json()["textQuery"] == query diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3e73ca6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "gmaps-cli" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +]