fix: guard geo parse helpers against non-dict JSON; de-dup timeout build (v0.2.3)

- _parse_ipify/_parse_reverse return None on a truthy non-dict body instead of raising
  AttributeError, honoring the documented 'None on any parse failure' contract (L9)
- build the geo request ClientTimeout once instead of twice (nit)
- drop the stale 'live proxy region- contract' wording from _state_slug's docstring (nit).

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-29 17:57:54 -04:00
parent a5b91bed0d
commit 449f790571
4 changed files with 11 additions and 7 deletions

View File

@ -12,9 +12,9 @@ Small sync helpers shared across projects. Base is stdlib only — **no dependen
## Install
```
commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.2
commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.3
# async address/geo lookups (fetch_ip / ip_location / fetch_location) need the extra:
commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.2
commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.3
```
The base install pulls **nothing** (stdlib). Only `commons[addr]` adds `aiohttp`, and

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "commons"
version = "0.2.2"
version = "0.2.3"
description = "small stdlib-only sync helpers: time/timezone deltas, dotted-path dict access, display masking, ip/address tooling, and retry/backoff"
requires-python = ">=3.10"
dependencies = []

View File

@ -63,4 +63,4 @@ __all__ = [
"aretry",
]
__version__ = "0.2.2"
__version__ = "0.2.3"

View File

@ -52,18 +52,22 @@ def _reverse_url(lat: float, lon: float) -> str:
def _parse_ipify(data: dict) -> Optional[str]:
"""pull the ip string out of an ipify response"""
if not isinstance(data, dict):
return None
ip = data.get("ip")
return ip or None
def _state_slug(state: str) -> str:
"""lowercase ascii-folded state slug with underscores, matching the live proxy region- contract"""
"""lowercase ascii-folded state slug with underscores (e.g. 'New York' -> 'new_york')"""
folded = unicodedata.normalize("NFKD", state).encode("ascii", "ignore").decode("ascii")
return folded.lower().replace(" ", "_")
def _parse_reverse(data: dict) -> Optional[dict]:
"""parse a nominatim reverse response into {country: iso2 lower, state: slug|None}"""
if not isinstance(data, dict):
return None
address = data.get("address")
if not isinstance(address, dict):
return None
@ -84,10 +88,10 @@ async def _get_json(
if not _HAVE_AIOHTTP:
raise RuntimeError(_MISSING)
owns = session is None
request_timeout = aiohttp.ClientTimeout(total=timeout)
if owns:
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout))
session = aiohttp.ClientSession(timeout=request_timeout)
try:
request_timeout = aiohttp.ClientTimeout(total=timeout)
async with session.get(url, headers=headers, timeout=request_timeout) as resp:
if resp.status != 200:
log.warning("address lookup %s -> %s", url, resp.status)