diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4a06b0..60172c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 3da11e4..ba7894b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 diff --git a/examples/async_client.py b/examples/async_client.py index 04daf38..ce39307 100644 --- a/examples/async_client.py +++ b/examples/async_client.py @@ -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: diff --git a/examples/download_proxy_list.py b/examples/download_proxy_list.py index 1744167..f2bb594 100644 --- a/examples/download_proxy_list.py +++ b/examples/download_proxy_list.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index f098d8c..5ec7228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [project] @@ -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", ] @@ -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"] diff --git a/src/webshare/_async_client.py b/src/webshare/_async_client.py index 651742f..748514f 100644 --- a/src/webshare/_async_client.py +++ b/src/webshare/_async_client.py @@ -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, ) @@ -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__( @@ -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, @@ -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) diff --git a/src/webshare/_base_client.py b/src/webshare/_base_client.py index f3d2df8..9461e74 100644 --- a/src/webshare/_base_client.py +++ b/src/webshare/_base_client.py @@ -32,6 +32,18 @@ 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 = ( @@ -39,6 +51,12 @@ "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 " @@ -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) @@ -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, @@ -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 diff --git a/src/webshare/_client.py b/src/webshare/_client.py index c5474a2..c517212 100644 --- a/src/webshare/_client.py +++ b/src/webshare/_client.py @@ -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, ) @@ -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. @@ -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, @@ -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) diff --git a/src/webshare/_http.py b/src/webshare/_http.py index 0867e0b..cbe7694 100644 --- a/src/webshare/_http.py +++ b/src/webshare/_http.py @@ -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 @@ -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 @@ -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} @@ -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 @@ -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 diff --git a/src/webshare/_models.py b/src/webshare/_models.py index d8c6c99..f5a4081 100644 --- a/src/webshare/_models.py +++ b/src/webshare/_models.py @@ -120,7 +120,12 @@ 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__}") @@ -128,6 +133,8 @@ def decode(cls: type[ModelT], data: object) -> ModelT: 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) diff --git a/src/webshare/_proxy_url.py b/src/webshare/_proxy_url.py index d636d3a..5309a0a 100644 --- a/src/webshare/_proxy_url.py +++ b/src/webshare/_proxy_url.py @@ -44,13 +44,13 @@ def _build_backbone_username( country codes first, then city, then session/rotate last.""" parts = [username] for code in country_codes or (): - if not _COUNTRY_CODE_RE.match(code): + if not _COUNTRY_CODE_RE.fullmatch(code): raise ValueError( f"Invalid country code {code!r}: must be a 2-letter ISO 3166-1 alpha-2 code." ) parts.append(code.lower()) if city is not None: - if not _CITY_RE.match(city): + if not _CITY_RE.fullmatch(city): raise ValueError( f"Invalid city {city!r}: city names may contain only letters and underscores." ) @@ -59,7 +59,9 @@ def _build_backbone_username( raise ValueError("session_id and rotate are mutually exclusive.") if session_id is not None: session = str(session_id) - if not session.isdigit(): + # `str.isdigit()` alone also accepts non-ASCII digit characters + # (e.g. superscripts, Arabic-indic digits); require plain ASCII 0-9. + if not (session.isascii() and session.isdigit()): raise ValueError(f"Invalid session_id {session_id!r}: must be numeric.") parts.append(session) elif rotate: @@ -164,11 +166,14 @@ def proxy_list_download_path( search: str | None = None, ) -> str: """Build the path portion of the proxy list download URL.""" - country_segment = "-".join(code.upper() for code in country_codes) if country_codes else "-" + country_segment = ( + "-".join(quote(code.upper(), safe="") for code in country_codes) if country_codes else "-" + ) search_segment = quote(search, safe="") if search else "-" return ( f"/api/v2/proxy/list/download/{quote(token, safe='')}/{country_segment}/" - f"{proxy_protocol}/{authentication_method}/{endpoint_mode}/{search_segment}/" + f"{quote(proxy_protocol, safe='')}/{quote(authentication_method, safe='')}/" + f"{quote(endpoint_mode, safe='')}/{search_segment}/" ) diff --git a/src/webshare/resources/invoices.py b/src/webshare/resources/invoices.py index 7270d93..fcb71d4 100644 --- a/src/webshare/resources/invoices.py +++ b/src/webshare/resources/invoices.py @@ -8,7 +8,7 @@ from webshare._requester import AsyncResource, SyncResource -def _download_spec(*, subscription_transaction_id: str) -> RequestSpec: +def _download_spec(*, subscription_transaction_id: int | str) -> RequestSpec: # Note: this path has no trailing slash, unlike most endpoints. return RequestSpec( method="GET", @@ -21,14 +21,18 @@ class Invoices(SyncResource): def download( self, *, - subscription_transaction_id: str, + subscription_transaction_id: int | str, timeout: float | None = None, headers: Mapping[str, str] | None = None, max_retries: int | None = None, subuser_id: int | str | None = None, federated_user_id: int | str | None = None, ) -> bytes: - """Download an invoice as PDF bytes.""" + """Download an invoice as PDF bytes. + + ``subscription_transaction_id`` accepts the ``Transaction.id`` integer + directly (sent as a string query parameter either way). + """ return self._client.request_bytes( _download_spec(subscription_transaction_id=subscription_transaction_id).with_options( timeout, headers, max_retries, subuser_id, federated_user_id @@ -40,14 +44,18 @@ class AsyncInvoices(AsyncResource): async def download( self, *, - subscription_transaction_id: str, + subscription_transaction_id: int | str, timeout: float | None = None, headers: Mapping[str, str] | None = None, max_retries: int | None = None, subuser_id: int | str | None = None, federated_user_id: int | str | None = None, ) -> bytes: - """Download an invoice as PDF bytes.""" + """Download an invoice as PDF bytes. + + ``subscription_transaction_id`` accepts the ``Transaction.id`` integer + directly (sent as a string query parameter either way). + """ return await self._client.request_bytes( _download_spec(subscription_transaction_id=subscription_transaction_id).with_options( timeout, headers, max_retries, subuser_id, federated_user_id diff --git a/src/webshare/resources/plans.py b/src/webshare/resources/plans.py index 315514b..59bd9e1 100644 --- a/src/webshare/resources/plans.py +++ b/src/webshare/resources/plans.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from datetime import datetime -from webshare._http import RequestSpec +from webshare._http import RequestSpec, drop_json_nulls from webshare._pagination import AsyncPage, SyncPage from webshare._requester import AsyncResource, SyncResource from webshare.types.commerce import Plan, PlanCancelResponse @@ -32,7 +32,7 @@ def _update_spec(id: int, *, automatic_refresh_next_at: str | datetime | None) - return RequestSpec( method="PATCH", path=f"/api/v2/subscription/plan/{id}/", - json_body={"automatic_refresh_next_at": value}, + json_body=drop_json_nulls({"automatic_refresh_next_at": value}), ) diff --git a/src/webshare/resources/proxy_replacements.py b/src/webshare/resources/proxy_replacements.py index c042396..5fe3fdf 100644 --- a/src/webshare/resources/proxy_replacements.py +++ b/src/webshare/resources/proxy_replacements.py @@ -114,8 +114,8 @@ def create( ``proxies_removed``/``proxies_added`` without modifying the list. ``to_replace`` supports types ``ip_range``, ``ip_address``, ``asn``, ``country``; ``replace_with`` supports ``ip_range``, ``asn``, - ``country``, ``any`` (not ``ip_address``). Unavailable when - ``plan.pool_filter`` is ``residential``. + ``country``, ``any`` (not ``ip_address``). Unavailable when the + plan's ``proxy_subtype`` is ``residential``. """ return self._client.request_model( _create_spec( diff --git a/src/webshare/resources/referral.py b/src/webshare/resources/referral.py index 2ed9a32..4bc0060 100644 --- a/src/webshare/resources/referral.py +++ b/src/webshare/resources/referral.py @@ -81,9 +81,12 @@ def _get_credit_spec(id: int) -> RequestSpec: def _list_earnouts_spec(*, page: int | None, page_size: int | None) -> RequestSpec: - # Note: the docs record this list path without a trailing slash. + # Verified against the live API: the docs record this list path without a + # trailing slash, but the server 301s that to the slashed path. return RequestSpec( - method="GET", path="/api/v2/referral/earnout", query={"page": page, "page_size": page_size} + method="GET", + path="/api/v2/referral/earnout/", + query={"page": page, "page_size": page_size}, ) diff --git a/src/webshare/resources/replaced_proxies.py b/src/webshare/resources/replaced_proxies.py index eb138ba..a406c91 100644 --- a/src/webshare/resources/replaced_proxies.py +++ b/src/webshare/resources/replaced_proxies.py @@ -45,7 +45,8 @@ def _download_spec( path="/api/v2/proxy/list/replaced/download/", query={ "download_token": download_token, - "country_codes": "-".join(country_codes) if country_codes else None, + # Normalized to uppercase like proxies.download's sibling method. + "country_codes": "-".join(c.upper() for c in country_codes) if country_codes else None, "authentication_type": authentication_type, "mode": mode, "search": search, diff --git a/src/webshare/resources/verification.py b/src/webshare/resources/verification.py index 10133a1..5ec29f8 100644 --- a/src/webshare/resources/verification.py +++ b/src/webshare/resources/verification.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping, Sequence from datetime import datetime from typing import Literal @@ -43,8 +44,10 @@ def _flows_get_spec(id: int) -> RequestSpec: def _submit_evidence_spec( - id: int, *, explanation: str | None, files: Sequence[FileInput] | None + id: int, *, explanation: str | None, buffered_files: list[tuple[str, bytes]] | None ) -> RequestSpec: + if explanation is None and not buffered_files: + raise ValueError("submit_evidence requires at least one of `explanation` or `files`.") data: dict[str, str] = {} if explanation is not None: data["explanation"] = explanation @@ -52,8 +55,9 @@ def _submit_evidence_spec( method="POST", path=f"/api/v2/verification/flow/{id}/submit_evidence/", multipart_data=data, - # Buffered here so retried attempts re-send the same bytes. - multipart_files=buffer_files(files) if files is not None else None, + # Buffered by the caller (sync or async) so retried attempts re-send + # the same bytes. + multipart_files=buffered_files, ) @@ -95,14 +99,15 @@ def _questions_list_spec( def _submit_answer_spec( - question_id: int, *, answer: str, files: Sequence[FileInput] | None + question_id: int, *, answer: str, buffered_files: list[tuple[str, bytes]] | None ) -> RequestSpec: return RequestSpec( method="POST", path=f"/api/v2/verification/question/{question_id}/answer/", multipart_data={"answer": answer}, - # Buffered here so retried attempts re-send the same bytes. - multipart_files=buffer_files(files) if files is not None else None, + # Buffered by the caller (sync or async) so retried attempts re-send + # the same bytes. + multipart_files=buffered_files, ) @@ -201,12 +206,14 @@ def submit_evidence( """Submit evidence for a verification flow (multipart/form-data). ``files`` accepts file paths, bytes, binary file objects or - ``(filename, content)`` tuples. + ``(filename, content)`` tuples. At least one of ``explanation`` or + ``files`` is required. """ + buffered = buffer_files(files) if files is not None else None return self._client.request_model( - _submit_evidence_spec(id, explanation=explanation, files=files).with_options( - timeout, headers, max_retries, subuser_id, federated_user_id - ), + _submit_evidence_spec( + id, explanation=explanation, buffered_files=buffered + ).with_options(timeout, headers, max_retries, subuser_id, federated_user_id), VerificationFlow, ) @@ -279,8 +286,9 @@ def submit_answer( federated_user_id: int | str | None = None, ) -> VerificationAnswer: """Submit an answer with optional attachments (multipart/form-data).""" + buffered = buffer_files(files) if files is not None else None return self._client.request_model( - _submit_answer_spec(question_id, answer=answer, files=files).with_options( + _submit_answer_spec(question_id, answer=answer, buffered_files=buffered).with_options( timeout, headers, max_retries, subuser_id, federated_user_id ), VerificationAnswer, @@ -475,11 +483,17 @@ async def submit_evidence( subuser_id: int | str | None = None, federated_user_id: int | str | None = None, ) -> VerificationFlow: - """Submit evidence for a verification flow (multipart/form-data).""" + """Submit evidence for a verification flow (multipart/form-data). + + At least one of ``explanation`` or ``files`` is required. File + buffering is offloaded to a worker thread so reading large/slow files + does not block the event loop. + """ + buffered = await asyncio.to_thread(buffer_files, files) if files is not None else None return await self._client.request_model( - _submit_evidence_spec(id, explanation=explanation, files=files).with_options( - timeout, headers, max_retries, subuser_id, federated_user_id - ), + _submit_evidence_spec( + id, explanation=explanation, buffered_files=buffered + ).with_options(timeout, headers, max_retries, subuser_id, federated_user_id), VerificationFlow, ) @@ -550,9 +564,14 @@ async def submit_answer( subuser_id: int | str | None = None, federated_user_id: int | str | None = None, ) -> VerificationAnswer: - """Submit an answer with optional attachments (multipart/form-data).""" + """Submit an answer with optional attachments (multipart/form-data). + + File buffering is offloaded to a worker thread so reading large/slow + files does not block the event loop. + """ + buffered = await asyncio.to_thread(buffer_files, files) if files is not None else None return await self._client.request_model( - _submit_answer_spec(question_id, answer=answer, files=files).with_options( + _submit_answer_spec(question_id, answer=answer, buffered_files=buffered).with_options( timeout, headers, max_retries, subuser_id, federated_user_id ), VerificationAnswer, diff --git a/src/webshare/types/proxy.py b/src/webshare/types/proxy.py index ca788dc..3cb00f3 100644 --- a/src/webshare/types/proxy.py +++ b/src/webshare/types/proxy.py @@ -235,10 +235,10 @@ class ProxyActivity: ip_address: str | None hostname: str | None domain: str | None - port: float | None - proxy_port: float | None + port: int | None + proxy_port: int | None listen_address: str | None - listen_port: float | None + listen_port: int | None @dataclass diff --git a/tests/test_client.py b/tests/test_client.py index 8589675..65981cf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,8 +18,8 @@ def make_client(server: MockServer, **kwargs: object) -> Webshare: def test_auth_header_and_defaults(server: MockServer) -> None: server.enqueue(json_body=PROFILE) - client = make_client(server) - profile = client.profile.get() + with make_client(server) as client: + profile = client.profile.get() assert profile.id == 1 assert profile.email == "user@webshare.io" request = server.requests[0] @@ -33,8 +33,8 @@ def test_auth_header_and_defaults(server: MockServer) -> None: def test_api_key_from_environment(server: MockServer, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("WEBSHARE_API_KEY", "env-key") server.enqueue(json_body=PROFILE) - client = Webshare(base_url=server.base_url) - client.profile.get() + with Webshare(base_url=server.base_url) as client: + client.profile.get() assert server.requests[0].headers["Authorization"] == "Token env-key" @@ -46,13 +46,20 @@ def test_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None: AsyncWebshare() +def test_conflicting_credentials_raise(server: MockServer) -> None: + with pytest.raises(webshare.WebshareError, match="not both"): + Webshare(base_url=server.base_url, api_key="k", credentials_provider=lambda: "t") + with pytest.raises(webshare.WebshareError, match="not both"): + AsyncWebshare(base_url=server.base_url, api_key="k", credentials_provider=lambda: "t") + + def test_credentials_provider_called_per_request(server: MockServer) -> None: tokens = iter(["token-1", "token-2"]) - client = Webshare(base_url=server.base_url, credentials_provider=lambda: next(tokens)) - server.enqueue(json_body=PROFILE) - server.enqueue(json_body=PROFILE) - client.profile.get() - client.profile.get() + with Webshare(base_url=server.base_url, credentials_provider=lambda: next(tokens)) as client: + server.enqueue(json_body=PROFILE) + server.enqueue(json_body=PROFILE) + client.profile.get() + client.profile.get() assert server.requests[0].headers["Authorization"] == "Token token-1" assert server.requests[1].headers["Authorization"] == "Token token-2" @@ -61,41 +68,40 @@ async def test_async_credentials_provider(server: MockServer) -> None: async def provider() -> str: return "async-token" - client = AsyncWebshare(base_url=server.base_url, credentials_provider=provider) - server.enqueue(json_body=PROFILE) - profile = await client.profile.get() + async with AsyncWebshare(base_url=server.base_url, credentials_provider=provider) as client: + server.enqueue(json_body=PROFILE) + profile = await client.profile.get() assert profile.email == "user@webshare.io" assert server.requests[0].headers["Authorization"] == "Token async-token" - await client.close() def test_default_and_per_request_headers(server: MockServer) -> None: - client = make_client(server, default_headers={"X-Team": "infra"}) - server.enqueue(json_body=PROFILE) - client.profile.get(headers={"X-Trace": "abc"}) + with make_client(server, default_headers={"X-Team": "infra"}) as client: + server.enqueue(json_body=PROFILE) + client.profile.get(headers={"X-Trace": "abc"}) request = server.requests[0] assert request.headers["X-Team"] == "infra" assert request.headers["X-Trace"] == "abc" def test_subuser_and_federated_headers(server: MockServer) -> None: - client = make_client(server, subuser_id=7, federated_user_id=99) - server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []}) - server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []}) - client.proxies.list(mode="direct") - assert server.requests[0].headers["X-Subuser"] == "7" - assert server.requests[0].headers["X-Webshare-Federated-Access"] == "99" - # Per-request values override client-level values. - client.proxies.list(mode="direct", subuser_id=8, federated_user_id=100) - assert server.requests[1].headers["X-Subuser"] == "8" - assert server.requests[1].headers["X-Webshare-Federated-Access"] == "100" + with make_client(server, subuser_id=7, federated_user_id=99) as client: + server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []}) + server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []}) + client.proxies.list(mode="direct") + assert server.requests[0].headers["X-Subuser"] == "7" + assert server.requests[0].headers["X-Webshare-Federated-Access"] == "99" + # Per-request values override client-level values. + client.proxies.list(mode="direct", subuser_id=8, federated_user_id=100) + assert server.requests[1].headers["X-Subuser"] == "8" + assert server.requests[1].headers["X-Webshare-Federated-Access"] == "100" def test_timeout_raises_api_timeout_error(server: MockServer) -> None: server.enqueue(json_body=PROFILE, delay=1.0) - client = make_client(server, timeout=0.1, max_retries=0) - with pytest.raises(webshare.APITimeoutError): - client.profile.get() + with make_client(server, timeout=0.1, max_retries=0) as client: + with pytest.raises(webshare.APITimeoutError): + client.profile.get() async def test_async_timeout(server: MockServer) -> None: @@ -105,6 +111,25 @@ async def test_async_timeout(server: MockServer) -> None: await client.profile.get(max_retries=0) +def test_timeout_none_is_distinct_from_omitted() -> None: + # Omitted -> the 60s default; explicit None -> no timeout at all. + with Webshare(api_key="k") as default_client: + assert default_client.timeout == 60.0 + with Webshare(api_key="k", timeout=None) as no_timeout_client: + assert no_timeout_client.timeout is None + + +def test_redirects_are_followed(server: MockServer) -> None: + # Owned httpx clients follow redirects, so a 3xx surfaces the real + # response instead of a confusing decode error. + server.enqueue(status=302, headers={"Location": f"{server.base_url}/api/v2/profile/"}) + server.enqueue(json_body=PROFILE) + with make_client(server) as client: + assert client.profile.get().id == 1 + assert len(server.requests) == 2 + assert server.requests[1].path == "/api/v2/profile/" + + def test_context_manager_and_base_url_join(server: MockServer) -> None: server.enqueue(json_body=PROFILE) with Webshare(base_url=server.base_url + "/", api_key="k") as client: @@ -126,8 +151,8 @@ def test_empty_api_key_falls_back_to_environment( ) -> None: monkeypatch.setenv("WEBSHARE_API_KEY", "env-key") server.enqueue(json_body=PROFILE) - client = Webshare(base_url=server.base_url, api_key="") - client.profile.get() + with Webshare(base_url=server.base_url, api_key="") as client: + client.profile.get() assert server.requests[0].headers["Authorization"] == "Token env-key" @@ -135,21 +160,21 @@ def test_unauthenticated_operations_never_send_token(server: MockServer) -> None # Even on a credentialed client, `security: []` operations do not send # the Authorization header. server.enqueue(json_body={"referral_code": "abc", "promo_type": None, "promo_value": None}) - client = make_client(server) - info = client.referral.get_code_info(referral_code="abc") + with make_client(server) as client: + info = client.referral.get_code_info(referral_code="abc") assert info.referral_code == "abc" assert "Authorization" not in server.requests[0].headers def test_unauthenticated_client(server: MockServer, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("WEBSHARE_API_KEY", raising=False) - client = Webshare(base_url=server.base_url, unauthenticated=True) - server.enqueue(json_body={"referral_code": "abc", "promo_type": None, "promo_value": None}) - client.referral.get_code_info(referral_code="abc") - assert "Authorization" not in server.requests[0].headers - # Authenticated operations fail client-side with a clear message. - with pytest.raises(webshare.WebshareError, match="unauthenticated=True"): - client.profile.get() + with Webshare(base_url=server.base_url, unauthenticated=True) as client: + server.enqueue(json_body={"referral_code": "abc", "promo_type": None, "promo_value": None}) + client.referral.get_code_info(referral_code="abc") + assert "Authorization" not in server.requests[0].headers + # Authenticated operations fail client-side with a clear message. + with pytest.raises(webshare.WebshareError, match="unauthenticated=True"): + client.profile.get() assert len(server.requests) == 1 @@ -157,7 +182,8 @@ def test_source_header_default_format(server: MockServer) -> None: import re server.enqueue(json_body=PROFILE) - make_client(server).profile.get() + with make_client(server) as client: + client.profile.get() source = server.requests[0].headers["X-Webshare-Source"] assert re.fullmatch(r"WebshareSDK/\d+\.\d+\.\d+ \(Python; \d+\.\d+\.\d+[^)]*\)", source), source @@ -165,18 +191,18 @@ def test_source_header_default_format(server: MockServer) -> None: def test_source_header_override(server: MockServer) -> None: server.enqueue(json_body=PROFILE) server.enqueue(json_body=PROFILE) - client = make_client(server, source="WebshareCLI/1.2.3") - client.profile.get() - assert server.requests[0].headers["X-Webshare-Source"] == "WebshareCLI/1.2.3" - # Per-request headers still win over everything. - client.profile.get(headers={"X-Webshare-Source": "custom/0"}) - assert server.requests[1].headers["X-Webshare-Source"] == "custom/0" + with make_client(server, source="WebshareCLI/1.2.3") as client: + client.profile.get() + assert server.requests[0].headers["X-Webshare-Source"] == "WebshareCLI/1.2.3" + # Per-request headers still win over everything. + client.profile.get(headers={"X-Webshare-Source": "custom/0"}) + assert server.requests[1].headers["X-Webshare-Source"] == "custom/0" def test_default_headers_merge_case_insensitively(server: MockServer) -> None: server.enqueue(json_body=PROFILE) - client = make_client(server, default_headers={"accept": "text/plain"}) - client.profile.get() + with make_client(server, default_headers={"accept": "text/plain"}) as client: + client.profile.get() request = server.requests[0] accept_headers = [(k, v) for k, v in request.raw_headers if k.lower() == "accept"] assert accept_headers == [("accept", "text/plain")] @@ -187,9 +213,9 @@ def test_injected_http_client_timeout_is_respected(server: MockServer) -> None: server.enqueue(json_body=PROFILE, delay=1.0) http_client = httpx.Client(timeout=0.1) - client = Webshare(base_url=server.base_url, api_key="k", http_client=http_client) - with pytest.raises(webshare.APITimeoutError): - client.profile.get(max_retries=0) + with Webshare(base_url=server.base_url, api_key="k", http_client=http_client) as client: + with pytest.raises(webshare.APITimeoutError): + client.profile.get(max_retries=0) http_client.close() @@ -198,6 +224,8 @@ def test_explicit_timeout_overrides_injected_http_client(server: MockServer) -> server.enqueue(json_body=PROFILE, delay=0.3) http_client = httpx.Client(timeout=0.05) - client = Webshare(base_url=server.base_url, api_key="k", http_client=http_client, timeout=5.0) - assert client.profile.get(max_retries=0).id == 1 + with Webshare( + base_url=server.base_url, api_key="k", http_client=http_client, timeout=5.0 + ) as client: + assert client.profile.get(max_retries=0).id == 1 http_client.close() diff --git a/tests/test_errors.py b/tests/test_errors.py index 606c1b6..49f8213 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Iterator + import pytest import webshare @@ -9,20 +11,22 @@ from webshare import Webshare -def make_client(server: MockServer) -> Webshare: - return Webshare(base_url=server.base_url, api_key="k", max_retries=0) +@pytest.fixture +def client(server: MockServer) -> Iterator[Webshare]: + with Webshare(base_url=server.base_url, api_key="k", max_retries=0) as c: + yield c -def test_400_field_errors(server: MockServer) -> None: +def test_400_field_errors(server: MockServer, client: Webshare) -> None: server.enqueue(status=400, json_body={"mode": ["This field is required."]}) with pytest.raises(webshare.BadRequestError) as excinfo: - make_client(server).proxies.list(mode="direct") + client.proxies.list(mode="direct") error = excinfo.value assert error.status_code == 400 assert error.field_errors == {"mode": ["This field is required."]} -def test_400_field_errors_object_form(server: MockServer) -> None: +def test_400_field_errors_object_form(server: MockServer, client: Webshare) -> None: # The live API returns lists of objects, not the documented list of # strings; this is the real shape verbatim. server.enqueue( @@ -30,101 +34,103 @@ def test_400_field_errors_object_form(server: MockServer) -> None: json_body={"mode": [{"message": "This field is required.", "code": "required"}]}, ) with pytest.raises(webshare.BadRequestError) as excinfo: - make_client(server).proxies.list(mode="direct") + client.proxies.list(mode="direct") error = excinfo.value assert error.status_code == 400 assert error.field_errors == {"mode": ["This field is required."]} -def test_400_field_errors_bare_string_value(server: MockServer) -> None: +def test_400_field_errors_bare_string_value(server: MockServer, client: Webshare) -> None: server.enqueue(status=400, json_body={"mode": "This field is required."}) with pytest.raises(webshare.BadRequestError) as excinfo: - make_client(server).proxies.list(mode="direct") + client.proxies.list(mode="direct") assert excinfo.value.field_errors == {"mode": ["This field is required."]} -def test_401_authentication_error(server: MockServer) -> None: +def test_401_authentication_error(server: MockServer, client: Webshare) -> None: server.enqueue(status=401, json_body={"detail": "Invalid token."}) with pytest.raises(webshare.AuthenticationError) as excinfo: - make_client(server).profile.get() + client.profile.get() assert excinfo.value.detail == "Invalid token." # The live API does not send X-Request-ID; absence yields None. assert excinfo.value.request_id is None -def test_403_surfaces_code(server: MockServer) -> None: +def test_403_surfaces_code(server: MockServer, client: Webshare) -> None: server.enqueue( status=403, json_body={"detail": "Two factor authentication is needed.", "code": "2fa_needed"}, headers={"X-Request-ID": "req-123"}, ) with pytest.raises(webshare.PermissionDeniedError) as excinfo: - make_client(server).profile.get() + client.profile.get() error = excinfo.value assert error.code == "2fa_needed" assert error.request_id == "req-123" assert error.detail == "Two factor authentication is needed." -def test_404_not_found(server: MockServer) -> None: +def test_404_not_found(server: MockServer, client: Webshare) -> None: server.enqueue(status=404, json_body={"detail": "Not found."}) with pytest.raises(webshare.NotFoundError): - make_client(server).subusers.get(42) + client.subusers.get(42) -def test_429_rate_limit(server: MockServer) -> None: +def test_429_rate_limit(server: MockServer, client: Webshare) -> None: server.enqueue(status=429, json_body={"detail": "Request was throttled."}) with pytest.raises(webshare.RateLimitError) as excinfo: - make_client(server).profile.get() + client.profile.get() assert excinfo.value.status_code == 429 -def test_5xx_internal_server_error(server: MockServer) -> None: +def test_5xx_internal_server_error(server: MockServer, client: Webshare) -> None: server.enqueue(status=502, text="Bad Gateway") with pytest.raises(webshare.InternalServerError) as excinfo: - make_client(server).profile.get() + client.profile.get() error = excinfo.value assert error.status_code == 502 # Non-JSON bodies are kept as the detail text. assert error.detail == "Bad Gateway" -def test_bare_json_string_body(server: MockServer) -> None: +def test_bare_json_string_body(server: MockServer, client: Webshare) -> None: server.enqueue(status=400, json_body="something went wrong") with pytest.raises(webshare.BadRequestError) as excinfo: - make_client(server).profile.get() + client.profile.get() assert excinfo.value.detail == "something went wrong" -def test_errors_are_webshare_errors(server: MockServer) -> None: +def test_errors_are_webshare_errors(server: MockServer, client: Webshare) -> None: server.enqueue(status=400, json_body={"detail": "bad"}) with pytest.raises(webshare.WebshareError): - make_client(server).profile.get() + client.profile.get() -def test_2xx_non_json_raises_response_decode_error(server: MockServer) -> None: +def test_2xx_non_json_raises_response_decode_error(server: MockServer, client: Webshare) -> None: server.enqueue(status=200, text="maintenance", content_type="text/html") with pytest.raises(webshare.ResponseDecodeError) as excinfo: - make_client(server).profile.get() + client.profile.get() error = excinfo.value assert isinstance(error, webshare.WebshareError) assert error.status_code == 200 assert "maintenance" in error.body -def test_malformed_envelope_raises_response_decode_error(server: MockServer) -> None: +def test_malformed_envelope_raises_response_decode_error( + server: MockServer, client: Webshare +) -> None: server.enqueue(status=200, json_body={"count": 1, "next": None, "results": None}) with pytest.raises(webshare.ResponseDecodeError) as excinfo: - make_client(server).proxies.list(mode="direct") + client.proxies.list(mode="direct") assert excinfo.value.status_code == 200 assert "results" in str(excinfo.value) -def test_error_body_capped_and_detail_truncated(server: MockServer) -> None: +def test_error_body_capped_and_detail_truncated(server: MockServer, client: Webshare) -> None: huge = "x" * (1024 * 1024 + 4096) server.enqueue(status=500, text=huge) with pytest.raises(webshare.InternalServerError) as excinfo: - make_client(server).profile.get() + client.profile.get() error = excinfo.value # Raw capture is capped at 1 MiB; the human-facing detail at ~2 KB. assert isinstance(error.body, str) diff --git a/tests/test_integration.py b/tests/test_integration.py index 1d8059b..3295f79 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,8 +1,11 @@ """Integration test against the real API. -Runs only when the ``WEBSHARE_API_KEY`` environment variable is set. The -optional ``WEBSHARE_BASE_URL`` environment variable targets a non-production -host (default: production). +Runs only when the ``WEBSHARE_INTEGRATION_TEST`` environment variable is set +to a truthy value — a dedicated opt-in, separate from ``WEBSHARE_API_KEY`` +(which anyone using the SDK normally has set, and which would otherwise cause +a plain ``pytest`` run to silently hit production). ``WEBSHARE_API_KEY`` is +still required to authenticate; the optional ``WEBSHARE_BASE_URL`` environment +variable targets a non-production host (default: production). """ from __future__ import annotations @@ -14,8 +17,8 @@ from webshare import Webshare pytestmark = pytest.mark.skipif( - not os.environ.get("WEBSHARE_API_KEY"), - reason="WEBSHARE_API_KEY is not set", + os.environ.get("WEBSHARE_INTEGRATION_TEST", "").lower() not in ("1", "true", "yes"), + reason="WEBSHARE_INTEGRATION_TEST is not set", ) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index c8bba97..14e0387 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -26,13 +26,12 @@ def test_sync_iteration_across_pages(server: MockServer) -> None: next_path = "/api/v2/proxy/list/?mode=direct&page=2" server.enqueue(json_body=_page(server, ["d-1", "d-2"], next_path)) server.enqueue(json_body=_page(server, ["d-3"], None)) - client = Webshare(base_url=server.base_url, api_key="k") + with Webshare(base_url=server.base_url, api_key="k") as client: + page = client.proxies.list(mode="direct") + assert page.count == 3 + assert [p.id for p in page.results] == ["d-1", "d-2"] - page = client.proxies.list(mode="direct") - assert page.count == 3 - assert [p.id for p in page.results] == ["d-1", "d-2"] - - ids = [proxy.id for proxy in page] + ids = [proxy.id for proxy in page] assert ids == ["d-1", "d-2", "d-3"] assert len(server.requests) == 2 # The next URL is followed verbatim. @@ -44,13 +43,12 @@ def test_sync_iteration_across_pages(server: MockServer) -> None: def test_sync_next_page(server: MockServer) -> None: server.enqueue(json_body=_page(server, ["d-1"], "/api/v2/proxy/list/?page=2")) server.enqueue(json_body=_page(server, ["d-2"], None)) - client = Webshare(base_url=server.base_url, api_key="k") - - page = client.proxies.list(mode="direct") - second = page.next_page() - assert second is not None - assert [p.id for p in second.results] == ["d-2"] - assert second.next_page() is None + with Webshare(base_url=server.base_url, api_key="k") as client: + page = client.proxies.list(mode="direct") + second = page.next_page() + assert second is not None + assert [p.id for p in second.results] == ["d-2"] + assert second.next_page() is None async def test_async_iteration_across_pages(server: MockServer) -> None: @@ -71,10 +69,10 @@ def test_cross_origin_next_url_is_refused(server: MockServer) -> None: "results": [_proxy("d-1")], } server.enqueue(json_body=page_body) - client = Webshare(base_url=server.base_url, api_key="k") - page = client.proxies.list(mode="direct") - with pytest.raises(WebshareError, match="cross-origin"): - list(page) + with Webshare(base_url=server.base_url, api_key="k") as client: + page = client.proxies.list(mode="direct") + with pytest.raises(WebshareError, match="cross-origin"): + list(page) # The token was never sent to the foreign origin. assert len(server.requests) == 1 @@ -107,10 +105,9 @@ def test_starting_after_pagination(server: MockServer) -> None: } ) server.enqueue(json_body={"count": 2, "next": None, "previous": None, "results": [activity]}) - client = Webshare(base_url=server.base_url, api_key="k") - - page = client.proxy_activity.list(page_size=1) - items = list(page) + with Webshare(base_url=server.base_url, api_key="k") as client: + page = client.proxy_activity.list(page_size=1) + items = list(page) assert len(items) == 2 assert items[0].protocol == "http" assert server.requests[0].query == {"page_size": ["1"]} diff --git a/tests/test_resources.py b/tests/test_resources.py index e8a056c..594ec8f 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -12,8 +14,9 @@ @pytest.fixture -def client(server: MockServer) -> Webshare: - return Webshare(base_url=server.base_url, api_key="k") +def client(server: MockServer) -> Iterator[Webshare]: + with Webshare(base_url=server.base_url, api_key="k") as c: + yield c def test_proxies_list_decodes_models(server: MockServer, client: Webshare) -> None: @@ -257,3 +260,307 @@ def test_ip_authorization_roundtrip(server: MockServer, client: Webshare) -> Non client.ip_authorizations.delete(1337) assert server.requests[2].method == "DELETE" assert server.requests[2].path == "/api/v2/proxy/ipauthorization/1337/" + + +# -- Table-driven request-shape coverage for the previously-untested thin +# wrappers: billing, transactions, plans, profile preferences, proxy +# replacements, replaced proxies, referral, notifications, subusers +# create/update/delete, proxy_config stats/status. Each case checks that the +# method sends the right method/path/query/body, not the response decoding +# (already covered above and in test_models.py). + +_NO_BODY = object() +_EMPTY_PAGE: dict[str, object] = {"count": 0, "next": None, "previous": None, "results": []} + + +@dataclass +class _Case: + label: str + call: Callable[[Webshare], object] + method: str + path: str + query: dict[str, list[str]] = field(default_factory=dict) + body: object = _NO_BODY + response: object = field(default_factory=dict) + + +REQUEST_SHAPE_CASES = [ + _Case( + "billing.get_info", + lambda c: c.billing.get_info(), + "GET", + "/api/v2/subscription/billing_info/", + ), + _Case( + "billing.update_info", + lambda c: c.billing.update_info(name="Acme"), + "PATCH", + "/api/v2/subscription/billing_info/", + body={"name": "Acme"}, + ), + _Case( + "transactions.list", + lambda c: c.transactions.list(page=2), + "GET", + "/api/v2/payment/transaction/", + query={"page": ["2"]}, + response=_EMPTY_PAGE, + ), + _Case( + "transactions.get", lambda c: c.transactions.get(5), "GET", "/api/v2/payment/transaction/5/" + ), + _Case( + "plans.list", + lambda c: c.plans.list(), + "GET", + "/api/v2/subscription/plan/", + response=_EMPTY_PAGE, + ), + _Case("plans.get", lambda c: c.plans.get(9), "GET", "/api/v2/subscription/plan/9/"), + _Case( + "plans.update (no kwargs is a no-op, not a null-clearing PATCH)", + lambda c: c.plans.update(9), + "PATCH", + "/api/v2/subscription/plan/9/", + body={}, + ), + _Case( + "plans.update (with value)", + lambda c: c.plans.update(9, automatic_refresh_next_at="2024-01-01T00:00:00Z"), + "PATCH", + "/api/v2/subscription/plan/9/", + body={"automatic_refresh_next_at": "2024-01-01T00:00:00Z"}, + ), + _Case( + "plans.cancel", lambda c: c.plans.cancel(9), "POST", "/api/v2/subscription/plan/9/cancel/" + ), + _Case( + "profile.get_preferences", + lambda c: c.profile.get_preferences(), + "GET", + "/api/v2/profile/preferences/", + ), + _Case( + "profile.update_preferences", + lambda c: c.profile.update_preferences( + onboarding_activity_page_viewed_at="2024-01-01T00:00:00Z" + ), + "PATCH", + "/api/v2/profile/preferences/", + body={"onboarding_activity_page_viewed_at": "2024-01-01T00:00:00Z"}, + ), + _Case( + "proxy_replacements.list", + lambda c: c.proxy_replacements.list(), + "GET", + "/api/v3/proxy/replace/", + response=_EMPTY_PAGE, + ), + _Case( + "proxy_replacements.create", + lambda c: c.proxy_replacements.create( + to_replace={"type": "ip_address", "ip_address": "1.2.3.4"}, + replace_with=[{"type": "any"}], + ), + "POST", + "/api/v3/proxy/replace/", + body={ + "to_replace": {"type": "ip_address", "ip_address": "1.2.3.4"}, + "replace_with": [{"type": "any"}], + }, + ), + _Case( + "proxy_replacements.get", + lambda c: c.proxy_replacements.get(3), + "GET", + "/api/v3/proxy/replace/3/", + ), + _Case( + "replaced_proxies.list", + lambda c: c.replaced_proxies.list(), + "GET", + "/api/v2/proxy/list/replaced/", + response=_EMPTY_PAGE, + ), + _Case( + # Country codes are normalized to uppercase like proxies.download. + "replaced_proxies.download", + lambda c: c.replaced_proxies.download( + download_token="tok", + country_codes=["us"], + authentication_type="username", + mode="direct", + ), + "GET", + "/api/v2/proxy/list/replaced/download/", + query={ + "download_token": ["tok"], + "country_codes": ["US"], + "authentication_type": ["username"], + "mode": ["direct"], + "proxy_protocol": ["any"], + }, + response="", + ), + _Case( + "referral.get_config", lambda c: c.referral.get_config(), "GET", "/api/v2/referral/config/" + ), + _Case( + "referral.update_config", + lambda c: c.referral.update_config(mode="credits"), + "PATCH", + "/api/v2/referral/config/", + body={"mode": "credits"}, + ), + _Case( + "referral.get_coupon_code", + lambda c: c.referral.get_coupon_code(), + "GET", + "/api/v2/referral/coupon-code/", + ), + _Case( + "referral.apply_coupon_code", + lambda c: c.referral.apply_coupon_code(code="X"), + "POST", + "/api/v2/referral/coupon-code/", + body={"code": "X"}, + ), + _Case( + "referral.remove_coupon_code", + lambda c: c.referral.remove_coupon_code(), + "DELETE", + "/api/v2/referral/coupon-code/", + ), + _Case( + "referral.list_credits", + lambda c: c.referral.list_credits(), + "GET", + "/api/v2/referral/credit/", + response=_EMPTY_PAGE, + ), + _Case( + "referral.get_credit", + lambda c: c.referral.get_credit(4), + "GET", + "/api/v2/referral/credit/4/", + ), + _Case( + # Verified against the live API: the earnout list path needs a + # trailing slash or the server 301s it. + "referral.list_earnouts", + lambda c: c.referral.list_earnouts(), + "GET", + "/api/v2/referral/earnout/", + response=_EMPTY_PAGE, + ), + _Case( + "referral.get_earnout", + lambda c: c.referral.get_earnout(2), + "GET", + "/api/v2/referral/earnout/2/", + ), + _Case( + "notifications.list", + lambda c: c.notifications.list(), + "GET", + "/api/v2/notification/", + response=_EMPTY_PAGE, + ), + _Case("notifications.get", lambda c: c.notifications.get(7), "GET", "/api/v2/notification/7/"), + _Case( + "notifications.restore", + lambda c: c.notifications.restore(7), + "POST", + "/api/v2/notification/7/restore/", + ), + _Case( + "subusers.list", + lambda c: c.subusers.list(), + "GET", + "/api/v2/subuser/", + response=_EMPTY_PAGE, + ), + _Case( + "subusers.create", + lambda c: c.subusers.create(label="Test"), + "POST", + "/api/v2/subuser/", + body={"label": "Test"}, + ), + _Case("subusers.get", lambda c: c.subusers.get(2), "GET", "/api/v2/subuser/2/"), + _Case( + "subusers.update", + lambda c: c.subusers.update(2, label="New"), + "PATCH", + "/api/v2/subuser/2/", + body={"label": "New"}, + ), + _Case( + "subusers.delete", + lambda c: c.subusers.delete(2), + "DELETE", + "/api/v2/subuser/2/", + response="", + ), + _Case( + "subusers.refresh_proxy_list", + lambda c: c.subusers.refresh_proxy_list(2), + "POST", + "/api/v2/subuser/2/refresh/", + ), + _Case( + "proxy_config.get_stats", + lambda c: c.proxy_config.get_stats(plan_id=1), + "GET", + "/api/v3/proxy/list/stats", + query={"plan_id": ["1"]}, + ), + _Case( + "proxy_config.get_status", + lambda c: c.proxy_config.get_status(plan_id=1), + "GET", + "/api/v3/proxy/list/status", + query={"plan_id": ["1"]}, + ), + _Case( + "payment_methods.list", + lambda c: c.payment_methods.list(), + "GET", + "/api/v2/payment/method/", + response=_EMPTY_PAGE, + ), + _Case( + "payment_methods.get", + lambda c: c.payment_methods.get(3), + "GET", + "/api/v2/payment/method/3/", + ), + _Case( + "pending_payments.list", + lambda c: c.pending_payments.list(), + "GET", + "/api/v2/payment/pending/", + response=_EMPTY_PAGE, + ), + _Case( + "pending_payments.get", + lambda c: c.pending_payments.get(3), + "GET", + "/api/v2/payment/pending/3/", + ), +] + + +@pytest.mark.parametrize("case", REQUEST_SHAPE_CASES, ids=[c.label for c in REQUEST_SHAPE_CASES]) +def test_resource_request_shapes(server: MockServer, client: Webshare, case: _Case) -> None: + if case.response == "": + server.enqueue(status=204) + else: + server.enqueue(json_body=case.response) + case.call(client) + request = server.requests[-1] + assert request.method == case.method + assert request.path == case.path + assert request.query == case.query + if case.body is not _NO_BODY: + assert request.json() == case.body diff --git a/tests/test_retries.py b/tests/test_retries.py index 520c5a2..d757402 100644 --- a/tests/test_retries.py +++ b/tests/test_retries.py @@ -4,6 +4,7 @@ from pathlib import Path +import httpx import pytest import webshare @@ -17,8 +18,8 @@ def test_retry_on_429_honors_retry_after(server: MockServer, no_sleep: list[float]) -> None: server.enqueue(status=429, json_body={"detail": "throttled"}, headers={"Retry-After": "3"}) server.enqueue(json_body=PROFILE) - client = Webshare(base_url=server.base_url, api_key="k") - profile = client.profile.get() + with Webshare(base_url=server.base_url, api_key="k") as client: + profile = client.profile.get() assert profile.id == 1 assert len(server.requests) == 2 assert no_sleep == [3.0] @@ -28,8 +29,8 @@ def test_retry_on_5xx(server: MockServer, no_sleep: list[float]) -> None: server.enqueue(status=500, text="oops") server.enqueue(status=503, text="oops") server.enqueue(json_body=PROFILE) - client = Webshare(base_url=server.base_url, api_key="k") - assert client.profile.get().id == 1 + with Webshare(base_url=server.base_url, api_key="k") as client: + assert client.profile.get().id == 1 assert len(server.requests) == 3 assert len(no_sleep) == 2 @@ -37,33 +38,33 @@ def test_retry_on_5xx(server: MockServer, no_sleep: list[float]) -> None: def test_retries_exhausted(server: MockServer, no_sleep: list[float]) -> None: for _ in range(3): server.enqueue(status=429, json_body={"detail": "throttled"}) - client = Webshare(base_url=server.base_url, api_key="k", max_retries=2) - with pytest.raises(webshare.RateLimitError): - client.profile.get() + with Webshare(base_url=server.base_url, api_key="k", max_retries=2) as client: + with pytest.raises(webshare.RateLimitError): + client.profile.get() assert len(server.requests) == 3 def test_no_retry_on_post(server: MockServer, no_sleep: list[float]) -> None: server.enqueue(status=500, text="oops") - client = Webshare(base_url=server.base_url, api_key="k") - with pytest.raises(webshare.InternalServerError): - client.proxies.refresh() + with Webshare(base_url=server.base_url, api_key="k") as client: + with pytest.raises(webshare.InternalServerError): + client.proxies.refresh() assert len(server.requests) == 1 def test_post_retry_opt_in(server: MockServer, no_sleep: list[float]) -> None: server.enqueue(status=503, text="oops") server.enqueue(status=204) - client = Webshare(base_url=server.base_url, api_key="k", retry_non_idempotent=True) - client.proxies.refresh() + with Webshare(base_url=server.base_url, api_key="k", retry_non_idempotent=True) as client: + client.proxies.refresh() assert len(server.requests) == 2 def test_per_request_max_retries_override(server: MockServer, no_sleep: list[float]) -> None: server.enqueue(status=500, text="oops") - client = Webshare(base_url=server.base_url, api_key="k", max_retries=2) - with pytest.raises(webshare.InternalServerError): - client.profile.get(max_retries=0) + with Webshare(base_url=server.base_url, api_key="k", max_retries=2) as client: + with pytest.raises(webshare.InternalServerError): + client.profile.get(max_retries=0) assert len(server.requests) == 1 @@ -83,9 +84,9 @@ def test_multipart_retry_replays_file_bytes( evidence.write_bytes(b"replayable-bytes") server.enqueue(status=503, text="oops") server.enqueue(json_body={"id": 1, "type": "abuse_report", "state": "inflow"}) - client = Webshare(base_url=server.base_url, api_key="k", retry_non_idempotent=True) - with evidence.open("rb") as handle: - flow = client.verification.flows.submit_evidence(1, explanation="x", files=[handle]) + with Webshare(base_url=server.base_url, api_key="k", retry_non_idempotent=True) as client: + with evidence.open("rb") as handle: + flow = client.verification.flows.submit_evidence(1, explanation="x", files=[handle]) assert flow.id == 1 assert len(server.requests) == 2 # The file object was buffered at request-build time, so the retried @@ -100,9 +101,9 @@ def test_retry_after_exposed_on_non_retried_error( # POST is not retried by default; the parsed Retry-After is surfaced so # the caller can self-throttle. server.enqueue(status=429, json_body={"detail": "throttled"}, headers={"Retry-After": "7"}) - client = Webshare(base_url=server.base_url, api_key="k") - with pytest.raises(webshare.RateLimitError) as excinfo: - client.proxies.refresh() + with Webshare(base_url=server.base_url, api_key="k") as client: + with pytest.raises(webshare.RateLimitError) as excinfo: + client.proxies.refresh() assert excinfo.value.retry_after == 7.0 assert len(server.requests) == 1 @@ -145,3 +146,34 @@ def test_compute_backoff_bounds() -> None: for _ in range(50): delay = compute_backoff(attempt) assert 0 <= delay <= min(8.0, 0.5 * (2**attempt)) + + +def test_connection_error_mapped_and_retried(no_sleep: list[float]) -> None: + calls = {"n": 0} + + def fake_request(method: str, url: str, **kwargs: object) -> httpx.Response: + calls["n"] += 1 + raise httpx.ConnectError("boom") + + with Webshare(base_url="http://example.invalid", api_key="k") as client: + client._http.request = fake_request # type: ignore[assignment] + with pytest.raises(webshare.APIConnectionError): + client.profile.get() + # max_retries defaults to 2 -> 3 attempts total. + assert calls["n"] == 3 + assert len(no_sleep) == 2 + + +async def test_async_connection_error_mapped_and_retried(no_sleep: list[float]) -> None: + calls = {"n": 0} + + async def fake_request(method: str, url: str, **kwargs: object) -> httpx.Response: + calls["n"] += 1 + raise httpx.ConnectError("boom") + + async with AsyncWebshare(base_url="http://example.invalid", api_key="k") as client: + client._http.request = fake_request # type: ignore[assignment] + with pytest.raises(webshare.APIConnectionError): + await client.profile.get() + assert calls["n"] == 3 + assert len(no_sleep) == 2