Compare commits

...

9 Commits
v0.2.0 ... main

Author SHA1 Message Date
eef4b25f07 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
a5e36544d4 docs: note url()/aiohttp() percent-encode proxy credentials
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:41:36 -04:00
72c5342a6b fix: AP-1 percent-encode url() creds, AP-2 reject next(session=) collision
AP-1: url()/aiohttp() percent-encode user/password so reserved chars (/ # ? @) produce a
valid url, mirroring the parse-side unquote. AP-2: next(session=...) raises a clear
ValueError instead of an opaque str.format TypeError. net.current_ip uses
json(content_type=None) + dict guard.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:34:36 -04:00
932fb71c95 docs: pin install line to release, note unpinned-latest option
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:13:33 -04:00
0abc071f14 docs: show unpinned install line; note tag-pinning for reproducibility
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:07:17 -04:00
fc27d77000 fix: preserve URL-embedded proxy auth; clearer empty-list + malformed-template errors (v0.2.2)
- _proxy_from_dict server branch falls back to auth embedded in the server URL when no
  explicit username/password keys are given, so it isn't dropped and the proxy keys
  with its credentials instead of colliding auth-less (L5)
- AioProxies(proxies=[]) now raises a clear 'empty' error, not the misleading
  'provide exactly one of' (nit)
- a malformed template re-raises with a naming message instead of a bare str.format
  ValueError (nit).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 17:57:37 -04:00
260b92b66a docs: add v0.2.1 changelog entry (legible missing-template-field error)
the README changelog jumped from v0.2.0 to nothing; added the v0.2.1 entry documenting the legible ValueError for a template placeholder not supplied to next(**fields).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 01:39:21 -04:00
f618b6a6a1 style: drop inline comments in the template-field error path
the rationale they carried is already in the ValueError message; the lib convention is docstrings only.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:18:28 -04:00
aa661bd6de fix: legible error on missing template placeholder (v0.2.1)
next() on a template source did template.format(...) directly, so a placeholder not
supplied in next(**fields) leaked a bare KeyError. catch KeyError/IndexError and
raise a clear ValueError naming the missing placeholder.

verified: next() without a required field -> ValueError naming it; happy path intact.
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 15:50:33 -04:00
7 changed files with 50 additions and 13 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
# claude
CLAUDE.md
.claude/
# python
__pycache__/

View File

@ -9,14 +9,16 @@ edits. **Credentials are always injected — never hardcoded.**
## Install
```
aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.2.0
aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.2.2
# network helpers (current_ip / reset) need the extra:
aioproxies[net] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.2.0
aioproxies[net] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.2.2
```
The core has no dependencies. The `net` extra adds `aiohttp` for `current_ip` /
`reset`.
Drop the `@v0.2.2` suffix from the line above to install the latest unpinned.
## Formatting
```python
@ -33,6 +35,8 @@ p.key() # "1.2.3.4:8080:user:pass" (canonical identity; "host:port" if a
Auth-less (IP-authenticated) proxies are first-class: `"host:port"` parses and
every render shape omits the credentials. The 4-part form splits on the first three
colons, so a password may itself contain colons (`host:port:user:pa:ss:word`).
`url()` / `aiohttp()` percent-encode the credentials, so reserved characters
(`/ # ? @`) in a user or password still produce a valid URL.
## Sources
@ -200,6 +204,12 @@ await reset("https://provider/reset-url") # rotate upstream ip
## Changelog
### v0.2.1
- **Legible missing-template-field error:** a `template=` placeholder not supplied to
`next(**fields)` now raises a clear `ValueError` naming the field, instead of leaking
a bare `KeyError` from `str.format`.
### v0.2.0
- **Proxy health for rotating lists:** `burn`/`restore`/`is_burned` (dead `-1` vs

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aioproxies"
version = "0.2.0"
version = "0.2.2"
description = "proxy parsing, formatting, health, and pool management for aiohttp/aioweb, camoufox, and socks5"
requires-python = ">=3.10"
dependencies = []

View File

@ -18,4 +18,4 @@ __all__ = [
"to_proxy",
]
__version__ = "0.2.0"
__version__ = "0.2.2"

View File

@ -58,6 +58,8 @@ class AioProxies:
shuffle: bool = True,
cooldown: int = 0,
):
if proxies is not None and len(proxies) == 0:
raise ValueError("proxies list is empty; provide at least one proxy")
sources = [s for s in (template, proxies, static) if s]
if len(sources) != 1:
raise ValueError("provide exactly one of: template, proxies, static")
@ -123,7 +125,21 @@ class AioProxies:
if self._static is not None:
return self._static
if self.template is not None:
return parse(self.template.format(session=self.session_id(), **fields))
if "session" in fields:
# `session` is auto-filled with a fresh id; a caller-supplied one would
# collide in str.format with an opaque TypeError — reject it clearly
raise ValueError("'session' is filled automatically; do not pass it to next()")
try:
filled = self.template.format(session=self.session_id(), **fields)
except (KeyError, IndexError) as exc:
raise ValueError(
f"template placeholder {exc} not provided; pass it to next(**fields)"
) from exc
except ValueError as exc:
# a malformed template (e.g. an unmatched '{') makes str.format raise a
# bare ValueError; re-raise naming the cause so it isn't cryptic
raise ValueError(f"malformed proxy template {self.template!r}: {exc}") from exc
return parse(filled)
return self._next_from_list()
def _next_from_list(self) -> Proxy:

View File

@ -37,8 +37,10 @@ async def current_ip(
try:
async with aiohttp.ClientSession(timeout=t) as session:
async with session.get(test_url, proxy=p.url()) as resp:
data = await resp.json()
return data.get("ip")
# content_type=None: an echo endpoint may return text/plain json; the
# default would raise ContentTypeError and silently return None
data = await resp.json(content_type=None)
return data.get("ip") if isinstance(data, dict) else None
except Exception as exc:
log.warning("ip check failed: %s", exc)
return None

View File

@ -13,7 +13,7 @@ input shape. `canonical_key()` extends that to dict/url forms.
"""
from dataclasses import dataclass
from typing import Dict, Optional, Union
from urllib.parse import unquote, urlsplit
from urllib.parse import quote, unquote, urlsplit
SCHEME_HTTP = "http"
SCHEME_SOCKS5 = "socks5"
@ -48,9 +48,15 @@ class Proxy:
return f"{host}:{self.port}"
def url(self, scheme: str = SCHEME_HTTP) -> str:
"""render as a url, embedding auth when present"""
"""render as a url, embedding auth when present
credentials are percent-encoded so reserved chars (/ # ? @ :) in a user or
password produce a valid url; this mirrors the unquote() on the parse side.
"""
if self.has_auth:
return f"{scheme}://{self.user}:{self.password}@{self.host}:{self.port}"
user = quote(str(self.user), safe="")
password = quote(str(self.password), safe="")
return f"{scheme}://{user}:{password}@{self.host}:{self.port}"
return f"{scheme}://{self.host}:{self.port}"
def aiohttp(self) -> Dict[str, str]:
@ -133,8 +139,11 @@ def _proxy_from_dict(spec: Dict[str, str]) -> Proxy:
"""normalize an aiohttp / camoufox / socks5 dict into a Proxy"""
if "server" in spec:
proxy = _proxy_from_url(spec["server"])
user = spec.get("username")
password = spec.get("password")
# prefer explicit dict auth; otherwise fall back to auth embedded in the server
# URL (http://user:pass@host:port) so it isn't silently dropped — which would
# key the proxy auth-less and collide with a genuinely auth-less one
user = spec.get("username") or proxy.user
password = spec.get("password") or proxy.password
if user and password:
return Proxy(proxy.host, proxy.port, user, password)
return Proxy(proxy.host, proxy.port)