diff --git a/README.md b/README.md index 633f9bf1..0d7da15a 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,81 @@ except RateLimitExceededError as e: The client automatically retries requests up to 3 times (configurable via the `max_retries` request option) on 429 and 5xx responses, timeouts, and connection errors, using exponential backoff with jitter and honoring `Retry-After`. The SDK attaches an auto-generated `Idempotency-Key` (UUID v4) to every `POST` request and reuses the same key across its internal retries. +## HTTP Backends + +The SDK sends requests through [`httpx2`](https://pypi.org/project/httpx2/) by default. Pass your own configured client as `http_client` to control proxies, TLS, connection limits, or transports: + +```python +import httpx2 +from workos import AsyncWorkOSClient, WorkOSClient + +client = WorkOSClient( + api_key="sk_...", + http_client=httpx2.Client(proxy="http://proxy.internal:3128", verify="/etc/ssl/corp.pem"), +) + +async_client = AsyncWorkOSClient( + api_key="sk_...", + http_client=httpx2.AsyncClient(limits=httpx2.Limits(max_connections=20)), +) +``` + +`httpx` 0.28 clients are accepted as well; the two libraries share an API. The SDK closes only clients it created. A client you pass in stays open after `client.close()`, so close it yourself when you are done with it. + +### Custom backends + +Any object implementing `workos.HTTPBackend` (or `workos.AsyncHTTPBackend` for the async client) can be passed as `http_client`. The SDK hands the backend a fully built URL, the final headers, an optional `bytes` body, and a timeout in seconds, and expects a `workos.HTTPResponse` back. Raise `workos.TransportTimeout`, `workos.TransportConnectError`, or `workos.TransportError` for network failures so the SDK's retry logic can handle them. The `headers` mapping on the response must be case-insensitive. + +The following `aiohttp` adapter is an example, not a supported part of the SDK: + +```python +import asyncio + +import aiohttp +import yarl + +from workos import ( + AsyncWorkOSClient, + HTTPResponse, + TransportConnectError, + TransportError, + TransportTimeout, +) + + +class AiohttpBackend: + def __init__(self, session: aiohttp.ClientSession) -> None: + self._session = session + + async def request(self, method, url, *, headers, content, timeout) -> HTTPResponse: + try: + async with self._session.request( + method, + yarl.URL(url, encoded=True), # keep the SDK's percent-encoding intact + headers=headers, + data=content, + timeout=aiohttp.ClientTimeout(total=timeout), + allow_redirects=True, + ) as resp: + body = await resp.read() + return HTTPResponse(resp.status, resp.headers, body, method, str(resp.url)) + except (asyncio.TimeoutError, aiohttp.ServerTimeoutError) as exc: # before connection errors + raise TransportTimeout(str(exc)) from exc + except aiohttp.ClientConnectionError as exc: + raise TransportConnectError(str(exc)) from exc + except aiohttp.ClientError as exc: + raise TransportError(str(exc)) from exc + + async def close(self) -> None: + await self._session.close() + + +async def main() -> None: + async with aiohttp.ClientSession() as session: + client = AsyncWorkOSClient(api_key="sk_...", http_client=AiohttpBackend(session)) + page = await client.organizations.list_organizations() +``` + ## Per-Request Options Every API method accepts `request_options` for per-call overrides (local helpers such as webhook/Actions signature verification and PKCE utilities do not make HTTP calls and don't take `request_options`): diff --git a/pyproject.toml b/pyproject.toml index 5f4f3439..6eb4608b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.10" dependencies = [ "cryptography~=50.0", - "httpx~=0.28", + "httpx2~=2.13", "pyjwt~=2.12", "typing_extensions~=4.0; python_version < '3.11'", ] @@ -31,10 +31,10 @@ test = [ "pytest~=9.0", "pytest-asyncio~=1.3", "pytest-cov~=7.1", - "pytest-httpx~=0.36", + "httpx~=0.28", ] lint = ["ruff~=0.15"] -type_check = ["pyright~=1.1"] +type_check = ["pyright~=1.1", "httpx~=0.28"] nox = ["nox~=2026.2", "nox-uv~=0.7"] docs = ["pdoc>=14", "pydoc-markdown>=4", "black~=26.3"] diff --git a/src/workos/__init__.py b/src/workos/__init__.py index faa676ad..1a1182f4 100644 --- a/src/workos/__init__.py +++ b/src/workos/__init__.py @@ -14,6 +14,14 @@ ServerError, UnprocessableEntityError, ) +from ._http import ( + AsyncHTTPBackend, + HTTPBackend, + HTTPResponse, + TransportConnectError, + TransportError, + TransportTimeout, +) from ._pagination import AsyncPage, ListMetadata, SyncPage from .public_client import create_public_client from ._types import NOT_GIVEN, NotGiven, RequestOptions @@ -37,4 +45,10 @@ "NOT_GIVEN", "NotGiven", "create_public_client", + "HTTPBackend", + "AsyncHTTPBackend", + "HTTPResponse", + "TransportError", + "TransportTimeout", + "TransportConnectError", ] diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index bb1f17a3..24a907d4 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import json import os import platform import time @@ -10,9 +11,7 @@ from datetime import datetime, timezone from email.utils import parsedate_to_datetime from typing import Any, Dict, Optional, Sequence, Type, cast, overload -from urllib.parse import quote - -import httpx +from urllib.parse import quote, urlencode from ._errors import ( APIError, @@ -25,6 +24,16 @@ STATUS_CODE_TO_ERROR, _AUTH_CODE_TO_ERROR, ) +from ._http import ( + AsyncHTTPClient, + HTTPResponse, + SyncHTTPClient, + TransportConnectError, + TransportError, + TransportTimeout, + resolve_async_backend, + resolve_sync_backend, +) from ._pagination import AsyncPage, ListMetadata, SyncPage from ._types import D, Deserializable, RequestOptions @@ -96,8 +105,6 @@ def build_url( self, path: Sequence[str], params: Optional[Dict[str, Any]] = None ) -> str: """Build a full URL with query parameters for redirect/authorization endpoints.""" - from urllib.parse import urlencode - base = self._base_url.rstrip("/") url = f"{base}/{self._encode_path(path)}" if params: @@ -153,8 +160,8 @@ def _encode_path(path: Sequence[str]) -> str: Percent-encoding alone does not cover dot segments: ``.`` is an unreserved character that :func:`urllib.parse.quote` leaves as-is, and - httpx applies RFC 3986 dot-segment removal when it builds the request - URL, so a bare ``.`` or ``..`` segment would collapse the path onto the + HTTP clients such as httpx2 apply RFC 3986 dot-segment removal when they + build the request URL, so a bare ``.`` or ``..`` segment would collapse the path onto the parent resource (for example, turning a DELETE of one connected account into a DELETE of the whole user). Empty, ``.`` and ``..`` segments are never valid WorkOS identifiers, so they are rejected with @@ -194,6 +201,57 @@ def _resolve_max_retries(self, request_options: Optional[RequestOptions]) -> int return retries return self._max_retries + @staticmethod + def _query_value(value: Any) -> str: + """Stringify one query value the way httpx does.""" + if value is True: + return "true" + if value is False: + return "false" + if value is None: + return "" + return str(value) + + @staticmethod + def _encode_query(params: Optional[Dict[str, Any]]) -> str: + """Encode query parameters into a query string. + + Mirrors httpx 0.28 so behaviour is identical for every HTTP backend: + booleans become ``true`` / ``false``, ``None`` becomes an empty value, + lists and tuples repeat the key, everything else is ``str()``-ed. + Generated resources rely on this for raw ``bool``, ``int`` and ``list`` + values. + """ + if not params: + return "" + pairs: list[tuple[str, str]] = [] + for key, value in params.items(): + if isinstance(value, (list, tuple)): + for item in cast(Sequence[Any], value): + pairs.append((key, _BaseWorkOSClient._query_value(item))) + else: + pairs.append((key, _BaseWorkOSClient._query_value(value))) + return urlencode(pairs) + + @staticmethod + def _encode_body(body: Optional[Dict[str, Any]]) -> Optional[bytes]: + """Serialize a JSON body in httpx's compact form, or ``None`` for no body.""" + if body is None: + return None + return json.dumps( + body, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + def _build_request_url( + self, + path: Sequence[str], + params: Optional[Dict[str, Any]], + request_options: Optional[RequestOptions], + ) -> str: + url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}" + query = self._encode_query(params) + return f"{url}?{query}" if query else url + def _require_api_key(self) -> str: if not self._api_key: raise ConfigurationError( @@ -238,7 +296,7 @@ def _build_headers( return headers def _deserialize_response( - self, response: httpx.Response, model: Optional[Type[Deserializable]] + self, response: HTTPResponse, model: Optional[Type[Deserializable]] ) -> Any: if response.status_code == 204 or not response.content: return None @@ -251,13 +309,12 @@ def _deserialize_response( return data @staticmethod - def _raise_error(response: httpx.Response) -> None: + def _raise_error(response: HTTPResponse) -> None: """Raise an appropriate error based on the response status code.""" request_id = response.headers.get("x-request-id", "") raw_body = response.text - request = response.request - request_url = str(request.url) if request is not None else None - request_method = request.method if request is not None else None + request_url = response.request_url + request_method = response.request_method response_json: Optional[Dict[str, Any]] = None try: response_json = cast(Dict[str, Any], response.json()) @@ -384,6 +441,7 @@ def __init__( jwt_leeway: float = 0.0, max_retries: int = MAX_RETRIES, is_public: bool = False, + http_client: Optional[SyncHTTPClient] = None, ) -> None: """Initialize the WorkOS client. @@ -398,9 +456,15 @@ def __init__( / mobile / CLI). The API key is forced to None and the ``WORKOS_API_KEY`` environment variable is ignored. Use ``create_public_client`` instead of setting this directly. + http_client: HTTP client to send requests with. Accepts an + ``httpx2.Client``, an ``httpx.Client``, or any object implementing + :class:`workos.HTTPBackend`. Defaults to a new ``httpx2.Client`` + that this instance owns and closes. A client you pass in is + never closed by the SDK. Raises: ValueError: If neither api_key nor client_id is provided, directly or via environment variables. + TypeError: If ``http_client`` is not a supported client type. """ super().__init__( api_key=api_key, @@ -411,13 +475,17 @@ def __init__( max_retries=max_retries, is_public=is_public, ) - self._client = httpx.Client( - timeout=self._request_timeout, follow_redirects=True + self._backend, self._owns_http_client = resolve_sync_backend( + http_client, self._request_timeout ) def close(self) -> None: - """Close the underlying HTTP client and release resources.""" - self._client.close() + """Release the HTTP client if the SDK created it. + + A client passed in via ``http_client`` stays open; close it yourself. + """ + if self._owns_http_client: + self._backend.close() def __enter__(self) -> "WorkOSClient": return self @@ -463,19 +531,19 @@ def request( request_options: Optional[RequestOptions] = None, ) -> Any: """Make an HTTP request with retry logic.""" - url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}" + url = self._build_request_url(path, params, request_options) headers = self._build_headers(method, idempotency_key, request_options) + content = self._encode_body(body) timeout = self._resolve_timeout(request_options) max_retries = self._resolve_max_retries(request_options) last_error: Optional[Exception] = None for attempt in range(max_retries + 1): try: - response = self._client.request( - method=method.upper(), - url=url, - params=params, - json=body if body is not None else None, + response = self._backend.request( + method.upper(), + url, headers=headers, + content=content, timeout=timeout, ) if response.status_code in RETRY_STATUS_CODES and attempt < max_retries: @@ -487,19 +555,19 @@ def request( if response.status_code >= 400: self._raise_error(response) return self._deserialize_response(response, model) - except httpx.TimeoutException as e: + except TransportTimeout as e: last_error = e if attempt < max_retries: time.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSTimeoutError(f"Request timed out: {e}") from e - except httpx.ConnectError as e: + except TransportConnectError as e: last_error = e if attempt < max_retries: time.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSConnectionError(f"Connection failed: {e}") from e - except httpx.HTTPError as e: + except TransportError as e: last_error = e if attempt < max_retries: time.sleep(self._calculate_retry_delay(attempt)) @@ -615,6 +683,7 @@ def __init__( jwt_leeway: float = 0.0, max_retries: int = MAX_RETRIES, is_public: bool = False, + http_client: Optional[AsyncHTTPClient] = None, ) -> None: """Initialize the async WorkOS client. @@ -625,9 +694,15 @@ def __init__( request_timeout: HTTP request timeout in seconds. Falls back to WORKOS_REQUEST_TIMEOUT or 60. jwt_leeway: JWT clock skew leeway in seconds. max_retries: Maximum number of retries for failed requests. Defaults to 3. + http_client: HTTP client to send requests with. Accepts an + ``httpx2.AsyncClient``, an ``httpx.AsyncClient``, or any object + implementing :class:`workos.AsyncHTTPBackend`. Defaults to a new + ``httpx2.AsyncClient`` that this instance owns and closes. A + client you pass in is never closed by the SDK. Raises: ValueError: If neither api_key nor client_id is provided, directly or via environment variables. + TypeError: If ``http_client`` is not a supported client type. """ super().__init__( api_key=api_key, @@ -638,13 +713,17 @@ def __init__( max_retries=max_retries, is_public=is_public, ) - self._client = httpx.AsyncClient( - timeout=self._request_timeout, follow_redirects=True + self._backend, self._owns_http_client = resolve_async_backend( + http_client, self._request_timeout ) async def close(self) -> None: - """Close the underlying HTTP client and release resources.""" - await self._client.aclose() + """Release the HTTP client if the SDK created it. + + A client passed in via ``http_client`` stays open; close it yourself. + """ + if self._owns_http_client: + await self._backend.close() async def __aenter__(self) -> "AsyncWorkOSClient": return self @@ -690,19 +769,19 @@ async def request( request_options: Optional[RequestOptions] = None, ) -> Any: """Make an async HTTP request with retry logic.""" - url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}" + url = self._build_request_url(path, params, request_options) headers = self._build_headers(method, idempotency_key, request_options) + content = self._encode_body(body) timeout = self._resolve_timeout(request_options) max_retries = self._resolve_max_retries(request_options) last_error: Optional[Exception] = None for attempt in range(max_retries + 1): try: - response = await self._client.request( - method=method.upper(), - url=url, - params=params, - json=body if body is not None else None, + response = await self._backend.request( + method.upper(), + url, headers=headers, + content=content, timeout=timeout, ) if response.status_code in RETRY_STATUS_CODES and attempt < max_retries: @@ -714,19 +793,19 @@ async def request( if response.status_code >= 400: self._raise_error(response) return self._deserialize_response(response, model) - except httpx.TimeoutException as e: + except TransportTimeout as e: last_error = e if attempt < max_retries: await asyncio.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSTimeoutError(f"Request timed out: {e}") from e - except httpx.ConnectError as e: + except TransportConnectError as e: last_error = e if attempt < max_retries: await asyncio.sleep(self._calculate_retry_delay(attempt)) continue raise WorkOSConnectionError(f"Connection failed: {e}") from e - except httpx.HTTPError as e: + except TransportError as e: last_error = e if attempt < max_retries: await asyncio.sleep(self._calculate_retry_delay(attempt)) diff --git a/src/workos/_http.py b/src/workos/_http.py new file mode 100644 index 00000000..13d70dbd --- /dev/null +++ b/src/workos/_http.py @@ -0,0 +1,331 @@ +# @oagen-ignore-file +"""HTTP transport layer for the WorkOS client. + +``_base_client`` owns URL building, query and body encoding, retries and error +mapping. Everything that touches a concrete HTTP library lives here, behind the +:class:`HTTPBackend` / :class:`AsyncHTTPBackend` protocols, so a caller can +supply any HTTP client they like via ``http_client=``. +""" + +from __future__ import annotations + +import importlib +import inspect +import json +from dataclasses import dataclass +from typing import ( + Any, + Dict, + Mapping, + Optional, + Protocol, + Tuple, + Union, + cast, +) +from urllib.parse import urlsplit + +import httpx2 + +try: + import httpx +except ModuleNotFoundError as exc: + if exc.name != "httpx": + raise + # Keep runtime annotations resolvable without the optional legacy package. + import httpx2 as httpx + + +class TransportError(Exception): + """Network-level failure reported by an HTTP backend. + + Backends raise this family instead of their library's own exceptions so the + retry loop in ``_base_client`` never depends on a particular HTTP client. + """ + + +class TransportTimeout(TransportError): + """The request timed out.""" + + +class TransportConnectError(TransportError): + """A connection could not be established.""" + + +@dataclass(frozen=True) +class HTTPResponse: + """Backend-neutral view of an HTTP response. + + ``headers`` must be a case-insensitive mapping (``httpx2.Headers`` and + ``multidict.CIMultiDictProxy`` both qualify): the SDK reads ``Retry-After`` + and ``x-request-id`` without normalising case. + """ + + status_code: int + headers: Mapping[str, str] + content: bytes + request_method: str + request_url: str + + @property + def text(self) -> str: + """Body decoded as UTF-8, replacing undecodable bytes.""" + return self.content.decode("utf-8", errors="replace") + + def json(self) -> Any: + """Body parsed as JSON.""" + return json.loads(self.content) + + +class HTTPBackend(Protocol): + """Synchronous transport used by :class:`workos.WorkOSClient`. + + The SDK passes a fully built URL (path and query already encoded), the final + headers, an optional JSON body as bytes, and a timeout in seconds. Raise + :class:`TransportTimeout`, :class:`TransportConnectError` or + :class:`TransportError` for network failures so the SDK can retry them. + """ + + def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: ... + + def close(self) -> None: ... + + +class AsyncHTTPBackend(Protocol): + """Asynchronous transport used by :class:`workos.AsyncWorkOSClient`. + + Same contract as :class:`HTTPBackend` with coroutine methods. + """ + + async def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: ... + + async def close(self) -> None: ... + + +SyncHTTPClient = Union[httpx2.Client, httpx.Client, HTTPBackend] +AsyncHTTPClient = Union[httpx2.AsyncClient, httpx.AsyncClient, AsyncHTTPBackend] + + +# --- httpx family ------------------------------------------------------------- + +_HTTPX_MODULES = frozenset({"httpx2", "httpx"}) + + +def _httpx_root(obj: object) -> Optional[str]: + """Return ``"httpx2"`` or ``"httpx"`` if ``obj`` is (a subclass of) a client from that module.""" + for cls in type(obj).__mro__: + root = cls.__module__.partition(".")[0] + if root in _HTTPX_MODULES: + return root + return None + + +class _HttpxFamily: + """Classes from whichever httpx-compatible module a client was built with. + + ``httpx2`` is a fork of ``httpx`` 0.28 with an identical API, so one adapter + serves both. Only the exception and client classes differ by module. + """ + + def __init__(self, module_name: str) -> None: + module = importlib.import_module(module_name) + self.name = module_name + self.client: type[Any] = module.Client + self.async_client: type[Any] = module.AsyncClient + self.timeout_error: type[Exception] = module.TimeoutException + self.connect_error: type[Exception] = module.ConnectError + self.http_error: type[Exception] = module.HTTPError + + +def _wrap_response(response: Union[httpx2.Response, httpx.Response]) -> HTTPResponse: + request = response.request + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + content=response.content, + request_method=request.method, + request_url=str(request.url), + ) + + +class HttpxBackend: + """Adapter for ``httpx2.Client`` and the API-identical ``httpx.Client``.""" + + def __init__(self, client: Union[httpx2.Client, httpx.Client]) -> None: + self._client = client + self._family = _HttpxFamily(_httpx_root(client) or "httpx2") + + def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + try: + response = self._client.request( + method, + url, + # Explicit params merge defaults instead of replacing the URL query. + params=urlsplit(url).query or None, + headers=headers, + content=content, + timeout=timeout, + ) + except self._family.timeout_error as exc: + raise TransportTimeout(str(exc)) from exc + except self._family.connect_error as exc: + raise TransportConnectError(str(exc)) from exc + except self._family.http_error as exc: + raise TransportError(str(exc)) from exc + return _wrap_response(response) + + def close(self) -> None: + self._client.close() + + +class AsyncHttpxBackend: + """Adapter for ``httpx2.AsyncClient`` and the API-identical ``httpx.AsyncClient``.""" + + def __init__(self, client: Union[httpx2.AsyncClient, httpx.AsyncClient]) -> None: + self._client = client + self._family = _HttpxFamily(_httpx_root(client) or "httpx2") + + async def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + try: + response = await self._client.request( + method, + url, + # Explicit params merge defaults instead of replacing the URL query. + params=urlsplit(url).query or None, + headers=headers, + content=content, + timeout=timeout, + ) + except self._family.timeout_error as exc: + raise TransportTimeout(str(exc)) from exc + except self._family.connect_error as exc: + raise TransportConnectError(str(exc)) from exc + except self._family.http_error as exc: + raise TransportError(str(exc)) from exc + return _wrap_response(response) + + async def close(self) -> None: + await self._client.aclose() + + +# --- resolution ----------------------------------------------------------------- + + +def _describe(obj: object) -> str: + return f"{type(obj).__module__}.{type(obj).__qualname__}" + + +def _is_backend_like(obj: object) -> bool: + return callable(getattr(obj, "request", None)) and callable( + getattr(obj, "close", None) + ) + + +def _has_async_request(obj: object) -> bool: + return inspect.iscoroutinefunction(getattr(obj, "request", None)) + + +_UNSUPPORTED = ( + "Unsupported http_client {desc}: pass an httpx2 client, an httpx client, or an " + "object implementing workos.{protocol} (request() and close())." +) + + +def resolve_sync_backend( + http_client: Optional[SyncHTTPClient], timeout: float +) -> Tuple[HTTPBackend, bool]: + """Return ``(backend, owned)`` for :class:`workos.WorkOSClient`. + + ``owned`` is True only for the default client the SDK creates itself. The + SDK never closes a client the caller passed in. + """ + if http_client is None: + client = httpx2.Client(timeout=timeout, follow_redirects=True) + return HttpxBackend(client), True + root = _httpx_root(http_client) + if root is not None: + family = _HttpxFamily(root) + if isinstance(http_client, family.client): + return HttpxBackend(cast(httpx2.Client, http_client)), False + if isinstance(http_client, family.async_client): + raise TypeError( + f"WorkOSClient needs a synchronous HTTP client but got " + f"{root}.AsyncClient; pass {root}.Client, or use AsyncWorkOSClient." + ) + if _is_backend_like(http_client): + if _has_async_request(http_client): + raise TypeError( + "WorkOSClient needs an HTTPBackend with a synchronous request(); " + f"{_describe(http_client)}.request is a coroutine function. " + "Use AsyncWorkOSClient." + ) + return cast(HTTPBackend, http_client), False + raise TypeError( + _UNSUPPORTED.format(desc=_describe(http_client), protocol="HTTPBackend") + ) + + +def resolve_async_backend( + http_client: Optional[AsyncHTTPClient], timeout: float +) -> Tuple[AsyncHTTPBackend, bool]: + """Return ``(backend, owned)`` for :class:`workos.AsyncWorkOSClient`. + + ``owned`` is True only for the default client the SDK creates itself. The + SDK never closes a client the caller passed in. + """ + if http_client is None: + client = httpx2.AsyncClient(timeout=timeout, follow_redirects=True) + return AsyncHttpxBackend(client), True + root = _httpx_root(http_client) + if root is not None: + family = _HttpxFamily(root) + if isinstance(http_client, family.async_client): + return AsyncHttpxBackend(cast(httpx2.AsyncClient, http_client)), False + if isinstance(http_client, family.client): + raise TypeError( + f"AsyncWorkOSClient needs an asynchronous HTTP client but got " + f"{root}.Client; pass {root}.AsyncClient, or use WorkOSClient." + ) + if _is_backend_like(http_client): + if not _has_async_request(http_client): + raise TypeError( + "AsyncWorkOSClient needs an AsyncHTTPBackend whose request() is a " + f"coroutine function; {_describe(http_client)}.request is not. " + "Use WorkOSClient." + ) + return cast(AsyncHTTPBackend, http_client), False + raise TypeError( + _UNSUPPORTED.format(desc=_describe(http_client), protocol="AsyncHTTPBackend") + ) diff --git a/src/workos/public_client.py b/src/workos/public_client.py index b4bca338..e4ee11e9 100644 --- a/src/workos/public_client.py +++ b/src/workos/public_client.py @@ -4,10 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import Optional -if TYPE_CHECKING: - from ._client import WorkOSClient +from ._client import WorkOSClient +from ._http import SyncHTTPClient def create_public_client( @@ -15,6 +15,7 @@ def create_public_client( client_id: str, base_url: Optional[str] = None, request_timeout: Optional[int] = None, + http_client: Optional["SyncHTTPClient"] = None, ) -> "WorkOSClient": """Create a WorkOS client configured for public/PKCE-only usage. @@ -26,16 +27,16 @@ def create_public_client( client_id: The WorkOS client ID. base_url: Override the base URL. Defaults to ``https://api.workos.com``. request_timeout: HTTP request timeout in seconds. + http_client: HTTP client to send requests with; see :class:`workos.WorkOSClient`. Returns: A WorkOSClient instance with only ``client_id`` configured. """ - from ._client import WorkOSClient - return WorkOSClient( api_key=None, client_id=client_id, base_url=base_url, request_timeout=request_timeout, is_public=True, + http_client=http_client, ) diff --git a/tests/conftest.py b/tests/conftest.py index 065ed01c..9c22d5ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,10 @@ # @oagen-ignore-file +from __future__ import annotations + +from typing import Any, Dict, Iterator, List, Optional, Union + +import httpx2 import pytest import pytest_asyncio @@ -26,3 +31,96 @@ async def async_workos(): yield client finally: await client.close() + + +class HTTPXMock: + """Stand-in for the ``pytest-httpx`` fixture of the same name. + + ``pytest-httpx`` pins ``httpx==0.28.*`` and cannot intercept ``httpx2``. The + oagen-generated test files depend on this fixture's name and on the subset of + its API implemented here, so the shim is hand-maintained instead of changing + the emitter. Requests are intercepted the same way ``pytest-httpx`` does, by + patching the transport classes, so clients constructed directly in a test are + covered too. Responses are consumed first-in first-out. Like ``pytest-httpx``, + the fixture fails the test at teardown if any queued response was never + requested; call ``reset()`` to discard the queue deliberately. + """ + + def __init__(self) -> None: + self._queue: List[Union[httpx2.Response, Exception]] = [] + self._requests: List[httpx2.Request] = [] + + def add_response( + self, + *, + json: Any = None, + status_code: int = 200, + headers: Optional[Dict[str, str]] = None, + content: Optional[bytes] = None, + ) -> None: + self._queue.append( + httpx2.Response(status_code, json=json, headers=headers, content=content) + ) + + def add_exception(self, exception: Exception) -> None: + self._queue.append(exception) + + def get_requests(self) -> List[httpx2.Request]: + return list(self._requests) + + def get_request(self) -> Optional[httpx2.Request]: + if len(self._requests) > 1: + raise AssertionError( + f"{len(self._requests)} requests were sent; use get_requests()" + ) + return self._requests[0] if self._requests else None + + def reset(self) -> None: + self._queue.clear() + self._requests.clear() + + def unused_responses(self) -> int: + return len(self._queue) + + def _next(self, request: httpx2.Request) -> httpx2.Response: + self._requests.append(request) + if not self._queue: + raise httpx2.TimeoutException( + "No response registered for this request", request=request + ) + item = self._queue.pop(0) + if isinstance(item, Exception): + raise item + return item + + def handle_request(self, request: httpx2.Request) -> httpx2.Response: + request.read() + return self._next(request) + + async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: + await request.aread() + return self._next(request) + + +@pytest.fixture +def httpx_mock(monkeypatch: pytest.MonkeyPatch) -> Iterator[HTTPXMock]: + mock = HTTPXMock() + + def _sync( + _transport: httpx2.HTTPTransport, request: httpx2.Request + ) -> httpx2.Response: + return mock.handle_request(request) + + async def _async( + _transport: httpx2.AsyncHTTPTransport, request: httpx2.Request + ) -> httpx2.Response: + return await mock.handle_async_request(request) + + monkeypatch.setattr(httpx2.HTTPTransport, "handle_request", _sync) + monkeypatch.setattr(httpx2.AsyncHTTPTransport, "handle_async_request", _async) + yield mock + unused = mock.unused_responses() + if unused: + pytest.fail( + f"{unused} httpx_mock response(s) were registered but never requested" + ) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 52833dbb..e630a96f 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -201,10 +201,10 @@ def test_pagination_importable() -> None: def test_dependencies_available() -> None: """Verify core runtime dependencies are installed and importable.""" import cryptography - import httpx + import httpx2 import jwt - print("✓ Core dependencies available (httpx, cryptography, pyjwt)") + print("✓ Core dependencies available (httpx2, cryptography, pyjwt)") def main() -> int: diff --git a/tests/test_generated_client.py b/tests/test_generated_client.py index 2847631f..3dcdd66f 100644 --- a/tests/test_generated_client.py +++ b/tests/test_generated_client.py @@ -2,7 +2,7 @@ """Client tests: retries, errors, context manager, idempotency.""" -import httpx +import httpx2 as httpx import pytest from workos import WorkOSClient, AsyncWorkOSClient diff --git a/tests/test_http_backends.py b/tests/test_http_backends.py new file mode 100644 index 00000000..5c27a9e6 --- /dev/null +++ b/tests/test_http_backends.py @@ -0,0 +1,554 @@ +# @oagen-ignore-file + +"""HTTP backend protocol, the httpx/httpx2 adapter, resolution, and encoding parity.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from typing import Any, Dict, List, Optional, Union + +import httpx +import httpx2 +import pytest + +from workos import ( + AsyncWorkOSClient, + HTTPResponse, + NotFoundError, + TransportConnectError, + TransportError, + TransportTimeout, + WorkOSClient, + WorkOSError, +) +from workos import _base_client as base_client_module +from workos._base_client import _BaseWorkOSClient +from workos._errors import WorkOSConnectionError, WorkOSTimeoutError +from workos._http import AsyncHttpxBackend, HttpxBackend +from tests.generated_helpers import load_fixture + +API_KEY = "sk_test_123" +BASE = "https://api.workos.com" + + +def make_response( + status_code: int = 200, + body: Any = None, + headers: Optional[Dict[str, str]] = None, + method: str = "GET", + url: str = f"{BASE}/t", +) -> HTTPResponse: + content = b"" if body is None else json.dumps(body).encode() + return HTTPResponse( + status_code=status_code, + headers=httpx2.Headers(headers or {}), + content=content, + request_method=method, + request_url=url, + ) + + +class FakeBackend: + """Minimal HTTPBackend that replays queued responses or exceptions.""" + + def __init__(self, *items: Union[HTTPResponse, Exception]) -> None: + self.items: List[Union[HTTPResponse, Exception]] = list(items) + self.calls: List[Dict[str, Any]] = [] + self.closed = False + + def _record( + self, + method: str, + url: str, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + self.calls.append( + { + "method": method, + "url": url, + "headers": headers, + "content": content, + "timeout": timeout, + } + ) + item = self.items.pop(0) + if isinstance(item, Exception): + raise item + return item + + def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + return self._record(method, url, headers, content, timeout) + + def close(self) -> None: + self.closed = True + + +class AsyncFakeBackend(FakeBackend): + """Async twin of FakeBackend.""" + + async def request( # type: ignore[override] + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + return self._record(method, url, headers, content, timeout) + + async def close(self) -> None: # type: ignore[override] + self.closed = True + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(base_client_module.time, "sleep", lambda _: None) + + +class TestProtocolBackend: + def test_receives_full_url_headers_body_and_timeout(self) -> None: + backend = FakeBackend(make_response(200, {"ok": True})) + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + result = client.request( + "POST", + ("orgs", "org 1"), + params={"limit": 10, "enabled": True, "tags": ["a", "b"]}, + body={"name": "Acme"}, + request_options={"timeout": 7}, + ) + + assert result == {"ok": True} + call = backend.calls[0] + assert call["method"] == "POST" + assert call["url"] == f"{BASE}/orgs/org%201?limit=10&enabled=true&tags=a&tags=b" + assert call["content"] == b'{"name":"Acme"}' + assert call["headers"]["Content-Type"] == "application/json" + assert call["headers"]["Authorization"] == f"Bearer {API_KEY}" + assert call["timeout"] == 7.0 + + def test_no_params_and_no_body(self) -> None: + backend = FakeBackend(make_response(204)) + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + assert client.request("GET", ("t",)) is None + assert backend.calls[0]["url"] == f"{BASE}/t" + assert backend.calls[0]["content"] is None + + def test_retries_retryable_status_then_succeeds(self) -> None: + backend = FakeBackend( + make_response(429, {"message": "slow"}, {"Retry-After": "0"}), + make_response(200, {"ok": True}), + ) + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + assert client.request("GET", ("t",)) == {"ok": True} + assert len(backend.calls) == 2 + + def test_client_error_is_raised_not_retried(self) -> None: + backend = FakeBackend( + make_response(404, {"message": "missing"}, {"X-Request-Id": "req_1"}) + ) + client = WorkOSClient(api_key=API_KEY, http_client=backend, max_retries=3) + + with pytest.raises(NotFoundError) as exc_info: + client.request("GET", ("t",)) + + assert exc_info.value.request_id == "req_1" + assert exc_info.value.request_url == f"{BASE}/t" + assert exc_info.value.request_method == "GET" + assert len(backend.calls) == 1 + + @pytest.mark.parametrize( + ("transport_error", "sdk_error", "message"), + [ + (TransportTimeout("slow"), WorkOSTimeoutError, "Request timed out: slow"), + ( + TransportConnectError("refused"), + WorkOSConnectionError, + "Connection failed: refused", + ), + (TransportError("broken"), WorkOSError, "Network error: broken"), + ], + ) + def test_transport_errors_are_mapped( + self, transport_error: Exception, sdk_error: type, message: str + ) -> None: + backend = FakeBackend(transport_error) + client = WorkOSClient(api_key=API_KEY, http_client=backend, max_retries=0) + + with pytest.raises(sdk_error) as exc_info: + client.request("GET", ("t",)) + + assert str(exc_info.value) == message + assert exc_info.value.__cause__ is transport_error + + def test_transport_errors_are_retried(self) -> None: + backend = FakeBackend(TransportTimeout("slow"), make_response(200, {"ok": 1})) + client = WorkOSClient(api_key=API_KEY, http_client=backend, max_retries=1) + + assert client.request("GET", ("t",)) == {"ok": 1} + assert len(backend.calls) == 2 + + def test_injected_backend_is_not_closed(self) -> None: + backend = FakeBackend() + client = WorkOSClient(api_key=API_KEY, http_client=backend) + + client.close() + + assert backend.closed is False + + def test_default_client_is_closed(self) -> None: + client = WorkOSClient(api_key=API_KEY) + backend = client._backend + assert isinstance(backend, HttpxBackend) + + client.close() + + assert backend._client.is_closed + + +@pytest.mark.asyncio +class TestAsyncProtocolBackend: + async def test_retries_and_deserializes(self) -> None: + backend = AsyncFakeBackend( + make_response(503, {"message": "down"}, {"Retry-After": "0"}), + make_response(200, {"ok": True}), + ) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend) + + assert await client.request("GET", ("t",)) == {"ok": True} + assert len(backend.calls) == 2 + assert backend.calls[0]["url"] == f"{BASE}/t" + + async def test_transport_timeout_is_mapped(self) -> None: + backend = AsyncFakeBackend(TransportTimeout("slow")) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend, max_retries=0) + + with pytest.raises(WorkOSTimeoutError, match="Request timed out: slow"): + await client.request("GET", ("t",)) + + async def test_client_error_is_raised_not_retried(self) -> None: + backend = AsyncFakeBackend(make_response(404, {"message": "missing"})) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend) + + with pytest.raises(NotFoundError): + await client.request("GET", ("t",)) + + assert len(backend.calls) == 1 + + async def test_injected_backend_is_not_closed(self) -> None: + backend = AsyncFakeBackend() + client = AsyncWorkOSClient(api_key=API_KEY, http_client=backend) + + await client.close() + + assert backend.closed is False + + async def test_default_client_is_closed(self) -> None: + client = AsyncWorkOSClient(api_key=API_KEY) + backend = client._backend + assert isinstance(backend, AsyncHttpxBackend) + + await client.close() + + assert backend._client.is_closed + + +MODULES = [pytest.param(httpx2, id="httpx2"), pytest.param(httpx, id="httpx")] + + +@pytest.mark.parametrize("mod", MODULES) +class TestHttpxAdapter: + def test_wraps_response(self, mod: Any) -> None: + seen: Dict[str, Any] = {} + + def handler(request: Any) -> Any: + seen["timeout"] = request.extensions.get("timeout") + seen["content"] = request.content + return mod.Response(200, json={"a": 1}, headers={"X-Request-Id": "req_1"}) + + backend = HttpxBackend(mod.Client(transport=mod.MockTransport(handler))) + response = backend.request( + "POST", "https://x/p?q=1", headers={"H": "v"}, content=b"{}", timeout=5.0 + ) + + assert isinstance(response, HTTPResponse) + assert response.status_code == 200 + assert response.json() == {"a": 1} + assert response.headers.get("x-request-id") == "req_1" + assert response.request_method == "POST" + assert response.request_url == "https://x/p?q=1" + assert seen["content"] == b"{}" + assert seen["timeout"] == { + "connect": 5.0, + "read": 5.0, + "write": 5.0, + "pool": 5.0, + } + + @pytest.mark.parametrize( + ("raised", "expected"), + [ + ("TimeoutException", TransportTimeout), + ("ConnectError", TransportConnectError), + ("RemoteProtocolError", TransportError), + ], + ) + def test_maps_exceptions(self, mod: Any, raised: str, expected: type) -> None: + exc_cls = getattr(mod, raised) + + def handler(request: Any) -> Any: + raise exc_cls("boom") + + backend = HttpxBackend(mod.Client(transport=mod.MockTransport(handler))) + + with pytest.raises(expected) as exc_info: + backend.request("GET", "https://x/p", headers={}, content=None, timeout=1.0) + + assert isinstance(exc_info.value.__cause__, exc_cls) + + def test_end_to_end_through_client(self, mod: Any) -> None: + def handler(request: Any) -> Any: + return mod.Response(404, json={"message": "nope"}) + + http_client = mod.Client(transport=mod.MockTransport(handler)) + client = WorkOSClient(api_key=API_KEY, http_client=http_client) + + with pytest.raises(NotFoundError): + client.request("GET", ("t",)) + client.close() + + assert not http_client.is_closed + http_client.close() + + @pytest.mark.asyncio + async def test_async_end_to_end_through_client(self, mod: Any) -> None: + def handler(request: Any) -> Any: + return mod.Response(200, json={"ok": True}) + + http_client = mod.AsyncClient(transport=mod.MockTransport(handler)) + client = AsyncWorkOSClient(api_key=API_KEY, http_client=http_client) + + assert await client.request("GET", ("t",)) == {"ok": True} + await client.close() + + assert not http_client.is_closed + await http_client.aclose() + + @pytest.mark.asyncio + @pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) + async def test_client_query_defaults_preserve_filters_and_pagination( + self, mod: Any, asynchronous: bool + ) -> None: + requests: List[Any] = [] + first_page = load_fixture("list_user.json") + first_page["list_metadata"]["after"] = "cursor/after" + + def handler(request: Any) -> Any: + requests.append(request) + body = ( + first_page if len(requests) == 1 else {"data": [], "list_metadata": {}} + ) + return mod.Response(200, json=body) + + defaults = {"limit": 100, "order": "asc", "organization_id": "org_default"} + http_cls = mod.AsyncClient if asynchronous else mod.Client + http_client = http_cls(params=defaults, transport=mod.MockTransport(handler)) + try: + if asynchronous: + client = AsyncWorkOSClient(api_key=API_KEY, http_client=http_client) + page = await client.user_management.list_users( + email="alice@example.com", limit=7 + ) + users = [user async for user in page] + else: + sync_client = WorkOSClient(api_key=API_KEY, http_client=http_client) + sync_page = sync_client.user_management.list_users( + email="alice@example.com", limit=7 + ) + users = list(sync_page) + finally: + if asynchronous: + await http_client.aclose() + else: + http_client.close() + + expected = { + "limit": "7", + "order": "desc", + "organization_id": "org_default", + "email": "alice@example.com", + } + assert len(users) == 1 + assert len(requests) == 2 + assert dict(requests[0].url.params) == expected + assert dict(requests[1].url.params) == {**expected, "after": "cursor/after"} + assert http_client.params == mod.QueryParams(defaults) + + @pytest.mark.asyncio + async def test_async_adapter_maps_timeout(self, mod: Any) -> None: + def handler(request: Any) -> Any: + raise mod.TimeoutException("slow") + + backend = AsyncHttpxBackend( + mod.AsyncClient(transport=mod.MockTransport(handler)) + ) + + with pytest.raises(TransportTimeout): + await backend.request( + "GET", "https://x/p", headers={}, content=None, timeout=1.0 + ) + + +class TestResolution: + @pytest.mark.parametrize( + "httpx_installed", [False, True], ids=["without-httpx", "with-httpx"] + ) + def test_public_client_annotations_resolve_at_runtime( + self, httpx_installed: bool + ) -> None: + script = """ +import sys +from typing import get_args, get_type_hints + +if sys.argv[1] == "False": + sys.modules["httpx"] = None + +import httpx2 +from workos import ( + AsyncHTTPBackend, AsyncWorkOSClient, HTTPBackend, WorkOSClient, create_public_client, +) + +for factory, client_type, protocol in ( + (WorkOSClient.__init__, httpx2.Client, HTTPBackend), + (AsyncWorkOSClient.__init__, httpx2.AsyncClient, AsyncHTTPBackend), + (create_public_client, httpx2.Client, HTTPBackend), +): + client_types = get_args(get_type_hints(factory)["http_client"]) + assert client_type in client_types + assert protocol in client_types + assert type(None) in client_types + if sys.argv[1] == "True": + import httpx + assert getattr(httpx, client_type.__name__) in client_types + +assert get_type_hints(create_public_client)["return"] is WorkOSClient +""" + result = subprocess.run( + [sys.executable, "-c", script, str(httpx_installed)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + def test_default_backend_settings(self) -> None: + client = WorkOSClient(api_key=API_KEY, request_timeout=9) + backend = client._backend + assert isinstance(backend, HttpxBackend) + assert isinstance(backend._client, httpx2.Client) + assert backend._client.follow_redirects is True + assert backend._client.timeout == httpx2.Timeout(9) + client.close() + + def test_httpx_client_subclass_is_detected(self) -> None: + class MyClient(httpx2.Client): + pass + + http_client = MyClient() + client = WorkOSClient(api_key=API_KEY, http_client=http_client) + assert isinstance(client._backend, HttpxBackend) + http_client.close() + + def test_sync_client_rejects_async_httpx_client(self) -> None: + with pytest.raises(TypeError, match="use AsyncWorkOSClient"): + WorkOSClient(api_key=API_KEY, http_client=httpx2.AsyncClient()) # type: ignore[arg-type] + + def test_async_client_rejects_sync_httpx_client(self) -> None: + with pytest.raises(TypeError, match="use WorkOSClient"): + AsyncWorkOSClient(api_key=API_KEY, http_client=httpx2.Client()) # type: ignore[arg-type] + + def test_sync_client_rejects_async_backend(self) -> None: + with pytest.raises(TypeError, match="coroutine"): + WorkOSClient(api_key=API_KEY, http_client=AsyncFakeBackend()) # type: ignore[arg-type] + + def test_async_client_rejects_sync_backend(self) -> None: + with pytest.raises(TypeError, match="coroutine"): + AsyncWorkOSClient(api_key=API_KEY, http_client=FakeBackend()) # type: ignore[arg-type] + + def test_rejects_unrelated_object(self) -> None: + with pytest.raises(TypeError, match="HTTPBackend"): + WorkOSClient(api_key=API_KEY, http_client=object()) # type: ignore[arg-type] + + +class TestEncodingParity: + @pytest.mark.parametrize( + "params", + [ + {"limit": 10}, + {"enabled": True, "archived": False}, + {"after": None}, + {"domains": ["a.com", "b.com"]}, + {"order": ("asc", "desc")}, + {"search": "héllo wörld & co=1"}, + {"empty": ""}, + {"ratio": 1.5}, + {}, + None, + ], + ) + def test_query_matches_httpx(self, params: Optional[Dict[str, Any]]) -> None: + encoded = _BaseWorkOSClient._encode_query(params) + ours = httpx2.URL(f"{BASE}/p?{encoded}" if encoded else f"{BASE}/p") + theirs = httpx2.Request("GET", f"{BASE}/p", params=params).url + + assert ours.params == theirs.params + assert str(ours) == str(theirs) + with httpx2.Client( + transport=httpx2.MockTransport(lambda _: httpx2.Response(200)) + ) as client: + response = HttpxBackend(client).request( + "GET", str(ours), headers={}, content=None, timeout=1.0 + ) + assert response.request_url == str(theirs) + + def test_query_string_shape(self) -> None: + params = {"a": True, "b": None, "c": ["x", "y"], "d": 5} + assert _BaseWorkOSClient._encode_query(params) == "a=true&b=&c=x&c=y&d=5" + + def test_body_matches_httpx(self) -> None: + body = {"a": 1, "b": [1, 2], "s": "é", "nested": {"k": None}} + assert ( + _BaseWorkOSClient._encode_body(body) + == httpx2.Request("POST", BASE, json=body).content + ) + assert _BaseWorkOSClient._encode_body(None) is None + assert _BaseWorkOSClient._encode_body({}) == b"{}" + + def test_recorded_request_exposes_decoded_params( + self, workos: WorkOSClient, httpx_mock: Any + ) -> None: + httpx_mock.add_response(json={}) + + workos.request( + "GET", ("t",), params={"enabled": True, "limit": 5, "ids": ["a", "b"]} + ) + + request = httpx_mock.get_request() + assert request.url.params["enabled"] == "true" + assert request.url.params["limit"] == "5" + assert request.url.params.get_list("ids") == ["a", "b"] diff --git a/uv.lock b/uv.lock index 9f66a575..3ca7bf04 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' or sys_platform != 'emscripten'", +] [[package]] name = "anyio" @@ -629,6 +633,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -644,6 +661,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "humanize" version = "4.15.0" @@ -655,11 +698,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -1022,19 +1065,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "pytest-httpx" -version = "0.36.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, -] - [[package]] name = "pytokens" version = "0.4.1" @@ -1245,6 +1275,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typeapi" version = "2.3.0" @@ -1328,20 +1367,20 @@ version = "10.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "pyjwt" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "nox" }, { name = "nox-uv" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, - { name = "pytest-httpx" }, { name = "ruff" }, ] docs = [ @@ -1357,32 +1396,33 @@ nox = [ { name = "nox-uv" }, ] test = [ + { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, - { name = "pytest-httpx" }, ] type-check = [ + { name = "httpx" }, { name = "pyright" }, ] [package.metadata] requires-dist = [ { name = "cryptography", specifier = "~=50.0" }, - { name = "httpx", specifier = "~=0.28" }, + { name = "httpx2", specifier = "~=2.13" }, { name = "pyjwt", specifier = "~=2.12" }, { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.0" }, ] [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = "~=0.28" }, { name = "nox", specifier = "~=2026.2" }, { name = "nox-uv", specifier = "~=0.7" }, { name = "pyright", specifier = "~=1.1" }, { name = "pytest", specifier = "~=9.0" }, { name = "pytest-asyncio", specifier = "~=1.3" }, { name = "pytest-cov", specifier = "~=7.1" }, - { name = "pytest-httpx", specifier = "~=0.36" }, { name = "ruff", specifier = "~=0.15" }, ] docs = [ @@ -1396,12 +1436,15 @@ nox = [ { name = "nox-uv", specifier = "~=0.7" }, ] test = [ + { name = "httpx", specifier = "~=0.28" }, { name = "pytest", specifier = "~=9.0" }, { name = "pytest-asyncio", specifier = "~=1.3" }, { name = "pytest-cov", specifier = "~=7.1" }, - { name = "pytest-httpx", specifier = "~=0.36" }, ] -type-check = [{ name = "pyright", specifier = "~=1.1" }] +type-check = [ + { name = "httpx", specifier = "~=0.28" }, + { name = "pyright", specifier = "~=1.1" }, +] [[package]] name = "wrapt"