feat(python-sdk): move the REST API client onto pyqwest's httpx transport adapter (#1601)
## What Migrate all httpx REST API client traffic in the Python SDK — the E2B control plane (sandbox lifecycle, listing, templates, volumes control plane) — to [pyqwest](https://github.com/curioswitch/pyqwest) (Rust reqwest/hyper), using its httpx-compatible transport adapter (`pyqwest.httpx.PyqwestTransport` / `AsyncPyqwestTransport`). The generated openapi client and `ApiClient`/`AsyncApiClient` keep their httpx surface — logging event hooks, per-request timeouts, headers, and redirects behave as before — only the transport underneath is swapped. envd RPC already runs on pyqwest via connectrpc (#1558). This PR touches only the control-plane client; the rest of the stack builds on it: #1623 (envd HTTP API client), #1602 (volume content client), #1603 (template uploads). Requires **pyqwest 0.9** — pinned in `pyproject.toml` (`>=0.9.0,<0.10`) with `uv.lock` refreshed. 0.8 brought the `Proxy` object ([pyqwest#194](https://github.com/curioswitch/pyqwest/pull/194)) and request loggers ([pyqwest#197](https://github.com/curioswitch/pyqwest/pull/197)); 0.9 ([release notes](https://github.com/curioswitch/pyqwest/discussions/214)) folds the two adapter workarounds this PR used to carry into the adapter itself and makes redirect handling configurable, so the SDK no longer subclasses the adapter at all. ## How - `e2b/api/client_sync/__init__.py` / `client_async/__init__.py`: `get_transport` now returns a pyqwest-backed httpx transport — a `SyncHTTPTransport`/`HTTPTransport` (`tls_include_system_certs=True`, proxy, pool tuning mapped from `E2B_KEEPALIVE_EXPIRY`/`E2B_MAX_KEEPALIVE_CONNECTIONS`), wrapped in a `ConnectionRetryTransport` for connect-only retries honoring `E2B_CONNECTION_RETRIES`, wrapped in the stock `PyqwestTransport`/`AsyncPyqwestTransport` httpx adapter. - pyqwest transports are thread-safe and loop-independent (I/O runs on a Rust tokio runtime), so the caches are process-global keyed by proxy — previously one pool per thread (sync) / per event loop (async). - **`ApiClient` sheds its threading machinery**: the `transport_factory`/`async_transport_factory` plumbing, the thread-local `httpx.Client` cache, and the per-loop `WeakKeyDictionary` of `AsyncClient`s are gone. A single lazily-created httpx client (the generated base behavior, the same shape the volume client already uses) serves all threads and event loops; `httpx.Client` is documented thread-safe and nothing below it is loop-bound. Closing that client can't tear down the shared pool — the adapter transports don't override `close()`/`aclose()`. - **Host header** (upstream in 0.9): sending the `Host` header httpx auto-adds on an HTTP/2 connection makes the E2B API edge reset the stream with `PROTOCOL_ERROR` (reproduced with plain pyqwest against `api.e2b.app`); hyper derives `Host`/`:authority` from the URL. The adapter now skips a `host` header matching the URL, so the SDK-side strip is gone — and unlike that strip, a genuinely custom `Host` override is still forwarded. - **Timeout exceptions** (upstream in 0.9): pyqwest raises the builtin `TimeoutError`; the adapter maps it to `httpx.ReadTimeout` both while awaiting the response head and while reading the body, preserving the `httpx.TimeoutException` contract for callers. Connection, network, and protocol failures likewise arrive as `httpx.ConnectError`/`ConnectTimeout`, `httpx.ReadError`/`WriteError`, and `httpx.RemoteProtocolError` instead of leaking pyqwest/builtin types. - **Redirects**: the pyqwest transports are built with `follow_redirects=False` (0.9 made it configurable; reqwest's default is to follow). Otherwise redirects are followed inside the transport, hiding 3xx responses from httpx and leaving `response.history` empty — even though the generated clients ask for no redirect following. httpx owns them again, as with the transports this replaced. - **Proxy**: `proxy=` accepts a URL string, `httpx.URL`, or an `httpx.Proxy` — including its credentials (sent as `Proxy-Authorization`) and any headers configured for the proxy, via pyqwest's `Proxy` object. `proxy_to_config` normalizes all three into a `ProxyConfig` tuple that both keys the transport cache and builds the `pyqwest.Proxy`, so the same proxy URL with different credentials or headers gets its own pool. A per-proxy `ssl_context` has no counterpart and raises `InvalidArgumentException` rather than being silently dropped. (`ProxyConfig` is a `NamedTuple`, not a frozen dataclass: `tests/test_env_var_parsing.py` reloads `e2b.api`, and a dataclass `__eq__` compares class identity, so keys built before and after a reload would silently stop matching.) - **`ProxyTypes` is ours now**: the public type of the `proxy` option (already exported from `e2b`) used to be imported at runtime from httpx's private `_types` module in eleven modules. It is defined there as `Union[str, URL, Proxy]` — exactly the three forms the SDK's two narrowers accept — so it's spelled out once in `e2b.connection_config` and imported from there. Same public name, same type to a type checker, no private-module dependency, and a place for a pyqwest proxy type to land as the remaining transports move off httpx. `e2b.envd.client_shared.proxy_to_url` took a bare `object` while `e2b.api.proxy_to_config` took `Optional[ProxyTypes]`; both now say the same thing. `isinstance` narrowing stays rather than duck-typing `.url`/`.auth` — httpx is a required dependency here (the generated REST client *is* an httpx client, and envd file transfers use httpx directly), so probing attributes would trade a clear `InvalidArgumentException` on a mistyped argument for no dependency savings. - **Request logs**: pyqwest logs one line per request on the `pyqwest.access` logger and lifecycle records on `pyqwest`, both at `DEBUG` — the transport-level diagnostics httpcore used to provide, now that httpcore is out of the path. Noted on `get_transport`; the SDK's own `logger` option is unchanged and sits above it on the httpx client. - **HTTP/2**: negotiated via ALPN for TLS connections (reqwest default), equivalent to the `http2=True` transports this replaces. ## What stays behind (handled by the stacked PRs) - **envd HTTP API client** (file transfers, health checks): #1623, which also dedupes the transport plumbing this PR and #1558 each carry a copy of (the proxy narrowing, pool tuning, retry transport — envd keeps byte-identical duplicates until then). - **Volume content client**: its streaming download relies on httpx's per-read `read` timeout as an *idle* timeout, which the adapter can't express per request — #1602. - **Template build context upload**: one-off httpx client PUTing to S3 presigned URLs — #1603. ## Timeout semantics note `request_timeout` was previously httpx's per-phase timeout (connect/read/write each bounded separately, so a slow multi-phase request could exceed it in total). Through the adapter it becomes an overall deadline per API call (async: headers + body; sync: up to response headers). For the SDK's REST calls — all unary with small JSON bodies — this is a tightening, arguably closer to what `request_timeout` promises. ## Testing - `tests/test_api_client_transport.py` rewritten for the new semantics: global per-proxy transport caching, a single httpx client shared across threads/loops (including 32-way concurrent request tests against a local server), timeout → `httpx.ReadTimeout` mapping for both the response head and a stalled body (slow/stalling local server), redirects surfacing to httpx (302 returned as-is, `response.history` populated when the caller opts in), the connection-only retry policy, `proxy_to_config` conversion, and sync+async round-trips through a real local HTTP server exercising pyqwest end to end. The two host-header unit tests are gone with the subclasses they tested — that behavior is the adapter's now. - Two tests cover the pyqwest proxy/logging surface: an echo server standing in for a proxy asserts that the absolute-form request target, `Proxy-Authorization`, and the extra proxy header actually arrive, and the `pyqwest.access` record is asserted for an API call. - On pyqwest 0.9.0 from PyPI: `uv sync --locked`, unit suite (`tests/*.py`, 238 passed), `ruff check`, `ty check` — all green. - Integration against the production API (real key) was run on 0.8.0: `tests/sync/api_sync`, `tests/async/api_async`, create/kill/timeout/connect — all green. (These initially failed with `RemoteProtocolError: StreamReset` until the host header stopped being forwarded, so they genuinely exercise the new stack; that fix now comes from the adapter.) ## Usage example No API changes for the common path: ```python from e2b import Sandbox sbx = Sandbox.create() # control-plane calls now go through pyqwest Sandbox.list() sbx.kill() ``` Proxy handling — URL strings and `httpx.Proxy` objects work, credentials and proxy headers included: ```python Sandbox.create(proxy="http://user:pass@localhost:8030") # ok (unchanged) Sandbox.create(proxy=httpx.Proxy("http://localhost:8030", auth=("user", "pass"))) # sent as Proxy-Authorization Sandbox.create(proxy=httpx.Proxy("http://localhost:8030", headers={"X-Auth": "t"})) # sent to the proxy Sandbox.create(proxy=httpx.Proxy("https://localhost:8030", ssl_context=ctx)) # raises InvalidArgumentException ``` `ProxyTypes` — already exported from `e2b` — is now defined by the SDK rather than re-exported from `httpx._types`, with the same three members: ```python from e2b import ProxyTypes # Union[str, httpx.URL, httpx.Proxy] ``` Transport-level HTTP logs, replacing the httpcore records this migration removes: ```python import logging logging.basicConfig() logging.getLogger("pyqwest.access").setLevel(logging.DEBUG) Sandbox.create() # DEBUG pyqwest.access - HTTP Request: POST https://api.e2b.app/sandboxes "HTTP/2 201 Created" ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
---
|
||||
"@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, headers, and redirect handling (`follow_redirects`,
|
||||
`response.history`) behave as before.
|
||||
|
||||
One timeout semantics change: through the adapter, `request_timeout` is a
|
||||
deadline for the whole API call, where the previous transports applied it to
|
||||
each phase (connect, read, write) separately — a slow request could exceed it
|
||||
in total. For the REST API's small JSON exchanges this tightening is what
|
||||
`request_timeout` reads as promising; `0` still disables it.
|
||||
|
||||
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, whether they fire while waiting for the
|
||||
response head or while reading the response body, and connection, network, and
|
||||
protocol failures keep raising their `httpx` counterparts (`httpx.ConnectError`,
|
||||
`httpx.ReadError`, `httpx.RemoteProtocolError`).
|
||||
|
||||
`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.
|
||||
@@ -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 @@ limits = Limits(
|
||||
|
||||
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 @@ class ApiClient(AuthenticatedClient):
|
||||
"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 @@ class ApiClient(AuthenticatedClient):
|
||||
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):
|
||||
|
||||
@@ -1,32 +1,92 @@
|
||||
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 pyqwest import HTTPTransport, Request, Response
|
||||
from pyqwest.httpx import AsyncPyqwestTransport
|
||||
from pyqwest.middleware.retry import RetryTransport
|
||||
|
||||
from e2b.api import AsyncApiClient, connection_retries, limits
|
||||
from e2b.connection_config import ConnectionConfig
|
||||
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,
|
||||
)
|
||||
return AsyncApiClient(config, transport=get_transport(config), **kwargs)
|
||||
|
||||
|
||||
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
|
||||
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], AsyncPyqwestTransport] = {}
|
||||
|
||||
|
||||
def get_transport(config: ConnectionConfig) -> AsyncPyqwestTransport:
|
||||
"""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 = AsyncPyqwestTransport(
|
||||
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,
|
||||
# Redirects belong to the httpx client above (which the
|
||||
# generated clients leave off), not to reqwest.
|
||||
follow_redirects=False,
|
||||
),
|
||||
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 +94,19 @@ class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
|
||||
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 +115,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)
|
||||
|
||||
@@ -1,25 +1,83 @@
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
|
||||
import httpx
|
||||
import threading
|
||||
|
||||
from httpx._types import ProxyTypes
|
||||
from pyqwest import SyncHTTPTransport, SyncRequest, SyncResponse
|
||||
from pyqwest.httpx import PyqwestTransport
|
||||
from pyqwest.middleware.retry import SyncRetryTransport
|
||||
|
||||
from e2b.api import ApiClient, connection_retries, limits
|
||||
from e2b.connection_config import ConnectionConfig
|
||||
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,
|
||||
)
|
||||
return ApiClient(config, transport=get_transport(config), **kwargs)
|
||||
|
||||
|
||||
class TransportWithLogger(httpx.HTTPTransport):
|
||||
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], PyqwestTransport] = {}
|
||||
|
||||
|
||||
def get_transport(config: ConnectionConfig) -> PyqwestTransport:
|
||||
"""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 = PyqwestTransport(
|
||||
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,
|
||||
# Redirects belong to the httpx client above (which the
|
||||
# generated clients leave off), not to reqwest.
|
||||
follow_redirects=False,
|
||||
),
|
||||
max_retries=connection_retries,
|
||||
)
|
||||
)
|
||||
_transports[proxy] = transport
|
||||
return transport
|
||||
|
||||
|
||||
class EnvdTransportWithLogger(httpx.HTTPTransport):
|
||||
_thread_local = threading.local()
|
||||
|
||||
@property
|
||||
@@ -27,30 +85,6 @@ class TransportWithLogger(httpx.HTTPTransport):
|
||||
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:
|
||||
|
||||
@@ -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. :func:`e2b.api.proxy_to_config`
|
||||
narrows it to what the pyqwest REST transports take.
|
||||
"""
|
||||
|
||||
REQUEST_TIMEOUT: float = 60.0 # 60 seconds
|
||||
|
||||
KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds
|
||||
|
||||
@@ -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.9.0,<0.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
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 pyqwest import (
|
||||
Proxy,
|
||||
Request,
|
||||
SyncRequest,
|
||||
SyncTransport,
|
||||
Transport,
|
||||
)
|
||||
from pyqwest.httpx import AsyncPyqwestTransport, PyqwestTransport
|
||||
|
||||
from e2b.api.client_async import AsyncEnvdTransportWithLogger, AsyncTransportWithLogger
|
||||
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 +52,76 @@ 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_client_proxy_uses_explicit_transport(test_api_key):
|
||||
reset_sync_api_transports()
|
||||
config = ConnectionConfig(
|
||||
@@ -42,31 +135,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 +197,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 +208,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 +248,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 +257,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 +265,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,135 +294,39 @@ 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)
|
||||
|
||||
api_client = get_async_api_client(config)
|
||||
httpx_client = api_client.get_async_httpx_client()
|
||||
|
||||
try:
|
||||
assert httpx_client.timeout == httpx.Timeout(1.5)
|
||||
finally:
|
||||
await httpx_client.aclose()
|
||||
AsyncTransportWithLogger._instances.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_api_client_cache_reuses_within_loop_and_isolates_across_loops(
|
||||
test_api_key,
|
||||
):
|
||||
AsyncTransportWithLogger._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:
|
||||
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()),
|
||||
)
|
||||
|
||||
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)
|
||||
finally:
|
||||
await main_client.aclose()
|
||||
AsyncTransportWithLogger._instances.clear()
|
||||
|
||||
|
||||
def test_async_transport_not_reused_across_sequential_loops(test_api_key):
|
||||
AsyncTransportWithLogger._instances.clear()
|
||||
config = ConnectionConfig(api_key=test_api_key)
|
||||
|
||||
async def get_transport():
|
||||
return get_async_transport(config)
|
||||
|
||||
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
|
||||
finally:
|
||||
AsyncTransportWithLogger._instances.clear()
|
||||
|
||||
|
||||
def test_async_api_client_not_reused_across_sequential_loops(test_api_key):
|
||||
AsyncTransportWithLogger._instances.clear()
|
||||
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)
|
||||
|
||||
async def get_client():
|
||||
client = api_client.get_async_httpx_client()
|
||||
assert api_client.get_async_httpx_client() is client
|
||||
return client
|
||||
return api_client.get_async_httpx_client()
|
||||
|
||||
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()
|
||||
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 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
|
||||
assert api_client.get_async_httpx_client() is main_client
|
||||
assert other_loop_client is main_client
|
||||
finally:
|
||||
AsyncTransportWithLogger._instances.clear()
|
||||
await main_client.aclose()
|
||||
reset_async_api_transports()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_envd_transport_uses_separate_cache(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)
|
||||
|
||||
@@ -412,10 +334,309 @@ async def test_async_envd_transport_uses_separate_cache(test_api_key):
|
||||
api_transport = get_async_transport(config)
|
||||
envd_transport = get_async_envd_transport(config)
|
||||
|
||||
assert api_transport is not envd_transport
|
||||
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
|
||||
assert envd_transport._pool._http2 is True
|
||||
envd_pool = envd_transport._pool
|
||||
assert envd_pool._http2 is True # ty: ignore[possibly-missing-attribute]
|
||||
finally:
|
||||
AsyncTransportWithLogger._instances.clear()
|
||||
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, one starting with
|
||||
``/stall`` answers the head and then never sends the body, and one starting
|
||||
with ``/redirect`` answers 302 pointing at ``/sandboxes``."""
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/slow"):
|
||||
time.sleep(5)
|
||||
if self.path.startswith("/redirect"):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/sandboxes")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
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()
|
||||
if self.path.startswith("/stall"):
|
||||
self.wfile.flush()
|
||||
time.sleep(5)
|
||||
return
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class _EchoServer(ThreadingHTTPServer):
|
||||
# The concurrency tests open 32 connections at once. Windows resets
|
||||
# connections that overflow the listen backlog (request_queue_size,
|
||||
# default 5) instead of queueing them, which surfaces as a flaky
|
||||
# "connection was forcibly closed" WriteError mid-test.
|
||||
request_queue_size = 64
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def echo_server():
|
||||
server = _EchoServer(("127.0.0.1", 0), _EchoHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
httpx_client.close()
|
||||
reset_sync_api_transports()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
httpx_client.close()
|
||||
reset_sync_api_transports()
|
||||
|
||||
|
||||
@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 request(i: int) -> tuple[int, str]:
|
||||
response = await httpx_client.request("GET", f"/sandboxes/{i}")
|
||||
return response.status_code, response.json()["path"]
|
||||
|
||||
try:
|
||||
results = await asyncio.gather(*(request(i) for i in range(32)))
|
||||
assert list(results) == [(200, f"/sandboxes/{i}") for i in range(32)]
|
||||
finally:
|
||||
await httpx_client.aclose()
|
||||
reset_async_api_transports()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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:
|
||||
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()
|
||||
|
||||
|
||||
def test_sync_api_client_leaves_redirects_to_httpx(test_api_key, echo_server):
|
||||
# reqwest would otherwise follow redirects inside the transport, hiding them
|
||||
# from httpx: the generated client asks for no redirect following, so a 302
|
||||
# must surface as-is, and opting in must record the hop in `history`.
|
||||
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 httpx_client.follow_redirects is False
|
||||
response = httpx_client.request("GET", "/redirect")
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/sandboxes"
|
||||
assert response.history == []
|
||||
|
||||
followed = httpx_client.request("GET", "/redirect", follow_redirects=True)
|
||||
assert followed.status_code == 200
|
||||
assert followed.json()["path"] == "/sandboxes"
|
||||
assert [r.status_code for r in followed.history] == [302]
|
||||
finally:
|
||||
httpx_client.close()
|
||||
reset_sync_api_transports()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_api_client_leaves_redirects_to_httpx(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:
|
||||
assert httpx_client.follow_redirects is False
|
||||
response = await httpx_client.request("GET", "/redirect")
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/sandboxes"
|
||||
assert response.history == []
|
||||
|
||||
followed = await httpx_client.request("GET", "/redirect", follow_redirects=True)
|
||||
assert followed.status_code == 200
|
||||
assert followed.json()["path"] == "/sandboxes"
|
||||
assert [r.status_code for r in followed.history] == [302]
|
||||
finally:
|
||||
await httpx_client.aclose()
|
||||
reset_async_api_transports()
|
||||
|
||||
|
||||
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:
|
||||
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()
|
||||
|
||||
|
||||
def test_sync_api_client_body_timeout_raises_httpx_read_timeout(
|
||||
test_api_key, echo_server
|
||||
):
|
||||
# The head arrives in time and the body never does: httpx reads the body
|
||||
# after the transport returned, so that timeout is mapped on the stream.
|
||||
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", "/stall", timeout=0.2)
|
||||
finally:
|
||||
httpx_client.close()
|
||||
reset_sync_api_transports()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_api_client_body_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", "/stall", timeout=0.2)
|
||||
finally:
|
||||
await httpx_client.aclose()
|
||||
reset_async_api_transports()
|
||||
|
||||
Generated
+45
-45
@@ -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.9.0,<0.10" },
|
||||
{ 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.9.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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/59/97531fd9d0a06d54e84f5493775739b0c1d0d13ec704974d04fa423a3332/pyqwest-0.9.0.tar.gz", hash = "sha256:514dd0b37d7a1bcb978b5d6423d15f89cf7dcf8058ac2a37aa42cd30090de89e", size = 477462, upload-time = "2026-08-10T01:55:36.219Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/34/d556fa07d9bff21fc6c9c16738614c6336164b65d9ced1ce9f1c1044aaeb/pyqwest-0.9.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:517d2eb295d56ef1530cebe0cf290a66fa199c57955bd1c5f1dc592240fbb7d6", size = 5224594, upload-time = "2026-08-10T01:54:18.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/3f/1d7d1a08ebf8838c0c85e99ab5db5c1aae457f76ee03b60c8f4fc4948e01/pyqwest-0.9.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:408dc692835363b6824bee61ee9e4c9a5bba9c08d75b903f82f9a802e41d1b30", size = 5100753, upload-time = "2026-08-10T01:54:20.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/fe/22c4056b526baa4c2c953f4e99925ce0bb14e7747d66e6ac3448a34f76e5/pyqwest-0.9.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1abf1ab3a0c1b545651ab37a643acc7909c4e7644a63a2e4af36e3e230a0db5", size = 5619882, upload-time = "2026-08-10T01:54:22.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2c/8f1159a30bbc905b2fcbc386740a260e76ae64372feb263103438ef5201c/pyqwest-0.9.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b02e5c9ce57e079eb3716c464d3818ad2573d7743483c39526ad77ee95af806", size = 5564744, upload-time = "2026-08-10T01:54:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fd/ff15034d7874fd208d606bd80c924fb0b1091db8159c892672168b656de1/pyqwest-0.9.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:98bdaa30a3a8d8c71506a33c084e485a243fbf51e77130bb39e0f249dd116142", size = 5779373, upload-time = "2026-08-10T01:54:26.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/b5/eeafdeb65ad8fe9c39044aedcfc97610f4e5c7b5620ca9fe2d504f669108/pyqwest-0.9.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:10eba84f9d8512d23bd16dd60b1c77dab956470361559214981dda065df4ad1a", size = 5973306, upload-time = "2026-08-10T01:54:28.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/db/a8979dab5c591529f858d1505d09b3c8b6dec337921f15bed4858c55716e/pyqwest-0.9.0-cp310-abi3-win_amd64.whl", hash = "sha256:af4e859800b02cd1fcdd898af26a881c4a8ce9520f71f6cb0a5403523d2cbbdf", size = 4834706, upload-time = "2026-08-10T01:54:30.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/9e/d070ca1ce3c202952770436172889a4b0556a564a490baf726f7bb895eba/pyqwest-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:abe322cc1b63947b493bc06028615e0d5de655928159227aab64f60a0eb0e27d", size = 5244745, upload-time = "2026-08-10T01:54:32.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/97/ebded0bb35a79f51bb2d1519e34922d79c8825f9e71b696bb20f387a08c2/pyqwest-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6b2dc6a9c583859d031941e1041b8e711afe0ad4c130628207731cef2b1a486", size = 5101044, upload-time = "2026-08-10T01:54:34.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/2c/a7f117e793b4e27642807bf7230b9fa297ff1e1ac489fc260fc3d4350ae4/pyqwest-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:551e47714da72a72939958029eb37a754cecd974344471c4a2038583a66a5396", size = 5630841, upload-time = "2026-08-10T01:54:35.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/b4/be47fc141529941352a848cd77da581803137b733993a51bf464f2c48915/pyqwest-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3be3d38ccdab3077bf1cfe90c346ce927f9b49c56dcdf20e060f5f2502d01021", size = 5572909, upload-time = "2026-08-10T01:54:37.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/fd/703635e12710ce7ef5e961f5e1b22c609aecc8f07eedd79477b48d1edb74/pyqwest-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:45d9d424da878b22604799d1ce2a0a5df054924487ee5a851ad08025e2076e5c", size = 5794049, upload-time = "2026-08-10T01:54:39.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/15/941aad56b108c743df9d41e124be82ecb6be0eba1007e945b6a39f5dd1a0/pyqwest-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4420adcd2b756da706b31a04f095a521f6e440dab284b7a1b46c76e048c1f78", size = 5974542, upload-time = "2026-08-10T01:54:41.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/86/8445fe05b47322047347464a909ae95dba023bf8409b9967f66bda2905ad/pyqwest-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:25734bd9798bbfaa53c83d395649dd9ea9a791a64b9848734955cb78ad52c832", size = 4847034, upload-time = "2026-08-10T01:54:42.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/92/013fc3c94f0dc4eeca556431797da477d76ad43d69134837ba149468dcc3/pyqwest-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6e4c2afe6c7293d9e42cfdb0489b0b5f64002159c2dc9c45d9322c1b4ffb42cd", size = 5244793, upload-time = "2026-08-10T01:54:44.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/61/c685e0c595f3f1b7842a776c48aaf2b3f47270a61672967d9d38f7c5e7f6/pyqwest-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:67f153b94dfbb9aeabc38f20a1bada1acfd9361bec12db86268bd951be35a154", size = 5100715, upload-time = "2026-08-10T01:54:46.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/a1/a28b2cd704d7dd37043f1e6b398798576c12293f3efae84f00e7f6a5625c/pyqwest-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9942ffa243c8c2243e3ce04e3e3ec9b4c119346c963fd0051cd989f0defecf6", size = 5628787, upload-time = "2026-08-10T01:54:48.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/31/6255c7f17cd00e39f1c4df3003cea2d2d087e483fb4c49a6535307cd21e0/pyqwest-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f8f9c880c19826fead1838e12b042865b15df3ec844928cc74188a8b740f761", size = 5571946, upload-time = "2026-08-10T01:54:50.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/71/344738bdb38bddbc07cb29e5b1287c08db9e53d6b322581492571bdef748/pyqwest-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:56040761f6ca7abb036c7d288863bf143a9b166d824d98a604908ef20c6906e0", size = 5792872, upload-time = "2026-08-10T01:54:52.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ee/c5c74e3afb6dd0a25bca23bb4757d1a7135fab09a0a054036e837d49dd11/pyqwest-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:02a51df744521e45eb5379bc325b91de4512ec64a9f4aba3248f9166ec4e5b88", size = 5974192, upload-time = "2026-08-10T01:54:53.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/08/302960836e3e43ca3051313ee3b767534d1610bd3b8033c7bbad41b06e1b/pyqwest-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9a0e3123aee446d5f6e57c43cae87b4dab4b1af3fa970d114d74d5d4b9d075c", size = 4847050, upload-time = "2026-08-10T01:54:56.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/3e/a36940844325fbc59adf92116d3bcd1155cc2c72d1a390880886ca492bb4/pyqwest-0.9.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:fdab21bbedff4a21209135423f5f54f287a01dd6143cbd7aeab63d5d8c889654", size = 5246741, upload-time = "2026-08-10T01:54:57.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/8d/efb9741449a81f2489f6929a7691a2145662c3650e06bcb139c64f543d0a/pyqwest-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:21a04ddf4497ef3c7fe36cdc473c094999b7710005d38bd8a0dfd4a3feace12f", size = 5102423, upload-time = "2026-08-10T01:54:59.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/a8/0b667cf9c07e62861cfe24c09ad30357a9c2a0fc3863cfc5610af5b66e3e/pyqwest-0.9.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8c51c1a27ea529fffab90c127a045f0dfe1abc54d5b9ad5f4165e954439c9b2", size = 5627663, upload-time = "2026-08-10T01:55:01.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/7c/1f88be3d35afe92b79e361aa40faf3904e3a7852e8638a9d3d5c00891ab3/pyqwest-0.9.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f283ffc40a2774e0dc8fbb47e4a8f9f8a698a99feae3bf4ad75b340c98031b", size = 5572750, upload-time = "2026-08-10T01:55:03.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/3e/98c1e151772e77dcf00cb8b1c070f2c8edcb15fe12fdee30c1022006ca8e/pyqwest-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f73d4a8e75b5860741fbd0623a0ce792a53336dbfec5f34736004acd5446843", size = 5790615, upload-time = "2026-08-10T01:55:04.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f1/12bb4b119cffb7fe5301b2d997ec9ffc1719637f6f4faf2ff2315e13b201/pyqwest-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e34d55a53492b44b82b690c2b11957fc35e99ebab4db2e9bcb2a1e2621d71b62", size = 5976516, upload-time = "2026-08-10T01:55:06.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/55/046dbf68ccedd816770fd212c6c2612372a6f0d7ecf2d8273480db460ddf/pyqwest-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:48c367150783f6866d0af43c209cc405f8287743d7b2472c7d757fda2478fd57", size = 4841470, upload-time = "2026-08-10T01:55:08.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/cd/38ff77d145385afd71b2dbd6eb116251ce3ffddbed0edde16c64394fbec2/pyqwest-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10fae206270355c993b49c49bf9778995167f5efe591807d50a8e682921d71ea", size = 5222689, upload-time = "2026-08-10T01:55:09.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/95/d8c6804eeb72b7b89842b58f1f05d5cfda5be410c4a22442d2574d510d39/pyqwest-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5f475129fb792593222a9ce0fefbbc10bda1544a7411f3946d771c9a61c50e61", size = 5085846, upload-time = "2026-08-10T01:55:11.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e7/04fe8962ec416be0199093ebdd326653a5a7b8f53ddfdb29af81756f7c08/pyqwest-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78f6f676d349535cb094e8e3bcf33863238a142fb7dcc7afb9e61749e2351047", size = 5613296, upload-time = "2026-08-10T01:55:13.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0c/7a11728155a34838d2e7c78fb24c937f2cd844ec791ef416030c52f4bb5e/pyqwest-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f460f5fcdae8cc46e6a679128836f6cd47fbf4bf73e5c9d6ea868e4a90b2e7ee", size = 5557492, upload-time = "2026-08-10T01:55:15.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d5/8c6e68f0e978b5257b309809fec00d946c1ed20dacef97f25c23de5b91a9/pyqwest-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cba6804151e973d27c99b9f02b3f0bcab7270c3f99725b508ada9c147365cabd", size = 5774204, upload-time = "2026-08-10T01:55:16.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/20/fed89b62d50b4efad2ebe78c3867e0e402f824726652034728a5dd0a342e/pyqwest-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bc9dfeca326918d70a5368114de0fc627d9f64fafa36563ab484ae58ffe42c0d", size = 5964506, upload-time = "2026-08-10T01:55:19.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/de/30575019efebae84502269d097e706e89a30631e3719218b053e4e0dc179/pyqwest-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384693adc0908f16324e5b508a3c25fb9a92b88f9bdf7ccb6820507db4a95888", size = 4809013, upload-time = "2026-08-10T01:55:21.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/4e/46822676c7cfafbf510f2af84b510b6f3dc1f3c4df781a837ff75c7410fc/pyqwest-0.9.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c174d8ebde256c457f455dcdedf59446528d84874cb5967041be0d1bda05929", size = 5227559, upload-time = "2026-08-10T01:55:22.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/80/9d98ad770043e5dd3ad7b765f2acf911bd11b113e0280565277d2e707864/pyqwest-0.9.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d5e56192b03bca7ec511c9af4ed80fa10fc1c1871d0ccfe49050632785b46099", size = 5109747, upload-time = "2026-08-10T01:55:24.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/95/d1a88c8115c88f843b907e3d3bd1958eaef851cca023a47bba4257d974d9/pyqwest-0.9.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3fa3c93638fc645c7c626fe83032f53972673581878bd85702842f41665ad6c", size = 5631597, upload-time = "2026-08-10T01:55:26.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/3a/e568f9cf9ff75ca45ce7323ac916d076f9f4df4e957404ff0f0d3286dbe0/pyqwest-0.9.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869d1c630cbff38510207c5b57139f319acca21ed978f9ce6648fcd868df5fbd", size = 5573130, upload-time = "2026-08-10T01:55:28.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/c9/a261dbf9a4fd3cfa790221957c5cbe0d52e75ff7403dd91254aa977164b1/pyqwest-0.9.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:a0bc2ba9b6d3103c6f0cce59cca9e2e294d1707bca435b471a1917db73010801", size = 5794377, upload-time = "2026-08-10T01:55:30.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a4/389a32479767cba432451f2913ff55bcab13981c5cf561028aa54ba97ae1/pyqwest-0.9.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:bfb04e3e91166fe6e4eecdcfccb0b2490da7629984b7988cfd4b11daa1dbb616", size = 5977476, upload-time = "2026-08-10T01:55:32.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/26/91891131d47e1d9ad652ae6ff010bc9169ab051f20d888cd3471d87f070c/pyqwest-0.9.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fbff11bbffb572c084ccd76048c40bd57976b255d34f59280e56626f556cfaeb", size = 4833070, upload-time = "2026-08-10T01:55:34.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user