11 Commits
Author SHA1 Message Date
dsql 4d5caac8d8 chore: bump to 1.1.0 (logging-discipline audit)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-10 23:00:50 -04:00
dsql 5699aa9dde fix: mask the provider reset url before logging it
net.reset() logged the full reset_url at INFO on the success path; a provider reset url
can carry a rotation token in its query string, so this leaked it to logs. strip the
query + fragment before logging (stdlib urlsplit, no new dependency - the core stays
dependency-free) so the token cannot reach a log sink; scheme/host/path are kept for
diagnostics.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-09 02:10:45 -04:00
dsql e44c8950ee release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-09 18:53:15 -04:00
dsql 0b6b6b440b fix: cooldown handout never truncates a longer active burn deadline
A forced soonest-recovering handout unconditionally set timeout = now + cooldown,
silently collapsing an explicit burn(proxy, 3600) to the cooldown window. Cooldown now
only EXTENDS availability delay: it leaves a still-running longer timed burn untouched
and never resurrects a permanent _DEAD (-1) burn into a future timestamp, while a fine
proxy and a burn shorter than cooldown are cooled as before.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 19:26:31 -04:00
dsql 1adc8139b9 docs: fix README health-check snippet's invalid aiohttp kwarg; widen add()/replace() type hints to accept dict shapes
the flagship health-check snippet called session.get(url, proxies=proxy) on a
plain aiohttp session; aiohttp's ClientSession.get/_request has no `proxies=`
kwarg (only singular `proxy:`), so the snippet TypeErrors immediately if
followed verbatim. pm.get()'s dict output is documented (aiohttp()'s own
docstring) as being for aioweb's ExtendedSession(proxies=...), so the snippet
now names that session type instead of a bare aiohttp one.

add()/replace() type hints omitted Dict[str, str] even though both already
call to_proxy() at runtime and accept dict-shaped proxy specs, same as the
burn()/restore()/is_burned()/remove() family. purely annotation, no behavior
change.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:15:29 -04:00
dsql a4a0227d5d fix: URL-aware parse() at static/template/net.current_ip call sites; self-clear stale burn flag; guard replace([])
static=/template=/net.current_ip() fed URL-shaped input straight into parse(),
which only understands host:port(:user:pass) specs and mis-splits a url's
colons into a garbage Proxy (e.g. embedded-auth urls) instead of raising or
normalizing. all three now route through to_proxy(), the url-aware normalizer
add()/replace() already use.

the per-state `burned` flag (drives WARNING vs DEBUG on the cooldown
fallthrough log) was only ever cleared by restore(); a timed burn() that
lazily expires (is_burned() correctly reads False again) left `burned` stuck
True, permanently upgrading routine cooldown noise to WARNING. `burned` now
self-clears wherever the existing lazy-expiry check already finds the proxy
available (_next_from_list's fine-tier scan, is_burned()).

replace([]) silently wiped the entire pool with no error, even though the
constructor already rejects an empty proxies=[] list. replace() now raises
the same ValueError for an empty list on list sources.

parse()'s docstring corrected: it is spec-string only and does not understand
url shapes; points callers at to_proxy() for url/dict/spec input.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:15:06 -04:00
dsql 9649eb77c7 refactor: derive __version__ from package metadata (single source)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:59:41 -04:00
dsql 1ca3144245 fix: sync __version__ to 0.3.2 (drifted behind pyproject)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:25:17 -04:00
dsql e6663926c0 docs: pin install line to v0.3.2 (pyproject already bumped)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:23:52 -04:00
dsql bac459c5b5 fix: normalize_port raises a clear error on missing/empty port
canonical_key/key()/burn on a portless proxy url used to raise a bare
ValueError from int('') instead of naming the problem, breaking burn()'s
documented not-in-pool contract. normalize_port now raises a legible
'missing port' ValueError; to_proxy() on a portless url still succeeds.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:15:06 -04:00
dsql 84127a93ff docs: compress prose/module docstrings, em-dash->hyphen (de-bloat wave 1)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 00:12:54 -04:00
6 changed files with 127 additions and 94 deletions
+25 -4
View File
@@ -9,15 +9,15 @@ edits. **Credentials are always injected — never hardcoded.**
## Install ## Install
``` ```
aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.3.0 aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v1.0.0
# network helpers (current_ip / reset) need the extra: # network helpers (current_ip / reset) need the extra:
aioproxies[net] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.3.0 aioproxies[net] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v1.0.0
``` ```
The core has no dependencies. The `net` extra adds `aiohttp` for `current_ip` / The core has no dependencies. The `net` extra adds `aiohttp` for `current_ip` /
`reset`. `reset`.
Drop the `@v0.3.0` suffix from the line above to install the latest unpinned. Drop the `@v1.0.0` suffix from the line above to install the latest unpinned.
## Formatting ## Formatting
@@ -105,12 +105,14 @@ regardless of source.)
```python ```python
from aioproxies import AioProxies, ProxiesExhaustedError from aioproxies import AioProxies, ProxiesExhaustedError
from aioweb import ExtendedSession
pm = AioProxies(proxies=[...], cooldown=5) # 5s reuse spacing; cooldown defaults to 0 (off) pm = AioProxies(proxies=[...], cooldown=5) # 5s reuse spacing; cooldown defaults to 0 (off)
try: try:
proxy = pm.get() # next usable proxy, aiohttp dict proxy = pm.get() # next usable proxy, aiohttp dict
resp = await session.get(url, proxies=proxy) async with ExtendedSession(proxies=proxy) as session:
resp = await session.get(url)
if response_looks_blocked(resp): if response_looks_blocked(resp):
pm.burn(proxy, 600) # time out 10 min ... or pm.burn(proxy) for dead pm.burn(proxy, 600) # time out 10 min ... or pm.burn(proxy) for dead
except ProxiesExhaustedError: except ProxiesExhaustedError:
@@ -149,6 +151,9 @@ key (`host:port:user:pass`, or `host:port` auth-less; the port is normalized so
differing only by password are distinct slots. `burn` on a proxy not in the pool raises differing only by password are distinct slots. `burn` on a proxy not in the pool raises
`ValueError`; `restore` on a proxy not in the pool logs a warning and no-ops (matching `ValueError`; `restore` on a proxy not in the pool logs a warning and no-ops (matching
`remove`'s contract, as of v0.3.0 — previously it silently did nothing with no signal). `remove`'s contract, as of v0.3.0 — previously it silently did nothing with no signal).
A **portless** url (`http://user:pass@host`, no `:port`) parses fine via `to_proxy()`,
but keying it (`canonical_key`/`.key()`/`burn`/`add`/`remove`) raises `ValueError`
naming the missing port — a proxy needs a port to have a canonical identity.
### Cooldown ### Cooldown
@@ -209,6 +214,22 @@ await reset("https://provider/reset-url") # rotate upstream ip
## Changelog ## Changelog
### v0.3.2
- **Portless proxy url now fails loud and legible.** `canonical_key`/`.key()`
(and therefore `burn`/`add`/`remove`) on a proxy url with no `:port`
(`http://user:pass@host`) used to raise a bare `ValueError("invalid literal
for int() with base 10: ''")` from `normalize_port('')` — an unrelated `int()`
error that broke `burn()`'s documented "raises ValueError naming the key if
not in pool" contract. `normalize_port` now raises `ValueError("missing port:
...")` naming the problem instead. `to_proxy()` on a portless url still
succeeds (construction tolerates a missing port); only keying it raises.
### v0.3.1
- **Docs-only de-bloat pass.** Compressed module/internal docstrings and comments,
replaced mojibake em-dashes with plain hyphens in source. No behavior change.
### v0.3.0 ### v0.3.0
- **Stored-shape change: int unix deadlines.** `burn(proxy, seconds)` and the - **Stored-shape change: int unix deadlines.** `burn(proxy, seconds)` and the
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aioproxies" name = "aioproxies"
version = "0.3.0" version = "1.1.0"
description = "proxy parsing, formatting, health, and pool management for aiohttp/aioweb, camoufox, and socks5" description = "proxy parsing, formatting, health, and pool management for aiohttp/aioweb, camoufox, and socks5"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] dependencies = []
+6 -6
View File
@@ -1,9 +1,6 @@
"""aioproxies proxy parsing, formatting, and source management. """aioproxies - proxy parsing, formatting, and source management. see README for usage."""
from importlib.metadata import version, PackageNotFoundError
renders proxies for aiohttp/aioweb, camoufox, and socks5; manages session
templates (with caller-supplied fields like country/ttl), rotating lists, or a
static proxy. credentials are always injected, never hardcoded.
"""
from .manager import AioProxies, ProxiesExhaustedError, ProxyManager, aioproxies from .manager import AioProxies, ProxiesExhaustedError, ProxyManager, aioproxies
from .proxy import Proxy, canonical_key, parse, to_proxy from .proxy import Proxy, canonical_key, parse, to_proxy
@@ -18,4 +15,7 @@ __all__ = [
"to_proxy", "to_proxy",
] ]
__version__ = "0.3.0" try:
__version__ = version("aioproxies")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
+57 -53
View File
@@ -1,31 +1,19 @@
"""proxy source management: session templates, rotation, static. """proxy source management: session templates, rotation, static.
`AioProxies` is constructed with exactly one source and hands out `Proxy` objects. `AioProxies` takes exactly one source (template / proxies / static) and hands out
no module-level globals (rotation state is per-instance); a missing proxy file `Proxy` objects via `next()`/`get()`. no module-level globals; a missing proxy file
raises, never `sys.exit`. raises, never `sys.exit`. see README for the full source/method reference.
sources: proxy health (rotating list source only) is keyed by each proxy's canonical key
- template: a format string. `{session}` is filled with a fresh id on each (`host:port:user:pass`, or `host:port` auth-less; port is normalized so `:080` and
`next()`; other placeholders (`{country}`, `{ttl}`, ...) come from `:80` collapse to one key). on template/static sources health/pool methods are
`next(**fields)`. a bare `{}` is treated as the session slot unless escaped as no-ops that log a warning and return, so generic caller code can call them
`{{}}` (back-compat with simple templates). regardless of source.
- proxies: a list of specs cycled round-robin (deduped by canonical key)
- static: one fixed proxy
proxy health (rotating list source only) — burn/timeout, usage counters, reuse per-proxy `timeout` (the availability field) is shared by cooldown and timed
cooldown, pool edits (replace/add/remove) — keyed by each proxy's canonical key burns: `None`/`0` fine, `-1` dead/permanent, a future unix ts = timed out until
(`host:port:user:pass`, or `host:port` auth-less; port is normalized so `:080` then; durations only ever exist as call arguments, converted to `now + seconds`
and `:80` collapse to one key). on template/static sources these are no-ops that and discarded, never stored raw.
log a warning and return, so generic caller code can call them regardless of
source.
per-proxy state (keyed by canonical key): `uses` is a pure counter, incremented on
every handout, never drives selection, survives burns. `timeout` is the
availability state — `None`/`0` fine, `-1` dead/permanent (manual restore only), a
future unix ts = timed out until then. this core is sync: no timers, lazy expiry
checked only against `time.time()` at call time. cooldown and timed burns share
this field; durations only ever exist as call arguments, converted to `now +
seconds` and discarded — a raw duration is never stored.
""" """
import logging import logging
import random import random
@@ -68,7 +56,7 @@ class AioProxies:
self.session_len = session_len self.session_len = session_len
self.cooldown = cooldown self.cooldown = cooldown
self._shuffle = shuffle self._shuffle = shuffle
self._static = parse(static) if static is not None else None self._static = to_proxy(static) if static is not None else None
self._proxies = self._dedupe([parse(p) for p in proxies]) if proxies is not None else [] self._proxies = self._dedupe([parse(p) for p in proxies]) if proxies is not None else []
if self._proxies and shuffle: if self._proxies and shuffle:
random.shuffle(self._proxies) random.shuffle(self._proxies)
@@ -92,20 +80,14 @@ class AioProxies:
@staticmethod @staticmethod
def _normalize_template(template: str) -> str: def _normalize_template(template: str) -> str:
"""fill a bare `{}` session slot, escape-aware """fill a bare `{}` session slot with `{session}`; leave escaped `{{}}` untouched"""
a lone `{}` is treated as `{session}` (back-compat with simple templates).
an escaped literal `{{}}` — str.format's own convention for a literal `{}`
in the output — is left untouched so it survives to render as `{}`, not
`{session}`.
"""
return _BARE_SESSION_SLOT.sub("{session}", template) return _BARE_SESSION_SLOT.sub("{session}", template)
@classmethod @classmethod
def from_file(cls, path: str, **kwargs) -> "AioProxies": def from_file(cls, path: str, **kwargs) -> "AioProxies":
"""build a rotating manager from a newline-delimited proxy file """build a rotating manager from a newline-delimited proxy file
raises FileNotFoundError if the path is missing never exits the process. raises FileNotFoundError if the path is missing - never exits the process.
""" """
with open(path, "r", encoding="utf-8") as fh: with open(path, "r", encoding="utf-8") as fh:
lines = [ln.strip() for ln in fh if ln.strip()] lines = [ln.strip() for ln in fh if ln.strip()]
@@ -134,6 +116,17 @@ class AioProxies:
return False return False
return now >= timeout return now >= timeout
@staticmethod
def _clear_stale_burn(state: Dict[str, object]) -> None:
"""self-clear a lazily-expired timed burn's `burned` flag once found available
mirrors the lazy expiry already applied to `timeout` itself: a timed burn()
that has simply run out its clock should stop upgrading routine cooldown
fallthrough logs to warning, without requiring an explicit restore().
"""
if state["timeout"] not in (None, 0, _DEAD):
state["burned"] = False
def next(self, **fields: object) -> Proxy: def next(self, **fields: object) -> Proxy:
"""return the next proxy from the configured source """return the next proxy from the configured source
@@ -151,7 +144,7 @@ class AioProxies:
if self.template is not None: if self.template is not None:
if "session" in fields: if "session" in fields:
# `session` is auto-filled with a fresh id; a caller-supplied one would # `session` is auto-filled with a fresh id; a caller-supplied one would
# collide in str.format with an opaque TypeError reject it clearly # collide in str.format with an opaque TypeError - reject it clearly
raise ValueError("'session' is filled automatically; do not pass it to next()") raise ValueError("'session' is filled automatically; do not pass it to next()")
try: try:
filled = self.template.format(session=self.session_id(), **fields) filled = self.template.format(session=self.session_id(), **fields)
@@ -161,9 +154,9 @@ class AioProxies:
) from exc ) from exc
except ValueError as exc: except ValueError as exc:
# a malformed template (e.g. an unmatched '{') makes str.format raise a # a malformed template (e.g. an unmatched '{') makes str.format raise a
# bare ValueError; re-raise naming the cause so it isn't cryptic # bare ValueError - re-raise naming the cause so it isn't cryptic
raise ValueError(f"malformed proxy template {self.template!r}: {exc}") from exc raise ValueError(f"malformed proxy template {self.template!r}: {exc}") from exc
return parse(filled) return to_proxy(filled)
return self._next_from_list() return self._next_from_list()
def _next_from_list(self) -> Proxy: def _next_from_list(self) -> Proxy:
@@ -176,9 +169,10 @@ class AioProxies:
fine: List[int] = [] fine: List[int] = []
for offset in range(count): for offset in range(count):
idx = (self._index + offset) % count idx = (self._index + offset) % count
timeout = self._state[self._proxies[idx].key()]["timeout"] state = self._state[self._proxies[idx].key()]
if self._available(timeout, now): if self._available(state["timeout"], now):
fine.append(idx) fine.append(idx)
self._clear_stale_burn(state)
if fine: if fine:
chosen = fine[0] chosen = fine[0]
@@ -196,18 +190,21 @@ class AioProxies:
state = self._state[proxy.key()] state = self._state[proxy.key()]
state["uses"] = int(state["uses"]) + 1 state["uses"] = int(state["uses"]) + 1
if self.cooldown > 0: if self.cooldown > 0:
state["timeout"] = int(now) + self.cooldown # cooldown only ever EXTENDS availability delay - never shorten a longer
# active timeout (a still-running timed burn) down to now+cooldown. _DEAD
# (-1) is a permanent burn and must not be resurrected into a future ts.
cooled = int(now) + self.cooldown
current = state["timeout"]
if current == _DEAD:
pass
elif isinstance(current, (int, float)) and current > cooled:
pass
else:
state["timeout"] = cooled
return proxy return proxy
def _has_burned(self) -> bool: def _has_burned(self) -> bool:
"""whether the empty 'fine' tier reflects a genuine burn(), not just cooldown """whether any proxy carries a genuine burn (vs just cooldown resting), for log level choice"""
tracked directly via each state's `burned` flag (set by burn(), cleared by
restore()) so a real burn is never masked by the manager's own cooldown
resting on the same `timeout` field, regardless of whether cooldown is on.
used to pick warning (real trouble) vs debug (normal cooldown) on the
soonest-recovering path.
"""
return any(state["burned"] for state in self._state.values()) return any(state["burned"] for state in self._state.values())
def _soonest_recovering(self) -> Optional[int]: def _soonest_recovering(self) -> Optional[int]:
@@ -273,8 +270,11 @@ class AioProxies:
key = canonical_key(proxy) key = canonical_key(proxy)
if key not in self._state: if key not in self._state:
return False return False
timeout = self._state[key]["timeout"] state = self._state[key]
return not self._available(timeout, time.time()) available = self._available(state["timeout"], time.time())
if available:
self._clear_stale_burn(state)
return not available
def stats(self) -> List[Dict[str, object]]: def stats(self) -> List[Dict[str, object]]:
"""per-proxy usage + state snapshot (list sources only, else empty) """per-proxy usage + state snapshot (list sources only, else empty)
@@ -312,17 +312,21 @@ class AioProxies:
for state in self._state.values(): for state in self._state.values():
state["uses"] = 0 state["uses"] = 0
def replace(self, proxies: List[Union[str, Proxy]], *, keep_state: bool = False) -> None: def replace(self, proxies: List[Union[str, Proxy, Dict[str, str]]], *, keep_state: bool = False) -> None:
"""swap the entire proxy list """swap the entire proxy list
keep_state=False (default) wipes all per-proxy state (fresh batch). keep_state=False (default) wipes all per-proxy state (fresh batch).
keep_state=True preserves uses/timeout for proxies whose canonical key keep_state=True preserves uses/timeout for proxies whose canonical key
survives the swap; new proxies start clean, dropped ones are forgotten. survives the swap; new proxies start clean, dropped ones are forgotten.
resets the rotation index and honors the manager's shuffle setting. resets the rotation index and honors the manager's shuffle setting. raises
ValueError on an empty list, matching the constructor's guard - use remove()
to drop proxies one at a time if you actually want a smaller pool.
""" """
if not self._is_list_source(): if not self._is_list_source():
self._warn_non_list("replace()") self._warn_non_list("replace()")
return return
if len(proxies) == 0:
raise ValueError("proxies list is empty; provide at least one proxy")
old_state = self._state old_state = self._state
incoming = [to_proxy(p) for p in proxies] incoming = [to_proxy(p) for p in proxies]
if self._shuffle: if self._shuffle:
@@ -342,7 +346,7 @@ class AioProxies:
self._state = new_state self._state = new_state
self._index = 0 self._index = 0
def add(self, proxies: Union[str, Proxy, List[Union[str, Proxy]]]) -> None: def add(self, proxies: Union[str, Proxy, Dict[str, str], List[Union[str, Proxy, Dict[str, str]]]]) -> None:
"""append proxies to the pool, keeping existing state; skip duplicate keys""" """append proxies to the pool, keeping existing state; skip duplicate keys"""
if not self._is_list_source(): if not self._is_list_source():
self._warn_non_list("add()") self._warn_non_list("add()")
@@ -382,6 +386,6 @@ class AioProxies:
self._index %= len(self._proxies) self._index %= len(self._proxies)
# name aliases same class, call it whichever reads best at your call site # name aliases - same class, call it whichever reads best at your call site
ProxyManager = AioProxies ProxyManager = AioProxies
aioproxies = AioProxies aioproxies = AioProxies
+16 -3
View File
@@ -6,11 +6,24 @@ calling a function without it raises a clear error.
""" """
import logging import logging
from typing import Optional, Union from typing import Optional, Union
from urllib.parse import urlsplit, urlunsplit
from .proxy import Proxy, parse from .proxy import Proxy, to_proxy
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _safe_url(url: str) -> str:
"""strip query + fragment from a url for logging - a provider reset url can carry a
rotation token in its query/path; this drops the query/fragment (the usual token spot)
so the log line can't leak it. falls back to the raw url only if it won't parse"""
try:
parts = urlsplit(url)
except ValueError:
return url
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
try: try:
import aiohttp import aiohttp
@@ -32,7 +45,7 @@ async def current_ip(
""" """
if not _HAVE_AIOHTTP: if not _HAVE_AIOHTTP:
raise RuntimeError(_MISSING) raise RuntimeError(_MISSING)
p = parse(proxy) p = to_proxy(proxy)
t = aiohttp.ClientTimeout(total=timeout) t = aiohttp.ClientTimeout(total=timeout)
try: try:
async with aiohttp.ClientSession(timeout=t) as session: async with aiohttp.ClientSession(timeout=t) as session:
@@ -55,7 +68,7 @@ async def reset(reset_url: str, *, timeout: float = 15.0) -> bool:
async with aiohttp.ClientSession(timeout=t) as session: async with aiohttp.ClientSession(timeout=t) as session:
async with session.get(reset_url) as resp: async with session.get(reset_url) as resp:
ok = resp.status == 200 ok = resp.status == 200
log.info("proxy reset %s -> %s", reset_url, resp.status) log.info("proxy reset %s -> %s", _safe_url(reset_url), resp.status)
return ok return ok
except Exception as exc: except Exception as exc:
log.warning("proxy reset failed: %s", exc) log.warning("proxy reset failed: %s", exc)
+22 -27
View File
@@ -1,15 +1,12 @@
"""proxy parsing and formatting (pure, no network IO). """proxy parsing and formatting (pure, no network IO).
a `Proxy` holds host/port/optional-auth and renders the shapes different clients `Proxy` holds host/port/optional-auth and renders aiohttp/aioweb, camoufox, socks5,
want: an aiohttp/aioweb proxies dict, a camoufox proxy dict, a socks5 dict, or a and url shapes; auth-less (IP-authenticated) proxies are first-class throughout.
plain url. `parse` accepts the common "host:port" and "host:port:user:pass" string `parse` accepts "host:port" and "host:port:user:pass" - the user field may itself
forms (the user field may itself contain commas, e.g. session-param proxies; the contain commas and the password may itself contain colons.
password field may itself contain colons). auth-less (IP-authenticated) proxies
with no creds are first-class — every render shape handles them via `has_auth`.
`key()` produces a stable canonical identity (`host:port:user:pass`, or `key()` / `canonical_key()` produce a stable canonical identity so burn/remove/stats
`host:port` when auth-less) so burn/remove/stats recognize the same proxy from any recognize the same proxy from any input shape (spec string, dict, or url).
input shape. `canonical_key()` extends that to dict/url forms.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, Optional, Union from typing import Dict, Optional, Union
@@ -22,9 +19,12 @@ SCHEME_SOCKS5 = "socks5"
def normalize_port(port: Union[str, int]) -> str: def normalize_port(port: Union[str, int]) -> str:
"""canonical port string: parsed to int, rendered without zero-padding """canonical port string: parsed to int, rendered without zero-padding
keeps host:080 and host:80 as one canonical key. raises ValueError on a keeps host:080 and host:80 as one canonical key. raises ValueError naming the
non-integer port rather than silently keying it as-is. missing port if empty/None (e.g. a portless proxy url), or on a non-integer
port, rather than silently keying it as-is.
""" """
if port is None or port == "":
raise ValueError("missing port: proxy url or spec has no port")
return str(int(port)) return str(int(port))
@@ -97,12 +97,15 @@ class Proxy:
def parse(spec: Union[str, Proxy]) -> Proxy: def parse(spec: Union[str, Proxy]) -> Proxy:
"""parse a proxy spec into a Proxy """parse a colon-delimited proxy spec into a Proxy
accepts an existing Proxy (returned as-is) or a colon-delimited string in accepts an existing Proxy (returned as-is) or a colon-delimited string in
`host:port` or `host:port:user:pass` form. the 4-part form splits on the first `host:port` or `host:port:user:pass` form. the 4-part form splits on the first
three colons only, so a password may itself contain colons. raises ValueError three colons only, so a password may itself contain colons. this is spec-string
on anything else rather than guessing. only - it does not understand url shapes (`scheme://user:pass@host:port`); a
url has 3+ colons and would be misparsed as a 4-part spec rather than rejected.
use `to_proxy()` for url / dict / spec input, or anywhere the shape isn't
guaranteed to be a bare spec string.
""" """
if isinstance(spec, Proxy): if isinstance(spec, Proxy):
return spec return spec
@@ -117,14 +120,7 @@ def parse(spec: Union[str, Proxy]) -> Proxy:
def canonical_key(spec: Union[str, Proxy, Dict[str, str]]) -> str: def canonical_key(spec: Union[str, Proxy, Dict[str, str]]) -> str:
"""canonical identity key for any supported proxy shape """`to_proxy(spec).key()` - the canonical identity key for any supported proxy shape"""
accepts a spec string, a Proxy, a url string (`http://user:pass@host:port`,
`socks5://...`), an aiohttp dict (`{"http": url, "https": url}`), or a
camoufox/socks5 dict (`{"server": ..., "username": ..., "password": ...}`).
all forms collapse to the same `host:port:user:pass` (or `host:port` auth-less)
key, so burn/remove/stats recognize one proxy regardless of how it was passed.
"""
return to_proxy(spec).key() return to_proxy(spec).key()
@@ -132,8 +128,8 @@ def to_proxy(spec: Union[str, Proxy, Dict[str, str]]) -> Proxy:
"""normalize any supported proxy shape into a Proxy """normalize any supported proxy shape into a Proxy
accepts a spec string, a Proxy, a url string, an aiohttp dict, or a accepts a spec string, a Proxy, a url string, an aiohttp dict, or a
camoufox/socks5 dict — the same shapes `canonical_key` accepts. used both for camoufox/socks5 dict. used both for keying and for adding proxies to a pool
keying and for adding proxies to a pool from any shape. from any shape.
""" """
if isinstance(spec, Proxy): if isinstance(spec, Proxy):
return spec return spec
@@ -150,9 +146,8 @@ def _proxy_from_dict(spec: Dict[str, str]) -> Proxy:
"""normalize an aiohttp / camoufox / socks5 dict into a Proxy""" """normalize an aiohttp / camoufox / socks5 dict into a Proxy"""
if "server" in spec: if "server" in spec:
proxy = _proxy_from_url(spec["server"]) proxy = _proxy_from_url(spec["server"])
# prefer explicit dict auth; otherwise fall back to auth embedded in the server # prefer explicit dict auth; fall back to auth embedded in the server URL so
# URL (http://user:pass@host:port) so it isn't silently dropped which would # it isn't silently dropped, which would key the proxy auth-less
# key the proxy auth-less and collide with a genuinely auth-less one
user = spec.get("username") or proxy.user user = spec.get("username") or proxy.user
password = spec.get("password") or proxy.password password = spec.get("password") or proxy.password
if user and password: if user and password: