From 472041bacfeea11aeeec48f035b6ff5f0bd36eae Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 09:46:35 +0200 Subject: [PATCH 01/14] Decode compressed response bodies incrementally Previously each raw chunk was fully inflated in a single `decompress()` call before being re-chunked, so a small compressed chunk could inflate to an arbitrarily large buffer and `iter_bytes(chunk_size)` did not actually bound memory. Rework the content decoders to yield bounded pieces as they decode: `gzip`/`deflate` drain a shared `ZlibDecompressor` with `max_length`, `brotli` uses `output_buffer_limit` (now requires `brotli>=1.2.0`), and `zstd` uses `max_length` on the stdlib `compression.zstd` backend. `MultiDecoder` pipes children lazily so the bound holds across stacked encodings. `iter_bytes(chunk_size)` now bounds peak memory like urllib3's `read(amt)`. Also close the underlying stream when decoding raises part-way through, so a decode error releases the connection instead of leaking it. --- src/httpx2/httpx2/_content.py | 14 ++- src/httpx2/httpx2/_decoders.py | 159 +++++++++++++++++++++++---------- src/httpx2/httpx2/_models.py | 74 ++++++++------- src/httpx2/pyproject.toml | 2 +- tests/httpx2/test_decoders.py | 80 +++++++++++++++++ uv.lock | 2 +- 6 files changed, 247 insertions(+), 84 deletions(-) diff --git a/src/httpx2/httpx2/_content.py b/src/httpx2/httpx2/_content.py index c75b6121..f5ba32ef 100644 --- a/src/httpx2/httpx2/_content.py +++ b/src/httpx2/httpx2/_content.py @@ -2,7 +2,7 @@ import inspect import warnings -from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator, Mapping +from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Iterable, Iterator, Mapping from json import dumps as json_dumps from typing import ( Any, @@ -79,9 +79,15 @@ async def __aiter__(self) -> AsyncIterator[bytes]: yield chunk chunk = await self._stream.aread(self.CHUNK_SIZE) else: - # Otherwise iterate. - async for part in self._stream: - yield part + # Otherwise iterate, making sure the wrapped stream is closed even if the + # consumer stops early (e.g. an exception is raised part-way through decoding). + stream = self._stream.__aiter__() + try: + async for part in stream: + yield part + finally: + if isinstance(stream, AsyncGenerator): + await stream.aclose() class UnattachedStream(AsyncByteStream, SyncByteStream): diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index d811087c..a2f28f12 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -9,6 +9,7 @@ import codecs import functools import io +import itertools import sys import typing import zlib @@ -40,12 +41,16 @@ ZstdDecompressor = functools.partial(_ZstdDecompressor().decompressobj) _zstandard_installed: bool = False + # True only when the stdlib `compression.zstd` backend is in use (bounded, incremental decode). + _zstd_stdlib_backend: bool = False else: # pragma: no cover _zstandard_installed = False + _zstd_stdlib_backend = False try: from compression.zstd import ZstdDecompressor, ZstdError _zstandard_installed = True + _zstd_stdlib_backend = True # Either Python <3.14 or the distro doesn't have `compression.zstd`. except ImportError: try: @@ -57,11 +62,42 @@ pass +MAX_DECODE_CHUNK_SIZE = 2**16 # 64 KiB + + +class Decompressor(typing.Protocol): + @property + def unconsumed_tail(self) -> bytes: ... + + def decompress(self, data: bytes, max_length: int) -> bytes: ... + + def flush(self) -> bytes: ... + + +class ZlibDecompressor: + """ + Drain a `zlib`/`gzip` decompressor in bounded pieces so a small compressed + input cannot inflate to an unbounded buffer in a single call. + """ + + def __init__(self, decompressor: Decompressor) -> None: + self.decompressor = decompressor + + def __call__(self, data: bytes) -> typing.Iterator[bytes]: + decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE) + while decompressed: + yield decompressed + decompressed = self.decompressor.decompress(self.decompressor.unconsumed_tail, MAX_DECODE_CHUNK_SIZE) + + def flush(self) -> bytes: + return self.decompressor.flush() + + class ContentDecoder: - def decode(self, data: bytes) -> bytes: + def decode(self, data: bytes) -> typing.Iterator[bytes]: raise NotImplementedError() # pragma: no cover - def flush(self) -> bytes: + def flush(self) -> typing.Iterator[bytes]: raise NotImplementedError() # pragma: no cover @@ -70,11 +106,12 @@ class IdentityDecoder(ContentDecoder): Handle unencoded data. """ - def decode(self, data: bytes) -> bytes: - return data + def decode(self, data: bytes) -> typing.Iterator[bytes]: + if data: + yield data - def flush(self) -> bytes: - return b"" + def flush(self) -> typing.Iterator[bytes]: + yield from () class DeflateDecoder(ContentDecoder): @@ -86,22 +123,23 @@ class DeflateDecoder(ContentDecoder): def __init__(self) -> None: self.first_attempt = True - self.decompressor = zlib.decompressobj() + self.decompressor = ZlibDecompressor(zlib.decompressobj()) - def decode(self, data: bytes) -> bytes: + def decode(self, data: bytes) -> typing.Iterator[bytes]: was_first_attempt = self.first_attempt self.first_attempt = False try: - return self.decompressor.decompress(data) + yield from self.decompressor(data) except zlib.error as exc: if was_first_attempt: - self.decompressor = zlib.decompressobj(-zlib.MAX_WBITS) - return self.decode(data) - raise DecodingError(str(exc)) from exc + self.decompressor = ZlibDecompressor(zlib.decompressobj(-zlib.MAX_WBITS)) + yield from self.decode(data) + else: + raise DecodingError(str(exc)) from exc - def flush(self) -> bytes: + def flush(self) -> typing.Iterator[bytes]: try: - return self.decompressor.flush() + yield self.decompressor.flush() except zlib.error as exc: # pragma: no cover raise DecodingError(str(exc)) from exc @@ -114,17 +152,17 @@ class GZipDecoder(ContentDecoder): """ def __init__(self) -> None: - self.decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16) + self.decompressor = ZlibDecompressor(zlib.decompressobj(zlib.MAX_WBITS | 16)) - def decode(self, data: bytes) -> bytes: + def decode(self, data: bytes) -> typing.Iterator[bytes]: try: - return self.decompressor.decompress(data) + yield from self.decompressor(data) except zlib.error as exc: raise DecodingError(str(exc)) from exc - def flush(self) -> bytes: + def flush(self) -> typing.Iterator[bytes]: try: - return self.decompressor.flush() + yield self.decompressor.flush() except zlib.error as exc: # pragma: no cover raise DecodingError(str(exc)) from exc @@ -149,26 +187,32 @@ def __init__(self) -> None: self.decompressor = brotli.Decompressor() self.seen_data = False - self._decompress: typing.Callable[[bytes], bytes] + self._decompress: typing.Callable[..., bytes] if hasattr(self.decompressor, "decompress"): # The 'brotlicffi' package. self._decompress = self.decompressor.decompress # pragma: no cover else: # The 'brotli' package. - self._decompress = self.decompressor.process # pragma: no cover + self._decompress = self.decompressor.process - def decode(self, data: bytes) -> bytes: + def decode(self, data: bytes) -> typing.Iterator[bytes]: if not data: - return b"" + return self.seen_data = True try: - return self._decompress(data) + decompressed = self._decompress(data, output_buffer_limit=MAX_DECODE_CHUNK_SIZE) + while decompressed: + yield decompressed + decompressed = self._decompress(b"", output_buffer_limit=MAX_DECODE_CHUNK_SIZE) + except TypeError: # pragma: no cover + # Backend without `output_buffer_limit` (e.g. brotlicffi < 1.2.0); fall back to unbounded. + yield self._decompress(data) except brotli.error as exc: raise DecodingError(str(exc)) from exc - def flush(self) -> bytes: + def flush(self) -> typing.Iterator[bytes]: if not self.seen_data: - return b"" + return try: if hasattr(self.decompressor, "finish"): # Only available in the 'brotlicffi' package. @@ -177,9 +221,9 @@ def flush(self) -> bytes: # will never actually emit any data. However, it will potentially throw # errors if a truncated or damaged data stream has been used. self.decompressor.finish() # pragma: no cover - return b"" except brotli.error as exc: # pragma: no cover raise DecodingError(str(exc)) from exc + yield from () class ZStandardDecoder(ContentDecoder): @@ -199,30 +243,46 @@ def __init__(self) -> None: self.decompressor = ZstdDecompressor() self.seen_data = False - def decode(self, data: bytes) -> bytes: + def decode(self, data: bytes) -> typing.Iterator[bytes]: if not data: - return b"" + return self.seen_data = True - output = io.BytesIO() try: if self.decompressor.eof: data = self.decompressor.unused_data + data self.decompressor = ZstdDecompressor() - output.write(self.decompressor.decompress(data)) - while self.decompressor.eof and self.decompressor.unused_data: - unused_data = self.decompressor.unused_data + while True: + yield from self._decompress_frame(data) + if not (self.decompressor.eof and self.decompressor.unused_data): + break + data = self.decompressor.unused_data self.decompressor = ZstdDecompressor() - output.write(self.decompressor.decompress(unused_data)) except ZstdError as exc: raise DecodingError(str(exc)) from exc - return output.getvalue() - def flush(self) -> bytes: + def _decompress_frame(self, data: bytes) -> typing.Iterator[bytes]: + # The `sys.version_info` guard is what lets the type checker pick the right backend type; + # `_zstd_stdlib_backend` additionally handles a 3.14 install where `compression.zstd` is + # absent and the third-party `zstandard` backend is used instead. + if sys.version_info >= (3, 14) and _zstd_stdlib_backend: # pragma: no cover + # The stdlib `compression.zstd` decompressor bounds a single call's output. + decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE) + while decompressed: + yield decompressed + if self.decompressor.needs_input or self.decompressor.eof: + break + decompressed = self.decompressor.decompress(b"", MAX_DECODE_CHUNK_SIZE) + else: # pragma: no cover + # `zstandard`'s incremental (decompressobj) API has no per-call output bound, so a + # frame is decompressed in one shot rather than in bounded pieces. + yield self.decompressor.decompress(data) + + def flush(self) -> typing.Iterator[bytes]: if not self.seen_data: - return b"" + return if not self.decompressor.eof: raise DecodingError("Zstandard data is incomplete") # pragma: no cover - return b"" + yield from () class MultiDecoder(ContentDecoder): @@ -243,16 +303,25 @@ def __init__(self, encodings: typing.Sequence[str]) -> None: # Note that we reverse the order for decoding. self.children: list[ContentDecoder] = [SUPPORTED_DECODERS[coding]() for coding in reversed(codings)] - def decode(self, data: bytes) -> bytes: + def decode(self, data: bytes) -> typing.Iterator[bytes]: + streams: typing.Iterator[bytes] = iter((data,)) for child in self.children: - data = child.decode(data) - return data + streams = self._pipe(child.decode, streams) + yield from streams - def flush(self) -> bytes: - data = b"" + def flush(self) -> typing.Iterator[bytes]: + streams: typing.Iterator[bytes] = iter(()) for child in self.children: - data = child.decode(data) + child.flush() - return data + streams = itertools.chain(self._pipe(child.decode, streams), child.flush()) + yield from streams + + @staticmethod + def _pipe( + decode: typing.Callable[[bytes], typing.Iterator[bytes]], + upstream: typing.Iterator[bytes], + ) -> typing.Iterator[bytes]: + for chunk in upstream: + yield from decode(chunk) class ByteChunker: diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 388e8727..0d546354 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -1,13 +1,14 @@ from __future__ import annotations import codecs +import contextlib import datetime import email.message import json as jsonlib import re import typing import urllib.request -from collections.abc import Mapping +from collections.abc import AsyncGenerator, Mapping from http.cookiejar import Cookie, CookieJar from ._content import ByteStream, UnattachedStream, encode_request, encode_response @@ -878,12 +879,12 @@ def iter_bytes(self, chunk_size: int | None = None) -> typing.Iterator[bytes]: chunker = ByteChunker(chunk_size=chunk_size) with request_context(request=self._request): for raw_bytes in self.iter_raw(): - decoded = decoder.decode(raw_bytes) + for decoded in decoder.decode(raw_bytes): + for chunk in chunker.decode(decoded): + yield chunk + for decoded in decoder.flush(): for chunk in chunker.decode(decoded): - yield chunk - decoded = decoder.flush() - for chunk in chunker.decode(decoded): - yield chunk # pragma: no cover + yield chunk # pragma: no cover for chunk in chunker.flush(): yield chunk @@ -930,16 +931,17 @@ def iter_raw(self, chunk_size: int | None = None) -> typing.Iterator[bytes]: self._num_bytes_downloaded = 0 chunker = ByteChunker(chunk_size=chunk_size) - with request_context(request=self._request): - for raw_stream_bytes in self.stream: - self._num_bytes_downloaded += len(raw_stream_bytes) - for chunk in chunker.decode(raw_stream_bytes): - yield chunk - - for chunk in chunker.flush(): - yield chunk + try: + with request_context(request=self._request): + for raw_stream_bytes in self.stream: + self._num_bytes_downloaded += len(raw_stream_bytes) + for chunk in chunker.decode(raw_stream_bytes): + yield chunk - self.close() + for chunk in chunker.flush(): + yield chunk + finally: + self.close() def close(self) -> None: """ @@ -959,10 +961,11 @@ async def aread(self) -> bytes: Read and return the response content. """ if not hasattr(self, "_content"): - self._content = b"".join([part async for part in self.aiter_bytes()]) + async with contextlib.aclosing(self.aiter_bytes()) as parts: + self._content = b"".join([part async for part in parts]) return self._content - async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncGenerator[bytes, None]: """ A byte-iterator over the decoded response content. This allows us to handle gzip, deflate, brotli, and zstd encoded responses. @@ -975,13 +978,14 @@ async def aiter_bytes(self, chunk_size: int | None = None) -> typing.AsyncIterat decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) with request_context(request=self._request): - async for raw_bytes in self.aiter_raw(): - decoded = decoder.decode(raw_bytes) + async with contextlib.aclosing(self.aiter_raw()) as raw_stream: + async for raw_bytes in raw_stream: + for decoded in decoder.decode(raw_bytes): + for chunk in chunker.decode(decoded): + yield chunk + for decoded in decoder.flush(): for chunk in chunker.decode(decoded): - yield chunk - decoded = decoder.flush() - for chunk in chunker.decode(decoded): - yield chunk # pragma: no cover + yield chunk # pragma: no cover for chunk in chunker.flush(): yield chunk @@ -1013,7 +1017,7 @@ async def aiter_lines(self) -> typing.AsyncIterator[str]: for line in decoder.flush(): yield line - async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator[bytes]: + async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncGenerator[bytes, None]: """ A byte-iterator over the raw response content. """ @@ -1028,16 +1032,20 @@ async def aiter_raw(self, chunk_size: int | None = None) -> typing.AsyncIterator self._num_bytes_downloaded = 0 chunker = ByteChunker(chunk_size=chunk_size) - with request_context(request=self._request): - async for raw_stream_bytes in self.stream: - self._num_bytes_downloaded += len(raw_stream_bytes) - for chunk in chunker.decode(raw_stream_bytes): - yield chunk - - for chunk in chunker.flush(): - yield chunk + stream = self.stream.__aiter__() + try: + with request_context(request=self._request): + async for raw_stream_bytes in stream: + self._num_bytes_downloaded += len(raw_stream_bytes) + for chunk in chunker.decode(raw_stream_bytes): + yield chunk - await self.aclose() + for chunk in chunker.flush(): + yield chunk + finally: + if isinstance(stream, AsyncGenerator): + await stream.aclose() + await self.aclose() async def aclose(self) -> None: """ diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index 72096c36..86f854d0 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ [project.optional-dependencies] brotli = [ - "brotli; platform_python_implementation == 'CPython'", + "brotli>=1.2.0; platform_python_implementation == 'CPython'", "brotlicffi; platform_python_implementation != 'CPython'", ] cli = [ diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index f6b8a8d7..9bb2cbf4 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -67,6 +67,36 @@ def test_gzip() -> None: assert response.content == body +@pytest.mark.parametrize("wbits", (zlib.MAX_WBITS | 16, zlib.MAX_WBITS)) +def test_zlib_decoder_yields_bounded_chunks(wbits: int) -> None: + from httpx2._decoders import MAX_DECODE_CHUNK_SIZE, DeflateDecoder, GZipDecoder + + body = b"\x00" * (MAX_DECODE_CHUNK_SIZE * 4) + compressor = zlib.compressobj(9, zlib.DEFLATED, wbits) + compressed = compressor.compress(body) + compressor.flush() + + decoder: GZipDecoder | DeflateDecoder = GZipDecoder() if wbits & 16 else DeflateDecoder() + chunks = list(decoder.decode(compressed)) + + assert len(chunks) >= 4 + assert all(len(chunk) <= MAX_DECODE_CHUNK_SIZE for chunk in chunks) + assert b"".join(chunks) + b"".join(decoder.flush()) == body + + +def test_brotli_decoder_yields_bounded_chunks() -> None: + import brotli + + from httpx2._decoders import BrotliDecoder + + body = b"\x00" * (4 * 1024 * 1024) + chunks = list(BrotliDecoder().decode(brotli.compress(body))) + + assert len(chunks) > 1 + # `output_buffer_limit` is a soft cap that overshoots, but stays far below the full size. + assert max(len(chunk) for chunk in chunks) < len(body) // 4 + assert b"".join(chunks) == body + + def test_brotli() -> None: body = b"test 123" compressed_body = b"\x8b\x03\x80test 123\x03" @@ -93,6 +123,20 @@ def test_zstd() -> None: assert response.content == body +def test_zstd_decoder_yields_bounded_chunks() -> None: + if sys.version_info < (3, 14): # pragma: no cover + pytest.skip("zstd bounded decoding requires the stdlib `compression.zstd` backend (Python 3.14+)") + + from httpx2._decoders import MAX_DECODE_CHUNK_SIZE, ZStandardDecoder + + body = b"\x00" * (MAX_DECODE_CHUNK_SIZE * 4) + chunks = list(ZStandardDecoder().decode(zstd.compress(body))) + + assert len(chunks) > 1 + assert max(len(chunk) for chunk in chunks) <= MAX_DECODE_CHUNK_SIZE + assert b"".join(chunks) == body + + def test_zstd_decoding_error() -> None: compressed_body = "this_is_not_zstd_compressed_data" @@ -226,6 +270,30 @@ def test_multi_brotli_zstd() -> None: assert response.content == body +def test_multi_zstd_gzip() -> None: + # A gzip layer ahead of zstd means gzip's end-of-stream flush feeds b"" into + # the zstd decoder after its frame is complete; it must not raise. + body = b"test 123" + inner = zstd.compress(body) + compressor = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) + compressed_body = compressor.compress(inner) + compressor.flush() + + headers = [(b"Content-Encoding", b"zstd, gzip")] + response = httpx2.Response(200, headers=headers, content=compressed_body) + assert response.content == body + + +def test_multi_brotli_gzip() -> None: + body = b"test 123" + inner = b"\x8b\x03\x80test 123\x03" + compressor = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) + compressed_body = compressor.compress(inner) + compressor.flush() + + headers = [(b"Content-Encoding", b"br, gzip")] + response = httpx2.Response(200, headers=headers, content=compressed_body) + assert response.content == body + + def test_multi_decode_links_limit() -> None: headers = [(b"Content-Encoding", b", ".join([b"gzip"] * 6))] with pytest.raises(httpx2.DecodingError, match="Cannot apply more than 5 content encodings"): @@ -405,3 +473,15 @@ def test_invalid_content_encoding_header() -> None: content=body, ) assert response.content == body + + +@pytest.mark.anyio +async def test_streaming_decode_error_does_not_leak_stream() -> None: + # A decode error raised mid-stream must still close the underlying stream. Under strict async + # generator finalization an unclosed stream surfaces as a ResourceWarning (i.e. a test failure). + async def content() -> typing.AsyncIterator[bytes]: + yield b"this is not valid gzip" + + response = httpx2.Response(200, headers=[(b"Content-Encoding", b"gzip")], content=content()) + with pytest.raises(httpx2.DecodingError): + await response.aread() diff --git a/uv.lock b/uv.lock index f7eb530b..0f3d926a 100644 --- a/uv.lock +++ b/uv.lock @@ -1513,7 +1513,7 @@ zstd = [ [package.metadata] requires-dist = [ { name = "anyio", marker = "sys_platform != 'emscripten'", specifier = ">=4.10" }, - { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'" }, + { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'", specifier = ">=1.2.0" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'" }, { name = "click", marker = "extra == 'cli'", specifier = ">=8.4.2" }, { name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" }, From a3e8f0cb133a36d28921a120ccf1f2513ca64dfd Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 12:38:49 +0200 Subject: [PATCH 02/14] Adapt gzip decode benchmark to the incremental decoder output --- tests/test_benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index bfecd1bf..1d33b443 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -84,7 +84,7 @@ def test_bench_queryparams_merge(benchmark: BenchmarkFixture) -> None: def test_bench_gzip_decode(benchmark: BenchmarkFixture) -> None: def decode() -> bytes: decoder = GZipDecoder() - return decoder.decode(GZIP_BODY) + decoder.flush() + return b"".join(decoder.decode(GZIP_BODY)) + b"".join(decoder.flush()) benchmark(decode) From 58c4b89f4a8eca9e4ecc363db4ea4d108c58e240 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 12:49:06 +0200 Subject: [PATCH 03/14] Run the zstd bounded-chunk test on every backend --- tests/httpx2/test_decoders.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index 9bb2cbf4..24efa475 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -124,17 +124,16 @@ def test_zstd() -> None: def test_zstd_decoder_yields_bounded_chunks() -> None: - if sys.version_info < (3, 14): # pragma: no cover - pytest.skip("zstd bounded decoding requires the stdlib `compression.zstd` backend (Python 3.14+)") - - from httpx2._decoders import MAX_DECODE_CHUNK_SIZE, ZStandardDecoder + from httpx2._decoders import MAX_DECODE_CHUNK_SIZE, ZStandardDecoder, _zstd_stdlib_backend body = b"\x00" * (MAX_DECODE_CHUNK_SIZE * 4) chunks = list(ZStandardDecoder().decode(zstd.compress(body))) - assert len(chunks) > 1 - assert max(len(chunk) for chunk in chunks) <= MAX_DECODE_CHUNK_SIZE assert b"".join(chunks) == body + if _zstd_stdlib_backend: # pragma: no cover + # Only the stdlib `compression.zstd` backend bounds a single decompress call. + assert len(chunks) > 1 + assert max(len(chunk) for chunk in chunks) <= MAX_DECODE_CHUNK_SIZE def test_zstd_decoding_error() -> None: From 046d42803258a30f1fc56b6c6148ed9b657986dc Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 13:26:41 +0200 Subject: [PATCH 04/14] Pin brotlicffi>=1.2.0.0 for bounded output on non-CPython --- src/httpx2/pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index 86f854d0..b14adab2 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ [project.optional-dependencies] brotli = [ "brotli>=1.2.0; platform_python_implementation == 'CPython'", - "brotlicffi; platform_python_implementation != 'CPython'", + "brotlicffi>=1.2.0.0; platform_python_implementation != 'CPython'", ] cli = [ "click>=8.4.2", diff --git a/uv.lock b/uv.lock index 0f3d926a..aab7f09d 100644 --- a/uv.lock +++ b/uv.lock @@ -1514,7 +1514,7 @@ zstd = [ requires-dist = [ { name = "anyio", marker = "sys_platform != 'emscripten'", specifier = ">=4.10" }, { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'", specifier = ">=1.2.0" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'", specifier = ">=1.2.0.0" }, { name = "click", marker = "extra == 'cli'", specifier = ">=8.4.2" }, { name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" }, { name = "httpcore2", marker = "sys_platform != 'emscripten'", editable = "src/httpcore2" }, From 33c8c5376f73bff7ba11bc51ab9863475ea6f4ed Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 9 Aug 2026 13:35:23 +0200 Subject: [PATCH 05/14] Drop the redundant empty-chunk guard in IdentityDecoder --- src/httpx2/httpx2/_decoders.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index a2f28f12..a839c4c9 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -107,8 +107,7 @@ class IdentityDecoder(ContentDecoder): """ def decode(self, data: bytes) -> typing.Iterator[bytes]: - if data: - yield data + yield data def flush(self) -> typing.Iterator[bytes]: yield from () From dcdd375bb629d095aaf06c8c7beba718a52473b6 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 14 Aug 2026 10:09:31 +0200 Subject: [PATCH 06/14] Assert bounded decode memory via the public API instead of importing decoders --- tests/httpx2/test_decoders.py | 55 +++++++++++++---------------------- 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index 24efa475..1c08f8e1 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -2,6 +2,7 @@ import io import sys +import tracemalloc import typing import zlib @@ -68,33 +69,30 @@ def test_gzip() -> None: @pytest.mark.parametrize("wbits", (zlib.MAX_WBITS | 16, zlib.MAX_WBITS)) -def test_zlib_decoder_yields_bounded_chunks(wbits: int) -> None: - from httpx2._decoders import MAX_DECODE_CHUNK_SIZE, DeflateDecoder, GZipDecoder - - body = b"\x00" * (MAX_DECODE_CHUNK_SIZE * 4) +def test_decoding_bounds_peak_memory(wbits: int) -> None: + # A small compressed body that inflates to a large output must not be + # materialised in a single decode step. Even when the entire compressed + # body arrives as one raw chunk, peak memory stays far below the decoded + # size because decoding happens in bounded pieces. + decoded_size = 64 * 1024 * 1024 compressor = zlib.compressobj(9, zlib.DEFLATED, wbits) - compressed = compressor.compress(body) + compressor.flush() - - decoder: GZipDecoder | DeflateDecoder = GZipDecoder() if wbits & 16 else DeflateDecoder() - chunks = list(decoder.decode(compressed)) - - assert len(chunks) >= 4 - assert all(len(chunk) <= MAX_DECODE_CHUNK_SIZE for chunk in chunks) - assert b"".join(chunks) + b"".join(decoder.flush()) == body - + compressed = compressor.compress(b"\x00" * decoded_size) + compressor.flush() + encoding = b"gzip" if wbits & 16 else b"deflate" -def test_brotli_decoder_yields_bounded_chunks() -> None: - import brotli + def raw() -> typing.Iterator[bytes]: + yield compressed # deliver the whole bomb as a single raw chunk - from httpx2._decoders import BrotliDecoder + response = httpx2.Response(200, headers=[(b"Content-Encoding", encoding)], content=raw()) - body = b"\x00" * (4 * 1024 * 1024) - chunks = list(BrotliDecoder().decode(brotli.compress(body))) + total = 0 + tracemalloc.start() + for chunk in response.iter_bytes(): + total += len(chunk) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() - assert len(chunks) > 1 - # `output_buffer_limit` is a soft cap that overshoots, but stays far below the full size. - assert max(len(chunk) for chunk in chunks) < len(body) // 4 - assert b"".join(chunks) == body + assert total == decoded_size + assert peak < decoded_size // 8 def test_brotli() -> None: @@ -123,19 +121,6 @@ def test_zstd() -> None: assert response.content == body -def test_zstd_decoder_yields_bounded_chunks() -> None: - from httpx2._decoders import MAX_DECODE_CHUNK_SIZE, ZStandardDecoder, _zstd_stdlib_backend - - body = b"\x00" * (MAX_DECODE_CHUNK_SIZE * 4) - chunks = list(ZStandardDecoder().decode(zstd.compress(body))) - - assert b"".join(chunks) == body - if _zstd_stdlib_backend: # pragma: no cover - # Only the stdlib `compression.zstd` backend bounds a single decompress call. - assert len(chunks) > 1 - assert max(len(chunk) for chunk in chunks) <= MAX_DECODE_CHUNK_SIZE - - def test_zstd_decoding_error() -> None: compressed_body = "this_is_not_zstd_compressed_data" From 9372bf70ee7479f08b58f4d33b88414d90b06603 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 14 Aug 2026 10:23:18 +0200 Subject: [PATCH 07/14] Raise the decode chunk bound to 1 MiB to reduce per-call overhead --- src/httpx2/httpx2/_decoders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index a839c4c9..1df5c684 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -62,7 +62,7 @@ pass -MAX_DECODE_CHUNK_SIZE = 2**16 # 64 KiB +MAX_DECODE_CHUNK_SIZE = 2**20 # 1 MiB class Decompressor(typing.Protocol): From 463c1980cb1606ec9a400fc7da43e212dee67386 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 17 Aug 2026 14:06:13 +0200 Subject: [PATCH 08/14] Name the bounded zlib drain decompress() instead of __call__ --- src/httpx2/httpx2/_decoders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index 1df5c684..5a005563 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -83,7 +83,7 @@ class ZlibDecompressor: def __init__(self, decompressor: Decompressor) -> None: self.decompressor = decompressor - def __call__(self, data: bytes) -> typing.Iterator[bytes]: + def decompress(self, data: bytes) -> typing.Iterator[bytes]: decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE) while decompressed: yield decompressed @@ -128,7 +128,7 @@ def decode(self, data: bytes) -> typing.Iterator[bytes]: was_first_attempt = self.first_attempt self.first_attempt = False try: - yield from self.decompressor(data) + yield from self.decompressor.decompress(data) except zlib.error as exc: if was_first_attempt: self.decompressor = ZlibDecompressor(zlib.decompressobj(-zlib.MAX_WBITS)) @@ -155,7 +155,7 @@ def __init__(self) -> None: def decode(self, data: bytes) -> typing.Iterator[bytes]: try: - yield from self.decompressor(data) + yield from self.decompressor.decompress(data) except zlib.error as exc: raise DecodingError(str(exc)) from exc From 946fc102ee81569608367d1c6d803d9c348a3b5d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 17 Aug 2026 14:13:09 +0200 Subject: [PATCH 09/14] Support brotli backends without output_buffer_limit instead of pinning a floor --- src/httpx2/httpx2/_decoders.py | 34 +++++++++++++++++++++++++++------- src/httpx2/pyproject.toml | 4 ++-- uv.lock | 4 ++-- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index 5a005563..be968fa1 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -29,6 +29,24 @@ brotli = None +@functools.cache +def _brotli_bounds_output() -> bool: + """ + Return `True` if the installed brotli backend supports `output_buffer_limit`, + which is required to decode in bounded pieces. Older backends decode a whole + chunk in one call instead. + """ + decompressor = brotli.Decompressor() + decompress = getattr(decompressor, "decompress", None) or decompressor.process + try: + decompress(b"", output_buffer_limit=MAX_DECODE_CHUNK_SIZE) + except TypeError: + return False + except brotli.error: # pragma: no cover + pass + return True + + # Zstandard support is optional on Python <= 3.13. # On Python 3.14+, the stdlib includes an optional built-in zstd implementation. if typing.TYPE_CHECKING: @@ -199,13 +217,15 @@ def decode(self, data: bytes) -> typing.Iterator[bytes]: return self.seen_data = True try: - decompressed = self._decompress(data, output_buffer_limit=MAX_DECODE_CHUNK_SIZE) - while decompressed: - yield decompressed - decompressed = self._decompress(b"", output_buffer_limit=MAX_DECODE_CHUNK_SIZE) - except TypeError: # pragma: no cover - # Backend without `output_buffer_limit` (e.g. brotlicffi < 1.2.0); fall back to unbounded. - yield self._decompress(data) + if _brotli_bounds_output(): + decompressed = self._decompress(data, output_buffer_limit=MAX_DECODE_CHUNK_SIZE) + while decompressed: + yield decompressed + decompressed = self._decompress(b"", output_buffer_limit=MAX_DECODE_CHUNK_SIZE) + else: # pragma: no cover + # Backend without `output_buffer_limit` (e.g. brotli < 1.2.0), so the + # output of a single chunk cannot be bounded. + yield self._decompress(data) except brotli.error as exc: raise DecodingError(str(exc)) from exc diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index b14adab2..72096c36 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -54,8 +54,8 @@ dependencies = [ [project.optional-dependencies] brotli = [ - "brotli>=1.2.0; platform_python_implementation == 'CPython'", - "brotlicffi>=1.2.0.0; platform_python_implementation != 'CPython'", + "brotli; platform_python_implementation == 'CPython'", + "brotlicffi; platform_python_implementation != 'CPython'", ] cli = [ "click>=8.4.2", diff --git a/uv.lock b/uv.lock index aab7f09d..f7eb530b 100644 --- a/uv.lock +++ b/uv.lock @@ -1513,8 +1513,8 @@ zstd = [ [package.metadata] requires-dist = [ { name = "anyio", marker = "sys_platform != 'emscripten'", specifier = ">=4.10" }, - { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'", specifier = ">=1.2.0" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'", specifier = ">=1.2.0.0" }, + { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'" }, { name = "click", marker = "extra == 'cli'", specifier = ">=8.4.2" }, { name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" }, { name = "httpcore2", marker = "sys_platform != 'emscripten'", editable = "src/httpcore2" }, From a45a3586c10ba8efd0100135b88ea2029e7cf03e Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 17 Aug 2026 14:17:57 +0200 Subject: [PATCH 10/14] Require brotli 1.2.0 for bounded output --- src/httpx2/pyproject.toml | 4 ++-- uv.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index 72096c36..b14adab2 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -54,8 +54,8 @@ dependencies = [ [project.optional-dependencies] brotli = [ - "brotli; platform_python_implementation == 'CPython'", - "brotlicffi; platform_python_implementation != 'CPython'", + "brotli>=1.2.0; platform_python_implementation == 'CPython'", + "brotlicffi>=1.2.0.0; platform_python_implementation != 'CPython'", ] cli = [ "click>=8.4.2", diff --git a/uv.lock b/uv.lock index f7eb530b..aab7f09d 100644 --- a/uv.lock +++ b/uv.lock @@ -1513,8 +1513,8 @@ zstd = [ [package.metadata] requires-dist = [ { name = "anyio", marker = "sys_platform != 'emscripten'", specifier = ">=4.10" }, - { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'" }, + { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'", specifier = ">=1.2.0" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'", specifier = ">=1.2.0.0" }, { name = "click", marker = "extra == 'cli'", specifier = ">=8.4.2" }, { name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" }, { name = "httpcore2", marker = "sys_platform != 'emscripten'", editable = "src/httpcore2" }, From a12336f9ade6a940e1cdf226b2241ba94d7bca12 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 17 Aug 2026 14:45:20 +0200 Subject: [PATCH 11/14] Document why the brotli drain loop stops on empty output --- src/httpx2/httpx2/_decoders.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index be968fa1..346a7b70 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -218,6 +218,10 @@ def decode(self, data: bytes) -> typing.Iterator[bytes]: self.seen_data = True try: if _brotli_bounds_output(): + # Drain until the backend stops producing output. `output_buffer_limit` + # caps a single call, so a large chunk is emitted over several calls. + # Draining to empty also leaves `can_accept_more_data()` true, which the + # backend requires before it is passed non-empty input again. decompressed = self._decompress(data, output_buffer_limit=MAX_DECODE_CHUNK_SIZE) while decompressed: yield decompressed From e0ba7b1373f187c351721be6754a10f851fc0a39 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 18 Aug 2026 10:54:43 +0200 Subject: [PATCH 12/14] Bound Brotli and Zstandard decoder output --- README.md | 2 +- docs/index.md | 2 +- docs/quickstart.md | 2 +- src/httpx2/httpx2/_decoders.py | 88 ++++------------ src/httpx2/pyproject.toml | 2 +- tests/httpx2/test_decoders.py | 18 +++- uv.lock | 187 +++++++++++++++++---------------- 7 files changed, 136 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index a1d6b5b5..62c3da97 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ As well as these optional installs: * `rich` - Rich terminal support. *(Optional, with `httpx2[cli]`)* * `click` - Command line client support. *(Optional, with `httpx2[cli]`)* * `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx2[brotli]`)* -* `zstandard` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported via the stdlib [`compression.zstd`](https://docs.python.org/3/library/compression.zstd.html) module when it is available; the `httpx2[zstd]` extra installs nothing there, so decoding falls back to `zstandard` only on 3.13 and below.)* +* `backports.zstd` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported via the stdlib [`compression.zstd`](https://docs.python.org/3/library/compression.zstd.html) module when it is available, and the `httpx2[zstd]` extra installs nothing.)* A huge amount of credit is due to `requests` for the API layout that much of this work follows, as well as to `urllib3` for plenty of design diff --git a/docs/index.md b/docs/index.md index a149da3d..c8927da0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -119,7 +119,7 @@ As well as these optional installs: * `rich` - Rich terminal support. *(Optional, with `httpx2[cli]`)* * `click` - Command line client support. *(Optional, with `httpx2[cli]`)* * `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx2[brotli]`)* -* `zstandard` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported via the stdlib [`compression.zstd`][] module when it is available; the `httpx2[zstd]` extra installs nothing there, so decoding falls back to `zstandard` only on 3.13 and below.)* +* `backports.zstd` - Decoding for "zstd" compressed responses on Python 3.13 and below. *(Optional, with `httpx2[zstd]`. On Python 3.14+, `zstd` is supported via the stdlib [`compression.zstd`][] module when it is available, and the `httpx2[zstd]` extra installs nothing.)* A huge amount of credit is due to `requests` for the API layout that much of this work follows, as well as to `urllib3` for plenty of design diff --git a/docs/quickstart.md b/docs/quickstart.md index a5585c31..8aba629d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -102,7 +102,7 @@ Any `gzip` and `deflate` HTTP response encodings will automatically be decoded for you. If `brotlipy` is installed, then the `brotli` response encoding will be supported. The `zstd` response encoding is supported on Python 3.14+ via the stdlib [`compression.zstd`][] module when it is -available; on Python 3.13 and below it requires the `zstandard` package. +available; on Python 3.13 and below it requires the `backports.zstd` package. For example, to create an image from binary data returned by a request, you can use the following code: diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index 346a7b70..c166786d 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -7,7 +7,6 @@ from __future__ import annotations import codecs -import functools import io import itertools import sys @@ -29,55 +28,25 @@ brotli = None -@functools.cache -def _brotli_bounds_output() -> bool: - """ - Return `True` if the installed brotli backend supports `output_buffer_limit`, - which is required to decode in bounded pieces. Older backends decode a whole - chunk in one call instead. - """ - decompressor = brotli.Decompressor() - decompress = getattr(decompressor, "decompress", None) or decompressor.process - try: - decompress(b"", output_buffer_limit=MAX_DECODE_CHUNK_SIZE) - except TypeError: - return False - except brotli.error: # pragma: no cover - pass - return True - - # Zstandard support is optional on Python <= 3.13. # On Python 3.14+, the stdlib includes an optional built-in zstd implementation. if typing.TYPE_CHECKING: - # We keep checking Python version in the type checker path because try..except doesn't help type checkers. if sys.version_info >= (3, 14): from compression.zstd import ZstdDecompressor, ZstdError else: - from zstandard import ZstdDecompressor as _ZstdDecompressor, ZstdError + from backports.zstd import ZstdDecompressor, ZstdError - ZstdDecompressor = functools.partial(_ZstdDecompressor().decompressobj) - - _zstandard_installed: bool = False - # True only when the stdlib `compression.zstd` backend is in use (bounded, incremental decode). - _zstd_stdlib_backend: bool = False + _zstandard_installed: bool else: # pragma: no cover - _zstandard_installed = False - _zstd_stdlib_backend = False try: - from compression.zstd import ZstdDecompressor, ZstdError + if sys.version_info >= (3, 14): + from compression.zstd import ZstdDecompressor, ZstdError + else: + from backports.zstd import ZstdDecompressor, ZstdError _zstandard_installed = True - _zstd_stdlib_backend = True - # Either Python <3.14 or the distro doesn't have `compression.zstd`. except ImportError: - try: - from zstandard import ZstdDecompressor as _ZstdDecompressor, ZstdError - - ZstdDecompressor = functools.partial(_ZstdDecompressor().decompressobj) - _zstandard_installed = True - except ImportError: - pass + _zstandard_installed = False MAX_DECODE_CHUNK_SIZE = 2**20 # 1 MiB @@ -217,19 +186,12 @@ def decode(self, data: bytes) -> typing.Iterator[bytes]: return self.seen_data = True try: - if _brotli_bounds_output(): - # Drain until the backend stops producing output. `output_buffer_limit` - # caps a single call, so a large chunk is emitted over several calls. - # Draining to empty also leaves `can_accept_more_data()` true, which the - # backend requires before it is passed non-empty input again. - decompressed = self._decompress(data, output_buffer_limit=MAX_DECODE_CHUNK_SIZE) - while decompressed: - yield decompressed - decompressed = self._decompress(b"", output_buffer_limit=MAX_DECODE_CHUNK_SIZE) - else: # pragma: no cover - # Backend without `output_buffer_limit` (e.g. brotli < 1.2.0), so the - # output of a single chunk cannot be bounded. - yield self._decompress(data) + # The C backend may allocate nearly twice the requested threshold. + output_buffer_limit = MAX_DECODE_CHUNK_SIZE // 2 + decompressed = self._decompress(data, output_buffer_limit=output_buffer_limit) + while decompressed: + yield decompressed + decompressed = self._decompress(b"", output_buffer_limit=output_buffer_limit) except brotli.error as exc: raise DecodingError(str(exc)) from exc @@ -252,8 +214,7 @@ def flush(self) -> typing.Iterator[bytes]: class ZStandardDecoder(ContentDecoder): """Handle 'zstd' RFC 8878 decoding. - If running on Python 3.14+ or a distro that doesn't have the `compression.zstd` stdlib module, requires either: - `pip install zstandard` or `pip install httpx2[zstd]`. + On Python 3.13 and below, this requires `pip install httpx2[zstd]`. """ # inspired by the ZstdDecoder implementation in urllib3 @@ -284,21 +245,12 @@ def decode(self, data: bytes) -> typing.Iterator[bytes]: raise DecodingError(str(exc)) from exc def _decompress_frame(self, data: bytes) -> typing.Iterator[bytes]: - # The `sys.version_info` guard is what lets the type checker pick the right backend type; - # `_zstd_stdlib_backend` additionally handles a 3.14 install where `compression.zstd` is - # absent and the third-party `zstandard` backend is used instead. - if sys.version_info >= (3, 14) and _zstd_stdlib_backend: # pragma: no cover - # The stdlib `compression.zstd` decompressor bounds a single call's output. - decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE) - while decompressed: - yield decompressed - if self.decompressor.needs_input or self.decompressor.eof: - break - decompressed = self.decompressor.decompress(b"", MAX_DECODE_CHUNK_SIZE) - else: # pragma: no cover - # `zstandard`'s incremental (decompressobj) API has no per-call output bound, so a - # frame is decompressed in one shot rather than in bounded pieces. - yield self.decompressor.decompress(data) + decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE) + while decompressed: + yield decompressed + if self.decompressor.needs_input or self.decompressor.eof: + break + decompressed = self.decompressor.decompress(b"", MAX_DECODE_CHUNK_SIZE) def flush(self) -> typing.Iterator[bytes]: if not self.seen_data: diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index 41b6b041..e6c0627c 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -74,7 +74,7 @@ ws = [ ] # TODO(Marcelo): Remove when Python 3.13 reaches EOL. zstd = [ - "zstandard>=0.18.0; python_version <= '3.13'", + "backports.zstd>=1.0.0; python_version <= '3.13'", ] [project.scripts] diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index 1c08f8e1..a149c3af 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -6,6 +6,7 @@ import typing import zlib +import brotli import chardet import pytest @@ -14,7 +15,7 @@ if sys.version_info >= (3, 14): # pragma: no cover from compression import zstd else: # pragma: no cover - import zstandard as zstd + from backports import zstd def test_deflate() -> None: @@ -108,6 +109,21 @@ def test_brotli() -> None: assert response.content == body +@pytest.mark.parametrize(("encoding", "compress"), [(b"br", brotli.compress), (b"zstd", zstd.compress)]) +def test_decoding_bounds_output_chunks(encoding: bytes, compress: typing.Callable[[bytes], bytes]) -> None: + body = b"\x00" * (2**20 + 1) + compressed_body = compress(body) + + def raw() -> typing.Iterator[bytes]: + yield compressed_body + + response = httpx2.Response(200, headers=[(b"Content-Encoding", encoding)], content=raw()) + chunks = list(response.iter_bytes()) + + assert b"".join(chunks) == body + assert max(map(len, chunks)) <= 2**20 + + def test_zstd() -> None: body = b"test 123" compressed_body = zstd.compress(body) diff --git a/uv.lock b/uv.lock index 0c530073..ada8736d 100644 --- a/uv.lock +++ b/uv.lock @@ -308,6 +308,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "backports-zstd" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/b5/5a873da082bd08acd6a497f7aae224e94a7c27fa8f24488089cc50a16c84/backports_zstd-1.6.0.tar.gz", hash = "sha256:80a7859ffe70bf239d7a2ce15293bdeb5b4280ff7dc326ffab312b0e254dbb24", size = 1000009, upload-time = "2026-06-14T10:50:58.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/8d/3f8e7a0fd319b3c0dbf0c4f751336309bb50a873b9185c2f5d228ff0d21b/backports_zstd-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:73000459db113a658c4fb0510100ef0e79137b5828bf957b7709aacae4eb1b87", size = 437068, upload-time = "2026-06-14T10:49:05.528Z" }, + { url = "https://files.pythonhosted.org/packages/db/14/4700047713a60131efcb3977a9892fab60bc9dd6634272550b8f1c5a427d/backports_zstd-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6e78d5e28f812b39f92397806ecddd4a6f3bf35531a8c039a1f187abc931af8", size = 363456, upload-time = "2026-06-14T10:49:07.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/34/92de2f1bd5ee29b24302c871b9f3c19155bf9478cd3af5a0dfd70fa2f483/backports_zstd-1.6.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:32f04d54ec1fdf3aa648b24a10b1c9234ed2046cc4af7a8850cbc236c05d42f3", size = 507392, upload-time = "2026-06-14T10:49:08.63Z" }, + { url = "https://files.pythonhosted.org/packages/5a/95/ed5b8b026c6df1a59681a73396f63cfd10e17ccfbc6315974745a8b7d834/backports_zstd-1.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83415af3c64550a56cc20b4cce59bbaa81f21d28466d7adf98feff011ecbc66d", size = 476957, upload-time = "2026-06-14T10:49:09.894Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/1a82ede48d9df99c8245cb38622cd1a9b388b34f89e1cb7b6650913b493d/backports_zstd-1.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3c17e6a267d13de9cbf14bf2ebfa87e03d26692456fc67d2dbed9da4f479b18", size = 582618, upload-time = "2026-06-14T10:49:11.097Z" }, + { url = "https://files.pythonhosted.org/packages/6b/70/441ed36e230b0f66d7d49382c58249b540139c5b1aa096ace1ff00bd7873/backports_zstd-1.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:75578c71644b031118ce938855a53530708db7f4af6e83e2f8840d5a1de990f8", size = 642278, upload-time = "2026-06-14T10:49:12.545Z" }, + { url = "https://files.pythonhosted.org/packages/58/a3/8f5737bdb02576577a018c10a4c345a5b4b2e63cc3811baeecc054f71c00/backports_zstd-1.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4ae7ed5a6d813450cc2d818284ea3db9721edcef50a56aae42ea06feec38c6e", size = 492492, upload-time = "2026-06-14T10:49:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a9/c086507f535c2466a25bf83a876666c9ccde07b17ce81680217ac17355fe/backports_zstd-1.6.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e9a8370c8ed873083d5de956d6b2e60adbad31e52d7a11111c96ef01d1910ae", size = 566440, upload-time = "2026-06-14T10:49:15.483Z" }, + { url = "https://files.pythonhosted.org/packages/89/bb/778aaccb58c4d2fba3482438c0d33c6a3a413710ecdb2ee8559ff28632fc/backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c2d1ccfe088e8279d605011a3575619a74526c261be357695b3258c0f636115a", size = 482894, upload-time = "2026-06-14T10:49:16.982Z" }, + { url = "https://files.pythonhosted.org/packages/3a/88/87ad188ce971c15bce933e13c4dc2939e741a9cb06a8bd692ef8614c3ed4/backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e73a550dbeb84e8fa50f8385f7735e9a4735b465851ef617d02f80ab10e44e7e", size = 510822, upload-time = "2026-06-14T10:49:18.274Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8c/01714884a14b836abfdb1d80339acbb39515b0e92e615c4b65caf0eec257/backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84f92e5a60a78c72ccda79d0417d311a1f6da18f446423ed411726d545bf7b56", size = 586941, upload-time = "2026-06-14T10:49:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/63/43/e2cb44bbe3f7485d6ad8493211f0c8fffdb7e02fb94203fd968751d9fad7/backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0eb4281f402b94d397b7482f6d9efd04c28274e4ed6eb57eb1f87bdd091a6a87", size = 564255, upload-time = "2026-06-14T10:49:20.968Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/eb0f00f6f7778db3710f757d77f5699c325548037dd975b52b186394125e/backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d6b9b06323e3ba947c0003b2d70e02f33c90c36bc6262a92eb8201afc4a1aa08", size = 632836, upload-time = "2026-06-14T10:49:22.177Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b8/5434897431de92e79ebfb2c02e1ab3cd228e92853b7ff981b2cffcce7355/backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8872a0e9f1af975966b5be6af7eebd3dc4046f15e470b719316516dc3d137cd6", size = 496501, upload-time = "2026-06-14T10:49:23.444Z" }, + { url = "https://files.pythonhosted.org/packages/db/76/754939c3914e9724e20a50b75b17fdc27aeb24d697eb61c3e93438a42920/backports_zstd-1.6.0-cp310-cp310-win32.whl", hash = "sha256:c14fa5dc39a804f1b92d63506f450eca5c59647a18d197d1a564b89dac1be1ce", size = 291527, upload-time = "2026-06-14T10:49:24.568Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/adcebdc2caa2e3d3d496ac2501f0ada49ad2942ee36e5f88a944bca9ca92/backports_zstd-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:8219d6fceae6b39535c4ac323dba0923d10f781d59962ff3504e693fdcafa92c", size = 329024, upload-time = "2026-06-14T10:49:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/74/10/12edc0b401a08aba157b9d331748ac0f0e9890af0a58a9c72425063d1450/backports_zstd-1.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:b7bc9a0b66097f03820a54316d2fdd0beb38859cf98f10d63e94c55450ed8920", size = 291597, upload-time = "2026-06-14T10:49:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/428dd82228b1b6d62d5a1bf312c29e6c125af6a182fcfd82768ca179dcc7/backports_zstd-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4fc41b2df5529cad5ceb230319e82728096d4b353ce8d4df68a2ec37e291bb8", size = 437067, upload-time = "2026-06-14T10:49:28.335Z" }, + { url = "https://files.pythonhosted.org/packages/ef/48/768edf21fe33bae8d874470b1be136681d4d32eb820a32e1c98262ebe39b/backports_zstd-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:83391ef5935cc0f329b1abca414ae20ffe40d335fc21a4b5e664f08a74317d5f", size = 363454, upload-time = "2026-06-14T10:49:29.784Z" }, + { url = "https://files.pythonhosted.org/packages/29/8a/d462c2e5071eb573378f0d26760f6590613086fdf59c2d3c66bdfffb9f41/backports_zstd-1.6.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7d3f64c503af7b60115b97c16feaf75bd191ef2c978d5c0c7725a6682bef63c5", size = 507393, upload-time = "2026-06-14T10:49:31.077Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/af58363b0dd0b497282ecef1fa99789b03cc1885a01a41394cad42ceeff6/backports_zstd-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0308990ffc998df3c7ed35276bde049728b5c3956203cae40d80893576a41459", size = 476957, upload-time = "2026-06-14T10:49:32.53Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fd/5fbdf2275cefae95c4b3509f6db2dc372d0587ebafea342d28781d51d932/backports_zstd-1.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c298785e2fadeab82342040f2d9ce764ce500e6da6a6d99a2de514e63580b5a", size = 582618, upload-time = "2026-06-14T10:49:33.723Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/7dd45c53c907ea67f635c3900b58bb3347c01dc2ded441402028aae0ef9c/backports_zstd-1.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae106fe16e36efc60ab098d02478d30aa0e31e1420eb4ecf0116459253bc6361", size = 642279, upload-time = "2026-06-14T10:49:34.938Z" }, + { url = "https://files.pythonhosted.org/packages/4d/25/a9e37dd035027565fa0b7e367da50e88a6ab26e7fd413269aa118e25258b/backports_zstd-1.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7293fefe15f0e5852bdb4ad1e0e26f3cbd4d3e61c19f751ecc4ff34bc1eb237d", size = 492486, upload-time = "2026-06-14T10:49:36.06Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/659686bf8f7c53ea279e1c44038504b82a6901cee2f5ae83c30bbf581301/backports_zstd-1.6.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ece8e7288db5b827ef8c64b2f78519f1a173a8991a625978fce02eccd7654fe9", size = 566440, upload-time = "2026-06-14T10:49:37.536Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1a/c7ea5a0ff607a1a6066bb7c7cb65ae20e2f85da6adc69ab77fd8943e180c/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28eef3881164f3c23ce58ed59e4684103bdd279583eb2d299858c9e9b72fde9a", size = 482899, upload-time = "2026-06-14T10:49:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/83/48/bd2b91100ee4fe6bb4d816e3659cbbb0cda5dd32760d2379c54d1752ec25/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:481a1e9bd8f419fdc625307aa20234687f99368c75df511ef589693c5fea4c6f", size = 510826, upload-time = "2026-06-14T10:49:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/25/fe/fa28509d7ce2ad59404e7ce738a2fd858e12dfd9a896629f10330222a7fb/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3b6713371f8987a1178df93cb36f29eef191f224021e2d656b2f11ce60d26816", size = 586941, upload-time = "2026-06-14T10:49:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/45/28/757daf2399aa71bb37f9f7f48b42ab03fc51c340eccfad2fec92a23f6aa3/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b0ddbcd2866b8ff1a2836e4b0e4d44788f5b992d83fac75a38cda8f9a2bee079", size = 564261, upload-time = "2026-06-14T10:49:42.49Z" }, + { url = "https://files.pythonhosted.org/packages/4e/53/9b9db30cb2c148a69c40ad7647aa787338041f3dc81c5b22113286e590e9/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2914abea516704bdafb2090acd3f15b5f9debecfabd15b8dd8285b2ad3b92209", size = 632869, upload-time = "2026-06-14T10:49:43.981Z" }, + { url = "https://files.pythonhosted.org/packages/81/a4/1692fbb88af8aaf900a53619fcc95c9e45d9ff162223a47fd672a9893c8d/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd085eafa2aac6f883afd28210a3231f717f25409a1e44a39bb7b04c8c5b5646", size = 496496, upload-time = "2026-06-14T10:49:45.118Z" }, + { url = "https://files.pythonhosted.org/packages/93/42/c5a66c47320bd12ce84a7341330ea582d67069bdb70214bca0b6bf394cfd/backports_zstd-1.6.0-cp311-cp311-win32.whl", hash = "sha256:b81b4cf3d6e0ad7ac92bef248f49fafc954262c5fb0f7e19d6aac497e5a856b2", size = 291613, upload-time = "2026-06-14T10:49:46.473Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f2/f22c19b4cdde429805ff5ac8dd77a95569a7c4cb8991741b2ff0d538f220/backports_zstd-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:10b61850c4112952e05aa6e6cce8c9a5936fbeadb321e154216705cc76a14afa", size = 329078, upload-time = "2026-06-14T10:49:47.71Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/e902a3f1eb92c4907b5f47f90cb3c2734ee315c4ff67179fc111343b45ba/backports_zstd-1.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:068ef3d8c18815a2e3a752f766313e19910e7c50939b956923748d9c04ebcb1b", size = 291727, upload-time = "2026-06-14T10:49:48.929Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/009af3a9532d4cc66d5385391c512210fae32ab2442605f26aca1d8d2957/backports_zstd-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0466b14723f3b7697669c00ee66fe16e30e25636b286b0a923fa86fa3d8a753c", size = 437407, upload-time = "2026-06-14T10:49:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/0c/76/f7c02efde81ebb9993586f9e435d2fd1191a6f806f640e4eeb8d004493ed/backports_zstd-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d146926e997d2d3de8212bdcbf4985344a2622ca3bec458d8908000a84fd883", size = 363519, upload-time = "2026-06-14T10:49:51.383Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5e/0cf66f12472fe3e082cc4134395a7e0b8746cfb30aabd74251ce8fafa9a7/backports_zstd-1.6.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:460fd6b3f338c659507ae36cfd6b58ac9942a2ff233c5cf574416dfec0451a84", size = 507756, upload-time = "2026-06-14T10:49:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/95/7ed25c90369360f96f8bfa961540845e063377c32a43b775201af66a588c/backports_zstd-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c2b1f4a640c51130caa92cef5bf72bd3c3dbbcfbf814c37403aa0601b1811b0", size = 477578, upload-time = "2026-06-14T10:49:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/e3/75/f16b1d3e33ca396525847c81d96e3de7bc74d2c6f9ca2ddee76b0c450697/backports_zstd-1.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:beb43e9885202c8d4f3762319ed4d5e98e197622afbff8439fbbdd81d08938b9", size = 583029, upload-time = "2026-06-14T10:49:55.132Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/a17b111b631e1c79a0e570881c1a266c661b936585afa395435a458b1991/backports_zstd-1.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fbb746522ebfc11155f1cd688e2c48ef3d74125e38b63eabdaab068a055c3e88", size = 641741, upload-time = "2026-06-14T10:49:56.42Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b2/d17b2722c636d64b4e77ddc68d8d0625719d39f94021be8719a218af4c0a/backports_zstd-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a99710fbb225d459d66def4dc2bb2cd4a9a0bdc8b799fc0621cfdd863be9c93", size = 495554, upload-time = "2026-06-14T10:49:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/2853e8b6c03f03795b6548ea61f82cc104d4f7ff2523a04bc69f46984663/backports_zstd-1.6.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f69365ee2b836939137de024a302395a1cb8654fb6dc5ffef6381105259c8f87", size = 570027, upload-time = "2026-06-14T10:49:59.003Z" }, + { url = "https://files.pythonhosted.org/packages/18/aa/83f37b81f3b8c6ea035bf260ec374648bd59372894c02323dc9de3cbdf77/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66cf8038893c7708ec345ffb3ac63c775d10f430f323ac2f0334fdb6a397c57c", size = 483594, upload-time = "2026-06-14T10:50:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6a/d77f8cd2ff642d3b3652c1ccab5b6583114dbf10f8cb0143531357c83998/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e514c71ca72f3b56bd8fbda1a6a5b7d1100a2764b42a3c74a38841f25f9b00ab", size = 511206, upload-time = "2026-06-14T10:50:01.86Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/99a60fe4d1aac8053769d2463271d5df37a7c11c387072fdbb0b16aed7f7/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7741e44f7938ec94f9a52678c8d19b7bc548522ffdc39c9e4481af8db545fa9a", size = 587416, upload-time = "2026-06-14T10:50:03.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1e/a9c003fe4d14bd4bf671598d4c7dcc1cef51e3513d9d7111ba1d07b6f07b/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97e8a9674652496c7612b528085dd5a296c052a2edc466ca1bfb7b0b27820413", size = 567615, upload-time = "2026-06-14T10:50:04.524Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b9/955bd604f692c550c7cb66d00bd7691ead5c86df8ebd23d7254eeaa90789/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:23a793f2fed4dbf0517319759a2cded0b0dd8e8d3797fe30badd5693e320c175", size = 632269, upload-time = "2026-06-14T10:50:05.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/9f61f612f8a4193484c78a1f26db82a50141234189885113ef0085a8a961/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b951113113ed4b8d173418a4f155c14b739dace626b3fa3f82be1831958d39e4", size = 500066, upload-time = "2026-06-14T10:50:07.446Z" }, + { url = "https://files.pythonhosted.org/packages/81/a3/19fb8c48d94139481c5ccaf2fb54c31b543fa635fd7bd7399aadd15752ac/backports_zstd-1.6.0-cp312-cp312-win32.whl", hash = "sha256:6430b34a2ae6fcc604672f4f913102563473d9a015bdca1ce8c95041cc1f2677", size = 291825, upload-time = "2026-06-14T10:50:08.762Z" }, + { url = "https://files.pythonhosted.org/packages/58/38/40ba081c6c71f0f22c64d3d54b912ad75a4e6812caa1397cbb15b5693b12/backports_zstd-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:08793876172551a930ce4d65c712cd516184d1a97070d4a1193e05bf0cf7040d", size = 329201, upload-time = "2026-06-14T10:50:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6c/f7116dd2edc6f960545f0d8616939eae3a20031b3b6669697d4f9fd83b2e/backports_zstd-1.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:03b7c59c71f7a597e2bcb3f8368371e9a660a1bdf1c37afc1f1ad1496a013c19", size = 291901, upload-time = "2026-06-14T10:50:11.198Z" }, + { url = "https://files.pythonhosted.org/packages/38/06/c430537d59c55d49bcd15ecf4b1aa965453219caad810a4f2b484816f4be/backports_zstd-1.6.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:2ace939e4d620e119423606f2d3d7115f8707733bf57f279ad9a9383f875986f", size = 400327, upload-time = "2026-06-14T10:50:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/36/48/2f8323bb0e3ebba88b54877a2979afeb83983fb2ca572f09ad61aae2d3a0/backports_zstd-1.6.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:4c68a9ed2df0cca51d774c521e68a34d2e3d9ebfc687ef8096adfd4f345b551d", size = 454276, upload-time = "2026-06-14T10:50:13.667Z" }, + { url = "https://files.pythonhosted.org/packages/7c/39/87a665244a65f5b87a06b848c29a8cce07e91d59c5988ee2a32c0293a21c/backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:30576f49b82328ec8af16c11100efe52ca88526f71bbe100ef6b4e707dc13bf2", size = 357457, upload-time = "2026-06-14T10:50:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8b/854d4a47bb8b7a48bfb2ed381c7b03a70efb4fc49f0e4a1509b38a2e1727/backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b4bddfcfb6679215d6f4dc5f79a1f9301af339480d70527a14b57a1f2e6b6cbf", size = 366139, upload-time = "2026-06-14T10:50:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/c3af43eb8df6f2581e157e18a3e0121eadb826055b2fde3f91ec188689cb/backports_zstd-1.6.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:65048ed08c5124f05ff9f355ab9703014bb2dbe7f8d9948ce193685b1775f442", size = 446683, upload-time = "2026-06-14T10:50:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/87cf3d883d386c10ac52f5322604fb9afdd204229f4c47d4a820a839b8ff/backports_zstd-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5918fc6b31437208721276964323933cd86077b8d5b469c59c1b3fd2c8220a05", size = 436869, upload-time = "2026-06-14T10:50:19.113Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/9479e6f0f18824ad38e8d7dd85161ab0842a198be669421232925bb30960/backports_zstd-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b6c8b02ab0ccb2431bb7bc238be91d158b308915e7b07937388e540466fe7e7", size = 363090, upload-time = "2026-06-14T10:50:20.302Z" }, + { url = "https://files.pythonhosted.org/packages/d9/74/a5e98fe108e17c91d9bc590a19e77f5d47d579e34d3f5bc098a949d6c27c/backports_zstd-1.6.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:711e6b98f8924e8b4a61ff97ab6321f33de024e1ed6a32f5123763aeda8459be", size = 507070, upload-time = "2026-06-14T10:50:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/392bb7dce7363b77bc5403060f418fad438b9cfdd3edd10d65cee7d8fd11/backports_zstd-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ba9ac10fc393e5123a08802e0e895a107cb4a66b9973d2844dbd8a343111e59", size = 477200, upload-time = "2026-06-14T10:50:22.91Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4d/dfb665806ba4f74bc48071d32006843b53568c4a17ff627a3061de5eaa09/backports_zstd-1.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f723219335387d7546412d8141e0303590600949b4184a1391a0c6a3c756058", size = 582724, upload-time = "2026-06-14T10:50:24.28Z" }, + { url = "https://files.pythonhosted.org/packages/57/b2/beeca7393a8310debd82ee2f0ce5c1801e8d7cb673f7f226f4a0866ca238/backports_zstd-1.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64b94d7a836568926a3309ff510c7f8261b881b341fd4992cabf4f0998878f8a", size = 643493, upload-time = "2026-06-14T10:50:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/38/26/ce90e9eed6f25aaa4a4fa305a2aaf2d2ad81fd69de8eb248ddd91c80d1e0/backports_zstd-1.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e39258a09b1c7ca70b5e94a5c5ccfe4700b4250b8077cfeab31d0f79565d4c9b", size = 492190, upload-time = "2026-06-14T10:50:27.205Z" }, + { url = "https://files.pythonhosted.org/packages/17/9b/37b9b146df1f5452419a96071a7017cbac212ec9b137d7a88ca46dc2aa9e/backports_zstd-1.6.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:15b1aae0f64cd742df4bba1d989d0a09a6ec619202543fdba684640454541fd3", size = 567432, upload-time = "2026-06-14T10:50:28.386Z" }, + { url = "https://files.pythonhosted.org/packages/06/66/81b30991be83237529f36335ac3682bce26409064b906ac6122874575196/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25b5ddc789480072551af571a746e9500356b2aff0499861cf2ca07ea7431e68", size = 483021, upload-time = "2026-06-14T10:50:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/49/2a/792c65dcc1e45eb0c1bdc012ee94b84867186bfe27a860d0813bd216f03b/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a13cfa3410a75e4cb87abdb669aaf79da861cb79299159054ff8f77b9671bc40", size = 510596, upload-time = "2026-06-14T10:50:31.657Z" }, + { url = "https://files.pythonhosted.org/packages/1d/22/01b92a600505620e4cb5f20429e181f30458b7207ca8b52ca5ca6068c35f/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2ddab55a5f54dec8acfad68ef70f1c704fd21919990ddc238afbd6f496e61c6a", size = 587143, upload-time = "2026-06-14T10:50:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/d8/60/4672f5110b9eb01388cc6225a739e3a5fcd749a63a9c4c1450a04fa27113/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fa305a84087e10d7a85e8a8a3dcba8cdbda4868f2180173b264b7b488fd37c55", size = 565238, upload-time = "2026-06-14T10:50:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3b/19928d60ea7d25820bf12ef88de74534ca85b56ff7cf13c1b0e74e3a3d7c/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:df27b57d214a3124fbe4e933ef5a903d4567f154260d9aece8c797a987f2a205", size = 633970, upload-time = "2026-06-14T10:50:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/df/97/c4cecb3e0ff53563ef9819f0395d919ceaae9c5147392ac23bac7afdb20f/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28fecd73459d74910ae1987ab84b7bef690d3dd860948430dd5555108b006daf", size = 496539, upload-time = "2026-06-14T10:50:37.015Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f4/46b2f29d2938a80e56e61a19f11ab093f531a9f8cd0ec8eeaac1246bcd99/backports_zstd-1.6.0-cp313-cp313-win32.whl", hash = "sha256:3e689af303df287142770abe3a48bbefd24dab4a09da5807d0e1fa8c75bab026", size = 291451, upload-time = "2026-06-14T10:50:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ad/b529f92166da61f496621345f95d2dc583c8ca5ac553c084a4ef6c12cd71/backports_zstd-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b067b1ef9c8e41fb0882c828aa37829938b5c0dab067eca72b23fc24c563b9da", size = 329023, upload-time = "2026-06-14T10:50:39.742Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/6be904d20345fbebec583ca83676e01f30c76118b283eb666d8ec8291ca1/backports_zstd-1.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:a838296f5b84c920172fb579cac894d255c1fc25457c7234613ddcfa385e49b7", size = 291636, upload-time = "2026-06-14T10:50:41.004Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/9ed88f528b9484f3847f07b9d1d014b496e048d391b4bc04cb0117bd71a5/backports_zstd-1.6.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6c73ae37dbf9207727ac095dedef864c05d836eaec962a47b3b64eaadaf1c6b6", size = 411126, upload-time = "2026-06-14T10:50:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/fe/26/bf8093d117cb6c36202ee7a2127f672c7b0c81f0c104ce28124534b75efa/backports_zstd-1.6.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:839faf90a7eb525a401978dc925df8c44bd12526e8ba1529b9f8a7106e729637", size = 340643, upload-time = "2026-06-14T10:50:43.571Z" }, + { url = "https://files.pythonhosted.org/packages/17/10/55f0860ed359d290e0eadd410da47ae720a1acf0d8362149e22acbe63223/backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f8f5c1c7c69a4b00889e52d9304a918a5b49010f9645768eb5fd0ad404f790ba", size = 421696, upload-time = "2026-06-14T10:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3e/8dd9cc6f3e697e4721e53d6b9ca8c95c9d51ad2e759e7fcee6885e5b71db/backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80bceebc9b58e959bede9b26cafe15b5b9526f3533a6dd06330c5da73cb9329", size = 395239, upload-time = "2026-06-14T10:50:46.186Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/ab92f8599749cb6063416dd9f46e084004c4e8db68e2eb768283012a6d27/backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79284c1dd702f4f24ed1a36e51555c907dd237b6c0d829595978f4089a2aeea9", size = 415202, upload-time = "2026-06-14T10:50:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f4/dd3ef995f6b22e23da145ac3ecc91e1f1fc4cb572b7f95e6b2b11de16782/backports_zstd-1.6.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1e20b3ecd0a711be82e964aca28554eabbc31ee69a20e5e7b8fd42268af46212", size = 315722, upload-time = "2026-06-14T10:50:49Z" }, + { url = "https://files.pythonhosted.org/packages/e8/09/898fe2f8196fa7ab825f5fed786c68581fdac7d23a8e20baa0cc01cb2f0b/backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:aeef8563b82ed4af328f98e5041c1b4800d86f68f857ffd1577d4d47dc9aa6cd", size = 411023, upload-time = "2026-06-14T10:50:50.286Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ad/6ad9af1596ab5f284bb53954be41396e13d23c81cdfe3d945402e8ee0215/backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb75e33131946fabd6319061df3b8b1d588fe0963183280e9b5f49f7772fc09", size = 340554, upload-time = "2026-06-14T10:50:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/f083d7c8a4ee5d0bb21b4d3144e76de9f655ca4dd0bffcb95baa5bc47a62/backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ef132cfb638e9a86bd5dc07fb4e1cb895bc55bce6bb5e759366e8b160d0747e2", size = 421694, upload-time = "2026-06-14T10:50:52.917Z" }, + { url = "https://files.pythonhosted.org/packages/41/d7/693b20f3ccae2e05d166f98fe55b1657451170b72c804ed9f6b98df520be/backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab70eace272d6f122b121c057e436709b50a28abf30d97aab28433c08f4a4095", size = 395237, upload-time = "2026-06-14T10:50:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/53/a1/484e0f9ec994bd2285d6747e7c8028350f1a177e9210bc57637898042d3b/backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17efb3d11137de5166dd51eedab9c36ad633402acba386eee8d715213ea47e49", size = 415201, upload-time = "2026-06-14T10:50:55.854Z" }, + { url = "https://files.pythonhosted.org/packages/3c/56/70860ece85cd49b564305cbc22bf6c4183975427ff6dfe2097e855f5dd5e/backports_zstd-1.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:994167ff6551b9c1ce226e0aab16295b98c94507b5701aa60d2c32b7d50796b1", size = 315721, upload-time = "2026-06-14T10:50:57.074Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -1509,12 +1602,13 @@ ws = [ { name = "wsproto" }, ] zstd = [ - { name = "zstandard", marker = "python_full_version < '3.14'" }, + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, ] [package.metadata] requires-dist = [ { name = "anyio", marker = "sys_platform != 'emscripten'", specifier = ">=4.10" }, + { name = "backports-zstd", marker = "python_full_version < '3.14' and extra == 'zstd'", specifier = ">=1.0.0" }, { name = "brotli", marker = "platform_python_implementation == 'CPython' and extra == 'brotli'", specifier = ">=1.2.0" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython' and extra == 'brotli'", specifier = ">=1.2.0.0" }, { name = "click", marker = "extra == 'cli'", specifier = ">=8.4.2" }, @@ -1528,7 +1622,6 @@ requires-dist = [ { name = "truststore", marker = "sys_platform != 'emscripten'", specifier = ">=0.10" }, { name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.5.0" }, { name = "wsproto", marker = "extra == 'ws'", specifier = ">=1.2" }, - { name = "zstandard", marker = "python_full_version < '3.14' and extra == 'zstd'", specifier = ">=0.18.0" }, ] provides-extras = ["brotli", "cli", "http2", "socks", "ws", "zstd"] @@ -4086,93 +4179,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964d wheels = [ { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, ] - -[[package]] -name = "zstandard" -version = "0.25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, - { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, - { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, - { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, - { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, - { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, - { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, - { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, - { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, - { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, - { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, - { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, - { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, - { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, - { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, - { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, - { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, - { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, - { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, - { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, - { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, - { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, - { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, - { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, - { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, - { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, - { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, - { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, - { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, - { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, - { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, - { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, - { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, - { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, - { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, - { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, - { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, -] From fbdc4de123c65ddedea2303d85c375e232d97434 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 18 Aug 2026 11:01:59 +0200 Subject: [PATCH 13/14] Close wrapped response streams on decode errors --- src/httpx2/httpx2/_client.py | 9 +++++++-- tests/httpx2/test_decoders.py | 38 ++++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index d1916e4e..4246a4c9 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -160,8 +160,13 @@ def __init__(self, stream: AsyncByteStream, start: float) -> None: self.elapsed: datetime.timedelta | None = None async def __aiter__(self) -> typing.AsyncIterator[bytes]: - async for chunk in self._stream: - yield chunk + stream = self._stream.__aiter__() + try: + async for chunk in stream: + yield chunk + finally: + if isinstance(stream, AsyncGenerator): + await stream.aclose() async def aclose(self) -> None: self.elapsed = datetime.timedelta(seconds=time.perf_counter() - self._start) diff --git a/tests/httpx2/test_decoders.py b/tests/httpx2/test_decoders.py index a149c3af..0d463e95 100644 --- a/tests/httpx2/test_decoders.py +++ b/tests/httpx2/test_decoders.py @@ -475,13 +475,37 @@ def test_invalid_content_encoding_header() -> None: assert response.content == body +def test_streaming_decode_error_closes_response() -> None: + response: httpx2.Response | None = None + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal response + response = httpx2.Response(200, headers={"Content-Encoding": "gzip"}, content=iter([b"invalid"])) + return response + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with pytest.raises(httpx2.DecodingError): + client.get("https://example.org") + + assert response is not None + assert response.is_closed + + @pytest.mark.anyio -async def test_streaming_decode_error_does_not_leak_stream() -> None: - # A decode error raised mid-stream must still close the underlying stream. Under strict async - # generator finalization an unclosed stream surfaces as a ResourceWarning (i.e. a test failure). +async def test_async_streaming_decode_error_closes_response() -> None: + response: httpx2.Response | None = None + async def content() -> typing.AsyncIterator[bytes]: - yield b"this is not valid gzip" + yield b"invalid" - response = httpx2.Response(200, headers=[(b"Content-Encoding", b"gzip")], content=content()) - with pytest.raises(httpx2.DecodingError): - await response.aread() + async def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal response + response = httpx2.Response(200, headers={"Content-Encoding": "gzip"}, content=content()) + return response + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + with pytest.raises(httpx2.DecodingError): + await client.get("https://example.org") + + assert response is not None + assert response.is_closed From 7924107785e0a26b266c354ab5bf4ac6e75d9877 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 18 Aug 2026 14:28:46 +0200 Subject: [PATCH 14/14] Inline bounded Zstandard decompression --- src/httpx2/httpx2/_decoders.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/httpx2/httpx2/_decoders.py b/src/httpx2/httpx2/_decoders.py index c166786d..73b302db 100644 --- a/src/httpx2/httpx2/_decoders.py +++ b/src/httpx2/httpx2/_decoders.py @@ -236,7 +236,12 @@ def decode(self, data: bytes) -> typing.Iterator[bytes]: data = self.decompressor.unused_data + data self.decompressor = ZstdDecompressor() while True: - yield from self._decompress_frame(data) + decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE) + while decompressed: + yield decompressed + if self.decompressor.needs_input or self.decompressor.eof: + break + decompressed = self.decompressor.decompress(b"", MAX_DECODE_CHUNK_SIZE) if not (self.decompressor.eof and self.decompressor.unused_data): break data = self.decompressor.unused_data @@ -244,14 +249,6 @@ def decode(self, data: bytes) -> typing.Iterator[bytes]: except ZstdError as exc: raise DecodingError(str(exc)) from exc - def _decompress_frame(self, data: bytes) -> typing.Iterator[bytes]: - decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE) - while decompressed: - yield decompressed - if self.decompressor.needs_input or self.decompressor.eof: - break - decompressed = self.decompressor.decompress(b"", MAX_DECODE_CHUNK_SIZE) - def flush(self) -> typing.Iterator[bytes]: if not self.seen_data: return