7 Commits
Author SHA1 Message Date
dsql 19e6f4aa06 feat: mask_url/mask_proxy display maskers; fix: deep_set now indexes into lists on numeric segments (commons-2)
mask_url/mask_proxy redact credentials from connection strings for logging
(display only, not a security control). deep_set previously destroyed a list
when a numeric path segment landed on it (isinstance(nxt, dict) check
replaced the list wholesale); it now mirrors deep_get's list indexing so a
get/set round-trip on the same dotted path never corrupts data, raising
IndexError on an out-of-range segment instead of silently mis-storing.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 17:36:03 -04:00
dsql 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
dsql 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
dsql 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
dsql 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
dsql 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
dsql 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
7 changed files with 140 additions and 24 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude # claude
CLAUDE.md .claude/
# python # python
__pycache__/ __pycache__/
+26 -3
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.2 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: # 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.3.1
``` ```
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.3.1` 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
@@ -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_get(data, "in.nope.here", "DEF") # "DEF" (missing -> default, no raise)
deep_set({}, "a.b.c", 9) # {"a": {"b": {"c": 9}}} 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 ## masking
Display helpers only — they format a value for showing; they are **not a security 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). control** (the underlying value is unchanged and still needs proper handling).
```python ```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" credit("4111 1111 1111 1234") # "•••• •••• •••• 1234"
cvv("123") # "•••" cvv("123") # "•••"
phantom("abcdef1234567890") # "abcdef...7890" phantom("abcdef1234567890") # "abcdef...7890"
provider("4111111111111111") # "VISA" (VISA/MC/AMEX/UPAY/DISC/JCB/DNRS/UNKW) 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 ## retry
Exponential-backoff retry, sync (`retry`) and async (`aretry`). Each works as a **call Exponential-backoff retry, sync (`retry`) and async (`aretry`). Each works as a **call
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project] [project]
name = "commons" name = "commons"
version = "0.2.2" version = "0.3.1"
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 = []
+9 -4
View File
@@ -2,8 +2,11 @@
timing: unix-timestamp deltas + timezone-aware datetime conversions, bare timing: unix-timestamp deltas + timezone-aware datetime conversions, bare
functions and a configurable Clock. functions and a configurable Clock.
paths: nested dict/list access by dotted path (deep_get / deep_set). paths: nested dict/list access by dotted path (deep_get / deep_set); deep_set
masking: display masking for cards / cvv / tokens (cosmetic, not a security control). 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, addr: ip/address tooling — pure stdlib ip utils (`commons.addr.ip`) in base,
async geo lookups (`commons.addr.geo`) behind the `commons[addr]` extra. async geo lookups (`commons.addr.geo`) behind the `commons[addr]` extra.
retry: exponential-backoff retry, sync (`retry`) and async (`aretry`), call or 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. addr`); its ip helpers live under `commons.addr.ip` to keep top-level uncluttered.
""" """
from . import addr, masking, paths, timing 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 .paths import deep_get, deep_set
from .retry import aretry, retry from .retry import aretry, retry
from .timing import ( from .timing import (
@@ -59,8 +62,10 @@ __all__ = [
"cvv", "cvv",
"phantom", "phantom",
"provider", "provider",
"mask_url",
"mask_proxy",
"retry", "retry",
"aretry", "aretry",
] ]
__version__ = "0.2.2" __version__ = "0.3.1"
+14 -6
View File
@@ -52,18 +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) -> str: def _state_slug(state) -> 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")
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(" ", "_") 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
@@ -84,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)
+55
View File
@@ -3,7 +3,17 @@
these are DISPLAY helpers only — they format a value for showing in a UI or log 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 (e.g. "•••• •••• •••• 1234"). they are not a security control: the underlying
value is unchanged and still needs proper handling (encryption at rest, etc.). 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: def _digits(value: str) -> str:
@@ -51,3 +61,48 @@ def provider(card_number: str) -> str:
if n.startswith(("30", "36", "38", "39")): if n.startswith(("30", "36", "38", "39")):
return "DNRS" return "DNRS"
return "UNKW" 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
+30 -5
View File
@@ -2,7 +2,10 @@
`deep_get(data, "in.this.old.notation")` walks dicts (and lists, when a segment is `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. 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 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: def deep_set(data: dict, path: str, value: Any, *, sep: str = ".") -> dict:
"""set a nested value by dotted path, creating intermediate dicts """set a nested value by dotted path, creating intermediate dicts
returns the same top-level dict for chaining. overwrites any non-dict value returns the same top-level dict for chaining. mirrors deep_get: a numeric segment
sitting where an intermediate dict is needed. 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) segments = path.split(sep)
cur = data cur = data
for seg in segments[:-1]: for seg in segments[:-1]:
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) nxt = cur.get(seg)
if not isinstance(nxt, dict): if not isinstance(nxt, (dict, list)):
nxt = {} nxt = {}
cur[seg] = nxt cur[seg] = nxt
cur = nxt cur = nxt
cur[segments[-1]] = value 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 return data