Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.0
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ client = webshare.Webshare(api_key="your-api-key")

For advanced use (for example OAuth tokens that need refreshing), pass a
`credentials_provider` callable returning the token; it is called once per
request. The async client also accepts async callables. `api_key` is shorthand
attempt (so refreshed tokens are picked up on retries, not just on the first
call). The async client also accepts async callables. `api_key` is shorthand
for a static provider.

```python
Expand Down Expand Up @@ -159,8 +160,9 @@ never 2FA-challenged.
## Retries

Failed requests are retried automatically with exponential backoff and full
jitter (base 0.5s, cap 8s): connection errors, timeouts, 408, 429 and 5xx.
`Retry-After` headers are honored (capped at 60s). The default is
jitter (base 0.5s, cap 8s): connection errors, timeouts, and status codes 408,
429, 500, 502, 503 and 504 (not every 5xx). `Retry-After` headers are honored
(capped at 60s). The default is
`max_retries=2` (three attempts total), configurable per client and per
request. Only idempotent requests (GET/PUT/DELETE) are retried by default;
pass `retry_non_idempotent=True` to the client to opt in POST/PATCH.
Expand All @@ -176,8 +178,10 @@ retries and `Retry-After` waits occur. Override it per client
client.profile.get(timeout=5.0)
```

If you inject your own `http_client` and do not set `timeout`, the injected
client's timeout configuration is used.
Pass `timeout=None` explicitly for no timeout (httpx's infinite wait) — this
is distinct from omitting `timeout` entirely, which uses the 60-second
default. If you inject your own `http_client` and omit `timeout` entirely, the
injected client's timeout configuration is used instead.

Every method also accepts per-request `headers`, `max_retries`, `subuser_id`
(sent as `X-Subuser` for sub-user masquerading) and `federated_user_id` (sent
Expand Down
4 changes: 3 additions & 1 deletion examples/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ async def main() -> None:

# Plan-scoped calls take the plan id from client.plans.list().
plans = await client.plans.list()
plan = next(p for p in plans.results if p.status == "active")
plan = next((p for p in plans.results if p.status == "active"), None)
if plan is None:
raise SystemExit("No active plan found on this account.")

page = await client.proxies.list(mode="direct", plan_id=plan.id, page_size=25)
async for proxy in page:
Expand Down
4 changes: 3 additions & 1 deletion examples/download_proxy_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
def main() -> None:
with webshare.Webshare() as client:
# Plan-scoped calls take the plan id from client.plans.list().
plan = next(p for p in client.plans.list() if p.status == "active")
plan = next((p for p in client.plans.list() if p.status == "active"), None)
if plan is None:
raise SystemExit("No active plan found on this account.")
config = client.proxy_config.get(plan_id=plan.id)
token = config.proxy_list_download_token
assert token is not None
Expand Down
6 changes: 2 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
Expand All @@ -22,6 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]

Expand Down Expand Up @@ -65,6 +66,3 @@ ignore = [
"E501",
]

[tool.ruff.lint.per-file-ignores]
# Wire field names use API grammar (e.g. `country_code__in`); keep them verbatim.
"tests/*" = ["RUF012"]
21 changes: 15 additions & 6 deletions src/webshare/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
import httpx

from webshare._base_client import (
NOT_GIVEN,
BaseClient,
_NotGiven,
auth_required_error,
conflicting_credentials_error,
missing_credentials_error,
resolve_static_api_key,
)
Expand Down Expand Up @@ -60,9 +63,9 @@ class AsyncWebshare(BaseClient):
Accepts the same options as ``Webshare``; ``credentials_provider`` may be
a sync or async callable, and ``http_client`` is an ``httpx.AsyncClient``.
``timeout`` bounds a single HTTP attempt (total call time may exceed it
with retries and Retry-After waits); when ``http_client`` is injected and
``timeout`` is not set, the injected client's timeout configuration is
used.
with retries and Retry-After waits); pass ``None`` explicitly for no
timeout. When ``http_client`` is injected and ``timeout`` is omitted
entirely, the injected client's timeout configuration is used instead.
"""

def __init__(
Expand All @@ -71,7 +74,7 @@ def __init__(
api_key: str | None = None,
credentials_provider: AsyncCredentialsProvider | None = None,
base_url: str | None = None,
timeout: float | None = None,
timeout: float | _NotGiven | None = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
http_client: httpx.AsyncClient | None = None,
default_headers: Mapping[str, str] | None = None,
Expand All @@ -95,15 +98,21 @@ def __init__(
if unauthenticated:
self._credentials_provider = None
elif credentials_provider is not None:
if api_key is not None:
raise conflicting_credentials_error()
self._credentials_provider = credentials_provider
else:
key = resolve_static_api_key(api_key)
if key is None:
raise missing_credentials_error()
self._credentials_provider = lambda: key
self._http = http_client if http_client is not None else httpx.AsyncClient()
self._http = (
http_client if http_client is not None else httpx.AsyncClient(follow_redirects=True)
)
self._owns_http = http_client is None
self._defer_timeout_to_http_client = http_client is not None and timeout is None
self._defer_timeout_to_http_client = http_client is not None and isinstance(
timeout, _NotGiven
)

self.proxies = AsyncProxies(self)
self.proxy_config = AsyncProxyConfigResource(self)
Expand Down
26 changes: 24 additions & 2 deletions src/webshare/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,31 @@

ModelT = TypeVar("ModelT")


class _NotGiven:
"""Sentinel distinguishing an omitted ``timeout`` from an explicit
``timeout=None`` (which requests httpx's no-timeout / infinite-wait
behavior)."""

def __repr__(self) -> str:
return "NOT_GIVEN"


NOT_GIVEN = _NotGiven()

_BODY_SNIPPET_CHARS = 2048

_MISSING_CREDENTIALS_MESSAGE = (
"No API key provided. Pass api_key=..., set the WEBSHARE_API_KEY environment "
"variable, or pass credentials_provider=... to the client constructor."
)

_CONFLICTING_CREDENTIALS_MESSAGE = (
"Pass either api_key= or credentials_provider= to the client constructor, not "
"both — credentials_provider would otherwise silently take precedence and the "
"api_key would be ignored."
)

_AUTH_REQUIRED_MESSAGE = (
"This operation requires authentication, but the client was constructed "
"with unauthenticated=True. Construct the client with api_key=..., the "
Expand All @@ -61,6 +79,10 @@ def missing_credentials_error() -> WebshareError:
return WebshareError(_MISSING_CREDENTIALS_MESSAGE)


def conflicting_credentials_error() -> WebshareError:
return WebshareError(_CONFLICTING_CREDENTIALS_MESSAGE)


def auth_required_error() -> WebshareError:
return WebshareError(_AUTH_REQUIRED_MESSAGE)

Expand All @@ -76,7 +98,7 @@ def __init__(
self,
*,
base_url: str | None,
timeout: float | None,
timeout: float | _NotGiven | None,
max_retries: int,
default_headers: Mapping[str, str] | None,
subuser_id: int | str | None,
Expand All @@ -85,7 +107,7 @@ def __init__(
source: str | None,
) -> None:
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
self.timeout = DEFAULT_TIMEOUT if isinstance(timeout, _NotGiven) else timeout
self.max_retries = max_retries
self.default_headers = dict(default_headers) if default_headers else None
self.subuser_id = subuser_id
Expand Down
19 changes: 14 additions & 5 deletions src/webshare/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
import httpx

from webshare._base_client import (
NOT_GIVEN,
BaseClient,
_NotGiven,
auth_required_error,
conflicting_credentials_error,
missing_credentials_error,
resolve_static_api_key,
)
Expand Down Expand Up @@ -70,8 +73,10 @@ class Webshare(BaseClient):
timeout: Request timeout in seconds (default 60). The timeout bounds
a single HTTP attempt (including reading the response body);
total call time may exceed it when retries and Retry-After
waits occur. When ``http_client`` is injected and ``timeout`` is
not set, the injected client's timeout configuration is used.
waits occur. Pass ``None`` explicitly for no timeout (httpx's
infinite wait). When ``http_client`` is injected and ``timeout``
is omitted entirely, the injected client's timeout configuration
is used instead.
max_retries: Default retry count for retryable failures (default 2).
http_client: An externally managed ``httpx.Client`` to send requests
with.
Expand All @@ -97,7 +102,7 @@ def __init__(
api_key: str | None = None,
credentials_provider: CredentialsProvider | None = None,
base_url: str | None = None,
timeout: float | None = None,
timeout: float | _NotGiven | None = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
http_client: httpx.Client | None = None,
default_headers: Mapping[str, str] | None = None,
Expand All @@ -121,15 +126,19 @@ def __init__(
if unauthenticated:
self._credentials_provider = None
elif credentials_provider is not None:
if api_key is not None:
raise conflicting_credentials_error()
self._credentials_provider = credentials_provider
else:
key = resolve_static_api_key(api_key)
if key is None:
raise missing_credentials_error()
self._credentials_provider = lambda: key
self._http = http_client if http_client is not None else httpx.Client()
self._http = http_client if http_client is not None else httpx.Client(follow_redirects=True)
self._owns_http = http_client is None
self._defer_timeout_to_http_client = http_client is not None and timeout is None
self._defer_timeout_to_http_client = http_client is not None and isinstance(
timeout, _NotGiven
)

self.proxies = Proxies(self)
self.proxy_config = ProxyConfigResource(self)
Expand Down
41 changes: 36 additions & 5 deletions src/webshare/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ def encode_query(params: Mapping[str, QueryValue] | None) -> dict[str, str]:
out[key] = value
elif isinstance(value, (int, float)):
out[key] = str(value)
else:
elif value:
out[key] = ",".join(str(item) for item in value)
# An empty sequence is omitted rather than sent as `?key=` (DRF reads
# that as "filter on empty string", not "no filter").
return out


Expand All @@ -155,11 +157,19 @@ def join_url(base_url: str, path: str) -> str:


def same_origin(url_a: str, url_b: str) -> bool:
"""Whether two URLs share an origin (scheme + host + port)."""
"""Whether two URLs share an origin (scheme + host + port).

A malformed port (out of range or non-numeric) makes ``SplitResult.port``
raise ``ValueError``; treated as a non-matching origin rather than
propagating a bare ``ValueError``.
"""
a, b = urlsplit(url_a), urlsplit(url_b)
scheme_a, scheme_b = a.scheme.lower(), b.scheme.lower()
port_a = a.port if a.port is not None else _DEFAULT_PORTS.get(scheme_a)
port_b = b.port if b.port is not None else _DEFAULT_PORTS.get(scheme_b)
try:
port_a = a.port if a.port is not None else _DEFAULT_PORTS.get(scheme_a)
port_b = b.port if b.port is not None else _DEFAULT_PORTS.get(scheme_b)
except ValueError:
return False
host_a = (a.hostname or "").lower()
host_b = (b.hostname or "").lower()
return scheme_a == scheme_b and host_a == host_b and port_a == port_b
Expand All @@ -172,6 +182,20 @@ def truncate_text(text: str, limit: int) -> str:
return text[:limit] + "... (truncated)"


def truncate_utf8_bytes(text: str, limit: int) -> str:
"""Truncate ``text`` to at most ``limit`` UTF-8 encoded bytes.

A character-count slice of multi-byte text (e.g. CJK) can keep several
times more than ``limit`` bytes; this slices the encoded form instead,
dropping a trailing partial multi-byte sequence if the cut lands
mid-character.
"""
encoded = text.encode("utf-8")
if len(encoded) <= limit:
return text
return encoded[:limit].decode("utf-8", errors="ignore")


def drop_json_nulls(body: Mapping[str, Any]) -> dict[str, Any]:
"""Remove unset (``None``) fields from a JSON request body."""
return {key: value for key, value in body.items() if value is not None}
Expand Down Expand Up @@ -365,7 +389,7 @@ def make_api_error(
non-JSON bodies are all accepted. The captured body is capped at 1 MiB and
the human-facing detail truncated to ~2 KB.
"""
body_text = body_text[:MAX_ERROR_BODY_BYTES]
body_text = truncate_utf8_bytes(body_text, MAX_ERROR_BODY_BYTES)
parsed: object
try:
parsed = json.loads(body_text) if body_text else None
Expand All @@ -390,6 +414,13 @@ def make_api_error(
messages = _extract_field_messages(value)
if messages:
field_errors[key] = messages
elif isinstance(parsed, list):
# A bare JSON array (rather than the documented `{"detail": ...}` or
# field-error map shapes): still surface something human-readable
# instead of falling through to the generic "HTTP N error" message.
messages = _extract_field_messages(parsed)
if messages:
detail = " ".join(messages)
elif parsed is None and body_text:
detail = body_text

Expand Down
13 changes: 10 additions & 3 deletions src/webshare/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,21 @@ def _coerce(tp: Any, value: Any) -> Any:
def decode(cls: type[ModelT], data: object) -> ModelT:
"""Decode a JSON object into the dataclass ``cls``.

Unknown wire fields are ignored and missing fields become ``None``.
Unknown wire fields are ignored. A field absent from the wire payload is
passed as ``None`` only when the dataclass field itself has no default
(today, every model field is declared without one); a field that *does*
declare a default or ``default_factory`` is omitted from the constructor
call so that default applies, rather than being silently clobbered by an
explicit ``None``.
"""
if not isinstance(data, dict):
raise TypeError(f"Cannot decode {cls.__name__} from {type(data).__name__}")
hints = _type_hints(cast(type, cls))
kwargs: dict[str, Any] = {}
for field in dataclasses.fields(cast(Any, cls)):
wire_name = field.name[:-1] if field.name.endswith("_") else field.name
raw = data.get(wire_name)
kwargs[field.name] = _coerce(hints.get(field.name, Any), raw)
if wire_name in data:
kwargs[field.name] = _coerce(hints.get(field.name, Any), data[wire_name])
elif field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING:
kwargs[field.name] = None
return cls(**kwargs)
Loading
Loading