Compare commits

...

10 Commits
v0.2.0 ... 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
83a156fd31 fix: forward per-request timeout to geo lookups on an injected session
_get_json applied the timeout only when it created the session; when the caller passed their own session=, the timeout was silently dropped and the session default (aiohttp's 300s) governed. the per-request timeout is now passed to session.get(timeout=...) on both the owned and injected paths.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 01:10:25 -04:00
de6911fb05 fix: retry log uses total attempts as the denominator
the retry warning logged index/last_index (attempts-1), so a 3-attempt retry showed 'retry 1/2'. now logs index+1 of attempts. both retry and aretry.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:18:28 -04:00
c6e3dd1b54 fix: floor retry/aretry attempts at 1 (v0.2.1)
retry(fn, attempts=0) (or negative) silently returned None without ever calling fn,
looking like success. floor attempts at max(1, attempts) so the callable always runs
at least once; a failing call now fails loud after one try instead of no-op None.

verified: attempts=0/-5 -> 1 call (sync + async); failing fn raises after 1 try;
31/31 retry regression intact.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 16:11:34 -04:00
6 changed files with 33 additions and 11 deletions

2
.gitignore vendored
View File

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

View File

@ -12,14 +12,16 @@ 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.0
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.0
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
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
Unix ints stay the storable value; datetimes are produced on demand in whatever

View File

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

View File

@ -63,4 +63,4 @@ __all__ = [
"aretry",
]
__version__ = "0.2.0"
__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.
"""
import logging
import unicodedata
from typing import Optional
from urllib.parse import urlencode
@ -51,12 +52,26 @@ 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:
"""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]:
"""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
@ -66,7 +81,7 @@ def _parse_reverse(data: dict) -> Optional[dict]:
state = address.get("state")
return {
"country": str(country).lower(),
"state": state.lower().replace(" ", "-") if state else None,
"state": _state_slug(state) if state else None,
}
@ -77,10 +92,11 @@ 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:
async with session.get(url, headers=headers) as resp:
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)
return None

View File

@ -77,8 +77,10 @@ def retry(
`retry(fn, ...)` runs immediately; `@retry(...)` wraps a function. retries on the
`on` exceptions, stops early if `give_up(exc)` is true, re-raises the last
exception once `attempts` are exhausted. `sleep`/`rand` are injectable for tests.
`attempts` is floored at 1 so the callable always runs at least once.
"""
types = _as_types(on)
attempts = max(1, attempts)
def run(target: Callable, args, kwargs):
delays = list(_delays(attempts, backoff, factor, max_backoff))
@ -94,7 +96,7 @@ def retry(
wait = _jittered(delays[index], jitter, rand)
log.warning(
"retry %d/%d after %s: %s",
index + 1, last_index, type(exc).__name__, exc,
index + 1, attempts, type(exc).__name__, exc,
)
if wait > 0:
sleep(wait)
@ -128,8 +130,10 @@ def aretry(
async twin of `retry`. `await aretry(coro_fn, ...)` runs immediately;
`@aretry(...)` wraps a coroutine function. same semantics: retry on `on`, stop on
`give_up`, re-raise the last exception after `attempts`. `sleep`/`rand` injectable.
`attempts` is floored at 1 so the callable always runs at least once.
"""
types = _as_types(on)
attempts = max(1, attempts)
async def run(target: Callable, args, kwargs):
delays = list(_delays(attempts, backoff, factor, max_backoff))
@ -145,7 +149,7 @@ def aretry(
wait = _jittered(delays[index], jitter, rand)
log.warning(
"retry %d/%d after %s: %s",
index + 1, last_index, type(exc).__name__, exc,
index + 1, attempts, type(exc).__name__, exc,
)
if wait > 0:
await sleep(wait)