From c4c6f247ddecf32cef554187590b3d345f767287 Mon Sep 17 00:00:00 2001 From: gldc <58944145+gldc@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:41:18 -0400 Subject: [PATCH 1/5] feat: implement gmaps CLI with hermetic test suite Single-file, stdlib-only Google Maps CLI wrapping Places API (New) Text Search + Nearby + Details, Geocoding v4 forward/reverse, and Routes computeRoutes. Compact JSON envelope on stdout, structured errors on stderr, stable exit codes (0/2/3/4/5), and a `retryable` flag on every error. The API key is read only from GMAPS_API_KEY, sent only via the X-Goog-Api-Key header, and scrubbed from all output including error paths. Field masks are fixed per subcommand in one table (the billing-SKU lever) with a verbatim table-equality test guarding against cost regressions. The HTTP layer is a single injectable transport, so the whole surface is tested without network access: 79 hermetic tests covering the argparse surface, per-command request URL/headers/body, output trimming, the exit-code map, Unicode queries, --raw passthrough, and key redaction. Co-Authored-By: Claude Fable 5 --- gmaps.py | 624 ++++++++++++++++++++++++++++++++++ pyproject.toml | 43 +++ tests/test_gmaps.py | 790 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1457 insertions(+) create mode 100755 gmaps.py create mode 100644 pyproject.toml create mode 100644 tests/test_gmaps.py 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..046ddfd --- /dev/null +++ b/tests/test_gmaps.py @@ -0,0 +1,790 @@ +"""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_shape_default(): + 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) + # asserted separately via capsys in test_search_trim_output + + +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 From b2bd5684e0ca5d81f01d59d2b979b5af5962a07e Mon Sep 17 00:00:00 2001 From: gldc <58944145+gldc@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:41:18 -0400 Subject: [PATCH 2/5] ci: run pytest on Python 3.11 and 3.13 GitHub Actions workflow installing pytest via pip and running the hermetic suite on both interpreter versions for every push to main and pull request. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 7cae6ea1a5e114444d8f81a5d41c4b77a2b75638 Mon Sep 17 00:00:00 2001 From: gldc <58944145+gldc@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:41:18 -0400 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20full=20README=20=E2=80=94=20usage,?= =?UTF-8?q?=20agent=20contract,=20key=20setup,=20cost=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real examples for all six subcommands; the agent contract (JSON envelope, exit-code table, retryable semantics, key handling); GMAPS_API_KEY setup via gcloud with --api-target restrictions; and the field-mask / billing-SKU philosophy table. Co-Authored-By: Claude Fable 5 --- README.md | 234 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 214 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6f87c1b..1a6a2d2 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,228 @@ # 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 +``` + +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 +``` + +`--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 +``` -## Planned commands +Reverse geocode takes coordinates as `--at=LAT,LNG` or as a plain positional. +The flag form is handy when a negative latitude would otherwise look like a flag. +### `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..." ``` -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) + +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. + ## 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 From c13cf86c3da62cb0378ce892a4bfea8e738890a0 Mon Sep 17 00:00:00 2001 From: gldc <58944145+gldc@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:52:42 -0400 Subject: [PATCH 4/5] fix: drop assertion-free test; add agent SKILL.md + README pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete test_search_trim_shape_default (vacuous — no assertions; coverage lives in test_search_trim_output) - SKILL.md: portable agent-skill teaching the command surface, JSON/exit-code contract, and cost discipline - README: SKILL.md pointer; prefer --at= form for revgeocode in wrappers Co-Authored-By: Claude Fable 5 --- README.md | 8 ++++- SKILL.md | 70 +++++++++++++++++++++++++++++++++++++++ tests/test_gmaps.py | 18 ----------- uv.lock | 79 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 SKILL.md create mode 100644 uv.lock diff --git a/README.md b/README.md index 1a6a2d2..03f1859 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ gmaps revgeocode 45.5017,-73.5673 # positional form also works ``` Reverse geocode takes coordinates as `--at=LAT,LNG` or as a plain positional. -The flag form is handy when a negative latitude would otherwise look like a flag. +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 @@ -200,6 +202,10 @@ 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** — a single file, Python 3.11+ stdlib only. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..a0a90d1 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,70 @@ +--- +name: gmaps +description: Google Maps: search places, 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/tests/test_gmaps.py b/tests/test_gmaps.py index 046ddfd..7c926a1 100644 --- a/tests/test_gmaps.py +++ b/tests/test_gmaps.py @@ -184,24 +184,6 @@ def test_search_limit_maps_to_pagesize(): assert rec.body_json()["pageSize"] == 3 -def test_search_trim_shape_default(): - 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) - # asserted separately via capsys in test_search_trim_output - - def test_search_trim_output(capsys): payload = { "places": [ 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" }, +] From 6a941fddda90bf1fb2603494d11202cde3f91fa1 Mon Sep 17 00:00:00 2001 From: gldc <58944145+gldc@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:55:02 -0400 Subject: [PATCH 5/5] fix: quote SKILL.md description (valid YAML) and trim to 57 chars Co-Authored-By: Claude Fable 5 --- SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL.md b/SKILL.md index a0a90d1..54cee9a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: gmaps -description: Google Maps: search places, ratings, hours, routes (gmaps CLI). +description: "Google Maps: search, ratings, hours, routes (gmaps CLI)." --- # gmaps — Google Maps lookups from the shell