Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

7 changed files with 13 additions and 50 deletions

2
.gitignore vendored
View File

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

View File

@ -9,16 +9,14 @@ edits. **Credentials are always injected — never hardcoded.**
## Install ## Install
``` ```
aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.2.2 aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.2.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.2.2 aioproxies[net] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.2.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.2.2` suffix from the line above to install the latest unpinned.
## Formatting ## Formatting
```python ```python
@ -35,8 +33,6 @@ 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 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 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`). 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 ## Sources
@ -204,12 +200,6 @@ await reset("https://provider/reset-url") # rotate upstream ip
## Changelog ## 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 ### v0.2.0
- **Proxy health for rotating lists:** `burn`/`restore`/`is_burned` (dead `-1` vs - **Proxy health for rotating lists:** `burn`/`restore`/`is_burned` (dead `-1` vs

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aioproxies" name = "aioproxies"
version = "0.2.2" version = "0.2.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 = []

View File

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

View File

@ -58,8 +58,6 @@ class AioProxies:
shuffle: bool = True, shuffle: bool = True,
cooldown: int = 0, 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] sources = [s for s in (template, proxies, static) if s]
if len(sources) != 1: if len(sources) != 1:
raise ValueError("provide exactly one of: template, proxies, static") raise ValueError("provide exactly one of: template, proxies, static")
@ -125,21 +123,7 @@ class AioProxies:
if self._static is not None: if self._static is not None:
return self._static return self._static
if self.template is not None: if self.template is not None:
if "session" in fields: return parse(self.template.format(session=self.session_id(), **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() return self._next_from_list()
def _next_from_list(self) -> Proxy: def _next_from_list(self) -> Proxy:

View File

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

View File

@ -13,7 +13,7 @@ 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
from urllib.parse import quote, unquote, urlsplit from urllib.parse import unquote, urlsplit
SCHEME_HTTP = "http" SCHEME_HTTP = "http"
SCHEME_SOCKS5 = "socks5" SCHEME_SOCKS5 = "socks5"
@ -48,15 +48,9 @@ class Proxy:
return f"{host}:{self.port}" return f"{host}:{self.port}"
def url(self, scheme: str = SCHEME_HTTP) -> str: 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: if self.has_auth:
user = quote(str(self.user), safe="") return f"{scheme}://{self.user}:{self.password}@{self.host}:{self.port}"
password = quote(str(self.password), safe="")
return f"{scheme}://{user}:{password}@{self.host}:{self.port}"
return f"{scheme}://{self.host}:{self.port}" return f"{scheme}://{self.host}:{self.port}"
def aiohttp(self) -> Dict[str, str]: def aiohttp(self) -> Dict[str, str]:
@ -139,11 +133,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 user = spec.get("username")
# URL (http://user:pass@host:port) so it isn't silently dropped — which would password = spec.get("password")
# 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: if user and password:
return Proxy(proxy.host, proxy.port, user, password) return Proxy(proxy.host, proxy.port, user, password)
return Proxy(proxy.host, proxy.port) return Proxy(proxy.host, proxy.port)