diff --git a/modules/commands/aqi_command.py b/modules/commands/aqi_command.py index 6e0073f..01cf5da 100644 --- a/modules/commands/aqi_command.py +++ b/modules/commands/aqi_command.py @@ -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()}: " diff --git a/modules/location.py b/modules/location.py index 0928d99..dc99983 100644 --- a/modules/location.py +++ b/modules/location.py @@ -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( diff --git a/tests/test_location.py b/tests/test_location.py index b4eab08..4c1247c 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -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): diff --git a/tests/test_location_characterization.py b/tests/test_location_characterization.py index 8cbd101..a43dd32 100644 --- a/tests/test_location_characterization.py +++ b/tests/test_location_characterization.py @@ -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 # ---------------------------------------------------------------------------