diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..8efe7727 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,36 @@ +name: CodSpeed + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + id-token: write + contents: read + +jobs: + benchmarks: + name: Run benchmarks + runs-on: codspeed-macro + 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@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4 + with: + mode: walltime + run: uv run pytest tests/test_benchmark.py --codspeed diff --git a/pyproject.toml b/pyproject.toml index 76de1795..c6dfe351 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/test_benchmark.py.", ] [tool.coverage.run] source_pkgs = ["httpx2", "httpcore2", "tests"] -omit = ["src/httpcore2/httpcore2/_sync/*"] +omit = ["src/httpcore2/httpcore2/_sync/*", "tests/test_benchmark.py"] diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 00000000..e70ede52 --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import gzip +import io +import json +import socket +import threading + +import pytest + +import httpx2 +from httpcore2._backends.sync import SyncStream + +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)], +] + +SMALL_JSON: dict[str, object] = { + "id": 12345, + "items": [{"sku": f"SKU-{i}", "qty": i, "price": i * 1.5} for i in range(50)], +} +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(1024): + base.join("/path/to/resource?key=value") + + +def test_bench_request_json_post() -> None: + for _ in range(256): + httpx2.Request("POST", TYPICAL_URL, headers=HEADERS, json=SMALL_JSON) + + +def test_bench_request_multipart() -> None: + for _ in range(64): + 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_large() -> None: + for _ in range(64): + response = httpx2.Response( + 200, + headers=[("content-type", "application/json"), ("content-encoding", "gzip")], + content=GZIPPED_LARGE_JSON_BODY, + ) + response.read() + + +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" * 1024 * 1024) + + +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_stream_download() -> None: + with httpx2.Client(transport=httpx2.MockTransport(_stream_handler)) as client: + 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: + # 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(8192) + 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() 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"