Skip to content
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