2 Commits
Author SHA1 Message Date
dsql 4f835ff003 fix: provider() unicode-digit crash + phantom() short-value false-mask (v0.3.2)
provider() used str.isdigit(), which accepts unicode digit lookalikes (e.g. superscript
²) that int() can't parse, crashing on digit-only-looking input; _digits() now filters
on isdecimal()+isascii() so only real ASCII digits pass through. phantom() revealed the
entire value for len <= 10 while still looking masked (first6+last4 fully covers or
double-covers short strings); those now fully mask instead. Also compresses several
essay-length docstrings (retry, masking, paths) to a line plus the real nuance, with
zero behavior change, and bumps the README install pin.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 23:21:51 -04:00
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
6 changed files with 129 additions and 30 deletions
+27 -4
View File
@@ -12,15 +12,15 @@ 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.3 commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.3.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.3 commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.3.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
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. Drop the `@v0.3.2` suffix from the line above to install the latest unpinned.
## timing ## timing
@@ -99,22 +99,45 @@ 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"
phantom("1234567890") # "••••••••••" (len <= 10 fully masks, never a false reveal)
provider("4111111111111111") # "VISA" (VISA/MC/AMEX/UPAY/DISC/JCB/DNRS/UNKW) provider("4111111111111111") # "VISA" (VISA/MC/AMEX/UPAY/DISC/JCB/DNRS/UNKW)
provider("4111²111111111234") # "VISA" (unicode digit lookalikes ignored, never raises)
# 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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "commons" name = "commons"
version = "0.2.3" version = "0.3.2"
description = "small stdlib-based 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.3" __version__ = "0.3.2"
+59 -3
View File
@@ -3,12 +3,22 @@
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:
"""keep only the digit characters of a string""" """keep only ascii-decimal characters of a string (excludes unicode digit lookalikes)"""
return "".join(c for c in value if c.isdigit()) return "".join(c for c in value if c.isdecimal() and c.isascii())
def credit(card_number: str) -> str: def credit(card_number: str) -> str:
@@ -23,7 +33,13 @@ def cvv(value: str) -> str:
def phantom(value: str) -> str: def phantom(value: str) -> str:
"""show a long token as first-six...last-four (e.g. a hash or id)""" """show a long token as first-six...last-four (e.g. a hash or id)
values of length <= 10 fully mask instead — first6+last4 would otherwise
reveal (or double-reveal) every character while still looking masked.
"""
if len(value) <= 10:
return "" * len(value)
return f"{value[:6]}...{value[-4:]}" return f"{value[:6]}...{value[-4:]}"
@@ -51,3 +67,43 @@ 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: drop userinfo, ``***`` sensitive query values
param-name matching is case-insensitive; ``keys`` overrides 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 to ``:****``
a plain ``host:port`` (no auth) and any other non-conforming input pass through 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
+31 -10
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
@@ -33,18 +36,36 @@ 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 data for chaining
returns the same top-level dict for chaining. overwrites any non-dict value mirrors deep_get's list indexing (a numeric segment updates an existing list/tuple
sitting where an intermediate dict is needed. element rather than corrupting it); unlike deep_get, an out-of-range index raises
IndexError instead of silently mis-storing, since there's no safe default to fall back to.
""" """
segments = path.split(sep) segments = path.split(sep)
cur = data cur = data
for seg in segments[:-1]: for seg in segments[:-1]:
nxt = cur.get(seg) if isinstance(cur, list):
if not isinstance(nxt, dict): idx = int(seg)
nxt = {} if not -len(cur) <= idx < len(cur):
cur[seg] = nxt raise IndexError(f"deep_set: index {seg!r} out of range for list of length {len(cur)}")
cur = nxt nxt = cur[idx]
cur[segments[-1]] = value 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 return data
+2 -8
View File
@@ -72,11 +72,8 @@ def retry(
sleep: Callable[[float], None] = time.sleep, sleep: Callable[[float], None] = time.sleep,
rand: Callable[[], float] = random.random, rand: Callable[[], float] = random.random,
): ):
"""retry a sync callable with exponential backoff; call form or decorator """retry a sync callable with exponential backoff; call form (`retry(fn, ...)`) or decorator
`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. `attempts` is floored at 1 so the callable always runs at least once.
""" """
types = _as_types(on) types = _as_types(on)
@@ -125,11 +122,8 @@ def aretry(
sleep: Callable[[float], "asyncio.Future"] = asyncio.sleep, sleep: Callable[[float], "asyncio.Future"] = asyncio.sleep,
rand: Callable[[], float] = random.random, rand: Callable[[], float] = random.random,
): ):
"""retry an async callable with exponential backoff; call form or decorator """async twin of `retry`; call form (`await aretry(coro_fn, ...)`) or decorator, same semantics
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. `attempts` is floored at 1 so the callable always runs at least once.
""" """
types = _as_types(on) types = _as_types(on)