From f24f4635b370cd2ddaacd2a5a19c273f4baabc0f Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:03:36 +0200 Subject: [PATCH 01/10] Fix plans.update() sending a null instead of a no-op A bare plans.update(id) with no kwargs sent {"automatic_refresh_next_at": null}, which the backend rejects with a 400 (the field has no allow_null). Every other PATCH in the SDK treats an omitted argument as "leave this field alone" via drop_json_nulls; plans.update() was the one place that forgot it. --- src/webshare/resources/plans.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}), ) From 7fa9494f9d849a83bf6f921373e249fde6101e9c Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:03:40 +0200 Subject: [PATCH 02/10] Stop integration tests from firing at production by default They auto-enabled on WEBSHARE_API_KEY, the same variable every normal SDK user has set, so a plain pytest run with a real key would silently hit production. Gate them behind a dedicated WEBSHARE_INTEGRATION_TEST opt-in instead. --- tests/test_integration.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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", ) From b8bedbb09c0d875ea29c68017d7634cd7e361045 Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:03:46 +0200 Subject: [PATCH 03/10] Harden the HTTP layer against edge cases - Follow redirects on owned httpx clients so an http:// base URL or a 3xx response doesn't surface as a confusing decode error. - same_origin() no longer raises a bare ValueError on a malformed port in a pagination `next` URL; treated as a non-matching origin. - Empty sequences in query params are omitted instead of sent as `?key=` (which some filters read as "match empty string"). - Error bodies are capped by actual UTF-8 byte length rather than character count, and a top-level JSON-array error body now contributes its messages to the exception detail instead of being silently dropped. - Raise a clear error when both api_key and credentials_provider are passed, instead of silently ignoring api_key. - timeout=None is now distinguishable from an omitted timeout: passing it explicitly requests httpx's no-timeout behavior, while omitting it keeps the 60s default. --- src/webshare/_async_client.py | 21 +++++++++++++----- src/webshare/_base_client.py | 26 ++++++++++++++++++++-- src/webshare/_client.py | 19 +++++++++++----- src/webshare/_http.py | 41 ++++++++++++++++++++++++++++++----- 4 files changed, 89 insertions(+), 18 deletions(-) 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 From 827a9da81602678db475fdf3d75014bde81ef4c5 Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:03:51 +0200 Subject: [PATCH 04/10] Offload async file buffering and reject empty submit_evidence() calls Reading file inputs for a multipart upload was a synchronous disk read happening directly on the event loop in the async client; it now runs via asyncio.to_thread so a large or slow file doesn't stall other tasks. submit_evidence() with neither explanation nor files now raises client-side instead of silently sending an empty POST body. --- src/webshare/resources/verification.py | 45 ++++++++++++++++++-------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/webshare/resources/verification.py b/src/webshare/resources/verification.py index 10133a1..624bcaa 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,10 +206,12 @@ 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( + _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,9 +483,15 @@ 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( + _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, From 2e9ac96f3349e8c86ec829c3bcddbaaf73f67b0b Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:03:58 +0200 Subject: [PATCH 05/10] Tighten proxy URL validation and fix two live API mismatches - Country/city regexes use fullmatch instead of match, so a trailing newline in an otherwise-invalid value is no longer accepted. - proxy_list_download_path now validates and percent-encodes country_codes/proxy_protocol so a crafted value can't inject extra path segments. - session_id validation rejects non-ASCII digit characters that str.isdigit() alone accepts. - referral.list_earnouts() now hits /earnout/ with a trailing slash; the un-slashed path 301s on the live API, which the SDK doesn't follow into a paginated list decode. - replaced_proxies.download() now uppercases country codes like its proxies.download sibling for consistent behavior. --- src/webshare/_proxy_url.py | 15 ++++++++++----- src/webshare/resources/referral.py | 7 +++++-- src/webshare/resources/replaced_proxies.py | 3 ++- 3 files changed, 17 insertions(+), 8 deletions(-) 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/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, From bbf888deb77ce635e543e84d28fba4e505b027f7 Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:04:03 +0200 Subject: [PATCH 06/10] Fix ProxyActivity port typing and a decode() default-clobbering bug ProxyActivity.port/proxy_port/listen_port decoded to floats (8080.0) because they were typed float instead of int, unlike every other port field in the SDK. decode() also passed every missing wire field as an explicit None keyword argument, even fields with a dataclass default. That's harmless today since no model field declares one, but it would silently override any default added in the future. Missing fields are now omitted from the constructor call when the field has a default (or default_factory), so the model's own default applies. --- src/webshare/_models.py | 13 ++++++++++--- src/webshare/types/proxy.py | 6 +++--- 2 files changed, 13 insertions(+), 6 deletions(-) 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/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 From a4e10623ce512759858f9af956575b21619cb762 Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:04:09 +0200 Subject: [PATCH 07/10] Docs, build and example cleanup - README: retries cover {408,429,500,502,503,504}, not a blanket "5xx"; credentials_provider is called once per attempt (so it's re-invoked on retries), not once per request; document explicit timeout=None. - Pin hatchling>=1.27 in [build-system] for PEP 639 license metadata. - Add Python 3.14 to the CI matrix and package classifiers. - proxy_replacements.create() docstring now references the public proxy_subtype field instead of the internal pool_filter name. - The two examples that pick the active plan now raise a clear error when none exists instead of a bare StopIteration. - Drop a stale per-file ruff ignore whose comment didn't match what the rule actually checks (there were no violations to ignore). --- .github/workflows/ci.yml | 2 +- README.md | 14 +++++++++----- examples/async_client.py | 4 +++- examples/download_proxy_list.py | 4 +++- pyproject.toml | 6 ++---- src/webshare/resources/proxy_replacements.py | 4 ++-- 6 files changed, 20 insertions(+), 14 deletions(-) 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/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( From e29ba9f903f9044d27eccaa3b3551ab25a05728a Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:04:14 +0200 Subject: [PATCH 08/10] Expand resource test coverage and stop leaking test clients Add table-driven request-shape tests for the resource methods that had no coverage: billing, transactions, plans, profile preferences, proxy replacements, replaced proxies, referral, notifications, subusers create/update/delete, and proxy_config stats/status. Add connection- error mapping and retry tests for both the sync and async clients. Also switch scattered `client = Webshare(...)` instantiations in test_retries.py/test_pagination.py, and the shared fixture in test_resources.py, to context managers so the underlying httpx client gets closed. --- tests/test_pagination.py | 39 +++--- tests/test_resources.py | 252 ++++++++++++++++++++++++++++++++++++++- tests/test_retries.py | 74 ++++++++---- 3 files changed, 321 insertions(+), 44 deletions(-) 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..82e775a 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,248 @@ 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 From 50a176eceea70dbd4eae55b133037adf58bbcb79 Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:10:53 +0200 Subject: [PATCH 09/10] Apply ruff format A few lines from the async buffering and table-driven test changes were over the formatter's line length; ruff format wraps them. --- src/webshare/resources/verification.py | 12 ++-- tests/test_resources.py | 89 +++++++++++++++++++++----- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/webshare/resources/verification.py b/src/webshare/resources/verification.py index 624bcaa..5ec29f8 100644 --- a/src/webshare/resources/verification.py +++ b/src/webshare/resources/verification.py @@ -211,9 +211,9 @@ def submit_evidence( """ buffered = buffer_files(files) if files is not None else None return self._client.request_model( - _submit_evidence_spec(id, explanation=explanation, buffered_files=buffered).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, ) @@ -491,9 +491,9 @@ async def submit_evidence( """ 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, buffered_files=buffered).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, ) diff --git a/tests/test_resources.py b/tests/test_resources.py index 82e775a..594ec8f 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -285,7 +285,12 @@ class _Case: REQUEST_SHAPE_CASES = [ - _Case("billing.get_info", lambda c: c.billing.get_info(), "GET", "/api/v2/subscription/billing_info/"), + _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"), @@ -301,8 +306,16 @@ class _Case: 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( + "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)", @@ -318,7 +331,9 @@ class _Case: "/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( + "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(), @@ -327,7 +342,9 @@ class _Case: ), _Case( "profile.update_preferences", - lambda c: c.profile.update_preferences(onboarding_activity_page_viewed_at="2024-01-01T00:00:00Z"), + 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"}, @@ -352,7 +369,12 @@ class _Case: "replace_with": [{"type": "any"}], }, ), - _Case("proxy_replacements.get", lambda c: c.proxy_replacements.get(3), "GET", "/api/v3/proxy/replace/3/"), + _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(), @@ -364,7 +386,10 @@ class _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" + download_token="tok", + country_codes=["us"], + authentication_type="username", + mode="direct", ), "GET", "/api/v2/proxy/list/replaced/download/", @@ -377,7 +402,9 @@ class _Case: }, response="", ), - _Case("referral.get_config", lambda c: c.referral.get_config(), "GET", "/api/v2/referral/config/"), + _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"), @@ -386,7 +413,10 @@ class _Case: body={"mode": "credits"}, ), _Case( - "referral.get_coupon_code", lambda c: c.referral.get_coupon_code(), "GET", "/api/v2/referral/coupon-code/" + "referral.get_coupon_code", + lambda c: c.referral.get_coupon_code(), + "GET", + "/api/v2/referral/coupon-code/", ), _Case( "referral.apply_coupon_code", @@ -408,7 +438,12 @@ class _Case: "/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( + "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. @@ -418,7 +453,12 @@ class _Case: "/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( + "referral.get_earnout", + lambda c: c.referral.get_earnout(2), + "GET", + "/api/v2/referral/earnout/2/", + ), _Case( "notifications.list", lambda c: c.notifications.list(), @@ -428,9 +468,18 @@ class _Case: ), _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/" + "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.list", lambda c: c.subusers.list(), "GET", "/api/v2/subuser/", response=_EMPTY_PAGE), _Case( "subusers.create", lambda c: c.subusers.create(label="Test"), @@ -480,7 +529,12 @@ class _Case: "/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( + "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(), @@ -488,7 +542,12 @@ class _Case: "/api/v2/payment/pending/", response=_EMPTY_PAGE, ), - _Case("pending_payments.get", lambda c: c.pending_payments.get(3), "GET", "/api/v2/payment/pending/3/"), + _Case( + "pending_payments.get", + lambda c: c.pending_payments.get(3), + "GET", + "/api/v2/payment/pending/3/", + ), ] From 95d30433f48759219182cd2b8ca38419ef74dbbd Mon Sep 17 00:00:00 2001 From: Vito Meznaric Date: Mon, 27 Jul 2026 12:21:14 +0200 Subject: [PATCH 10/10] Accept int transaction ids in invoices.download and close remaining test clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transaction.id is an int, so invoices.download now takes int | str instead of forcing callers to stringify (verified against the live API — the query param serializes identically). Also converted the remaining bare client instantiations in test_client.py and test_errors.py to context managers, and added tests for conflicting credentials, explicit timeout=None, and redirect following. Claude-Session: https://claude.ai/code/session_018LB1vKDSPQRHAFPowUmkeR --- src/webshare/resources/invoices.py | 18 ++-- tests/test_client.py | 136 +++++++++++++++++------------ tests/test_errors.py | 62 +++++++------ 3 files changed, 129 insertions(+), 87 deletions(-) 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/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)