feat(location): enhance geocoding and error handling in location resolution

- Added asynchronous support for location resolution, improving performance and responsiveness.
- Introduced detailed error handling for invalid latitude and longitude inputs, providing specific feedback to users.
- Implemented a caching mechanism for geocoding results to optimize repeated lookups.
- Refactored the AQI command to streamline location handling and improve clarity in error messages.
- Expanded unit tests to cover new geocoding features and error scenarios, ensuring robust functionality.
This commit is contained in:
agessaman
2026-07-20 09:35:13 -07:00
parent 6cb5c34a9a
commit 3a896ec1ea
4 changed files with 331 additions and 110 deletions
+47 -25
View File
@@ -4,22 +4,27 @@ AQI command for the MeshCore Bot
Provides Air Quality Index information using OpenMeteo API
"""
from typing import Optional
import openmeteo_requests
import requests_cache
from retry_requests import retry
from ..models import MeshMessage
from ..location import (
OPTIONS_AQI,
ResolveOptions,
classify_location,
geocode_city_best_effort,
get_neighborhood_queries as location_neighborhood_queries,
resolve_location,
)
from ..location import (
get_neighborhood_queries as location_neighborhood_queries,
)
from ..models import MeshMessage
from ..utils import (
abbreviate_location,
get_nominatim_geocoder,
is_valid_timezone,
normalize_us_state,
)
from .base_command import BaseCommand
@@ -178,16 +183,12 @@ class AqiCommand(BaseCommand):
await self.send_response(message, self.astronomical_responses[location_lower])
return True
location, location_type = classify_location(
location, use_international_cities=True
)
try:
# Record execution for this user
self.record_execution(message.sender_id)
# Get AQI data for the location
aqi_data = await self.get_aqi_for_location(location, location_type)
# Get AQI data for the location (single resolve with intl rewrite)
aqi_data = await self.get_aqi_for_location(location)
# Send the response
await self.send_response(message, aqi_data)
@@ -198,12 +199,29 @@ class AqiCommand(BaseCommand):
await self.send_response(message, f"Error getting AQI data: {e}")
return True
async def get_aqi_for_location(self, location: str, location_type: str) -> str:
"""Get AQI data for a location (city or coordinates).
def _resolved_state_differs_from_default(self, address_info: Optional[dict]) -> bool:
"""True when reverse-geocode state/country differs from bot default_state."""
if not address_info or not self.default_state:
return False
country = address_info.get("country", "")
state = address_info.get("state", "")
default_abbr, default_full = normalize_us_state(self.default_state)
defaults = {d for d in (self.default_state, default_abbr, default_full) if d}
if country in ("United States", "US", "United States of America"):
abbr, full = normalize_us_state(state) if state else (None, None)
actuals = {a for a in (abbr, full, state) if a}
return bool(actuals) and actuals.isdisjoint(defaults)
actual_state = country or address_info.get("province") or ""
return bool(actual_state) and actual_state not in defaults
async def get_aqi_for_location(
self, location: str, location_type: Optional[str] = None
) -> str:
"""Get AQI data for a location (city, ZIP, or coordinates).
Args:
location: Location string (city name, ZIP, or "lat,lon").
location_type: Type of location ("city", "zipcode", "coordinates").
location: Raw location string (city name, ZIP, or "lat,lon").
location_type: Unused; kept for call-site/test compatibility.
Returns:
str: Formatted AQI string or error message.
@@ -212,25 +230,30 @@ class AqiCommand(BaseCommand):
opts = ResolveOptions(
default_state=self.default_state,
default_country=self.default_country,
use_international_cities=False, # already applied in execute/classify
use_neighborhoods=True,
use_structured_zip=True,
label_style="abbreviated",
use_international_cities=OPTIONS_AQI.use_international_cities,
use_neighborhoods=OPTIONS_AQI.use_neighborhoods,
use_structured_zip=OPTIONS_AQI.use_structured_zip,
label_style=OPTIONS_AQI.label_style,
include_address_info=True,
timeout=10,
)
# Pass already-classified input: resolve will re-classify coords/ZIP/city.
resolved = resolve_location(self.bot, location, options=opts)
if resolved.error == "invalid_latitude":
detail = resolved.error_detail or location
return f"Invalid latitude: {detail}. Must be between -90 and 90."
if resolved.error == "invalid_longitude":
detail = resolved.error_detail or location
return f"Invalid longitude: {detail}. Must be between -180 and 180."
if resolved.error == "invalid_coordinates":
return f"Invalid coordinates format: {location}. Use format: lat,lon (e.g., 47.6,-122.3)"
if resolved.error == "no_location_zipcode":
return f"Could not find ZIP code '{location.strip()}'"
if resolved.error == "no_location_city":
if "," in location:
return f"Could not find city '{location}'"
if "," in (resolved.query or location):
return f"Could not find city '{resolved.query or location}'"
region = self.default_state or self.default_country
return f"Could not find city '{location}' in {region}"
return f"Could not find city '{resolved.query or location}' in {region}"
if resolved.lat is None or resolved.lon is None:
return f"Could not find location '{location}'"
@@ -252,11 +275,10 @@ class AqiCommand(BaseCommand):
location_prefix = f"{city_display}: "
elif location_type == "zipcode":
location_prefix = f"{location.strip()}: "
else:
# Fall back to shorter abbreviation when over budget
elif self._resolved_state_differs_from_default(address_info):
# Over budget: only keep a short prefix when outside default region
short = abbreviate_location(city_display, max_length=20)
if len(f"{short}: {aqi_data}") <= 130:
location_prefix = f"{short}: "
location_prefix = f"{short}: "
elif location_type == "zipcode":
location_prefix = f"{location.strip()}: "
+56 -81
View File
@@ -9,6 +9,7 @@ city | optional repeater / region capitals / neighborhoods) with opt-in
from __future__ import annotations
import asyncio
import re
from dataclasses import dataclass, replace
from typing import Any, Literal, Mapping, Optional, Union
@@ -18,11 +19,8 @@ import requests
from .region_capitals import REGION_DEFAULT_NOTE, region_capital_query
from .utils import (
abbreviate_location,
geocode_city,
geocode_city_sync,
geocode_zipcode,
geocode_zipcode_sync,
is_country_name,
normalize_us_state,
rate_limited_nominatim_geocode_sync,
rate_limited_nominatim_reverse_sync,
@@ -40,11 +38,29 @@ _COUNTRY_WORD_INDICATORS = frozenset({
"spain", "australia", "japan", "china", "india", "brazil",
})
_COUNTRY_NICKNAMES = frozenset({
"uk", "uae", "united kingdom", "south korea", "north korea", "czech republic",
"sri lanka", "south africa", "puerto rico", "san marino", "vatican",
# Explicit allowlist for "city, country" intl geocode shortcut (legacy AQI list).
# Do not use utils.is_country_name here — its len>2 heuristic is too broad.
_COUNTRY_TOKENS = frozenset({
"canada", "mexico", "uk", "united kingdom", "france", "germany", "italy",
"spain", "australia", "japan", "china", "india", "brazil", "uae", "russia",
"korea", "thailand", "singapore", "egypt", "turkey", "israel", "south africa",
"kenya", "nigeria", "argentina", "peru", "chile", "colombia", "venezuela",
"cuba", "jamaica", "puerto rico", "iceland", "norway", "sweden", "denmark",
"finland", "poland", "czech republic", "hungary", "romania", "bulgaria",
"croatia", "serbia", "greece", "portugal", "ireland", "belgium",
"netherlands", "switzerland", "austria", "monaco", "andorra", "san marino",
"vatican", "luxembourg", "malta", "cyprus", "albania", "macedonia",
"montenegro", "bosnia", "slovenia", "slovakia", "lithuania", "latvia",
"estonia", "belarus", "ukraine", "moldova", "georgia", "armenia",
"azerbaijan", "kazakhstan", "uzbekistan", "kyrgyzstan", "tajikistan",
"turkmenistan", "afghanistan", "pakistan", "bangladesh", "sri lanka",
"nepal", "bhutan", "myanmar", "laos", "cambodia", "vietnam", "malaysia",
"indonesia", "philippines", "taiwan", "north korea", "south korea",
"mongolia",
})
GEOCODE_CACHE_CAP = 256
# Bare place → "city, country" (full-string match, including multi-word keys).
INTERNATIONAL_CITIES: dict[str, str] = {
"beijing": "beijing, china",
@@ -243,6 +259,7 @@ class ResolvedLocation:
display_name: Optional[str]
address_info: Optional[dict]
error: Optional[str] = None
error_detail: Optional[str] = None
region_note: Optional[str] = None
@@ -336,6 +353,15 @@ def reverse_geocode_region(
return city, suffix
def cache_put(
cache: dict, key: Any, value: Any, *, cap: int = GEOCODE_CACHE_CAP
) -> None:
"""Insert into a size-capped cache, evicting the oldest entry when full."""
if key not in cache and len(cache) >= cap:
cache.pop(next(iter(cache)))
cache[key] = value
def zip_to_city_string(
zipcode: str, *, timeout: int = 10, cache: Optional[dict[str, str]] = None, logger: Any = None
) -> Optional[str]:
@@ -356,21 +382,32 @@ def zip_to_city_string(
if logger:
logger.debug(f"Zippopotam ZIP lookup failed for {z}: {e}")
if name and cache is not None:
cache[z] = name
cache_put(cache, z, name)
return name
def parse_coordinates(raw: str) -> Optional[tuple[float, float]]:
"""Return (lat, lon) when valid; None for bad format or out-of-range."""
coords, _error, _detail = parse_coordinates_detailed(raw)
return coords
def parse_coordinates_detailed(
raw: str,
) -> tuple[Optional[tuple[float, float]], Optional[str], Optional[str]]:
"""Return ((lat, lon)|None, error_code|None, error_detail|None)."""
if not COORD_RE.match(raw or ""):
return None
return None, "invalid_coordinates", None
try:
a, b = raw.split(",", 1)
lat, lon = float(a.strip()), float(b.strip())
except ValueError:
return None
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return None
return lat, lon
return None, "invalid_coordinates", None
if not (-90 <= lat <= 90):
return None, "invalid_latitude", str(lat)
if not (-180 <= lon <= 180):
return None, "invalid_longitude", str(lon)
return (lat, lon), None, None
def _rewrite_space_country(location: str) -> str:
@@ -419,10 +456,7 @@ def get_neighborhood_queries(city: str) -> list[str]:
def _is_country_token(text: str) -> bool:
t = text.strip().lower()
if t in _COUNTRY_NICKNAMES:
return True
return is_country_name(text)
return text.strip().lower() in _COUNTRY_TOKENS
def _address_from_result(bot: Any, lat: float, lon: float, timeout: int) -> dict:
@@ -751,11 +785,12 @@ def resolve_location(
query, _ = classify_location(query, use_international_cities=True)
if location_type == "coordinates":
parsed = parse_coordinates(query)
if not parsed:
parsed, err, detail = parse_coordinates_detailed(query)
if err or not parsed:
return ResolvedLocation(
lat=None, lon=None, location_type="coordinates", query=query,
display_name=None, address_info=None, error="invalid_coordinates",
display_name=None, address_info=None,
error=err or "invalid_coordinates", error_detail=detail,
)
lat, lon = parsed
display = _display_from_address(
@@ -818,69 +853,9 @@ async def resolve_location_async(
*,
options: Optional[ResolveOptions] = None,
) -> ResolvedLocation:
"""Async twin for prefix/solarforecast; falls back to sync best-effort when needed."""
"""Async twin of ``resolve_location`` (same behavior via thread offload)."""
opts = options or OPTIONS_PREFIX
if raw is None or not str(raw).strip():
return resolve_location(bot, raw, options=opts)
text = str(raw).strip()
if opts.allow_repeater_names:
hit = lookup_repeater_lat_lon(bot, text)
if hit:
lat, lon, name = hit
return ResolvedLocation(
lat=lat, lon=lon, location_type="repeater", query=name,
display_name=name, address_info=None,
)
default_state = opts.default_state or bot.config.get("Weather", "default_state", fallback="")
default_country = opts.default_country or bot.config.get("Weather", "default_country", fallback="US")
query, location_type = classify_location(
text, use_international_cities=opts.use_international_cities
)
region_note = None
if location_type == "city" and opts.use_region_capitals:
cap = region_capital_query(query)
if cap:
query, region_note = cap, REGION_DEFAULT_NOTE
if location_type == "coordinates":
return resolve_location(bot, text, options=opts)
if location_type == "zipcode" and not opts.use_structured_zip:
lat, lon = await geocode_zipcode(
bot, query.strip(), default_country=default_country, timeout=opts.timeout
)
if lat is None or lon is None:
return ResolvedLocation(
lat=None, lon=None, location_type="zipcode", query=query,
display_name=None, address_info=None, error="no_location_zipcode",
)
return ResolvedLocation(
lat=lat, lon=lon, location_type="zipcode", query=query.strip(),
display_name=query.strip(), address_info=None, region_note=region_note,
)
if (
location_type == "city"
and not opts.use_neighborhoods
and "," not in query
):
lat, lon, address_info = await geocode_city(
bot, query, default_state=default_state, default_country=default_country,
include_address_info=opts.include_address_info, timeout=opts.timeout,
)
if lat is None or lon is None:
return ResolvedLocation(
lat=None, lon=None, location_type="city", query=query,
display_name=None, address_info=None, error="no_location_city",
)
return ResolvedLocation(
lat=lat, lon=lon, location_type="city", query=query,
display_name=query, address_info=address_info, region_note=region_note,
)
return resolve_location(bot, text, options=opts)
return await asyncio.to_thread(resolve_location, bot, raw, options=opts)
def resolve_with_message_fallbacks(
+132 -2
View File
@@ -2,23 +2,28 @@
from __future__ import annotations
import asyncio
import configparser
from unittest.mock import MagicMock, Mock, patch
import pytest
from modules.location import (
GEOCODE_CACHE_CAP,
INTERNATIONAL_CITIES,
OPTIONS_AQI,
OPTIONS_PREFIX,
OPTIONS_RAIN,
ResolveOptions,
cache_put,
classify_location,
geocode_city_best_effort,
get_neighborhood_queries,
parse_coordinates,
parse_coordinates_detailed,
resolve_location,
titlecase_location,
join_location,
resolve_location_async,
zip_to_city_string,
)
@@ -96,6 +101,24 @@ class TestParseCoordinates:
def test_invalid_format(self):
assert parse_coordinates("seattle") is None
def test_detailed_invalid_latitude(self):
coords, err, detail = parse_coordinates_detailed("91,0")
assert coords is None
assert err == "invalid_latitude"
assert detail == "91.0"
def test_detailed_invalid_longitude(self):
coords, err, detail = parse_coordinates_detailed("0,200")
assert coords is None
assert err == "invalid_longitude"
assert detail == "200.0"
def test_detailed_valid(self):
coords, err, detail = parse_coordinates_detailed("47.6,-122.3")
assert coords == pytest.approx((47.6, -122.3))
assert err is None
assert detail is None
@pytest.mark.unit
class TestNeighborhoods:
@@ -113,6 +136,50 @@ class TestKazakhstanDedup:
assert INTERNATIONAL_CITIES["kazakhstan"] == "nur-sultan, kazakhstan"
@pytest.mark.unit
class TestCountryTokenPath:
def test_france_uses_direct_nominatim(self, bot):
loc = _loc(48.85, 2.35, "Paris, France", {"city": "Paris", "country": "France"})
with patch(
"modules.location.rate_limited_nominatim_geocode_sync", return_value=loc
) as geo, patch(
"modules.location.geocode_city_sync",
) as shared, patch(
"modules.location.rate_limited_nominatim_reverse_sync", return_value=loc
):
lat, lon, _ = geocode_city_best_effort(bot, "paris, france")
geo.assert_called()
shared.assert_not_called()
assert lat == pytest.approx(48.85)
def test_texas_uses_shared_geocode(self, bot):
with patch(
"modules.location.geocode_city_sync",
return_value=(33.66, -95.55, {"city": "Paris", "state": "Texas", "country": "United States"}),
) as shared, patch(
"modules.location.rate_limited_nominatim_geocode_sync",
) as geo:
lat, lon, _ = geocode_city_best_effort(bot, "paris, texas")
shared.assert_called_once()
for call in geo.call_args_list:
assert call[0][1] != "paris, texas"
assert lat == pytest.approx(33.66)
def test_arbitrary_second_token_not_country(self, bot):
with patch(
"modules.location.geocode_city_sync",
return_value=(1.0, 2.0, {"city": "Foo"}),
) as shared, patch(
"modules.location.rate_limited_nominatim_geocode_sync",
) as geo:
geocode_city_best_effort(bot, "foo, bar")
geocode_city_best_effort(bot, "springfield, greene")
assert shared.call_count == 2
for call in geo.call_args_list:
q = call[0][1]
assert q not in ("foo, bar", "springfield, greene")
@pytest.mark.unit
class TestResolveLocation:
def test_coords(self, bot):
@@ -121,6 +188,16 @@ class TestResolveLocation:
assert r.lat == pytest.approx(47.6)
assert r.error is None
def test_invalid_latitude_error(self, bot):
r = resolve_location(bot, "91,0", options=OPTIONS_AQI)
assert r.error == "invalid_latitude"
assert r.error_detail == "91.0"
def test_invalid_longitude_error(self, bot):
r = resolve_location(bot, "0,200", options=OPTIONS_AQI)
assert r.error == "invalid_longitude"
assert r.error_detail == "200.0"
def test_empty_no_fallback(self, bot):
r = resolve_location(bot, None, options=OPTIONS_AQI)
assert r.error == "no_location"
@@ -172,6 +249,59 @@ class TestResolveLocation:
assert r.location_type == "zipcode"
@pytest.mark.unit
class TestZipCacheCap:
def test_cache_put_evicts_oldest(self):
cache: dict[str, str] = {}
for i in range(GEOCODE_CACHE_CAP + 5):
cache_put(cache, f"{i:05d}", f"City{i}")
assert len(cache) == GEOCODE_CACHE_CAP
assert "00000" not in cache
assert f"{GEOCODE_CACHE_CAP + 4:05d}" in cache
def test_zip_to_city_string_uses_capped_cache(self):
cache: dict[str, str] = {}
for i in range(GEOCODE_CACHE_CAP):
cache[f"{i:05d}"] = f"Old{i}"
mock_resp = Mock()
mock_resp.ok = True
mock_resp.json.return_value = {
"places": [{"place name": "Seattle", "state abbreviation": "WA"}]
}
with patch("modules.location.requests.get", return_value=mock_resp):
name = zip_to_city_string("99999", cache=cache)
assert name == "Seattle, WA"
assert len(cache) == GEOCODE_CACHE_CAP
assert "99999" in cache
assert "00000" not in cache
@pytest.mark.unit
class TestAsyncParity:
def test_coords_match_sync(self, bot):
sync = resolve_location(bot, "47.6,-122.3", options=OPTIONS_AQI)
async_r = asyncio.run(resolve_location_async(bot, "47.6,-122.3", options=OPTIONS_AQI))
assert async_r.lat == sync.lat
assert async_r.lon == sync.lon
assert async_r.error == sync.error
assert async_r.location_type == sync.location_type
def test_empty_match_sync(self, bot):
sync = resolve_location(bot, None, options=OPTIONS_PREFIX)
async_r = asyncio.run(resolve_location_async(bot, None, options=OPTIONS_PREFIX))
assert async_r.error == sync.error
def test_city_match_sync(self, bot):
with patch(
"modules.location.geocode_city_sync",
return_value=(47.6, -122.3, {"city": "Seattle", "state": "Washington", "country": "United States"}),
):
sync = resolve_location(bot, "seattle", options=OPTIONS_AQI)
async_r = asyncio.run(resolve_location_async(bot, "seattle", options=OPTIONS_AQI))
assert async_r.lat == sync.lat
assert async_r.display_name == sync.display_name
@pytest.mark.unit
class TestRainReexports:
def test_helpers_importable_from_rain(self):
+96 -2
View File
@@ -104,14 +104,21 @@ def aqi_cmd():
@pytest.mark.unit
class TestAqiLocationClassification:
"""Lock AQI's front-door location typing and rewrites."""
"""Lock AQI's front-door location typing and rewrites.
execute() passes the raw user location into get_aqi_for_location; typing and
intl rewrites happen inside resolve_location / classify_location.
"""
def _capture(self, cmd, content: str) -> tuple[str, str]:
from modules.location import classify_location
with patch.object(cmd, "get_aqi_for_location", new_callable=AsyncMock) as m:
m.return_value = "ok"
_run(cmd.execute(mock_message(content=content)))
assert m.called, f"get_aqi_for_location not called for {content!r}"
return m.call_args[0][0], m.call_args[0][1]
raw = m.call_args[0][0]
return classify_location(raw, use_international_cities=True)
def test_coordinates_basic(self, aqi_cmd):
loc, typ = self._capture(aqi_cmd, "aqi 47.6,-122.3")
@@ -168,6 +175,12 @@ class TestAqiLocationClassification:
assert typ == "city"
assert loc == "mexico city, mexico"
def test_execute_passes_raw_location(self, aqi_cmd):
with patch.object(aqi_cmd, "get_aqi_for_location", new_callable=AsyncMock) as m:
m.return_value = "ok"
_run(aqi_cmd.execute(mock_message(content="aqi mexico city")))
assert m.call_args[0][0] == "mexico city"
def test_astronomical_early_out_skips_geocode(self, aqi_cmd):
with patch.object(aqi_cmd, "get_aqi_for_location", new_callable=AsyncMock) as m:
_run(aqi_cmd.execute(mock_message(content="aqi mars")))
@@ -271,6 +284,87 @@ class TestAqiZipcodePath:
assert result.startswith("98101:") or "ok" in result
@pytest.mark.unit
class TestAqiCoordinateErrors:
def test_invalid_latitude_message(self, aqi_cmd):
result = _run(aqi_cmd.get_aqi_for_location("91,0"))
assert result == "Invalid latitude: 91.0. Must be between -90 and 90."
def test_invalid_longitude_message(self, aqi_cmd):
result = _run(aqi_cmd.get_aqi_for_location("0,200"))
assert result == "Invalid longitude: 200.0. Must be between -180 and 180."
@pytest.mark.unit
class TestAqiPrefixBudget:
def test_under_budget_includes_display_name(self, aqi_cmd):
from modules.location import ResolvedLocation
resolved = ResolvedLocation(
lat=47.6, lon=-122.3, location_type="city", query="seattle",
display_name="Seattle, WA",
address_info={"city": "Seattle", "state": "Washington", "country": "United States"},
)
with patch("modules.commands.aqi_command.resolve_location", return_value=resolved), \
patch.object(aqi_cmd, "get_openmeteo_aqi", return_value="🟢 20"):
result = _run(aqi_cmd.get_aqi_for_location("seattle"))
assert result.startswith("Seattle, WA:")
def test_over_budget_same_state_omits_prefix(self, aqi_cmd):
from modules.location import ResolvedLocation
aqi_cmd.default_state = "WA"
long_aqi = "X" * 120
resolved = ResolvedLocation(
lat=47.6, lon=-122.3, location_type="city", query="seattle",
display_name="Seattle, WA",
address_info={"city": "Seattle", "state": "Washington", "country": "United States"},
)
with patch("modules.commands.aqi_command.resolve_location", return_value=resolved), \
patch.object(aqi_cmd, "get_openmeteo_aqi", return_value=long_aqi):
result = _run(aqi_cmd.get_aqi_for_location("seattle"))
assert result == long_aqi
assert not result.startswith("Seattle")
def test_over_budget_different_state_keeps_short_prefix(self, aqi_cmd):
from modules.location import ResolvedLocation
aqi_cmd.default_state = "WA"
long_aqi = "X" * 120
resolved = ResolvedLocation(
lat=30.27, lon=-97.74, location_type="city", query="austin",
display_name="Austin, TX",
address_info={"city": "Austin", "state": "Texas", "country": "United States"},
)
with patch("modules.commands.aqi_command.resolve_location", return_value=resolved), \
patch.object(aqi_cmd, "get_openmeteo_aqi", return_value=long_aqi):
result = _run(aqi_cmd.get_aqi_for_location("austin"))
assert result.endswith(long_aqi)
assert result.startswith("Austin")
@pytest.mark.unit
class TestAqiIntlResolveWiring:
def test_london_rewrites_via_resolve(self, aqi_cmd):
loc = _make_geopy_location(
51.5, -0.12, "London, UK", {"city": "London", "country": "United Kingdom"}
)
with patch(
"modules.location.rate_limited_nominatim_geocode_sync", return_value=loc
) as geo, patch(
"modules.location.geocode_city_sync", return_value=(None, None, None)
), patch(
"modules.location.rate_limited_nominatim_reverse_sync", return_value=loc
), patch.object(aqi_cmd, "get_openmeteo_aqi", return_value="ok"):
result = _run(aqi_cmd.get_aqi_for_location("london"))
assert "ok" in result
# Intl rewrite should query "london, uk" on the country path
assert any(
isinstance(c[0][1], str) and "london" in c[0][1].lower()
for c in geo.call_args_list
)
# ---------------------------------------------------------------------------
# WX — type detection + thin geocode wrappers
# ---------------------------------------------------------------------------