Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b91bed0d | ||
|
|
83a156fd31 | ||
|
|
de6911fb05 | ||
|
|
c6e3dd1b54 |
@@ -12,9 +12,9 @@ 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.0
|
commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.2
|
||||||
# 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.0
|
commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.2
|
||||||
```
|
```
|
||||||
|
|
||||||
The base install pulls **nothing** (stdlib). Only `commons[addr]` adds `aiohttp`, and
|
The base install pulls **nothing** (stdlib). Only `commons[addr]` adds `aiohttp`, and
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "commons"
|
name = "commons"
|
||||||
version = "0.2.0"
|
version = "0.2.2"
|
||||||
description = "small stdlib-only sync helpers: time/timezone deltas, dotted-path dict access, display masking, ip/address tooling, and retry/backoff"
|
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"
|
requires-python = ">=3.10"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -63,4 +63,4 @@ __all__ = [
|
|||||||
"aretry",
|
"aretry",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.2.2"
|
||||||
|
|||||||
+10
-2
@@ -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
|
||||||
|
|
||||||
@@ -55,6 +56,12 @@ def _parse_ipify(data: dict) -> Optional[str]:
|
|||||||
return ip or None
|
return ip or None
|
||||||
|
|
||||||
|
|
||||||
|
def _state_slug(state: str) -> str:
|
||||||
|
"""lowercase ascii-folded state slug with underscores, matching the live proxy region- contract"""
|
||||||
|
folded = unicodedata.normalize("NFKD", 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}"""
|
||||||
address = data.get("address")
|
address = data.get("address")
|
||||||
@@ -66,7 +73,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +87,8 @@ async def _get_json(
|
|||||||
if owns:
|
if owns:
|
||||||
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout))
|
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout))
|
||||||
try:
|
try:
|
||||||
async with session.get(url, headers=headers) as resp:
|
request_timeout = aiohttp.ClientTimeout(total=timeout)
|
||||||
|
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)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ def retry(
|
|||||||
`retry(fn, ...)` runs immediately; `@retry(...)` wraps a function. retries on the
|
`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
|
`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.
|
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)
|
types = _as_types(on)
|
||||||
|
attempts = max(1, attempts)
|
||||||
|
|
||||||
def run(target: Callable, args, kwargs):
|
def run(target: Callable, args, kwargs):
|
||||||
delays = list(_delays(attempts, backoff, factor, max_backoff))
|
delays = list(_delays(attempts, backoff, factor, max_backoff))
|
||||||
@@ -94,7 +96,7 @@ def retry(
|
|||||||
wait = _jittered(delays[index], jitter, rand)
|
wait = _jittered(delays[index], jitter, rand)
|
||||||
log.warning(
|
log.warning(
|
||||||
"retry %d/%d after %s: %s",
|
"retry %d/%d after %s: %s",
|
||||||
index + 1, last_index, type(exc).__name__, exc,
|
index + 1, attempts, type(exc).__name__, exc,
|
||||||
)
|
)
|
||||||
if wait > 0:
|
if wait > 0:
|
||||||
sleep(wait)
|
sleep(wait)
|
||||||
@@ -128,8 +130,10 @@ def aretry(
|
|||||||
async twin of `retry`. `await aretry(coro_fn, ...)` runs immediately;
|
async twin of `retry`. `await aretry(coro_fn, ...)` runs immediately;
|
||||||
`@aretry(...)` wraps a coroutine function. same semantics: retry on `on`, stop on
|
`@aretry(...)` wraps a coroutine function. same semantics: retry on `on`, stop on
|
||||||
`give_up`, re-raise the last exception after `attempts`. `sleep`/`rand` injectable.
|
`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)
|
types = _as_types(on)
|
||||||
|
attempts = max(1, attempts)
|
||||||
|
|
||||||
async def run(target: Callable, args, kwargs):
|
async def run(target: Callable, args, kwargs):
|
||||||
delays = list(_delays(attempts, backoff, factor, max_backoff))
|
delays = list(_delays(attempts, backoff, factor, max_backoff))
|
||||||
@@ -145,7 +149,7 @@ def aretry(
|
|||||||
wait = _jittered(delays[index], jitter, rand)
|
wait = _jittered(delays[index], jitter, rand)
|
||||||
log.warning(
|
log.warning(
|
||||||
"retry %d/%d after %s: %s",
|
"retry %d/%d after %s: %s",
|
||||||
index + 1, last_index, type(exc).__name__, exc,
|
index + 1, attempts, type(exc).__name__, exc,
|
||||||
)
|
)
|
||||||
if wait > 0:
|
if wait > 0:
|
||||||
await sleep(wait)
|
await sleep(wait)
|
||||||
|
|||||||
Reference in New Issue
Block a user