Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e6f4aa06 | ||
|
|
1dc27ebc1a | ||
|
|
f8476fe8d4 | ||
|
|
12cf07919f | ||
|
|
4be69f3c95 | ||
|
|
5d444eaf16 | ||
|
|
449f790571 | ||
|
|
a5b91bed0d | ||
|
|
83a156fd31 | ||
|
|
de6911fb05 | ||
|
|
c6e3dd1b54 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
# claude
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
|
||||
@@ -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.3.1
|
||||
# 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.3.1
|
||||
```
|
||||
|
||||
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.3.1` suffix from the line above to install the latest unpinned.
|
||||
|
||||
## timing
|
||||
|
||||
Unix ints stay the storable value; datetimes are produced on demand in whatever
|
||||
@@ -97,22 +99,43 @@ deep_get(data, "items.1.id") # "b" (numeric segment indexes a list)
|
||||
deep_get(data, "in.nope.here", "DEF") # "DEF" (missing -> default, no raise)
|
||||
|
||||
deep_set({}, "a.b.c", 9) # {"a": {"b": {"c": 9}}}
|
||||
deep_set(data, "items.1.id", "B") # updates the list element in place
|
||||
deep_get(data, "items.1.id") # "B" (get/set share the same grammar)
|
||||
deep_set(data, "items.9.id", "X") # raises IndexError (out of range, no
|
||||
# silent mis-store)
|
||||
```
|
||||
|
||||
`deep_set` mirrors `deep_get`'s list indexing: a numeric segment over an existing
|
||||
list/tuple updates that element in place rather than replacing the list with a dict.
|
||||
An out-of-range numeric segment raises `IndexError` instead of silently corrupting
|
||||
the structure.
|
||||
|
||||
## masking
|
||||
|
||||
Display helpers only — they format a value for showing; they are **not a security
|
||||
control** (the underlying value is unchanged and still needs proper handling).
|
||||
|
||||
```python
|
||||
from commons import credit, cvv, phantom, provider
|
||||
from commons import credit, cvv, phantom, provider, mask_url, mask_proxy
|
||||
|
||||
credit("4111 1111 1111 1234") # "•••• •••• •••• 1234"
|
||||
cvv("123") # "•••"
|
||||
phantom("abcdef1234567890") # "abcdef...7890"
|
||||
provider("4111111111111111") # "VISA" (VISA/MC/AMEX/UPAY/DISC/JCB/DNRS/UNKW)
|
||||
|
||||
# redact credentials in a connection string before logging it
|
||||
mask_url("https://u:pw@api.x/v2?apiKey=SECRET&ip=8.8.8.8")
|
||||
# -> "https://api.x/v2?apiKey=***&ip=8.8.8.8" (userinfo dropped, secret query masked)
|
||||
mask_url("redis://:pw@127.0.0.1:6379/0") # -> "redis://127.0.0.1:6379/0"
|
||||
mask_proxy("1.2.3.4:8080:user:supersecret") # -> "1.2.3.4:8080:user:****"
|
||||
mask_proxy("1.2.3.4:8080") # -> "1.2.3.4:8080" (no auth, untouched)
|
||||
```
|
||||
|
||||
`mask_url` strips `user:pass@` userinfo and replaces the values of sensitive query
|
||||
params (`apiKey`, `token`, `password`, `secret`, …; override via `keys=`) with `***`.
|
||||
`mask_proxy` bullets the password of a `host:port:user:password` spec. Non-URL /
|
||||
non-conforming input is returned unchanged.
|
||||
|
||||
## retry
|
||||
|
||||
Exponential-backoff retry, sync (`retry`) and async (`aretry`). Each works as a **call
|
||||
|
||||
+2
-2
@@ -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.3.1"
|
||||
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 = []
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
timing: unix-timestamp deltas + timezone-aware datetime conversions, bare
|
||||
functions and a configurable Clock.
|
||||
paths: nested dict/list access by dotted path (deep_get / deep_set).
|
||||
masking: display masking for cards / cvv / tokens (cosmetic, not a security control).
|
||||
paths: nested dict/list access by dotted path (deep_get / deep_set); deep_set
|
||||
mirrors deep_get's list indexing, so a get/set round-trip on the same
|
||||
path never destroys a list.
|
||||
masking: display masking for cards / cvv / tokens, plus url/proxy credential
|
||||
redaction for logging (cosmetic, not a security control).
|
||||
addr: ip/address tooling — pure stdlib ip utils (`commons.addr.ip`) in base,
|
||||
async geo lookups (`commons.addr.geo`) behind the `commons[addr]` extra.
|
||||
retry: exponential-backoff retry, sync (`retry`) and async (`aretry`), call or
|
||||
@@ -16,7 +19,7 @@ for instance-scoped control. addr is exposed as a submodule (`from commons impor
|
||||
addr`); its ip helpers live under `commons.addr.ip` to keep top-level uncluttered.
|
||||
"""
|
||||
from . import addr, masking, paths, timing
|
||||
from .masking import credit, cvv, phantom, provider
|
||||
from .masking import credit, cvv, mask_proxy, mask_url, phantom, provider
|
||||
from .paths import deep_get, deep_set
|
||||
from .retry import aretry, retry
|
||||
from .timing import (
|
||||
@@ -59,8 +62,10 @@ __all__ = [
|
||||
"cvv",
|
||||
"phantom",
|
||||
"provider",
|
||||
"mask_url",
|
||||
"mask_proxy",
|
||||
"retry",
|
||||
"aretry",
|
||||
]
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.1"
|
||||
|
||||
+19
-3
@@ -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
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
these are DISPLAY helpers only — they format a value for showing in a UI or log
|
||||
(e.g. "•••• •••• •••• 1234"). they are not a security control: the underlying
|
||||
value is unchanged and still needs proper handling (encryption at rest, etc.).
|
||||
|
||||
card helpers: `credit`, `cvv`, `provider`. token: `phantom`. url/proxy redaction
|
||||
for logging connection strings: `mask_url`, `mask_proxy`.
|
||||
"""
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
SENSITIVE_QUERY_KEYS = frozenset(
|
||||
{"apikey", "api_key", "key", "token", "access_token", "refresh_token",
|
||||
"auth", "password", "passwd", "pwd", "pass", "secret", "client_secret",
|
||||
"sig", "signature", "session"}
|
||||
)
|
||||
|
||||
|
||||
def _digits(value: str) -> str:
|
||||
@@ -51,3 +61,48 @@ def provider(card_number: str) -> str:
|
||||
if n.startswith(("30", "36", "38", "39")):
|
||||
return "DNRS"
|
||||
return "UNKW"
|
||||
|
||||
|
||||
def mask_url(url: str, *, keys: "frozenset[str] | None" = None) -> str:
|
||||
"""redact credentials in a url for logging
|
||||
|
||||
strips userinfo (``user:pass@host`` -> ``host``) and replaces the values of
|
||||
sensitive query parameters (apiKey, token, password, secret, ...) with
|
||||
``***``, keeping the parameter name so the shape stays readable. matching is
|
||||
case-insensitive on the parameter name; pass ``keys`` to override the default
|
||||
sensitive-name set. non-url or unparseable input is returned unchanged.
|
||||
"""
|
||||
sensitive = SENSITIVE_QUERY_KEYS if keys is None else frozenset(k.lower() for k in keys)
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return url
|
||||
if not parts.scheme and not parts.netloc:
|
||||
return url
|
||||
netloc = parts.netloc
|
||||
if "@" in netloc:
|
||||
netloc = netloc.rsplit("@", 1)[1]
|
||||
query = parts.query
|
||||
if query:
|
||||
pairs = parse_qsl(query, keep_blank_values=True)
|
||||
query = urlencode(
|
||||
[(k, "***" if k.lower() in sensitive else v) for k, v in pairs],
|
||||
quote_via=lambda s, *_: s,
|
||||
)
|
||||
return urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
|
||||
|
||||
|
||||
def mask_proxy(spec: str) -> str:
|
||||
"""redact the password of a ``host:port:user:password`` proxy string
|
||||
|
||||
renders ``host:port`` when there is no auth, or ``host:port:user:****`` when
|
||||
a user/password is present — never the password itself. leaves a plain
|
||||
``host:port`` untouched and returns non-conforming input unchanged.
|
||||
"""
|
||||
parts = spec.split(":")
|
||||
if len(parts) == 2:
|
||||
return spec
|
||||
if len(parts) == 4:
|
||||
host, port, user, _ = parts
|
||||
return f"{host}:{port}:{user}:****"
|
||||
return spec
|
||||
|
||||
+34
-9
@@ -2,7 +2,10 @@
|
||||
|
||||
`deep_get(data, "in.this.old.notation")` walks dicts (and lists, when a segment is
|
||||
a number) and returns a default instead of raising on a missing/!wrong path.
|
||||
`deep_set` writes a nested value, creating intermediate dicts.
|
||||
`deep_set` writes a nested value, creating intermediate dicts and indexing into
|
||||
existing lists on numeric segments (mirroring deep_get), so a get/set round-trip
|
||||
on the same path never corrupts a list. an out-of-range numeric segment raises
|
||||
IndexError rather than silently mis-storing.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
@@ -35,16 +38,38 @@ def deep_get(data: Any, path: str, default: Any = None, *, sep: str = ".") -> An
|
||||
def deep_set(data: dict, path: str, value: Any, *, sep: str = ".") -> dict:
|
||||
"""set a nested value by dotted path, creating intermediate dicts
|
||||
|
||||
returns the same top-level dict for chaining. overwrites any non-dict value
|
||||
sitting where an intermediate dict is needed.
|
||||
returns the same top-level dict for chaining. mirrors deep_get: a numeric segment
|
||||
indexes into an existing list/tuple instead of being treated as a dict key, so a
|
||||
read-modify-write round-trip using the same path never corrupts a list. overwrites
|
||||
any non-dict/non-list value sitting where an intermediate container is needed. an
|
||||
out-of-range list index raises IndexError rather than silently mis-storing (deep_get
|
||||
returns default on out-of-range; deep_set has no safe default to fall back to, so it
|
||||
fails loud instead).
|
||||
"""
|
||||
segments = path.split(sep)
|
||||
cur = data
|
||||
for seg in segments[:-1]:
|
||||
nxt = cur.get(seg)
|
||||
if not isinstance(nxt, dict):
|
||||
nxt = {}
|
||||
cur[seg] = nxt
|
||||
cur = nxt
|
||||
cur[segments[-1]] = value
|
||||
if isinstance(cur, list):
|
||||
idx = int(seg)
|
||||
if not -len(cur) <= idx < len(cur):
|
||||
raise IndexError(f"deep_set: index {seg!r} out of range for list of length {len(cur)}")
|
||||
nxt = cur[idx]
|
||||
if not isinstance(nxt, (dict, list)):
|
||||
nxt = {}
|
||||
cur[idx] = nxt
|
||||
cur = nxt
|
||||
else:
|
||||
nxt = cur.get(seg)
|
||||
if not isinstance(nxt, (dict, list)):
|
||||
nxt = {}
|
||||
cur[seg] = nxt
|
||||
cur = nxt
|
||||
last = segments[-1]
|
||||
if isinstance(cur, list):
|
||||
idx = int(last)
|
||||
if not -len(cur) <= idx < len(cur):
|
||||
raise IndexError(f"deep_set: index {last!r} out of range for list of length {len(cur)}")
|
||||
cur[idx] = value
|
||||
else:
|
||||
cur[last] = value
|
||||
return data
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user