diff --git a/.changeset/python-pyqwest-rest-transport.md b/.changeset/python-pyqwest-rest-transport.md new file mode 100644 index 0000000000..1909dae142 --- /dev/null +++ b/.changeset/python-pyqwest-rest-transport.md @@ -0,0 +1,46 @@ +--- +"@e2b/python-sdk": minor +--- + +Move the REST API client (sandbox lifecycle, listing, templates, volumes +control plane) onto [`pyqwest`](https://pypi.org/project/pyqwest/) (Rust +reqwest/hyper) via its httpx-compatible transport adapter, replacing the +httpx-native `HTTPTransport`/`AsyncHTTPTransport`. The generated httpx client +API is unchanged — only the transport underneath is swapped — so logging +event hooks, per-request timeouts, and headers behave as before. + +Because pyqwest transports are thread-safe and loop-independent (I/O runs on +a Rust runtime), the API connection pool is now shared process-wide per +proxy, instead of one pool per thread (sync) or per event loop (async), and +`ApiClient` no longer maintains per-thread/per-loop httpx client caches — a +single httpx client serves all threads and event loops. +Connection-establishment failures are retried with backoff +(`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of +the previous transports. Timeouts keep raising `httpx.ReadTimeout` (an +`httpx.TimeoutException`), as before. + +`proxy` for API calls takes a URL string (e.g. +`proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or +socks5h), an `httpx.URL`, or an `httpx.Proxy` — including its credentials +(sent as `Proxy-Authorization`) and any headers configured for the proxy. The +one `httpx.Proxy` option pyqwest cannot express, a per-proxy `ssl_context`, +raises `InvalidArgumentException` rather than being silently dropped. + +Low-level HTTP logs stay available: where enabling the `httpcore` logger used +to show connection-level detail, pyqwest logs one line per request on the +`pyqwest.access` logger and request lifecycle records on `pyqwest`, both at +`DEBUG` and off unless enabled: + +```python +import logging + +logging.basicConfig() +logging.getLogger("pyqwest.access").setLevel(logging.DEBUG) +# DEBUG pyqwest.access - HTTP Request: POST https://api.e2b.app/sandboxes "HTTP/2 201 Created" +``` + +The SDK's own `logger` option is unchanged and independent of these. + +envd traffic is not affected: RPC (commands, PTY, filesystem watch) already +runs on pyqwest via `connectrpc`, and the envd HTTP API (file transfers, +health checks) keeps its httpx transports. diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 15ef836bfb..a6e10900bc 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -1,23 +1,22 @@ -import asyncio import json import logging import os import re -import threading -import weakref from dataclasses import dataclass from types import TracebackType -from typing import Callable, Optional, Protocol, Union +from typing import NamedTuple, Optional, Protocol, Tuple, Union import httpx from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout +from pyqwest import Proxy from e2b.api.client.client import AuthenticatedClient from e2b.api.client.types import Response from e2b.api.metadata import default_headers -from e2b.connection_config import ConnectionConfig +from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.exceptions import ( AuthenticationException, + InvalidArgumentException, RateLimitException, SandboxException, ) @@ -70,6 +69,61 @@ async def on_response(response: Response) -> None: connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES") or "3") +# Mirror the httpx pool tuning above with pyqwest's equivalents for the REST +# API transports. `pool_max_idle_per_host` is per host rather than httpx's +# global idle cap, but API traffic goes to a single host, so the values map +# directly; reqwest has no counterpart to `E2B_MAX_CONNECTIONS` (it does not +# cap concurrent connections). +pool_idle_timeout = float(os.getenv("E2B_KEEPALIVE_EXPIRY") or "300") +pool_max_idle_per_host = int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS") or "20") + + +class ProxyConfig(NamedTuple): + """The ``proxy`` connection option in the shape pyqwest transports take. + + A tuple so it can key the transport caches directly: it is hashable and + compares by value, where a ``pyqwest.Proxy`` compares by identity and + would hand every call its own connection pool.""" + + url: str + auth: Optional[Tuple[str, str]] = None + headers: Tuple[Tuple[str, str], ...] = () + + def to_pyqwest(self) -> Proxy: + """The ``pyqwest.Proxy`` to hand a transport.""" + return Proxy(self.url, auth=self.auth, headers=self.headers or None) + + +def proxy_to_config(proxy: Optional[ProxyTypes]) -> Optional[ProxyConfig]: + """Convert the ``proxy`` connection option — a URL string, an + ``httpx.URL``, or an ``httpx.Proxy`` — to the proxy configuration pyqwest + transports take: a proxy URL (scheme http, https, socks5, or socks5h, + credentials allowed in the userinfo), basic-auth credentials, and headers + to send to the proxy. An ``httpx.Proxy`` ``ssl_context`` has no pyqwest + counterpart and is rejected rather than silently dropped.""" + if proxy is None: + return None + if isinstance(proxy, str): + return ProxyConfig(proxy) + if isinstance(proxy, httpx.URL): + return ProxyConfig(str(proxy)) + if isinstance(proxy, httpx.Proxy): + if proxy.ssl_context is not None: + raise InvalidArgumentException( + "E2B API calls don't support httpx.Proxy ssl_context" + ) + # httpx.Proxy splits userinfo out of the URL into `.auth`; pyqwest + # takes the credentials the same way, so they pass straight through. + return ProxyConfig( + str(proxy.url), + auth=proxy.auth, + headers=tuple(proxy.headers.items()), + ) + raise InvalidArgumentException( + "E2B API calls support only URL-string, httpx.URL, and httpx.Proxy " + 'proxies, e.g. proxy="http://user:pass@localhost:8030"' + ) + @dataclass class SandboxCreateResponse: @@ -154,31 +208,20 @@ def validate_api_key(api_key: str) -> None: class ApiClient(AuthenticatedClient): """ The client for interacting with the E2B API. + + A single lazily-created httpx client (see the generated + ``AuthenticatedClient``) serves all threads and event loops: the pyqwest + transports it delegates to are thread-safe and loop-independent, and + ``httpx.Client`` is documented thread-safe. """ def __init__( self, config: ConnectionConfig, transport: Optional[Union[BaseTransport, AsyncBaseTransport]] = None, - transport_factory: Optional[Callable[[], BaseTransport]] = None, - async_transport_factory: Optional[Callable[[], AsyncBaseTransport]] = None, *args, **kwargs, ): - if transport is not None and ( - transport_factory is not None or async_transport_factory is not None - ): - raise ValueError("Use either transport or transport_factory, not both") - - self._transport_factory = transport_factory - self._async_transport_factory = async_transport_factory - self._thread_local = threading.local() - # Keyed weakly by the event loop object itself, not id(loop) — - # CPython reuses object ids, so a new loop could otherwise inherit - # a client bound to a previous, closed loop. - self._async_clients: weakref.WeakKeyDictionary[ - asyncio.AbstractEventLoop, httpx.AsyncClient - ] = weakref.WeakKeyDictionary() self._proxy = config.proxy if config.api_key is None: @@ -223,12 +266,10 @@ def __init__( "event_hooks": self._logging_event_hooks(), } if transport is not None: + # The proxy lives in the transport; passing `proxy` here too + # would mount a fresh, never-closed proxy transport per client. httpx_args["transport"] = transport - if ( - transport is None - and transport_factory is None - and async_transport_factory is None - ): + else: httpx_args["proxy"] = config.proxy # config.request_timeout is None when the timeout is explicitly @@ -249,53 +290,6 @@ def __init__( def _logging_event_hooks(self) -> dict: return make_logging_event_hooks(self._logger) - def _headers_with_auth(self) -> dict: - return { - **self._headers, - self.auth_header_name: ( - f"{self.prefix} {self.token}" if self.prefix else self.token - ), - } - - def get_httpx_client(self) -> httpx.Client: - if self._client is not None or self._transport_factory is None: - return super().get_httpx_client() - - client = getattr(self._thread_local, "client", None) - if client is None: - client = httpx.Client( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers_with_auth(), - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - event_hooks=self._httpx_args.get("event_hooks"), - transport=self._transport_factory(), - ) - self._thread_local.client = client - return client - - def get_async_httpx_client(self) -> httpx.AsyncClient: - if self._async_client is not None or self._async_transport_factory is None: - return super().get_async_httpx_client() - - loop = asyncio.get_running_loop() - client = self._async_clients.get(loop) - if client is None: - client = httpx.AsyncClient( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers_with_auth(), - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - event_hooks=self._httpx_args.get("event_hooks"), - transport=self._async_transport_factory(), - ) - self._async_clients[loop] = client - return client - # We need to override the logging hooks for the async usage class AsyncApiClient(ApiClient): diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py index dd001a3a41..d4de1b49a8 100644 --- a/packages/python-sdk/e2b/api/client_async/__init__.py +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -1,32 +1,114 @@ import asyncio +import threading import weakref -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, Union import httpx -from httpx._types import ProxyTypes - -from e2b.api import AsyncApiClient, connection_retries, limits -from e2b.connection_config import ConnectionConfig +from pyqwest import HTTPTransport, Request, Response +from pyqwest.httpx import AsyncPyqwestTransport +from pyqwest.middleware.retry import RetryTransport + +from e2b.api import ( + AsyncApiClient, + ProxyConfig, + connection_retries, + limits, + pool_idle_timeout, + pool_max_idle_per_host, + proxy_to_config, +) +from e2b.connection_config import ConnectionConfig, ProxyTypes TransportKey = Tuple[bool, Optional[ProxyTypes]] def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: - return AsyncApiClient( - config, - async_transport_factory=lambda: get_transport(config), - **kwargs, - ) - - -class AsyncTransportWithLogger(httpx.AsyncHTTPTransport): + return AsyncApiClient(config, transport=get_transport(config), **kwargs) + + +class AsyncApiPyqwestTransport(AsyncPyqwestTransport): + """The SDK's tweaks on the stock pyqwest httpx adapter. + + Strip the ``Host`` header httpx adds to every request: hyper derives + HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and + forwarding an explicit ``host`` header on an HTTP/2 connection makes the + E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host`` + overrides are therefore not honored, matching hyper's URL-derived + behavior.) + + Re-raise pyqwest's timeouts (the builtin ``TimeoutError``, and + ``asyncio.TimeoutError`` from the adapter's deadline — distinct types + until Python 3.11) as ``httpx.ReadTimeout``, preserving the + ``httpx.TimeoutException`` contract the httpx-native transport gave + callers.""" + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if "host" in request.headers: + del request.headers["host"] + try: + return await super().handle_async_request(request) + except (TimeoutError, asyncio.TimeoutError) as e: + raise httpx.ReadTimeout(str(e), request=request) from e + + +class ConnectionRetryTransport(RetryTransport): + """Retry only failures establishing the connection, matching the + connect-only ``retries`` of the httpx transport this replaced: pyqwest + raises the builtin ``ConnectionError`` only before the request was + written, so these retries can never replay a request the API may have + received. The retry middleware's default policy would otherwise also + retry I/O errors and 429/5xx responses for idempotent methods.""" + + def should_retry_response( + self, request: Request, response: Union[Response, Exception] + ) -> bool: + return isinstance(response, ConnectionError) + + +_transport_lock = threading.Lock() +# One transport (= one connection pool) per proxy; None is the direct pool. +# pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd +# transports below, the transport is not bound to an event loop and the +# cache is process-global rather than per-loop. +_transports: Dict[Optional[ProxyConfig], "AsyncApiPyqwestTransport"] = {} + + +def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport": + """The shared pyqwest-backed httpx transport for REST API calls. For TLS + connections ALPN negotiates the HTTP version (HTTP/2 against the E2B + API), like the http2-enabled httpx transport this replaced. + + Requests are logged by pyqwest itself on the ``pyqwest.access`` and + ``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level + diagnostics httpcore used to provide. The SDK's own ``logger`` option is + separate and sits above this, on the httpx client.""" + proxy = proxy_to_config(config.proxy) + with _transport_lock: + transport = _transports.get(proxy) + if transport is None: + transport = AsyncApiPyqwestTransport( + ConnectionRetryTransport( + HTTPTransport( + tls_include_system_certs=True, + proxy=proxy.to_pyqwest() if proxy is not None else None, + pool_idle_timeout=pool_idle_timeout, + pool_max_idle_per_host=pool_max_idle_per_host, + ), + max_retries=connection_retries, + ) + ) + _transports[proxy] = transport + return transport + + +class AsyncEnvdTransportWithLogger(httpx.AsyncHTTPTransport): # Keyed weakly by the event loop object itself, not id(loop) — CPython # reuses object ids, so a new loop could otherwise inherit a transport # bound to a previous, closed loop. _instances: weakref.WeakKeyDictionary[ asyncio.AbstractEventLoop, - Dict[TransportKey, "AsyncTransportWithLogger"], + Dict[TransportKey, "AsyncEnvdTransportWithLogger"], ] = weakref.WeakKeyDictionary() @property @@ -34,17 +116,19 @@ def pool(self): return self._pool -def _get_cached_transport(cls, config: ConnectionConfig, http2: bool): +def get_envd_transport( + config: ConnectionConfig, http2: bool = True +) -> AsyncEnvdTransportWithLogger: loop = asyncio.get_running_loop() - loop_instances = cls._instances.get(loop) + loop_instances = AsyncEnvdTransportWithLogger._instances.get(loop) if loop_instances is None: loop_instances = {} - cls._instances[loop] = loop_instances + AsyncEnvdTransportWithLogger._instances[loop] = loop_instances key: TransportKey = (http2, config.proxy) transport = loop_instances.get(key) if transport is None: - transport = cls( + transport = AsyncEnvdTransportWithLogger( limits=limits, proxy=config.proxy, http2=http2, @@ -53,22 +137,3 @@ def _get_cached_transport(cls, config: ConnectionConfig, http2: bool): loop_instances[key] = transport return transport - - -def get_transport( - config: ConnectionConfig, http2: bool = True -) -> AsyncTransportWithLogger: - return _get_cached_transport(AsyncTransportWithLogger, config, http2) - - -class AsyncEnvdTransportWithLogger(AsyncTransportWithLogger): - _instances: weakref.WeakKeyDictionary[ - asyncio.AbstractEventLoop, - Dict[TransportKey, "AsyncEnvdTransportWithLogger"], - ] = weakref.WeakKeyDictionary() - - -def get_envd_transport( - config: ConnectionConfig, http2: bool = True -) -> AsyncEnvdTransportWithLogger: - return _get_cached_transport(AsyncEnvdTransportWithLogger, config, http2) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py index 41fb4f023d..b5ffa06ee3 100644 --- a/packages/python-sdk/e2b/api/client_sync/__init__.py +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -1,25 +1,103 @@ -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, Union import httpx import threading -from httpx._types import ProxyTypes - -from e2b.api import ApiClient, connection_retries, limits -from e2b.connection_config import ConnectionConfig +from pyqwest import SyncHTTPTransport, SyncRequest, SyncResponse +from pyqwest.httpx import PyqwestTransport +from pyqwest.middleware.retry import SyncRetryTransport + +from e2b.api import ( + ApiClient, + ProxyConfig, + connection_retries, + limits, + pool_idle_timeout, + pool_max_idle_per_host, + proxy_to_config, +) +from e2b.connection_config import ConnectionConfig, ProxyTypes TransportKey = Tuple[bool, Optional[ProxyTypes]] def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: - return ApiClient( - config, - transport_factory=lambda: get_transport(config), - **kwargs, - ) - - -class TransportWithLogger(httpx.HTTPTransport): + return ApiClient(config, transport=get_transport(config), **kwargs) + + +class ApiPyqwestTransport(PyqwestTransport): + """The SDK's tweaks on the stock pyqwest httpx adapter. + + Strip the ``Host`` header httpx adds to every request: hyper derives + HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and + forwarding an explicit ``host`` header on an HTTP/2 connection makes the + E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host`` + overrides are therefore not honored, matching hyper's URL-derived + behavior.) + + Re-raise pyqwest's timeouts (the builtin ``TimeoutError``) as + ``httpx.ReadTimeout``, preserving the ``httpx.TimeoutException`` contract + the httpx-native transport gave callers.""" + + def handle_request(self, request: httpx.Request) -> httpx.Response: + if "host" in request.headers: + del request.headers["host"] + try: + return super().handle_request(request) + except TimeoutError as e: + raise httpx.ReadTimeout(str(e), request=request) from e + + +class ConnectionRetryTransport(SyncRetryTransport): + """Retry only failures establishing the connection, matching the + connect-only ``retries`` of the httpx transport this replaced: pyqwest + raises the builtin ``ConnectionError`` only before the request was + written, so these retries can never replay a request the API may have + received. The retry middleware's default policy would otherwise also + retry I/O errors and 429/5xx responses for idempotent methods.""" + + def should_retry_response( + self, request: SyncRequest, response: Union[SyncResponse, Exception] + ) -> bool: + return isinstance(response, ConnectionError) + + +_transport_lock = threading.Lock() +# One transport (= one connection pool) per proxy; None is the direct pool. +# pyqwest transports are thread-safe, so unlike the httpx envd transports +# below, the cache is process-global rather than per-thread. +_transports: Dict[Optional[ProxyConfig], "ApiPyqwestTransport"] = {} + + +def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport": + """The shared pyqwest-backed httpx transport for REST API calls. For TLS + connections ALPN negotiates the HTTP version (HTTP/2 against the E2B + API), like the http2-enabled httpx transport this replaced. + + Requests are logged by pyqwest itself on the ``pyqwest.access`` and + ``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level + diagnostics httpcore used to provide. The SDK's own ``logger`` option is + separate and sits above this, on the httpx client.""" + proxy = proxy_to_config(config.proxy) + with _transport_lock: + transport = _transports.get(proxy) + if transport is None: + transport = ApiPyqwestTransport( + ConnectionRetryTransport( + SyncHTTPTransport( + tls_include_system_certs=True, + proxy=proxy.to_pyqwest() if proxy is not None else None, + pool_idle_timeout=pool_idle_timeout, + pool_max_idle_per_host=pool_max_idle_per_host, + ), + max_retries=connection_retries, + ) + ) + _transports[proxy] = transport + return transport + + +class EnvdTransportWithLogger(httpx.HTTPTransport): _thread_local = threading.local() @property @@ -27,30 +105,6 @@ def pool(self): return self._pool -def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWithLogger: - instances: Dict[TransportKey, TransportWithLogger] = getattr( - TransportWithLogger._thread_local, "instances", {} - ) - key: TransportKey = (http2, config.proxy) - cached = instances.get(key) - if cached is not None: - return cached - - transport = TransportWithLogger( - limits=limits, - proxy=config.proxy, - http2=http2, - retries=connection_retries, - ) - instances[key] = transport - TransportWithLogger._thread_local.instances = instances - return transport - - -class EnvdTransportWithLogger(TransportWithLogger): - _thread_local = threading.local() - - def get_envd_transport( config: ConnectionConfig, http2: bool = True ) -> EnvdTransportWithLogger: diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 81f4a09460..0de3688470 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -1,14 +1,24 @@ import logging import os -from typing import cast, Optional, Dict, TypedDict +from typing import cast, Optional, Dict, TypedDict, Union -from httpx._types import ProxyTypes +import httpx from typing_extensions import Unpack from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain +ProxyTypes = Union[str, httpx.URL, httpx.Proxy] +"""The forms the ``proxy`` option accepts: a URL string, an ``httpx.URL``, or +an ``httpx.Proxy``. + +Identical to ``httpx._types.ProxyTypes``, spelled out here so the SDK doesn't +import httpx's private module at runtime — and so the union can grow a pyqwest +proxy type as the transports move off httpx. The functions that narrow it are +:func:`e2b.api.proxy_to_config` and :func:`e2b.envd.client_shared.proxy_to_url`. +""" + REQUEST_TIMEOUT: float = 60.0 # 60 seconds KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds diff --git a/packages/python-sdk/e2b/envd/client_shared.py b/packages/python-sdk/e2b/envd/client_shared.py index 3ed99a9ecb..950f6bb7c2 100644 --- a/packages/python-sdk/e2b/envd/client_shared.py +++ b/packages/python-sdk/e2b/envd/client_shared.py @@ -23,6 +23,7 @@ from connectrpc.errors import ConnectError from protobuf import Message +from e2b.connection_config import ProxyTypes from e2b.exceptions import InvalidArgumentException # Mirror the httpx pool tuning in `e2b.api.limits` with pyqwest's equivalents. @@ -170,7 +171,7 @@ class _RPCCompression(TypedDict): } -def proxy_to_url(proxy: object) -> Optional[str]: +def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]: """Narrow the ``proxy`` connection option to the proxy URL string pyqwest transports take (scheme http, https, socks5, or socks5h, credentials in the URL userinfo). ``httpx.URL`` and ``httpx.Proxy`` — which the REST diff --git a/packages/python-sdk/e2b/volume/client_async/__init__.py b/packages/python-sdk/e2b/volume/client_async/__init__.py index 132bc43a7d..27ee13d608 100644 --- a/packages/python-sdk/e2b/volume/client_async/__init__.py +++ b/packages/python-sdk/e2b/volume/client_async/__init__.py @@ -5,10 +5,10 @@ import httpx from httpx import Limits -from httpx._types import ProxyTypes from e2b.api import connection_retries, make_async_logging_event_hooks from e2b.api.metadata import default_headers +from e2b.connection_config import ProxyTypes from e2b.exceptions import AuthenticationException from e2b.volume.client.client import AuthenticatedClient as AsyncVolumeApiClient from e2b.volume.connection_config import VolumeConnectionConfig diff --git a/packages/python-sdk/e2b/volume/client_sync/__init__.py b/packages/python-sdk/e2b/volume/client_sync/__init__.py index c6a55cb37d..288f7b4983 100644 --- a/packages/python-sdk/e2b/volume/client_sync/__init__.py +++ b/packages/python-sdk/e2b/volume/client_sync/__init__.py @@ -4,10 +4,10 @@ import httpx from httpx import Limits -from httpx._types import ProxyTypes from e2b.api import connection_retries, make_logging_event_hooks from e2b.api.metadata import default_headers +from e2b.connection_config import ProxyTypes from e2b.exceptions import AuthenticationException from e2b.volume.client.client import AuthenticatedClient as VolumeApiClient from e2b.volume.connection_config import VolumeConnectionConfig diff --git a/packages/python-sdk/e2b/volume/connection_config.py b/packages/python-sdk/e2b/volume/connection_config.py index 2662604001..2e902208f4 100644 --- a/packages/python-sdk/e2b/volume/connection_config.py +++ b/packages/python-sdk/e2b/volume/connection_config.py @@ -3,10 +3,10 @@ from typing import Dict, Optional, TypedDict -from httpx._types import ProxyTypes from typing_extensions import Unpack from e2b.api.metadata import package_version +from e2b.connection_config import ProxyTypes REQUEST_TIMEOUT: float = 60.0 # 60 seconds diff --git a/packages/python-sdk/e2b/volume/volume_async.py b/packages/python-sdk/e2b/volume/volume_async.py index 2e0d711ba6..2e86ce4389 100644 --- a/packages/python-sdk/e2b/volume/volume_async.py +++ b/packages/python-sdk/e2b/volume/volume_async.py @@ -3,7 +3,6 @@ import httpx -from httpx._types import ProxyTypes from typing_extensions import Unpack from e2b.api import handle_api_exception @@ -19,7 +18,7 @@ ) from e2b.api.client.types import Response from e2b.api.client_async import get_api_client as get_core_api_client -from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.connection_config import ApiParams, ConnectionConfig, ProxyTypes from e2b.exceptions import NotFoundException, VolumeException from e2b.volume.client.api.volumes import ( get_volumecontent_volume_id_path as get_path, diff --git a/packages/python-sdk/e2b/volume/volume_sync.py b/packages/python-sdk/e2b/volume/volume_sync.py index 9017526ded..5ca28d9dde 100644 --- a/packages/python-sdk/e2b/volume/volume_sync.py +++ b/packages/python-sdk/e2b/volume/volume_sync.py @@ -4,7 +4,6 @@ import httpx -from httpx._types import ProxyTypes from typing_extensions import Unpack from e2b.api import handle_api_exception @@ -20,7 +19,7 @@ ) from e2b.api.client.types import Response from e2b.api.client_sync import get_api_client as get_core_api_client -from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.connection_config import ApiParams, ConnectionConfig, ProxyTypes from e2b.exceptions import NotFoundException, VolumeException from e2b.volume.client.api.volumes import ( get_volumecontent_volume_id_path as get_path, diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index 98ea4f4738..5e022c6e98 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "dockerfile-parse>=2.0.1,<3", "rich>=14.0.0", "connectrpc>=0.11.1,<0.12", - "pyqwest>=0.7.0,<0.8", + "pyqwest>=0.8.0,<0.9", ] [project.urls] diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py index e54848e98a..97fc9c32fd 100644 --- a/packages/python-sdk/tests/test_api_client_transport.py +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -1,23 +1,49 @@ import asyncio -import gc +import base64 +import json +import logging +import ssl +import threading +import time from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import cast import httpx import pytest - -from e2b.api.client_async import AsyncEnvdTransportWithLogger, AsyncTransportWithLogger +from pyqwest import ( + Headers, + Proxy, + Request, + Response, + SyncRequest, + SyncResponse, + SyncTransport, + Transport, +) +from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport + +import e2b.api.client_async as client_async +import e2b.api.client_sync as client_sync +from e2b.api import ProxyConfig, proxy_to_config +from e2b.api.client_async import AsyncEnvdTransportWithLogger from e2b.api.client_async import get_api_client as get_async_api_client from e2b.api.client_async import get_envd_transport as get_async_envd_transport from e2b.api.client_async import get_transport as get_async_transport -from e2b.api.client_sync import EnvdTransportWithLogger, TransportWithLogger +from e2b.api.client_sync import EnvdTransportWithLogger from e2b.api.client_sync import get_api_client as get_sync_api_client from e2b.api.client_sync import get_envd_transport as get_sync_envd_transport from e2b.api.client_sync import get_transport as get_sync_transport -from e2b.connection_config import ConnectionConfig +from e2b.connection_config import ConnectionConfig, ProxyTypes +from e2b.exceptions import InvalidArgumentException def reset_sync_api_transports(): - TransportWithLogger._thread_local.instances = {} + client_sync._transports.clear() + + +def reset_async_api_transports(): + client_async._transports.clear() def reset_sync_envd_transports(): @@ -29,6 +55,116 @@ def run_in_worker_thread(fn): return executor.submit(fn).result() +def test_proxy_to_config_narrows_urls(): + assert proxy_to_config(None) is None + assert proxy_to_config("http://127.0.0.1:9999") == ProxyConfig( + "http://127.0.0.1:9999" + ) + assert proxy_to_config(httpx.URL("http://127.0.0.1:9999")) == ProxyConfig( + "http://127.0.0.1:9999" + ) + # Guards callers that ignore the type hint. + with pytest.raises(InvalidArgumentException, match="URL-string"): + proxy_to_config(cast(ProxyTypes, object())) + + +def test_proxy_to_config_converts_httpx_proxy(): + # Everything pyqwest's Proxy can express carries over: the URL, the + # credentials httpx.Proxy splits off the URL, and headers for the proxy. + assert proxy_to_config(httpx.Proxy("http://127.0.0.1:9999")) == ProxyConfig( + "http://127.0.0.1:9999" + ) + assert proxy_to_config( + httpx.Proxy("http://user:pass@127.0.0.1:9999") + ) == ProxyConfig("http://127.0.0.1:9999", auth=("user", "pass")) + assert proxy_to_config( + httpx.Proxy("http://127.0.0.1:9999", auth=("user@x", "p@ss")) + ) == ProxyConfig("http://127.0.0.1:9999", auth=("user@x", "p@ss")) + assert proxy_to_config( + httpx.Proxy("http://127.0.0.1:9999", headers={"X-Auth": "t"}) + ) == ProxyConfig("http://127.0.0.1:9999", headers=(("x-auth", "t"),)) + + # A per-proxy TLS context has no pyqwest counterpart; rejected rather than + # silently dropped. + with pytest.raises(InvalidArgumentException, match="ssl_context"): + proxy_to_config( + httpx.Proxy( + "https://127.0.0.1:9999", ssl_context=ssl.create_default_context() + ) + ) + + +def test_proxy_config_builds_a_pyqwest_proxy(): + config = ProxyConfig( + "http://127.0.0.1:9999", auth=("user", "pass"), headers=(("x-auth", "t"),) + ) + assert isinstance(config.to_pyqwest(), Proxy) + # Equal configs are one cache key, even though the Proxy objects they + # build compare by identity. + assert config == ProxyConfig( + "http://127.0.0.1:9999", auth=("user", "pass"), headers=(("x-auth", "t"),) + ) + assert config.to_pyqwest() != config.to_pyqwest() + + +def test_connection_retry_policy_retries_only_connection_errors(): + # The inner transport is never invoked by should_retry_response. + sync_policy = client_sync.ConnectionRetryTransport(cast(SyncTransport, object())) + async_policy = client_async.ConnectionRetryTransport(cast(Transport, object())) + sync_request = SyncRequest("GET", "https://example.com") + async_request = Request("GET", "https://example.com") + + assert sync_policy.should_retry_response(sync_request, ConnectionError("refused")) + # TimeoutError is an OSError but not a ConnectionError: the request + # may have been written, so it must not be replayed. + assert not sync_policy.should_retry_response(sync_request, TimeoutError()) + assert not sync_policy.should_retry_response(sync_request, RuntimeError()) + + assert async_policy.should_retry_response(async_request, ConnectionError("refused")) + assert not async_policy.should_retry_response(async_request, TimeoutError()) + assert not async_policy.should_retry_response(async_request, RuntimeError()) + + +def test_sync_api_transport_strips_host_header(): + # Forwarding httpx's auto-added Host header on an HTTP/2 connection makes + # the E2B API edge reset the stream with PROTOCOL_ERROR; hyper derives + # Host/:authority from the URL instead. + captured = {} + + class FakeInner: + def execute_sync(self, request): + captured["headers"] = dict(request.headers.items()) + return SyncResponse(status=200, headers=Headers(()), content=b"") + + transport = client_sync.ApiPyqwestTransport(FakeInner()) + request = httpx.Request("GET", "https://api.example.com/health") + assert "host" in request.headers + + response = transport.handle_request(request) + + assert response.status_code == 200 + assert "host" not in captured["headers"] + + +@pytest.mark.asyncio +async def test_async_api_transport_strips_host_header(): + captured = {} + + class FakeInner: + async def execute(self, request): + captured["headers"] = dict(request.headers.items()) + return Response(status=200, headers=Headers(()), content=b"") + + transport = client_async.AsyncApiPyqwestTransport(FakeInner()) + request = httpx.Request("GET", "https://api.example.com/health") + assert "host" in request.headers + + response = await transport.handle_async_request(request) + + assert response.status_code == 200 + assert "host" not in captured["headers"] + + def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): reset_sync_api_transports() config = ConnectionConfig( @@ -42,31 +178,13 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): try: assert "proxy" not in api_client._httpx_args assert httpx_client._transport is get_sync_transport(config) + assert isinstance(httpx_client._transport, PyqwestTransport) assert httpx_client._mounts == {} finally: httpx_client.close() reset_sync_api_transports() -def test_sync_get_transport_http2_opt_out_returns_distinct_instance(test_api_key): - reset_sync_api_transports() - config = ConnectionConfig(api_key=test_api_key) - - try: - http2_transport = get_sync_transport(config) - http1_transport = get_sync_transport(config, http2=False) - - assert http2_transport is not http1_transport - assert http2_transport._pool._http2 is True - assert http1_transport._pool._http2 is False - # Subsequent calls with the same http2 flag return the cached - # instance. - assert get_sync_transport(config) is http2_transport - assert get_sync_transport(config, http2=False) is http1_transport - finally: - reset_sync_api_transports() - - def test_sync_get_transport_keyed_by_proxy(test_api_key): reset_sync_api_transports() proxied_config = ConnectionConfig( @@ -122,7 +240,9 @@ def test_sync_api_client_request_timeout_zero_disables_timeout(test_api_key): reset_sync_api_transports() -def test_sync_envd_transport_uses_separate_cache(test_api_key): +def test_sync_envd_transport_uses_separate_stack(test_api_key): + # envd file-transfer traffic stays on the httpx-native transport (its RPC + # migration is a separate change); only REST API calls go through pyqwest. reset_sync_api_transports() reset_sync_envd_transports() config = ConnectionConfig(api_key=test_api_key) @@ -131,67 +251,31 @@ def test_sync_envd_transport_uses_separate_cache(test_api_key): api_transport = get_sync_transport(config) envd_transport = get_sync_envd_transport(config) - assert api_transport is not envd_transport + assert isinstance(api_transport, PyqwestTransport) + assert isinstance(envd_transport, httpx.HTTPTransport) assert get_sync_transport(config) is api_transport assert get_sync_envd_transport(config) is envd_transport - assert envd_transport._pool._http2 is True + envd_pool = envd_transport._pool + assert envd_pool._http2 is True # ty: ignore[possibly-missing-attribute] finally: reset_sync_api_transports() reset_sync_envd_transports() -def test_sync_api_transport_cache_reuses_within_thread_and_isolates_across_threads( - test_api_key, -): - reset_sync_api_transports() - config = ConnectionConfig(api_key=test_api_key) - - try: - main_transport = get_sync_transport(config) - same_thread_transport = get_sync_transport(config) - worker_thread_transport = run_in_worker_thread( - lambda: get_sync_transport(config) - ) - - assert same_thread_transport is main_transport - assert worker_thread_transport is not main_transport - finally: - reset_sync_api_transports() - - -def test_sync_api_client_cache_reuses_within_thread_and_isolates_across_threads( - test_api_key, -): +def test_sync_api_client_is_shared_across_threads(test_api_key): + # httpx.Client is thread-safe and the pyqwest transport underneath is + # too, so a single client (and its pool) serves all threads — the + # per-thread client caching this replaced is gone. reset_sync_api_transports() config = ConnectionConfig(api_key=test_api_key) api_client = get_sync_api_client(config) - def get_worker_client_ids(): - httpx_client = api_client.get_httpx_client() - try: - return ( - id(httpx_client), - id(api_client.get_httpx_client()), - id(httpx_client._transport), - id(get_sync_transport(config)), - ) - finally: - httpx_client.close() - try: main_client = api_client.get_httpx_client() - ( - worker_client_id, - worker_cached_client_id, - worker_transport_id, - worker_cached_transport_id, - ) = run_in_worker_thread(get_worker_client_ids) + worker_client = run_in_worker_thread(api_client.get_httpx_client) assert api_client.get_httpx_client() is main_client - assert worker_client_id == worker_cached_client_id - assert worker_transport_id == worker_cached_transport_id - assert worker_client_id != id(main_client) - assert worker_transport_id != id(main_client._transport) + assert worker_client is main_client finally: main_client.close() reset_sync_api_transports() @@ -207,7 +291,8 @@ def test_sync_envd_transport_cache_is_thread_local(test_api_key): assert main_transport is get_sync_envd_transport(config) assert thread_transport is not main_transport - assert main_transport._pool._http2 is True + main_pool = main_transport._pool + assert main_pool._http2 is True # ty: ignore[possibly-missing-attribute] assert thread_transport._pool._http2 is True finally: reset_sync_envd_transports() @@ -215,7 +300,7 @@ def test_sync_envd_transport_cache_is_thread_local(test_api_key): @pytest.mark.asyncio async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() config = ConnectionConfig( api_key=test_api_key, proxy="http://127.0.0.1:9999", @@ -223,44 +308,20 @@ async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): api_client = get_async_api_client(config) httpx_client = api_client.get_async_httpx_client() - transport = AsyncTransportWithLogger._instances[asyncio.get_running_loop()][ - (True, "http://127.0.0.1:9999") - ] try: assert "proxy" not in api_client._httpx_args - assert httpx_client._transport is transport + assert httpx_client._transport is get_async_transport(config) + assert isinstance(httpx_client._transport, AsyncPyqwestTransport) assert httpx_client._mounts == {} finally: await httpx_client.aclose() - AsyncTransportWithLogger._instances.clear() - - -@pytest.mark.asyncio -async def test_async_get_transport_http2_opt_out_returns_distinct_instance( - test_api_key, -): - AsyncTransportWithLogger._instances.clear() - config = ConnectionConfig(api_key=test_api_key) - - try: - http2_transport = get_async_transport(config) - http1_transport = get_async_transport(config, http2=False) - - assert http2_transport is not http1_transport - assert http2_transport._pool._http2 is True - assert http1_transport._pool._http2 is False - # Subsequent calls with the same http2 flag return the cached - # instance. - assert get_async_transport(config) is http2_transport - assert get_async_transport(config, http2=False) is http1_transport - finally: - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() @pytest.mark.asyncio async def test_async_get_transport_keyed_by_proxy(test_api_key): - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() proxied_config = ConnectionConfig( api_key=test_api_key, proxy="http://127.0.0.1:9999", @@ -276,146 +337,246 @@ async def test_async_get_transport_keyed_by_proxy(test_api_key): assert get_async_transport(proxied_config) is proxied_transport assert get_async_transport(direct_config) is direct_transport finally: - AsyncTransportWithLogger._instances.clear() + reset_async_api_transports() @pytest.mark.asyncio -async def test_async_api_client_applies_request_timeout(test_api_key): - AsyncTransportWithLogger._instances.clear() - config = ConnectionConfig(api_key=test_api_key, request_timeout=1.5) - +async def test_async_api_client_is_shared_across_loops(test_api_key): + # pyqwest's I/O runs on its own Rust runtime, so neither the transport + # nor the httpx client wrapper is bound to an event loop — a single + # client serves all loops (the per-loop client caching this replaced is + # gone). + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key) api_client = get_async_api_client(config) - httpx_client = api_client.get_async_httpx_client() + + async def get_client(): + return api_client.get_async_httpx_client() try: - assert httpx_client.timeout == httpx.Timeout(1.5) + main_client = api_client.get_async_httpx_client() + other_loop_client = await asyncio.get_running_loop().run_in_executor( + None, + lambda: asyncio.run(get_client()), + ) + + assert api_client.get_async_httpx_client() is main_client + assert other_loop_client is main_client finally: - await httpx_client.aclose() - AsyncTransportWithLogger._instances.clear() + await main_client.aclose() + reset_async_api_transports() @pytest.mark.asyncio -async def test_async_api_client_cache_reuses_within_loop_and_isolates_across_loops( - test_api_key, -): - AsyncTransportWithLogger._instances.clear() +async def test_async_envd_transport_uses_separate_stack(test_api_key): + reset_async_api_transports() + AsyncEnvdTransportWithLogger._instances.clear() config = ConnectionConfig(api_key=test_api_key) - api_client = get_async_api_client(config) - async def get_client_ids(): - httpx_client = api_client.get_async_httpx_client() - try: - return ( - id(httpx_client), - id(api_client.get_async_httpx_client()), - id(httpx_client._transport), - id(get_async_transport(config)), - ) - finally: - await httpx_client.aclose() + try: + api_transport = get_async_transport(config) + envd_transport = get_async_envd_transport(config) + assert isinstance(api_transport, AsyncPyqwestTransport) + assert isinstance(envd_transport, httpx.AsyncHTTPTransport) + assert get_async_transport(config) is api_transport + assert get_async_envd_transport(config) is envd_transport + envd_pool = envd_transport._pool + assert envd_pool._http2 is True # ty: ignore[possibly-missing-attribute] + finally: + reset_async_api_transports() + AsyncEnvdTransportWithLogger._instances.clear() + + +class _EchoHandler(BaseHTTPRequestHandler): + """Answers every GET with a JSON echo of the request headers; a path + starting with ``/slow`` sleeps 5 seconds first.""" + + def do_GET(self): + if self.path.startswith("/slow"): + time.sleep(5) + headers = {k.lower(): v for k, v in self.headers.items()} + body = json.dumps({"path": self.path, "headers": headers}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +@pytest.fixture +def echo_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _EchoHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() try: - main_client = api_client.get_async_httpx_client() - ( - worker_client_id, - worker_cached_client_id, - worker_transport_id, - worker_cached_transport_id, - ) = await asyncio.get_running_loop().run_in_executor( - None, - lambda: asyncio.run(get_client_ids()), + yield f"http://127.0.0.1:{server.server_address[1]}" + finally: + server.shutdown() + thread.join() + + +def test_sync_transport_sends_proxy_credentials_and_headers(test_api_key, echo_server): + # Everything an httpx.Proxy can express reaches the proxy: the echo server + # stands in for one, so the request arrives in absolute form with the + # credentials and the extra headers configured for it. + reset_sync_api_transports() + config = ConnectionConfig( + api_key=test_api_key, + proxy=httpx.Proxy( + echo_server, auth=("user", "pass"), headers={"X-Proxy-Token": "t"} + ), + ) + client = httpx.Client(transport=get_sync_transport(config)) + + try: + echoed = client.get("http://proxied.invalid/health").json() + assert echoed["path"] == "http://proxied.invalid/health" + assert echoed["headers"]["proxy-authorization"] == ( + "Basic " + base64.b64encode(b"user:pass").decode() ) + assert echoed["headers"]["x-proxy-token"] == "t" + finally: + client.close() + reset_sync_api_transports() - assert api_client.get_async_httpx_client() is main_client - assert worker_client_id == worker_cached_client_id - assert worker_transport_id == worker_cached_transport_id - assert worker_client_id != id(main_client) - assert worker_transport_id != id(main_client._transport) + +def test_transport_emits_pyqwest_access_log(test_api_key, echo_server, caplog): + # pyqwest logs every request on `pyqwest.access` at DEBUG — the + # transport-level diagnostics httpcore used to provide, and separate from + # the SDK's own `logger` option. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + with caplog.at_level(logging.DEBUG, logger="pyqwest.access"): + assert httpx_client.request("GET", "/sandboxes").status_code == 200 + + messages = [ + r.getMessage() for r in caplog.records if r.name == "pyqwest.access" + ] + # The stdlib test server answers HTTP/1.0. + assert messages == [ + f'HTTP Request: GET {echo_server}/sandboxes "HTTP/1.0 200 OK"' + ] finally: - await main_client.aclose() - AsyncTransportWithLogger._instances.clear() + httpx_client.close() + reset_sync_api_transports() -def test_async_transport_not_reused_across_sequential_loops(test_api_key): - AsyncTransportWithLogger._instances.clear() - config = ConnectionConfig(api_key=test_api_key) +def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + assert isinstance(httpx_client._transport, PyqwestTransport) + response = httpx_client.request("GET", "/sandboxes") + assert response.status_code == 200 + echoed = response.json() + assert echoed["path"] == "/sandboxes" + assert echoed["headers"]["x-api-key"] == test_api_key + assert echoed["headers"]["package_version"] + finally: + httpx_client.close() + reset_sync_api_transports() - async def get_transport(): - return get_async_transport(config) + +def test_sync_api_client_serves_concurrent_threads(test_api_key, echo_server): + # The scenario the removed per-thread client caching used to guard: one + # client, one shared pyqwest pool, many threads at once. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + def request(i: int) -> tuple[int, str]: + response = httpx_client.request("GET", f"/sandboxes/{i}") + return response.status_code, response.json()["path"] try: - loop_a = asyncio.new_event_loop() - try: - transport_a = loop_a.run_until_complete(get_transport()) - finally: - loop_a.close() - del loop_a - gc.collect() - - # The cache entry dies with the loop, so a later loop can never - # inherit a transport bound to a closed loop, even when CPython - # reuses the dead loop's object id. - assert len(AsyncTransportWithLogger._instances) == 0 - - loop_b = asyncio.new_event_loop() - try: - transport_b = loop_b.run_until_complete(get_transport()) - finally: - loop_b.close() - - assert transport_b is not transport_a + with ThreadPoolExecutor(max_workers=16) as executor: + results = list(executor.map(request, range(32))) + + assert results == [(200, f"/sandboxes/{i}") for i in range(32)] finally: - AsyncTransportWithLogger._instances.clear() + httpx_client.close() + reset_sync_api_transports() -def test_async_api_client_not_reused_across_sequential_loops(test_api_key): - AsyncTransportWithLogger._instances.clear() - config = ConnectionConfig(api_key=test_api_key) +@pytest.mark.asyncio +async def test_async_api_client_serves_concurrent_requests(test_api_key, echo_server): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() - async def get_client(): - client = api_client.get_async_httpx_client() - assert api_client.get_async_httpx_client() is client - return client + async def request(i: int) -> tuple[int, str]: + response = await httpx_client.request("GET", f"/sandboxes/{i}") + return response.status_code, response.json()["path"] try: - loop_a = asyncio.new_event_loop() - try: - client_a = loop_a.run_until_complete(get_client()) - loop_a.run_until_complete(client_a.aclose()) - finally: - loop_a.close() - del loop_a - gc.collect() - - assert len(api_client._async_clients) == 0 - - loop_b = asyncio.new_event_loop() - try: - client_b = loop_b.run_until_complete(get_client()) - loop_b.run_until_complete(client_b.aclose()) - finally: - loop_b.close() - - assert client_b is not client_a + results = await asyncio.gather(*(request(i) for i in range(32))) + assert list(results) == [(200, f"/sandboxes/{i}") for i in range(32)] finally: - AsyncTransportWithLogger._instances.clear() + await httpx_client.aclose() + reset_async_api_transports() @pytest.mark.asyncio -async def test_async_envd_transport_uses_separate_cache(test_api_key): - AsyncTransportWithLogger._instances.clear() - AsyncEnvdTransportWithLogger._instances.clear() - config = ConnectionConfig(api_key=test_api_key) +async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_server): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() try: - api_transport = get_async_transport(config) - envd_transport = get_async_envd_transport(config) + assert isinstance(httpx_client._transport, AsyncPyqwestTransport) + response = await httpx_client.request("GET", "/sandboxes") + assert response.status_code == 200 + echoed = response.json() + assert echoed["path"] == "/sandboxes" + assert echoed["headers"]["x-api-key"] == test_api_key + assert echoed["headers"]["package_version"] + finally: + await httpx_client.aclose() + reset_async_api_transports() - assert api_transport is not envd_transport - assert get_async_transport(config) is api_transport - assert get_async_envd_transport(config) is envd_transport - assert envd_transport._pool._http2 is True + +def test_sync_api_client_timeout_raises_httpx_read_timeout(test_api_key, echo_server): + # pyqwest raises the builtin TimeoutError; the transport re-raises it as + # httpx.ReadTimeout to keep the httpx.TimeoutException contract. + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + with pytest.raises(httpx.ReadTimeout): + httpx_client.request("GET", "/slow", timeout=0.2) finally: - AsyncTransportWithLogger._instances.clear() - AsyncEnvdTransportWithLogger._instances.clear() + httpx_client.close() + reset_sync_api_transports() + + +@pytest.mark.asyncio +async def test_async_api_client_timeout_raises_httpx_read_timeout( + test_api_key, echo_server +): + reset_async_api_transports() + config = ConnectionConfig(api_key=test_api_key, api_url=echo_server) + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + + try: + with pytest.raises(httpx.ReadTimeout): + await httpx_client.request("GET", "/slow", timeout=0.2) + finally: + await httpx_client.aclose() + reset_async_api_transports() diff --git a/packages/python-sdk/tests/test_envd_client_transport.py b/packages/python-sdk/tests/test_envd_client_transport.py index 844ec50a21..d4f72cb500 100644 --- a/packages/python-sdk/tests/test_envd_client_transport.py +++ b/packages/python-sdk/tests/test_envd_client_transport.py @@ -1,6 +1,9 @@ +from typing import cast + import httpx import pytest +from e2b.connection_config import ProxyTypes from e2b.envd import client_async, client_sync from e2b.envd.client_shared import proxy_to_url from e2b.exceptions import InvalidArgumentException @@ -64,7 +67,7 @@ def test_proxy_to_url_rejects_httpx_proxy_ssl_context(): def test_proxy_to_url_rejects_unknown_types(): with pytest.raises(InvalidArgumentException, match="URL-string"): - proxy_to_url(object()) + proxy_to_url(cast(ProxyTypes, object())) def test_sync_transport_is_cached_per_proxy(): diff --git a/packages/python-sdk/uv.lock b/packages/python-sdk/uv.lock index 7ea6e0def3..e83ec3baf0 100644 --- a/packages/python-sdk/uv.lock +++ b/packages/python-sdk/uv.lock @@ -228,7 +228,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, { name = "packaging", specifier = ">=24.1" }, { name = "protobuf-py", specifier = ">=0.1.1,<0.2" }, - { name = "pyqwest", specifier = ">=0.7.0,<0.8" }, + { name = "pyqwest", specifier = ">=0.8.0,<0.9" }, { name = "python-dateutil", specifier = ">=2.8.2" }, { name = "rich", specifier = ">=14.0.0" }, { name = "typing-extensions", specifier = ">=4.10.0" }, @@ -811,55 +811,55 @@ wheels = [ [[package]] name = "pyqwest" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/05/8576f0eeb44d3fe14c70e2bafa77ae6930c5e6f42b93c568719ea4456715/pyqwest-0.7.0.tar.gz", hash = "sha256:ac65f2243f3e814e7f4aad3f2fcfe78f89aad2de2a825eaaabf4c102f1937843", size = 457991, upload-time = "2026-07-19T05:20:06.61Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/49/7f26a2c2a3e8bbab4862bcbf4f54b706d9524fa9705cc0127553b81ed161/pyqwest-0.7.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b2339f3b55ae179e0cb55bbd5792ae1c954d31f80de7b4dde0e95b03576b6be", size = 5159904, upload-time = "2026-07-19T05:18:57.757Z" }, - { url = "https://files.pythonhosted.org/packages/20/f1/4aba1976566936ca873e1a726a0d9bdf8195a0dc4e4f78122f6050328566/pyqwest-0.7.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2159c7e2eaf2563cdadfda17314e2b1747c30603979bf73db8daef311247db82", size = 5044476, upload-time = "2026-07-19T05:18:59.473Z" }, - { url = "https://files.pythonhosted.org/packages/fe/06/45908e5cda7fa28ba60df5ed2091056893c394c3217a796bc6577c4bd294/pyqwest-0.7.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a826f1b492a7497f469c8467bd51faf582733654bb2bc9fcdd4fa502f3e6ca1", size = 5560021, upload-time = "2026-07-19T05:19:01.097Z" }, - { url = "https://files.pythonhosted.org/packages/f2/3d/603f5c9446e8c13a4970b816246d7928e895408a7ed1778d98de3e25ea43/pyqwest-0.7.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da43c7e86bee9e74ff474d4829cd398fb9220ff0d84d633094ea6797fdfe03a1", size = 5506130, upload-time = "2026-07-19T05:19:02.812Z" }, - { url = "https://files.pythonhosted.org/packages/33/6d/2984aa02f4d0296dddb5df159e1af6c6e0ddc0b507d592f13d6ccd0b3162/pyqwest-0.7.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d1c71327c323a19dc90a0dafb1d68fb13f4f775a2498a26bf95f17165e64da9f", size = 5719961, upload-time = "2026-07-19T05:19:04.213Z" }, - { url = "https://files.pythonhosted.org/packages/19/06/e30c0d31eea17cabf99ef90c7c02e14cc94ca7d59793d397de9359148693/pyqwest-0.7.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c002e0fc31a96f1c47986299710f49ed4551e4a912a7cdb69aba6fd94db6d544", size = 5908279, upload-time = "2026-07-19T05:19:05.595Z" }, - { url = "https://files.pythonhosted.org/packages/eb/db/a1368faae1cbd094a0d179b76de69b8d1908bd45898d3bcd29ac98622072/pyqwest-0.7.0-cp310-abi3-win_amd64.whl", hash = "sha256:847e4468b5379a219b91a13dd3c92dd3b7b3d9f59af23e4c6776e663594cb241", size = 4755104, upload-time = "2026-07-19T05:19:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/b3/38/c493e85ebd17d3a5c56eb53fbea36a1a6f396b999b38173ffde513576eb1/pyqwest-0.7.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4988fa1c368072886dce48f52fcbabe9f25784c6e189a9a6f2b42f6e9f383973", size = 5172962, upload-time = "2026-07-19T05:19:09.229Z" }, - { url = "https://files.pythonhosted.org/packages/d6/68/1b340fdc7735b5682d308cabc2d65ea76c03a6cc2c30072a0c14c24f716c/pyqwest-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dfb61138c802c7317d840a274fa24f9d878301f693268e9186a9896452017a71", size = 5032604, upload-time = "2026-07-19T05:19:10.884Z" }, - { url = "https://files.pythonhosted.org/packages/4d/52/eecd858568ef6586cbdc3c419a9a0b1e8f891e2968f0f6b2a9fa9bdaac19/pyqwest-0.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6fdc0601590bdc547007828627082f0dc0e445e92d900dccb227e51898d7243", size = 5558302, upload-time = "2026-07-19T05:19:12.348Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8f/b67d0056b18a9f99a1c9253cb983d5c4361e3e102d729df6623b71d6d939/pyqwest-0.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f82c971810695f5fd7962859a2469616c83b7aca6664d8f147287ee5ff430cd", size = 5501483, upload-time = "2026-07-19T05:19:13.892Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a6/69b87c4c80ced01645a143679458895dcb5c0e5ca0b3d4a43d979939cce6/pyqwest-0.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9dc2f405803b94525ab030c01cdac7093bcc1a5da6802de3345a868a0f51b3da", size = 5717334, upload-time = "2026-07-19T05:19:15.541Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/5891b72b9193b692b50ecdc4e26e7e3687f9763f5f263646bc8f997f5b62/pyqwest-0.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:537f71f97a533fa355d02c15ed9f0cc05fb8915996cb921caa87fe7310b457ee", size = 5902447, upload-time = "2026-07-19T05:19:17.273Z" }, - { url = "https://files.pythonhosted.org/packages/6a/80/1b280618af3f9d36081449d153efc5620f0eea93ac169574fc69208d2b91/pyqwest-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:196aa73fb7eee4b6c052d6f4172a4321904a7208331f4322ccee3ee7d0adbc78", size = 4750469, upload-time = "2026-07-19T05:19:18.916Z" }, - { url = "https://files.pythonhosted.org/packages/c1/9c/64c8df83a152804ac3074fb879c1229c3aca55a12dc33dc73c72e93405e6/pyqwest-0.7.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:72d001892ed570df1dcc489ef8b0a03bc7abd4fbdca10625c358a87e66b226ad", size = 5171308, upload-time = "2026-07-19T05:19:20.332Z" }, - { url = "https://files.pythonhosted.org/packages/14/d1/46e629541a3f7231dd978fa9939e6ddcd075238880395bcfb16544769165/pyqwest-0.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:03da0797bac5c2eb1a40f02afccd4b5aad5ad0d375bb993df2f5e0c692fc0c6c", size = 5032449, upload-time = "2026-07-19T05:19:21.967Z" }, - { url = "https://files.pythonhosted.org/packages/44/3c/132194c747696e41ad5745c51587bd20057d800b6a64fb901ce2ac990149/pyqwest-0.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:837e18aef4490eed25b4194d49f16732ccf1abf2cdd0845e75d75d2a4428b98c", size = 5556847, upload-time = "2026-07-19T05:19:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5d/6726a70e89c60bf3cd30d5d918eff68dc1d1ceb9c911331d7fb57977bfc3/pyqwest-0.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:418e8c3a67ca6226bfb6147b8668203c74d9c872657117086f4c264a674d7980", size = 5500333, upload-time = "2026-07-19T05:19:25.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/3f/329df64d3e8778bc311a22e432280cd317619c3f9b7e414f375127bb90b0/pyqwest-0.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31176abbbcbe6d03740967b5c0241deaa60b328792644cb2e317a34d6b076433", size = 5717248, upload-time = "2026-07-19T05:19:26.963Z" }, - { url = "https://files.pythonhosted.org/packages/2e/93/6670afb97c6ce7b5ba27981e023d9edc7413e509bbe18afebb729834d9e6/pyqwest-0.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e21102f9c13234a0066d42bb5429dc5c311d7fd99401878837d9675a7f6e03b9", size = 5902814, upload-time = "2026-07-19T05:19:28.637Z" }, - { url = "https://files.pythonhosted.org/packages/51/d3/bf9440a03c97f408743313215d8c7e9875b0b2b0d2ef97f5fbb5066cd57d/pyqwest-0.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca19e96b5e902a6d35e18cee7f5e90612fca6ab169378d42242cdcb638578cee", size = 4750510, upload-time = "2026-07-19T05:19:30.587Z" }, - { url = "https://files.pythonhosted.org/packages/dc/25/8b0919bf504309982857e564e3035ffd799d7aaa23457eb351485573e5ee/pyqwest-0.7.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4e4e79187c3e4ebd07d663d8dc7a7cba865c72020332e41807426a7e3526fda0", size = 5177972, upload-time = "2026-07-19T05:19:32.051Z" }, - { url = "https://files.pythonhosted.org/packages/52/12/d652213fe336c52b75fb4df710f5cff08f8a1dcc9e385c9120f18476f5c9/pyqwest-0.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc2c569481ade1bc0b89b07daf1b49b20a1337a53207ffe0ef325260f509404", size = 5032608, upload-time = "2026-07-19T05:19:33.772Z" }, - { url = "https://files.pythonhosted.org/packages/82/b0/406c91563140b87ade5c5935410997b26449b8eb2f91511bc52741ae38fb/pyqwest-0.7.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ef1bfbdca9ec9c8a7b223268f5ef8d45694da7226929454b0cb40f2e3d1ddd5", size = 5558937, upload-time = "2026-07-19T05:19:35.174Z" }, - { url = "https://files.pythonhosted.org/packages/7f/17/a0a09936b5dd5d9d7517289aee3683bbf6554b0853bce5280ead9da27336/pyqwest-0.7.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4c2f6a6830becfa1c5bb94aa169ad854da5749a95a440aee759932d5965c213", size = 5500603, upload-time = "2026-07-19T05:19:37.023Z" }, - { url = "https://files.pythonhosted.org/packages/00/34/ccec52ca9f14fbe11292fadbe89c771353e0f46d58aa88d8e93d6e018117/pyqwest-0.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0d11128ccc382f64fcdf92d3bce6be7191489c1b3a9a3f0dbdf89e55451638de", size = 5719784, upload-time = "2026-07-19T05:19:38.649Z" }, - { url = "https://files.pythonhosted.org/packages/16/cc/58dca466c236e497cbb2e75b97a2489225d21f5063d932ab7d1723294112/pyqwest-0.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5832373c7bcccfbc3bb79c44b592e0871631139a2ef5aef771b8fe2b7bd4301d", size = 5901396, upload-time = "2026-07-19T05:19:40.402Z" }, - { url = "https://files.pythonhosted.org/packages/06/a0/983ea3b859828bef8372896e9c407f62bf426b84526d0b0cf9fd9abce411/pyqwest-0.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:7330070c4497e564007985716ac347cb9a7500eba5c9610500653a2ce4cfe86e", size = 4751036, upload-time = "2026-07-19T05:19:42.159Z" }, - { url = "https://files.pythonhosted.org/packages/92/1d/e2fe3dfe6d87989017412f394abc45435a3af2526e998c83cf836937b23c/pyqwest-0.7.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:02fd606a35be7803a770071f642a375e55df4c2025e49b10b859430f424c4492", size = 5165801, upload-time = "2026-07-19T05:19:43.82Z" }, - { url = "https://files.pythonhosted.org/packages/f4/fe/90126ac71fe09c729f36c166cb71b5148dd8a80e47f6f232bf71494604a2/pyqwest-0.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a807835c6a0777f0cc57c321c78c7bf162f6354698844d78db6e03ba9edd83dc", size = 5025128, upload-time = "2026-07-19T05:19:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/e5/15/ee84ce834705d613c0b113fb4cf9d274011cfcc6c6ededb52d92a38a2367/pyqwest-0.7.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86d84871cb0a572700dece3a6c45c294721f655f3d0b85477333209b487ecef1", size = 5551017, upload-time = "2026-07-19T05:19:47.046Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/18b4540fb276f9b540018efe024aca0801acfe4209417d4faed85bccdb04/pyqwest-0.7.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:005ddd2a777d30ca7ce4de12df1336760e14d46394ab56299a9e81626d78b991", size = 5493032, upload-time = "2026-07-19T05:19:49.038Z" }, - { url = "https://files.pythonhosted.org/packages/6c/91/f25477eb80c375378a876cf2bac80c55ec9ea36b94bd7d4b2b2931860c85/pyqwest-0.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca02089249c292ab9c148fa86c06927701897090226a13e392a6ec53312380ea", size = 5712503, upload-time = "2026-07-19T05:19:50.542Z" }, - { url = "https://files.pythonhosted.org/packages/f4/63/c5be988c55c69c87de950e3dabab322f82a78dc7c2ebdf89af32626bd473/pyqwest-0.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da3bc117c1380e9577994da15b8236f7c3bb400111d6be4b57a9b2e4f30c9a79", size = 5893782, upload-time = "2026-07-19T05:19:52.286Z" }, - { url = "https://files.pythonhosted.org/packages/82/a7/96144e6db9a49eb5e42734562ac5d387c2b78fe1142674d3a284ce188ef7/pyqwest-0.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a599ddac7ded32ed62d15ca90bc9c77652ba34b225cf17a404821cec92a189fa", size = 4744187, upload-time = "2026-07-19T05:19:53.921Z" }, - { url = "https://files.pythonhosted.org/packages/ed/42/26cf547b2e43078b4663e717715a289f31fc873d65d45cf354f364faa744/pyqwest-0.7.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bebc51e3c9c6339d81964c6e60516a5f5c9fe793c82da57ef7c2d87a55741829", size = 5170766, upload-time = "2026-07-19T05:19:55.472Z" }, - { url = "https://files.pythonhosted.org/packages/dc/bc/1aac42f34bd3e5ac4c13a2c267dba6bda4bdea594ff96c7851dff930c8f1/pyqwest-0.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:37623b492aea7ccd3b6cfe3179110643d28c2cdc83c765aac3d207b4321995d2", size = 5053361, upload-time = "2026-07-19T05:19:56.973Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/e3d8cfd2c7be0e12773ee4f767efc03be50ccdd28c6155d0b176bf1d8a9a/pyqwest-0.7.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02ff96a7746de208a4b8c78abe780c413d66576146a4ec64efd348b0c8a8328c", size = 5573900, upload-time = "2026-07-19T05:19:58.536Z" }, - { url = "https://files.pythonhosted.org/packages/39/a4/7dd4d548be6d54b195d737164dc67c39d9c5df40aa76a873fe1d2d9b4426/pyqwest-0.7.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd2e9bfa81198db7023202962a94920986a9a3dd12fe2f9d310f0cfcdddc092c", size = 5510692, upload-time = "2026-07-19T05:20:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/48/13/c070baf81da8653803328b632baddb5647b66cc7c6353c58f024ca82277c/pyqwest-0.7.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:e1548708dbeb29a60db199c70b9e93733d6b334e7efa1dfa5e3846a4f890db6d", size = 5735516, upload-time = "2026-07-19T05:20:01.487Z" }, - { url = "https://files.pythonhosted.org/packages/84/46/e0f2a02ebf504db6bdf4bf343f0d3362eccf8dd5a325382fab1305fa7022/pyqwest-0.7.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4bb3cf0975ee829b6c76e1c42f01863122ce71b1a952e507e260a9d29eb9b183", size = 5916743, upload-time = "2026-07-19T05:20:03.132Z" }, - { url = "https://files.pythonhosted.org/packages/07/7c/4a35df79a142e5fcb5f46fce2d1d70dd72325a65db4139bbba4fbed0d9b9/pyqwest-0.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2ea1ce4d630c92cb2cc6b3bf91ada0053b051dfd5c0662d89957ebd829cfdcce", size = 4759967, upload-time = "2026-07-19T05:20:04.837Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/25/305acffe35817ac5dd3659f06bf5c7522cf2d6b1cbfb6e71dfd4e7cf28b0/pyqwest-0.8.0.tar.gz", hash = "sha256:138a6a01f5e3bdd92abed412f35bf122b5a7c41cadaffe9c8b3ba0dc2dc5d822", size = 469648, upload-time = "2026-07-31T03:33:42.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/38/76280a079b0f84dcc05c14eba16b6cd39bef9e8aa0a51e916abf71fea637/pyqwest-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2976d8fc93b7b4fdb9bfc7ad231e6584d81b9777ad88c8256e02cfa8b690364d", size = 5216354, upload-time = "2026-07-31T03:32:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/afde50b6814e6c053237fb6182f3ffcbe6562fdbfd0c44b6b6589cb37ea7/pyqwest-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:afe8a86b28ef637b85d2239758f91ef3acdb01bd569fb8abcb4ec70b368fd2d7", size = 5095245, upload-time = "2026-07-31T03:32:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/d9/18/0b0d177754b337afaf9b7d7b0de476ae250bab3bbf105568211fa83da2d6/pyqwest-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8d948f99dc80e1d4879bbfb4ff073e1d7566dc307d483f6857907b9b634631d", size = 5614817, upload-time = "2026-07-31T03:32:21.005Z" }, + { url = "https://files.pythonhosted.org/packages/dc/12/d5b2e201ca3c374174644fbf201329ff6635ebabef0fcc49701f8d168984/pyqwest-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bcd1edaa837e136af6982c31c17100a16b6e4918e7237a34229928754edae35", size = 5557336, upload-time = "2026-07-31T03:32:22.725Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/d5ba098c323d7f5eb9cb9ebf075060d14c3364bf176d9815655065462bc7/pyqwest-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c25504a5fe4d72f43b17253c9e1dc4cda426e9fa0a72474c4555455f63706c1", size = 5775102, upload-time = "2026-07-31T03:32:24.766Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e5/1b1bae527e4de93b9eddbf15a78f1ce5198aece5c169478e385364e0f1f1/pyqwest-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:513fe7649d727cccb5d6e04999e91fed5f61f5e20a8556a2f96d3d1aeea02001", size = 5963251, upload-time = "2026-07-31T03:32:26.792Z" }, + { url = "https://files.pythonhosted.org/packages/44/14/a9d883fc59df1f34fe42764277ca26de11d74dd3aa3d9f9f4a35d7414f37/pyqwest-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:7896646bd426db0cbd7a5d4ebd0dacd59f1a2c3cdc12fb2667234e9504e33b03", size = 4834356, upload-time = "2026-07-31T03:32:28.921Z" }, + { url = "https://files.pythonhosted.org/packages/2e/49/c0640e19718c4533590bcaba647ad3145f793c66c125cb62c399e580b1a6/pyqwest-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c4970ad95d4d51aa07980687d07e1f4003b68bc1fa1e46c9202f1b008c95662e", size = 5232611, upload-time = "2026-07-31T03:32:30.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/85/25c440e1dc31320afa9de8a3ab43b0bc6f5ef5090e43fac350b5de4db623/pyqwest-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:63f341cf695b9c8b9b184a0f1c67f6e578b833502f1f21a9359f031b5b77a627", size = 5089482, upload-time = "2026-07-31T03:32:32.817Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7c/a5c49001dc0685043512c5497a7820fe091d765f93a730ace81708ed8f5e/pyqwest-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed43c61eebe85aa078ff922378aac9904c9d0be06c99da5826c865c28f8468dc", size = 5618803, upload-time = "2026-07-31T03:32:34.801Z" }, + { url = "https://files.pythonhosted.org/packages/39/af/6835aaeb451f629886bae0b924a864b73b906c84944be37fea43298c163a/pyqwest-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7988aeb1dce1ee0d3dbcf9d6c8d3a2a821d9c56402c95c057567363ed01116c0", size = 5556779, upload-time = "2026-07-31T03:32:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/ea/66/2f130d7dc3341112f1a620984d4af17cacf2a17085a1eaa98dfaa35da028/pyqwest-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37ebb0bfeaa5a6b5c6ebd1cfdbb3e4d89ac343e46be07a993e8af6473ee82b1b", size = 5779456, upload-time = "2026-07-31T03:32:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/14a30458a69e6f1b8e007ff6bd6fdf867cc28de45d046374f45087d31fd8/pyqwest-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4471266ff506d02f21350921abd0adbaa08d6b7db6d24408c2ed9635e187e263", size = 5962595, upload-time = "2026-07-31T03:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/05/22/db95030afe6c5adc90b2864e194d8074175675a90703282286e3370ecd53/pyqwest-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:5b605cb927b5bbd0b1247a5439d90068fee84802003bd6609c48e985aeb15570", size = 4824467, upload-time = "2026-07-31T03:32:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/96900a0797bf6fcf281de0f8e9cd612e13df983cc5f9ce7aa2bd21d1ecab/pyqwest-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa840884e19a5777159d99c9bcc38b2f0f536484dfa0edbc4041a50134afc6e5", size = 5230908, upload-time = "2026-07-31T03:32:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/ae/be/55f23ecbf28c247b856caec896c5ed301ccb9e4f96e3aaa193174182e032/pyqwest-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1aabb09561a75e16d38c83d341fa3ec1c5009a7f91f300f620493e885ac1fb25", size = 5088999, upload-time = "2026-07-31T03:32:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/0d/32/0c05267a312102f8840e1e08350382f6f46be7304d84e6cb9b19317e107f/pyqwest-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6db97734f909d865115e45c213a983a3f0a0b67697fe2937779df92a07827aaa", size = 5618462, upload-time = "2026-07-31T03:32:48.71Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e9/c36af7b2b34364fe6abc0737f8ae47bed87377c87e2a035e0429a716477a/pyqwest-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44c266a84125017b09c89199d6355c6eeffaf381bf46670c49ec46353d9e5b0", size = 5555364, upload-time = "2026-07-31T03:32:50.588Z" }, + { url = "https://files.pythonhosted.org/packages/44/18/da3f1469ba6866a695c510a287debf30034d84cc93f8cab021bf930834fb/pyqwest-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:29cd8eca7aafa14b6ef164c4360bad0db155c68ac16bc907bbadc9a414ea4b5b", size = 5777961, upload-time = "2026-07-31T03:32:52.58Z" }, + { url = "https://files.pythonhosted.org/packages/bd/89/56c3543dd06d7ef19d54c4d698455ed9f32cc779b42b896d46149a6f73f1/pyqwest-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c2d8f4057b8bd3c540c4d0b596b8110e1d49d7a06be46de6dd0db864e2238b4b", size = 5962311, upload-time = "2026-07-31T03:32:54.747Z" }, + { url = "https://files.pythonhosted.org/packages/fc/95/82a428e536b1bc1fe655f22ade122f55c476c3d06ee6c8223fe32de124ba/pyqwest-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:568be49f10da2bdc97482093f713f36b6f3b7b4392977044c942b18b32eebceb", size = 4824529, upload-time = "2026-07-31T03:32:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e3/64b5bc3152c8201864b09d398843ed4a4fdf5e66eb951ca0d4a4eddc6f13/pyqwest-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8b33afc7289534488968b3ee95fc452724cb20c0b9dc64bd432f9eadd7f8e86b", size = 5232837, upload-time = "2026-07-31T03:32:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/071af07d1d74910a9efb5c6a9e93823534aac8f56f446b8984a42660ee58/pyqwest-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:51195116f455d027348a53a04af0463b6657ebc514a311c5952df0134de14ea8", size = 5087870, upload-time = "2026-07-31T03:33:00.971Z" }, + { url = "https://files.pythonhosted.org/packages/70/d9/75f20faab72384780946107de99f1f5a98b5486462861defe4050bad7b03/pyqwest-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd49b8955cf167efa828052c0a08a00fa29b45f712d60f75979187cf0a7342d", size = 5620684, upload-time = "2026-07-31T03:33:03.082Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5c/150e91e8d58ae22522b77de8a3a28295b484e7676ce689c33f2182db10f0/pyqwest-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dded7f50f96e53a978784db7ceab4a7e7ff7529fc94b804603e0a3cfd5915162", size = 5557460, upload-time = "2026-07-31T03:33:05.161Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/3b60ffa85a2d4ca1218d40fc00d60ff32dc0777a01e1299376ebdc76f48c/pyqwest-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c27059c54a5ead5922f4599e31b9c727cf0bf4343be813fc64695a4b7bf24dec", size = 5780272, upload-time = "2026-07-31T03:33:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/75/1c/ae5377a9cf7e0da2389b76d092a67cfd0e0ee58d6dd0ecc9eee2d1066a5c/pyqwest-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:44f0a713a3414f50123aab649bd2591dac0264b150778f10934a28c5e11d27f2", size = 5964492, upload-time = "2026-07-31T03:33:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/43f650ea9c68c6a4251c6e0d5ae02f7aa4e7bd35d7ea6c5ebbb15f9af2d4/pyqwest-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:6b076960044609de8611961609b3ed00ed8dedf1504d957d7c3f354e64ed6ce9", size = 4822044, upload-time = "2026-07-31T03:33:11.863Z" }, + { url = "https://files.pythonhosted.org/packages/01/c6/208138925d0e25f1c4cf4d8b5949ed590ecc71098cb1968152627a0f91fe/pyqwest-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:778ce6f447d481913c787a2ee8d8623ac0b1fe673281c686f6b99e8689815857", size = 5217325, upload-time = "2026-07-31T03:33:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/8b8dd86ee0b96672c2338816957c285b71db08a90848db68c8b937c9c0db/pyqwest-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:19d58b0f0ca2d21832c26aa096954283b57404515bf196df5b2ec894111ccc1a", size = 5076014, upload-time = "2026-07-31T03:33:15.805Z" }, + { url = "https://files.pythonhosted.org/packages/44/7f/738077c5fd9229d61965faa946c2f54ddf90e7f910162fca2f07763107c8/pyqwest-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47de00287d20233782e391083fa197e89bb531b194131520dd9ec2641b57777", size = 5602382, upload-time = "2026-07-31T03:33:17.924Z" }, + { url = "https://files.pythonhosted.org/packages/88/df/b95b88e31593689d7b3d4a6e2f5550cd686a8648f50b70ec27a2bdbe41a3/pyqwest-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9445cccde16f4a1ef0409a42677ccbd8350cdde0a407bb3db6353c0f921fa812", size = 5547598, upload-time = "2026-07-31T03:33:19.888Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/26105b8c6209dc13b0339ed2e5d3425476643619595f4d9e74acec9279ed/pyqwest-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18f9fa7543070d31a334970b979bdc58acfde4b163fae8f012dac9cc7122632f", size = 5765677, upload-time = "2026-07-31T03:33:21.931Z" }, + { url = "https://files.pythonhosted.org/packages/54/07/d14c467eddd3d89b561b6891df15af2ace5f20f92e1fc7dfc4d33068f627/pyqwest-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ace2147bd302c13997a0baec3eca5a2176aeb78ba1a019564bd78baa72554cf3", size = 5951710, upload-time = "2026-07-31T03:33:24.263Z" }, + { url = "https://files.pythonhosted.org/packages/da/91/fa6805b5d0a85ae8f1633b90c0a1c4c81e8ccc803e4c6ace68f04012a29b/pyqwest-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed4b17453cbfe5bfaf9afb646fa4900621f958d9fb488b356b5a819d61b5cfe1", size = 4816517, upload-time = "2026-07-31T03:33:26.371Z" }, + { url = "https://files.pythonhosted.org/packages/55/d5/b047c6fcce7536018169d33e15ad927af9d8581f3f01725303cabaf099ac/pyqwest-0.8.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b93263e0fdc8c8e33bfcb55f1a678acf8cc2d6257d6ef4c4785f0fe32f62708d", size = 5217204, upload-time = "2026-07-31T03:33:28.445Z" }, + { url = "https://files.pythonhosted.org/packages/96/ee/ccdb687682c7b969fb2ce4a87e35e8bb99a75d82c6276c8c62f87de39cc0/pyqwest-0.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f7fb0e669bd0e5b024d27ed98799c2dc19fd0eb4cdde8b508e887bc0708fa51", size = 5096201, upload-time = "2026-07-31T03:33:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/7ef06ad3f5d2f23891e7ceea25132ff9d297c1a079fe420b05db13cd49c8/pyqwest-0.8.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b03c1159c0ae2b45870d3009c79942f70a01e7122810d0eb7a4273f90cd36410", size = 5620096, upload-time = "2026-07-31T03:33:32.3Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/ff28a9af929aec5726448b8ddceffa95c0f4be5fd18d16fe44d8f15e0847/pyqwest-0.8.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14423de4e06014bb8549405add86dca1e866b5cd9c335790fcff9eee9263ece3", size = 5561862, upload-time = "2026-07-31T03:33:34.523Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7c/303f32fabcf261afb3a118a95ecb8e68e5be060e9216b1d3808bf9fd1512/pyqwest-0.8.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:91793f0b180f38d0d5eec063b4f791104ee51cd6515791325e644b3b1a02f418", size = 5782390, upload-time = "2026-07-31T03:33:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/c7/40/556b7a947bb93ff33787c3d240c7b9d5f107f066663a681329cad4ce2b01/pyqwest-0.8.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1389bbc67290e848413b9900ab358efae3991a35dc6be5169f65e507e8d4d77c", size = 5970151, upload-time = "2026-07-31T03:33:38.779Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/a12b6109c3c274a5a680f96f4b6b108dba94958075116bca4d3d2ace2eab/pyqwest-0.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bf5caf0e25a26145e08643be9b65831c0a2c9bf6a19d1e6c9b1ad5a2c06cf3e9", size = 4825893, upload-time = "2026-07-31T03:33:40.887Z" }, ] [[package]]