From cc4c1bd160847484d89603759a347628d8de7ee0 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:42:43 -0700 Subject: [PATCH 01/11] Add CodSpeed benchmark suite --- .github/workflows/benchmark.yml | 36 ++++++++++++ pyproject.toml | 4 +- tests/benchmarks/__init__.py | 0 tests/benchmarks/http.py | 50 ++++++++++++++++ tests/benchmarks/test_http.py | 101 ++++++++++++++++++++++++++++++++ tests/benchmarks/test_urls.py | 59 +++++++++++++++++++ tests/benchmarks/urls.py | 8 +++ uv.lock | 33 +++++++++++ 8 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/benchmark.yml create mode 100644 tests/benchmarks/__init__.py create mode 100644 tests/benchmarks/http.py create mode 100644 tests/benchmarks/test_http.py create mode 100644 tests/benchmarks/test_urls.py create mode 100644 tests/benchmarks/urls.py diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..f0f7e1f5 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,36 @@ +name: CodSpeed + +on: + push: + branches: ["main"] + pull_request: + branches: ["main", "version-*"] + +permissions: + id-token: write + contents: read + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.13" + enable-cache: true + + - name: Install dependencies + run: scripts/install + shell: bash + + - name: Run the benchmarks + uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 + with: + mode: instrumentation + run: uv run pytest tests/benchmarks/ --codspeed -n 0 diff --git a/pyproject.toml b/pyproject.toml index 76de1795..7b3373f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "coverage[toml]==7.10.6", "cryptography==46.0.7", "pytest>=9.0.3", + "pytest-codspeed>=4.1.1", "pytest-httpbin==2.0.0", "pytest-trio==0.8.0", "trio==0.31.0", @@ -78,8 +79,9 @@ filterwarnings = [ markers = [ "copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup", "network: marks tests which require network connection. Used in 3rd-party build environments that have network disabled.", + "benchmark: marks CodSpeed benchmark tests under tests/benchmarks/.", ] [tool.coverage.run] source_pkgs = ["httpx2", "httpcore2", "tests"] -omit = ["src/httpcore2/httpcore2/_sync/*"] +omit = ["src/httpcore2/httpcore2/_sync/*", "tests/benchmarks/*"] diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmarks/http.py b/tests/benchmarks/http.py new file mode 100644 index 00000000..e6fa4466 --- /dev/null +++ b/tests/benchmarks/http.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import gzip +import json + +from httpx2 import Request, Response + +SIMPLE_HEADERS: list[tuple[str, str]] = [ + ("host", "example.org"), + ("user-agent", "httpx2-bench/1.0"), + ("accept", "*/*"), + ("accept-encoding", "gzip, deflate, br"), + ("connection", "keep-alive"), +] + +LARGE_HEADERS: list[tuple[str, str]] = [ + *SIMPLE_HEADERS, + *[(f"x-custom-{i}", f"value-{i}") for i in range(32)], + ("cookie", "; ".join(f"k{i}=v{i}" for i in range(16))), + ("authorization", "Bearer " + "a" * 256), +] + +JSON_PAYLOAD: dict[str, object] = { + "id": 12345, + "items": [{"sku": f"SKU-{i}", "qty": i, "price": i * 1.5} for i in range(50)], + "metadata": {"region": "eu-west-1", "tags": ["alpha", "beta", "gamma"]}, +} +JSON_BODY: bytes = json.dumps(JSON_PAYLOAD).encode() + +GZIPPED_JSON_BODY: bytes = gzip.compress(JSON_BODY) + + +def make_plain_response() -> Response: + return Response(200, headers=SIMPLE_HEADERS, content=b"Hello, world!") + + +def make_json_response() -> Response: + return Response(200, headers=[("content-type", "application/json")], content=JSON_BODY) + + +def make_gzip_response() -> Response: + return Response( + 200, + headers=[("content-type", "application/json"), ("content-encoding", "gzip")], + content=GZIPPED_JSON_BODY, + ) + + +def make_request() -> Request: + return Request("POST", "https://example.org/path?x=1", headers=SIMPLE_HEADERS, json=JSON_PAYLOAD) diff --git a/tests/benchmarks/test_http.py b/tests/benchmarks/test_http.py new file mode 100644 index 00000000..b9330b91 --- /dev/null +++ b/tests/benchmarks/test_http.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import pytest + +import httpx2 +from tests.benchmarks.http import ( + GZIPPED_JSON_BODY, + JSON_BODY, + JSON_PAYLOAD, + LARGE_HEADERS, + SIMPLE_HEADERS, + make_gzip_response, + make_json_response, + make_plain_response, + make_request, +) + +pytestmark = pytest.mark.benchmark + + +def test_bench_headers_construct_simple() -> None: + httpx2.Headers(SIMPLE_HEADERS) + + +def test_bench_headers_construct_large() -> None: + httpx2.Headers(LARGE_HEADERS) + + +def test_bench_headers_getitem() -> None: + headers = httpx2.Headers(LARGE_HEADERS) + headers["user-agent"] + headers["accept-encoding"] + headers["x-custom-15"] + + +def test_bench_request_simple_get() -> None: + httpx2.Request("GET", "https://example.org/path?x=1", headers=SIMPLE_HEADERS) + + +def test_bench_request_json_post() -> None: + httpx2.Request("POST", "https://example.org/path", headers=SIMPLE_HEADERS, json=JSON_PAYLOAD) + + +def test_bench_request_multipart() -> None: + httpx2.Request( + "POST", + "https://example.org/upload", + data={"name": "value", "other": "field"}, + files={"file": ("hello.txt", b"x" * 4096, "text/plain")}, + ) + + +def test_bench_response_construct_plain() -> None: + make_plain_response() + + +def test_bench_response_read_json() -> None: + response = make_json_response() + response.read() + response.json() + + +def test_bench_response_gzip_decode() -> None: + response = make_gzip_response() + response.read() + + +def test_bench_request_read_body() -> None: + request = make_request() + request.read() + + +@pytest.fixture +def mock_client() -> httpx2.Client: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=JSON_BODY, headers=[("content-type", "application/json")]) + + transport = httpx2.MockTransport(handler) + return httpx2.Client(transport=transport, headers=SIMPLE_HEADERS) + + +def test_bench_client_roundtrip_json(mock_client: httpx2.Client) -> None: + response = mock_client.get("https://example.org/path?x=1") + response.json() + + +def test_bench_client_roundtrip_post(mock_client: httpx2.Client) -> None: + mock_client.post("https://example.org/path", json=JSON_PAYLOAD) + + +def test_bench_client_roundtrip_gzip() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 200, + content=GZIPPED_JSON_BODY, + headers=[("content-type", "application/json"), ("content-encoding", "gzip")], + ) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + response = client.get("https://example.org/path") + response.read() diff --git a/tests/benchmarks/test_urls.py b/tests/benchmarks/test_urls.py new file mode 100644 index 00000000..1e816dc8 --- /dev/null +++ b/tests/benchmarks/test_urls.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest + +from httpx2 import URL, QueryParams +from httpx2._urlparse import urlparse +from tests.benchmarks.urls import ( + INTERNATIONAL_URL, + IPV6_URL, + LONG_QUERY_URL, + RELATIVE_TARGET, + SIMPLE_URL, + TYPICAL_URL, +) + +pytestmark = pytest.mark.benchmark + + +def test_bench_urlparse_simple() -> None: + urlparse(SIMPLE_URL) + + +def test_bench_urlparse_typical() -> None: + urlparse(TYPICAL_URL) + + +def test_bench_urlparse_long_query() -> None: + urlparse(LONG_QUERY_URL) + + +def test_bench_urlparse_international() -> None: + urlparse(INTERNATIONAL_URL) + + +def test_bench_urlparse_ipv6() -> None: + urlparse(IPV6_URL) + + +def test_bench_url_construct_typical() -> None: + URL(TYPICAL_URL) + + +def test_bench_url_join_relative() -> None: + base = URL(TYPICAL_URL) + base.join(RELATIVE_TARGET) + + +def test_bench_url_copy_with() -> None: + url = URL(TYPICAL_URL) + url.copy_with(path="/other", params={"a": "1", "b": "2"}) + + +def test_bench_queryparams_construct() -> None: + QueryParams([("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("a", "5")]) + + +def test_bench_queryparams_str() -> None: + params = QueryParams([("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("a", "5")]) + str(params) diff --git a/tests/benchmarks/urls.py b/tests/benchmarks/urls.py new file mode 100644 index 00000000..04a4d736 --- /dev/null +++ b/tests/benchmarks/urls.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +SIMPLE_URL = "https://example.org/" +TYPICAL_URL = "https://www.example.org:8443/path/to/resource?key=value&other=1#frag" +LONG_QUERY_URL = "https://api.example.org/v1/search?" + "&".join(f"k{i}=v{i}" for i in range(64)) +INTERNATIONAL_URL = "https://例え.テスト/パス/ファイル?キー=値" +IPV6_URL = "https://[2001:db8::1]:8443/path?x=1" +RELATIVE_TARGET = "/path/to/resource?key=value" diff --git a/uv.lock b/uv.lock index f5a126dd..f070a877 100644 --- a/uv.lock +++ b/uv.lock @@ -38,6 +38,7 @@ dev = [ { name = "httpx2", extras = ["brotli", "cli", "http2", "socks", "zstd"], editable = "src/httpx2" }, { name = "mypy", specifier = "==1.17.1" }, { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-codspeed", specifier = ">=4.1.1" }, { name = "pytest-httpbin", specifier = "==2.0.0" }, { name = "pytest-trio", specifier = "==0.8.0" }, { name = "ruff", specifier = "==0.12.11" }, @@ -2745,6 +2746,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-codspeed" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/1e/213eb4d263140fb907e9fcc5813fdcadb864d6832bf8e2d3f7fd88ca0096/pytest_codspeed-4.5.0.tar.gz", hash = "sha256:deb6ab9c9b07eba56fcb7b97206c7e48aaff697b6f73a013d8dbe4f62e76afd3", size = 209664, upload-time = "2026-04-28T13:12:17.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/2d/dd7be8a84dac07f0b72a1372252fc66688533a7771910cdd58544a8b6f36/pytest_codspeed-4.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ddc80dda2018aae3bcac9571d47de26aacd9cfb1764b3a1704fa269474cc83f7", size = 222525, upload-time = "2026-04-28T13:11:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/09/06/1daee2c11b5873dd42799f989a0d4b39ba1c33dbe4adc6339f1c48edb28e/pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:108ae3fecf8a665f017f2abc92a4d9740c57eb8432436baeb489053787427504", size = 822704, upload-time = "2026-04-28T13:11:51.732Z" }, + { url = "https://files.pythonhosted.org/packages/9d/47/85b5a6f3ee82cd19374abd244df6fc011e9acd559fc283bdf8cbc6e156f6/pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8b7a880f2cac69d167affe5e85d9fc7f21beeb1c7591ef2109fbc0983b806a4", size = 823667, upload-time = "2026-04-28T13:11:53.15Z" }, + { url = "https://files.pythonhosted.org/packages/60/f9/be1fa43649c9f71cc06d9f2330fb1cac3beddf6357effc9a1817f4831728/pytest_codspeed-4.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6da6f26435512110736dd258021bbf7859caf4d2a21c7ed06a86b67a999fac7", size = 222523, upload-time = "2026-04-28T13:11:54.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/89/9237a2d569b60f84183f6ae6193c6a4a135e5644ee08fed44bcf03d26545/pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be191120b1cb0252b443ef37887c94772bab4ca0c42cad7c15bcbcfcbb656ac4", size = 822696, upload-time = "2026-04-28T13:11:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/7dd0a37fb85e19d8b2b7366f9615c4e17335f23060275dcfa792ce8b482f/pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474730e996d424b17f7301d4b846261cca92d195b9fcb7de38599be9d68ee9ac", size = 823671, upload-time = "2026-04-28T13:11:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3d/bf21b10c6d497378785b47e9cefcfc4a43e543443e120c03469940f14a61/pytest_codspeed-4.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db706a7a4200e8e236c31c77935fedcc0edbf44959ab8c156297909d9e8cfd33", size = 222601, upload-time = "2026-04-28T13:11:58.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/65/97823f28ae60921bf353773490906f9095e9d208a6d4bec2e7913695a5e6/pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac844078bd8760e7fc66debe1e90b4593dfce15f60f26b334e1137d4902df3a9", size = 822916, upload-time = "2026-04-28T13:11:59.648Z" }, + { url = "https://files.pythonhosted.org/packages/95/10/4763d26e8255f243c96e39543d398afb2c64900d3785b8af1898b23a6ce0/pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66ecd52a277a5e5f0013e29084b49f9c5f60026d0585f58b86463cb188df5029", size = 823963, upload-time = "2026-04-28T13:12:00.976Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7b/8108a06fcad6160759efc0a1d44e359414a4d23e52bb7079ca95be24058e/pytest_codspeed-4.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcc3309d046082a6e0dbd1d9f2bc5c83b0446c93ff011e3880b47c69bf8042cf", size = 222602, upload-time = "2026-04-28T13:12:01.974Z" }, + { url = "https://files.pythonhosted.org/packages/28/b4/4a43ce824cabe2ab8a727e31f90aa403dd2cd580576057024065a3ea74a3/pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12b49954268ed6828ce5a8d87aff13888946c254bff4ef9472bb4d5ae5272667", size = 822868, upload-time = "2026-04-28T13:12:02.988Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/a319da002c800915b9f6a63b2da1e6cdd3230cafb9dea255cec4033e85f8/pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbeeb76d98335037670068c0d30319415f896e9c37eca510249b74684b460925", size = 823928, upload-time = "2026-04-28T13:12:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/da/5b/d46caecce8aa7519477df75351e312203a20836bec2fcc15256ec34c001b/pytest_codspeed-4.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1b73f71e7cb5c83cf5d765d5ca39d08bb1090a9d2d2268496a22ca24b1776e3a", size = 222618, upload-time = "2026-04-28T13:12:05.482Z" }, + { url = "https://files.pythonhosted.org/packages/81/1d/8f34de29cfc3516df25a4553a6d7912735fbde9a276d448b1e00eb35a345/pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399e146240a52458aa4b5fc861a88551bc52eb9e2d30c8f8b328ddebc084e4f6", size = 822814, upload-time = "2026-04-28T13:12:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/089614f7bd75fee1388885b886c3f6c1a332ffdce28a4b6b77d3aac7014f/pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d4b43f59d1c31e7c193567369f767647e466f95126671c90be084c58633544f", size = 823857, upload-time = "2026-04-28T13:12:08.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/69/5f4a032df6508e8c59049b2fcfce568b79e40b82878df12a2e401a034336/pytest_codspeed-4.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4ef8651294386c032d86070893f8349929280162cf22210dbd488697ce26de21", size = 222781, upload-time = "2026-04-28T13:12:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/86a1efde2968bfc83e4fcd60ef1a1094be7f83460799296a12d563522a67/pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca31f5d0e783823a78442d5434382eb32f3885153d1833eb645c92d0c499470b", size = 828703, upload-time = "2026-04-28T13:12:10.502Z" }, + { url = "https://files.pythonhosted.org/packages/58/4e/eae070c50cb82e44f831dd5b24c854cb641906732bdf74f6314e71c1f266/pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16ddd1a9f2dc0615479b2ba3f445a2e3587ce1316296fc79224700e73db06408", size = 829278, upload-time = "2026-04-28T13:12:11.879Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2d/8dd5e44a5518ba3cd1d63d1f2e631e318330d28cfbe15e548e89d429e289/pytest_codspeed-4.5.0-py3-none-any.whl", hash = "sha256:b19bfb734dcbd47b78022285a6eb9f2bf6331ef1bb8c15c2775058945d5f4ce3", size = 214090, upload-time = "2026-04-28T13:12:16.755Z" }, +] + [[package]] name = "pytest-httpbin" version = "2.0.0" From 23cf533cd59fbfe2cf40e479c8ea8fa62fe35051 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:45:44 -0700 Subject: [PATCH 02/11] Consolidate benchmarks into a single test_benchmark.py --- .github/workflows/benchmark.yml | 2 +- tests/benchmarks/http.py | 50 -------------- tests/benchmarks/test_benchmark.py | 71 ++++++++++++++++++++ tests/benchmarks/test_http.py | 101 ----------------------------- tests/benchmarks/test_urls.py | 59 ----------------- tests/benchmarks/urls.py | 8 --- 6 files changed, 72 insertions(+), 219 deletions(-) delete mode 100644 tests/benchmarks/http.py create mode 100644 tests/benchmarks/test_benchmark.py delete mode 100644 tests/benchmarks/test_http.py delete mode 100644 tests/benchmarks/test_urls.py delete mode 100644 tests/benchmarks/urls.py diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index f0f7e1f5..eae24dc4 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -4,7 +4,7 @@ on: push: branches: ["main"] pull_request: - branches: ["main", "version-*"] + branches: ["main"] permissions: id-token: write diff --git a/tests/benchmarks/http.py b/tests/benchmarks/http.py deleted file mode 100644 index e6fa4466..00000000 --- a/tests/benchmarks/http.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import gzip -import json - -from httpx2 import Request, Response - -SIMPLE_HEADERS: list[tuple[str, str]] = [ - ("host", "example.org"), - ("user-agent", "httpx2-bench/1.0"), - ("accept", "*/*"), - ("accept-encoding", "gzip, deflate, br"), - ("connection", "keep-alive"), -] - -LARGE_HEADERS: list[tuple[str, str]] = [ - *SIMPLE_HEADERS, - *[(f"x-custom-{i}", f"value-{i}") for i in range(32)], - ("cookie", "; ".join(f"k{i}=v{i}" for i in range(16))), - ("authorization", "Bearer " + "a" * 256), -] - -JSON_PAYLOAD: dict[str, object] = { - "id": 12345, - "items": [{"sku": f"SKU-{i}", "qty": i, "price": i * 1.5} for i in range(50)], - "metadata": {"region": "eu-west-1", "tags": ["alpha", "beta", "gamma"]}, -} -JSON_BODY: bytes = json.dumps(JSON_PAYLOAD).encode() - -GZIPPED_JSON_BODY: bytes = gzip.compress(JSON_BODY) - - -def make_plain_response() -> Response: - return Response(200, headers=SIMPLE_HEADERS, content=b"Hello, world!") - - -def make_json_response() -> Response: - return Response(200, headers=[("content-type", "application/json")], content=JSON_BODY) - - -def make_gzip_response() -> Response: - return Response( - 200, - headers=[("content-type", "application/json"), ("content-encoding", "gzip")], - content=GZIPPED_JSON_BODY, - ) - - -def make_request() -> Request: - return Request("POST", "https://example.org/path?x=1", headers=SIMPLE_HEADERS, json=JSON_PAYLOAD) diff --git a/tests/benchmarks/test_benchmark.py b/tests/benchmarks/test_benchmark.py new file mode 100644 index 00000000..dece3b92 --- /dev/null +++ b/tests/benchmarks/test_benchmark.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import gzip +import json + +import pytest + +import httpx2 +from httpx2._urlparse import urlparse + +pytestmark = pytest.mark.benchmark + +TYPICAL_URL = "https://www.example.org:8443/path/to/resource?key=value&other=1#frag" + +HEADERS: list[tuple[str, str]] = [ + ("host", "example.org"), + ("user-agent", "httpx2-bench/1.0"), + ("accept", "*/*"), + ("accept-encoding", "gzip, deflate, br"), + *[(f"x-custom-{i}", f"value-{i}") for i in range(16)], +] + +JSON_PAYLOAD: dict[str, object] = { + "id": 12345, + "items": [{"sku": f"SKU-{i}", "qty": i, "price": i * 1.5} for i in range(50)], +} +JSON_BODY = json.dumps(JSON_PAYLOAD).encode() +GZIPPED_JSON_BODY = gzip.compress(JSON_BODY) + + +def test_bench_urlparse() -> None: + urlparse(TYPICAL_URL) + + +def test_bench_url_join() -> None: + httpx2.URL(TYPICAL_URL).join("/path/to/resource?key=value") + + +def test_bench_queryparams() -> None: + httpx2.QueryParams([("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("a", "5")]) + + +def test_bench_headers_construct() -> None: + httpx2.Headers(HEADERS) + + +def test_bench_request_json_post() -> None: + httpx2.Request("POST", TYPICAL_URL, headers=HEADERS, json=JSON_PAYLOAD) + + +def test_bench_response_gzip_decode() -> None: + response = httpx2.Response( + 200, + headers=[("content-type", "application/json"), ("content-encoding", "gzip")], + content=GZIPPED_JSON_BODY, + ) + response.read() + + +def _json_handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=JSON_BODY, headers=[("content-type", "application/json")]) + + +def test_bench_client_get_json() -> None: + with httpx2.Client(transport=httpx2.MockTransport(_json_handler)) as client: + client.get(TYPICAL_URL).json() + + +def test_bench_client_post_json() -> None: + with httpx2.Client(transport=httpx2.MockTransport(_json_handler)) as client: + client.post(TYPICAL_URL, json=JSON_PAYLOAD) diff --git a/tests/benchmarks/test_http.py b/tests/benchmarks/test_http.py deleted file mode 100644 index b9330b91..00000000 --- a/tests/benchmarks/test_http.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import pytest - -import httpx2 -from tests.benchmarks.http import ( - GZIPPED_JSON_BODY, - JSON_BODY, - JSON_PAYLOAD, - LARGE_HEADERS, - SIMPLE_HEADERS, - make_gzip_response, - make_json_response, - make_plain_response, - make_request, -) - -pytestmark = pytest.mark.benchmark - - -def test_bench_headers_construct_simple() -> None: - httpx2.Headers(SIMPLE_HEADERS) - - -def test_bench_headers_construct_large() -> None: - httpx2.Headers(LARGE_HEADERS) - - -def test_bench_headers_getitem() -> None: - headers = httpx2.Headers(LARGE_HEADERS) - headers["user-agent"] - headers["accept-encoding"] - headers["x-custom-15"] - - -def test_bench_request_simple_get() -> None: - httpx2.Request("GET", "https://example.org/path?x=1", headers=SIMPLE_HEADERS) - - -def test_bench_request_json_post() -> None: - httpx2.Request("POST", "https://example.org/path", headers=SIMPLE_HEADERS, json=JSON_PAYLOAD) - - -def test_bench_request_multipart() -> None: - httpx2.Request( - "POST", - "https://example.org/upload", - data={"name": "value", "other": "field"}, - files={"file": ("hello.txt", b"x" * 4096, "text/plain")}, - ) - - -def test_bench_response_construct_plain() -> None: - make_plain_response() - - -def test_bench_response_read_json() -> None: - response = make_json_response() - response.read() - response.json() - - -def test_bench_response_gzip_decode() -> None: - response = make_gzip_response() - response.read() - - -def test_bench_request_read_body() -> None: - request = make_request() - request.read() - - -@pytest.fixture -def mock_client() -> httpx2.Client: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, content=JSON_BODY, headers=[("content-type", "application/json")]) - - transport = httpx2.MockTransport(handler) - return httpx2.Client(transport=transport, headers=SIMPLE_HEADERS) - - -def test_bench_client_roundtrip_json(mock_client: httpx2.Client) -> None: - response = mock_client.get("https://example.org/path?x=1") - response.json() - - -def test_bench_client_roundtrip_post(mock_client: httpx2.Client) -> None: - mock_client.post("https://example.org/path", json=JSON_PAYLOAD) - - -def test_bench_client_roundtrip_gzip() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response( - 200, - content=GZIPPED_JSON_BODY, - headers=[("content-type", "application/json"), ("content-encoding", "gzip")], - ) - - with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: - response = client.get("https://example.org/path") - response.read() diff --git a/tests/benchmarks/test_urls.py b/tests/benchmarks/test_urls.py deleted file mode 100644 index 1e816dc8..00000000 --- a/tests/benchmarks/test_urls.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -import pytest - -from httpx2 import URL, QueryParams -from httpx2._urlparse import urlparse -from tests.benchmarks.urls import ( - INTERNATIONAL_URL, - IPV6_URL, - LONG_QUERY_URL, - RELATIVE_TARGET, - SIMPLE_URL, - TYPICAL_URL, -) - -pytestmark = pytest.mark.benchmark - - -def test_bench_urlparse_simple() -> None: - urlparse(SIMPLE_URL) - - -def test_bench_urlparse_typical() -> None: - urlparse(TYPICAL_URL) - - -def test_bench_urlparse_long_query() -> None: - urlparse(LONG_QUERY_URL) - - -def test_bench_urlparse_international() -> None: - urlparse(INTERNATIONAL_URL) - - -def test_bench_urlparse_ipv6() -> None: - urlparse(IPV6_URL) - - -def test_bench_url_construct_typical() -> None: - URL(TYPICAL_URL) - - -def test_bench_url_join_relative() -> None: - base = URL(TYPICAL_URL) - base.join(RELATIVE_TARGET) - - -def test_bench_url_copy_with() -> None: - url = URL(TYPICAL_URL) - url.copy_with(path="/other", params={"a": "1", "b": "2"}) - - -def test_bench_queryparams_construct() -> None: - QueryParams([("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("a", "5")]) - - -def test_bench_queryparams_str() -> None: - params = QueryParams([("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("a", "5")]) - str(params) diff --git a/tests/benchmarks/urls.py b/tests/benchmarks/urls.py deleted file mode 100644 index 04a4d736..00000000 --- a/tests/benchmarks/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -SIMPLE_URL = "https://example.org/" -TYPICAL_URL = "https://www.example.org:8443/path/to/resource?key=value&other=1#frag" -LONG_QUERY_URL = "https://api.example.org/v1/search?" + "&".join(f"k{i}=v{i}" for i in range(64)) -INTERNATIONAL_URL = "https://例え.テスト/パス/ファイル?キー=値" -IPV6_URL = "https://[2001:db8::1]:8443/path?x=1" -RELATIVE_TARGET = "/path/to/resource?key=value" From b36f9ce970bb20778ff0d5c1b3212b1a68780920 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:46:49 -0700 Subject: [PATCH 03/11] Flatten benchmark folder into tests/test_benchmark.py --- .github/workflows/benchmark.yml | 2 +- pyproject.toml | 4 ++-- tests/benchmarks/__init__.py | 0 tests/{benchmarks => }/test_benchmark.py | 0 4 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 tests/benchmarks/__init__.py rename tests/{benchmarks => }/test_benchmark.py (100%) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index eae24dc4..03f59e80 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -33,4 +33,4 @@ jobs: uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 with: mode: instrumentation - run: uv run pytest tests/benchmarks/ --codspeed -n 0 + run: uv run pytest tests/test_benchmark.py --codspeed -n 0 diff --git a/pyproject.toml b/pyproject.toml index 7b3373f3..c6dfe351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,9 +79,9 @@ filterwarnings = [ markers = [ "copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup", "network: marks tests which require network connection. Used in 3rd-party build environments that have network disabled.", - "benchmark: marks CodSpeed benchmark tests under tests/benchmarks/.", + "benchmark: marks CodSpeed benchmark tests under tests/test_benchmark.py.", ] [tool.coverage.run] source_pkgs = ["httpx2", "httpcore2", "tests"] -omit = ["src/httpcore2/httpcore2/_sync/*", "tests/benchmarks/*"] +omit = ["src/httpcore2/httpcore2/_sync/*", "tests/test_benchmark.py"] diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/benchmarks/test_benchmark.py b/tests/test_benchmark.py similarity index 100% rename from tests/benchmarks/test_benchmark.py rename to tests/test_benchmark.py From 2ba9427bfe19b250da4b0b768aae6125c4950043 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:47:47 -0700 Subject: [PATCH 04/11] Drop -n 0 from benchmark workflow (no xdist installed) --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 03f59e80..fde58da1 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -33,4 +33,4 @@ jobs: uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 with: mode: instrumentation - run: uv run pytest tests/test_benchmark.py --codspeed -n 0 + run: uv run pytest tests/test_benchmark.py --codspeed From 6a8926fc11a53f1db8fcfd72761b01463ae4c4ef Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:49:43 -0700 Subject: [PATCH 05/11] Repin CodSpeedHQ/action to the current v4 SHA --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index fde58da1..df25aa83 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -30,7 +30,7 @@ jobs: shell: bash - name: Run the benchmarks - uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 + uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4 with: mode: instrumentation run: uv run pytest tests/test_benchmark.py --codspeed From dd5b58d1e4b73e6d5474aea81ecb8590d83a9b82 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:52:45 -0700 Subject: [PATCH 06/11] Trim benchmark suite to 7 representative cases --- tests/test_benchmark.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index dece3b92..efb7a12e 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -1,12 +1,12 @@ from __future__ import annotations import gzip +import io import json import pytest import httpx2 -from httpx2._urlparse import urlparse pytestmark = pytest.mark.benchmark @@ -27,27 +27,27 @@ JSON_BODY = json.dumps(JSON_PAYLOAD).encode() GZIPPED_JSON_BODY = gzip.compress(JSON_BODY) - -def test_bench_urlparse() -> None: - urlparse(TYPICAL_URL) - - def test_bench_url_join() -> None: httpx2.URL(TYPICAL_URL).join("/path/to/resource?key=value") -def test_bench_queryparams() -> None: - httpx2.QueryParams([("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("a", "5")]) - - -def test_bench_headers_construct() -> None: - httpx2.Headers(HEADERS) - - def test_bench_request_json_post() -> None: httpx2.Request("POST", TYPICAL_URL, headers=HEADERS, json=JSON_PAYLOAD) +def test_bench_request_multipart() -> None: + request = httpx2.Request( + "POST", + "https://example.org/upload", + data={"name": "value", "other": "field", "description": "a longer text field"}, + files={ + "small": ("hello.txt", b"x" * 4096, "text/plain"), + "large": ("payload.bin", io.BytesIO(b"y" * 65536), "application/octet-stream"), + }, + ) + request.read() + + def test_bench_response_gzip_decode() -> None: response = httpx2.Response( 200, @@ -57,6 +57,12 @@ def test_bench_response_gzip_decode() -> None: response.read() +def test_bench_response_iter_bytes() -> None: + response = httpx2.Response(200, content=b"x" * 1_048_576) + for _ in response.iter_bytes(chunk_size=8192): + pass + + def _json_handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=JSON_BODY, headers=[("content-type", "application/json")]) From 788afc7ec5b5830169093fa9949b8d4440070a38 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:53:38 -0700 Subject: [PATCH 07/11] Inline stream size as 8192 * 128 --- 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 efb7a12e..4824cc03 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -58,7 +58,7 @@ def test_bench_response_gzip_decode() -> None: def test_bench_response_iter_bytes() -> None: - response = httpx2.Response(200, content=b"x" * 1_048_576) + response = httpx2.Response(200, content=b"x" * 8192 * 128) for _ in response.iter_bytes(chunk_size=8192): pass From 5efde12aaa0d1177d7cd4b8b66a66aed2982f6f2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 16 May 2026 22:58:59 -0700 Subject: [PATCH 08/11] Amplify benchmark workloads to clear CI noise floor --- tests/test_benchmark.py | 66 +++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 4824cc03..7b32bd57 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -27,51 +27,65 @@ JSON_BODY = json.dumps(JSON_PAYLOAD).encode() GZIPPED_JSON_BODY = gzip.compress(JSON_BODY) + def test_bench_url_join() -> None: - httpx2.URL(TYPICAL_URL).join("/path/to/resource?key=value") + base = httpx2.URL(TYPICAL_URL) + for _ in range(64): + base.join("/path/to/resource?key=value") def test_bench_request_json_post() -> None: - httpx2.Request("POST", TYPICAL_URL, headers=HEADERS, json=JSON_PAYLOAD) + for _ in range(16): + httpx2.Request("POST", TYPICAL_URL, headers=HEADERS, json=JSON_PAYLOAD) def test_bench_request_multipart() -> None: - request = httpx2.Request( - "POST", - "https://example.org/upload", - data={"name": "value", "other": "field", "description": "a longer text field"}, - files={ - "small": ("hello.txt", b"x" * 4096, "text/plain"), - "large": ("payload.bin", io.BytesIO(b"y" * 65536), "application/octet-stream"), - }, - ) - request.read() + for _ in range(16): + request = httpx2.Request( + "POST", + "https://example.org/upload", + data={"name": "value", "other": "field", "description": "a longer text field"}, + files={ + "small": ("hello.txt", b"x" * 4096, "text/plain"), + "large": ("payload.bin", io.BytesIO(b"y" * 65536), "application/octet-stream"), + }, + ) + request.read() def test_bench_response_gzip_decode() -> None: - response = httpx2.Response( - 200, - headers=[("content-type", "application/json"), ("content-encoding", "gzip")], - content=GZIPPED_JSON_BODY, - ) - response.read() - - -def test_bench_response_iter_bytes() -> None: - response = httpx2.Response(200, content=b"x" * 8192 * 128) - for _ in response.iter_bytes(chunk_size=8192): - pass + for _ in range(32): + response = httpx2.Response( + 200, + headers=[("content-type", "application/json"), ("content-encoding", "gzip")], + content=GZIPPED_JSON_BODY, + ) + response.read() def _json_handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=JSON_BODY, headers=[("content-type", "application/json")]) +def _stream_handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"x" * 8192 * 128) + + def test_bench_client_get_json() -> None: with httpx2.Client(transport=httpx2.MockTransport(_json_handler)) as client: - client.get(TYPICAL_URL).json() + for _ in range(8): + client.get(TYPICAL_URL).json() def test_bench_client_post_json() -> None: with httpx2.Client(transport=httpx2.MockTransport(_json_handler)) as client: - client.post(TYPICAL_URL, json=JSON_PAYLOAD) + for _ in range(8): + client.post(TYPICAL_URL, json=JSON_PAYLOAD) + + +def test_bench_client_get_stream() -> None: + with httpx2.Client(transport=httpx2.MockTransport(_stream_handler)) as client: + for _ in range(8): + with client.stream("GET", TYPICAL_URL) as response: + for _ in response.iter_bytes(chunk_size=8192): + pass From d3067c892e1dcac9fd0897f7599a81b3141ad9b5 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 17 May 2026 06:21:18 -0700 Subject: [PATCH 09/11] Switch to walltime mode and reshape benchmarks for real I/O coverage --- .github/workflows/benchmark.yml | 2 +- tests/test_benchmark.py | 82 +++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index df25aa83..277aef84 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -32,5 +32,5 @@ jobs: - name: Run the benchmarks uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4 with: - mode: instrumentation + mode: walltime run: uv run pytest tests/test_benchmark.py --codspeed diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 7b32bd57..32dea65b 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -3,10 +3,13 @@ import gzip import io import json +import socket +import threading import pytest import httpx2 +from httpcore2._backends.sync import SyncStream pytestmark = pytest.mark.benchmark @@ -20,27 +23,34 @@ *[(f"x-custom-{i}", f"value-{i}") for i in range(16)], ] -JSON_PAYLOAD: dict[str, object] = { +SMALL_JSON: dict[str, object] = { "id": 12345, "items": [{"sku": f"SKU-{i}", "qty": i, "price": i * 1.5} for i in range(50)], } -JSON_BODY = json.dumps(JSON_PAYLOAD).encode() -GZIPPED_JSON_BODY = gzip.compress(JSON_BODY) +LARGE_JSON: dict[str, object] = { + "records": [ + {"id": i, "name": f"record-{i}", "tags": [f"t{j}" for j in range(8)], "active": bool(i % 2)} + for i in range(2048) + ], +} +SMALL_JSON_BODY = json.dumps(SMALL_JSON).encode() +LARGE_JSON_BODY = json.dumps(LARGE_JSON).encode() +GZIPPED_LARGE_JSON_BODY = gzip.compress(LARGE_JSON_BODY) def test_bench_url_join() -> None: base = httpx2.URL(TYPICAL_URL) - for _ in range(64): + for _ in range(1024): base.join("/path/to/resource?key=value") def test_bench_request_json_post() -> None: - for _ in range(16): - httpx2.Request("POST", TYPICAL_URL, headers=HEADERS, json=JSON_PAYLOAD) + for _ in range(256): + httpx2.Request("POST", TYPICAL_URL, headers=HEADERS, json=SMALL_JSON) def test_bench_request_multipart() -> None: - for _ in range(16): + for _ in range(64): request = httpx2.Request( "POST", "https://example.org/upload", @@ -53,39 +63,61 @@ def test_bench_request_multipart() -> None: request.read() -def test_bench_response_gzip_decode() -> None: - for _ in range(32): +def test_bench_response_gzip_decode_large() -> None: + for _ in range(64): response = httpx2.Response( 200, headers=[("content-type", "application/json"), ("content-encoding", "gzip")], - content=GZIPPED_JSON_BODY, + content=GZIPPED_LARGE_JSON_BODY, ) response.read() -def _json_handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, content=JSON_BODY, headers=[("content-type", "application/json")]) +def _large_json_handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=LARGE_JSON_BODY, headers=[("content-type", "application/json")]) def _stream_handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, content=b"x" * 8192 * 128) + return httpx2.Response(200, content=b"x" * 1024 * 1024) -def test_bench_client_get_json() -> None: - with httpx2.Client(transport=httpx2.MockTransport(_json_handler)) as client: - for _ in range(8): - client.get(TYPICAL_URL).json() +def test_bench_client_post_large_json() -> None: + with httpx2.Client(transport=httpx2.MockTransport(_large_json_handler)) as client: + for _ in range(16): + client.post(TYPICAL_URL, json=LARGE_JSON).json() -def test_bench_client_post_json() -> None: - with httpx2.Client(transport=httpx2.MockTransport(_json_handler)) as client: - for _ in range(8): - client.post(TYPICAL_URL, json=JSON_PAYLOAD) - - -def test_bench_client_get_stream() -> None: +def test_bench_client_stream_download() -> None: with httpx2.Client(transport=httpx2.MockTransport(_stream_handler)) as client: - for _ in range(8): + for _ in range(16): with client.stream("GET", TYPICAL_URL) as response: for _ in response.iter_bytes(chunk_size=8192): pass + + +def test_bench_sync_stream_write_large() -> None: + payload = b"x" * 4 * 1024 * 1024 # 4 MB + reader_sock, writer_sock = socket.socketpair() + try: + drained: list[int] = [] + + def drain() -> None: + total = 0 + while True: + chunk = reader_sock.recv(65536) + if not chunk: + break + total += len(chunk) + drained.append(total) + + thread = threading.Thread(target=drain) + thread.start() + + stream = SyncStream(writer_sock) + stream.write(payload) + stream.close() + thread.join() + + assert drained == [len(payload)] + finally: + reader_sock.close() From dea101c6cb2f651c6e98ab3faadbd4d433cf06cf Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 17 May 2026 06:23:07 -0700 Subject: [PATCH 10/11] Run walltime benchmarks on CodSpeed Macro Runner --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 277aef84..8efe7727 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -13,7 +13,7 @@ permissions: jobs: benchmarks: name: Run benchmarks - runs-on: ubuntu-latest + runs-on: codspeed-macro steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: From 5054cbb46b3401a9ef3f1a579a5ce6699d374ceb Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 17 May 2026 06:49:04 -0700 Subject: [PATCH 11/11] Force partial sends in sync_stream_write bench to expose slicing cost --- tests/test_benchmark.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 32dea65b..e70ede52 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -99,12 +99,17 @@ def test_bench_sync_stream_write_large() -> None: payload = b"x" * 4 * 1024 * 1024 # 4 MB reader_sock, writer_sock = socket.socketpair() try: + # Small kernel buffers + small reader chunks force many partial sends on Linux, + # which is what exercises the buffer-slicing loop inside SyncStream.write. + writer_sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 8192) + reader_sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8192) + drained: list[int] = [] def drain() -> None: total = 0 while True: - chunk = reader_sock.recv(65536) + chunk = reader_sock.recv(8192) if not chunk: break total += len(chunk)