Compare commits

..

7 Commits
v0.2.1 ... main

Author SHA1 Message Date
1dc27ebc1a docs: soften pyproject desc to 'stdlib-based' (base is zero-dep; [addr] adds aiohttp)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 22:02:53 -04:00
f8476fe8d4 chore: ignore .claude/ dir (CLAUDE.md now lives under .claude/)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:55:13 -04:00
12cf07919f fix: C1 coerce non-str state in _state_slug so geo parse can't TypeError
a malformed non-string 'state' reached unicodedata.normalize and raised TypeError out of
fetch_location, breaking the 'None on any parse failure' contract; str() it first.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:34:40 -04:00
4be69f3c95 docs: pin install line to release, note unpinned-latest option
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:13:38 -04:00
5d444eaf16 docs: show unpinned install line; note tag-pinning for reproducibility
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:07:22 -04:00
449f790571 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>
2026-06-29 17:57:54 -04:00
a5b91bed0d fix: state slug uses underscore + ascii-fold to match the proxy region- contract
addr.geo.fetch_location produced a hyphen slug with no unicode fold (new-york / québec), but the live proxy region- token the original utils.py fed expects underscore + ascii-fold (new_york / quebec). a multi-word state silently routed to an unrecognized region with no error. added _state_slug (NFKD ascii-fold, lowercase, spaces->underscore) and routed _parse_reverse through it. bump to v0.2.2.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 03:25:14 -04:00
5 changed files with 26 additions and 9 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
# claude # claude
CLAUDE.md .claude/
# python # python
__pycache__/ __pycache__/

View File

@ -12,14 +12,16 @@ Small sync helpers shared across projects. Base is stdlib only — **no dependen
## Install ## Install
``` ```
commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1 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: # 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.1 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 The base install pulls **nothing** (stdlib). Only `commons[addr]` adds `aiohttp`, and
only for the geo lookups — the pure `commons.addr.ip` utilities ship in base. only for the geo lookups — the pure `commons.addr.ip` utilities ship in base.
Drop the `@v0.2.3` suffix from the line above to install the latest unpinned.
## timing ## timing
Unix ints stay the storable value; datetimes are produced on demand in whatever Unix ints stay the storable value; datetimes are produced on demand in whatever

View File

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

View File

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

View File

@ -12,6 +12,7 @@ security: the only secret is geo.ipify's `api_key`, which is a REQUIRED keyword
`ip_location` the caller injects it. nothing is hardcoded here. `ip_location` the caller injects it. nothing is hardcoded here.
""" """
import logging import logging
import unicodedata
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
@ -51,12 +52,26 @@ def _reverse_url(lat: float, lon: float) -> str:
def _parse_ipify(data: dict) -> Optional[str]: def _parse_ipify(data: dict) -> Optional[str]:
"""pull the ip string out of an ipify response""" """pull the ip string out of an ipify response"""
if not isinstance(data, dict):
return None
ip = data.get("ip") ip = data.get("ip")
return ip or None return ip or None
def _state_slug(state) -> str:
"""lowercase ascii-folded state slug with underscores (e.g. 'New York' -> 'new_york')
coerces to str first so a malformed non-string `state` doesn't raise TypeError out
of unicodedata.normalize and break the 'None on any parse failure' contract.
"""
folded = unicodedata.normalize("NFKD", str(state)).encode("ascii", "ignore").decode("ascii")
return folded.lower().replace(" ", "_")
def _parse_reverse(data: dict) -> Optional[dict]: def _parse_reverse(data: dict) -> Optional[dict]:
"""parse a nominatim reverse response into {country: iso2 lower, state: slug|None}""" """parse a nominatim reverse response into {country: iso2 lower, state: slug|None}"""
if not isinstance(data, dict):
return None
address = data.get("address") address = data.get("address")
if not isinstance(address, dict): if not isinstance(address, dict):
return None return None
@ -66,7 +81,7 @@ def _parse_reverse(data: dict) -> Optional[dict]:
state = address.get("state") state = address.get("state")
return { return {
"country": str(country).lower(), "country": str(country).lower(),
"state": state.lower().replace(" ", "-") if state else None, "state": _state_slug(state) if state else None,
} }
@ -77,10 +92,10 @@ async def _get_json(
if not _HAVE_AIOHTTP: if not _HAVE_AIOHTTP:
raise RuntimeError(_MISSING) raise RuntimeError(_MISSING)
owns = session is None owns = session is None
if owns:
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout))
try:
request_timeout = aiohttp.ClientTimeout(total=timeout) request_timeout = aiohttp.ClientTimeout(total=timeout)
if owns:
session = aiohttp.ClientSession(timeout=request_timeout)
try:
async with session.get(url, headers=headers, timeout=request_timeout) as resp: async with session.get(url, headers=headers, timeout=request_timeout) as resp:
if resp.status != 200: if resp.status != 200:
log.warning("address lookup %s -> %s", url, resp.status) log.warning("address lookup %s -> %s", url, resp.status)