diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..792a87c6 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,34 @@ +--- +name: CodSpeed + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + id-token: write + contents: read + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + steps: + - uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 + with: + python-version: "3.13" + + - name: Install dependencies + run: scripts/install + shell: bash + + - name: Run the benchmarks + uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4 + with: + mode: instrumentation + run: uv run pytest tests/benchmarks/ --codspeed diff --git a/.gitignore b/.gitignore index ed8bcf67..001c08df 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ venv/ htmlcov/ site/ dist/ +.codspeed/ diff --git a/pyproject.toml b/pyproject.toml index bc9e1f01..4c6f62fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dev = [ "pytest==9.0.2", "pytest-mock==3.15.1", "pytest-xdist[psutil]==3.8.0", + "pytest-codspeed>=4.1.1", "mypy==1.19.1", "types-click==7.1.8", "types-pyyaml==6.0.12.20250915", @@ -135,7 +136,7 @@ filterwarnings = [ parallel = true source_pkgs = ["uvicorn", "tests"] plugins = ["coverage_conditional_plugin"] -omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py"] +omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py", "tests/benchmarks/*"] [tool.coverage.report] precision = 2 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..9ee5a73f --- /dev/null +++ b/tests/benchmarks/http.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeAlias + +from uvicorn._types import ASGIApplication, Scope +from uvicorn.config import Config +from uvicorn.lifespan.off import LifespanOff +from uvicorn.lifespan.on import LifespanOn +from uvicorn.protocols.http.h11_impl import H11Protocol +from uvicorn.server import ServerState + +if TYPE_CHECKING: + from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol + from uvicorn.protocols.websockets.websockets_impl import WebSocketProtocol + from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSProtocol + + WSProtocol: TypeAlias = WebSocketProtocol | _WSProtocol + HTTPProtocol: TypeAlias = H11Protocol | HttpToolsProtocol + + +SIMPLE_GET_REQUEST = b"\r\n".join([b"GET / HTTP/1.1", b"Host: example.org", b"", b""]) + +SIMPLE_POST_REQUEST = b"\r\n".join( + [ + b"POST / HTTP/1.1", + b"Host: example.org", + b"Content-Type: application/json", + b"Content-Length: 18", + b"", + b'{"hello": "world"}', + ] +) + +LARGE_POST_REQUEST = b"\r\n".join( + [ + b"POST / HTTP/1.1", + b"Host: example.org", + b"Content-Type: text/plain", + b"Content-Length: 100000", + b"", + b"x" * 100000, + ] +) + +HTTP10_GET_REQUEST = b"\r\n".join([b"GET / HTTP/1.0", b"Host: example.org", b"", b""]) + +CONNECTION_CLOSE_REQUEST = b"\r\n".join([b"GET / HTTP/1.1", b"Host: example.org", b"Connection: close", b"", b""]) + +START_POST_REQUEST = b"\r\n".join( + [ + b"POST / HTTP/1.1", + b"Host: example.org", + b"Content-Type: application/json", + b"Content-Length: 18", + b"", + b"", + ] +) + +FINISH_POST_REQUEST = b'{"hello": "world"}' + + +class MockTransport: + def __init__(self) -> None: + self.buffer = b"" + self.closed = False + self.read_paused = False + + def get_extra_info(self, key: Any) -> Any: + return { + "sockname": ("127.0.0.1", 8000), + "peername": ("127.0.0.1", 8001), + "sslcontext": False, + }.get(key) + + def write(self, data: bytes) -> None: + self.buffer += data + + def close(self) -> None: + self.closed = True + + def pause_reading(self) -> None: + self.read_paused = True + + def resume_reading(self) -> None: + self.read_paused = False + + def is_closing(self) -> bool: + return self.closed + + def clear_buffer(self) -> None: + self.buffer = b"" + + def set_protocol(self, protocol: asyncio.Protocol) -> None: + pass + + +class MockTimerHandle: + def __init__( + self, loop_later_list: list[MockTimerHandle], delay: float, callback: Callable[[], None], args: tuple[Any, ...] + ) -> None: + self.loop_later_list = loop_later_list + self.delay = delay + self.callback = callback + self.args = args + self.cancelled = False + + def cancel(self) -> None: + if not self.cancelled: + self.cancelled = True + self.loop_later_list.remove(self) + + +class MockLoop: + def __init__(self) -> None: + self._tasks: list[asyncio.Task[Any]] = [] + self._later: list[MockTimerHandle] = [] + + def create_task(self, coroutine: Any) -> Any: + self._tasks.insert(0, coroutine) + return MockTask() + + def call_later(self, delay: float, callback: Callable[[], None], *args: Any) -> MockTimerHandle: + handle = MockTimerHandle(self._later, delay, callback, args) + self._later.insert(0, handle) + return handle + + async def run_one(self) -> Any: + return await self._tasks.pop() + + +class MockTask: + def add_done_callback(self, callback: Callable[[], None]) -> None: + pass + + +class MockProtocol(asyncio.Protocol): + loop: MockLoop + transport: MockTransport + timeout_keep_alive_task: asyncio.TimerHandle | None + ws_protocol_class: type[WSProtocol] | None + scope: Scope + + +def get_connected_protocol( + app: ASGIApplication, + http_protocol_cls: type[HTTPProtocol], + lifespan: LifespanOff | LifespanOn | None = None, + **kwargs: Any, +) -> MockProtocol: + loop = MockLoop() + transport = MockTransport() + config = Config(app=app, **kwargs) + lifespan = lifespan or LifespanOff(config) + server_state = ServerState() + protocol = http_protocol_cls(config=config, server_state=server_state, app_state=lifespan.state, _loop=loop) # type: ignore + protocol.connection_made(transport) # type: ignore[arg-type] + return protocol # type: ignore[return-value] diff --git a/tests/benchmarks/test_http.py b/tests/benchmarks/test_http.py new file mode 100644 index 00000000..2adb7186 --- /dev/null +++ b/tests/benchmarks/test_http.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.benchmarks.http import ( + CONNECTION_CLOSE_REQUEST, + FINISH_POST_REQUEST, + HTTP10_GET_REQUEST, + LARGE_POST_REQUEST, + SIMPLE_GET_REQUEST, + SIMPLE_POST_REQUEST, + START_POST_REQUEST, + get_connected_protocol, +) +from tests.response import Response +from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope + +if TYPE_CHECKING: + from tests.benchmarks.http import HTTPProtocol + +pytestmark = [pytest.mark.anyio, pytest.mark.benchmark] + +_plain_text_app = Response("Hello, world", media_type="text/plain") +_no_content_app = Response(b"", status_code=204) +_chunked_app = Response(b"Hello, world!", status_code=200, headers={"transfer-encoding": "chunked"}) + + +async def _body_echo_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: + body = b"" + while True: + message = await receive() + body += message.get("body", b"") # type: ignore[operator] + if not message.get("more_body", False): + break + headers = [(b"content-length", str(len(body)).encode())] + await send({"type": "http.response.start", "status": 200, "headers": headers}) + await send({"type": "http.response.body", "body": body}) + + +async def test_bench_simple_get(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_plain_text_app, http_protocol_cls) + protocol.data_received(SIMPLE_GET_REQUEST) + await protocol.loop.run_one() + + +async def test_bench_simple_post(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_plain_text_app, http_protocol_cls) + protocol.data_received(SIMPLE_POST_REQUEST) + await protocol.loop.run_one() + + +async def test_bench_large_post(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_plain_text_app, http_protocol_cls) + protocol.data_received(LARGE_POST_REQUEST) + await protocol.loop.run_one() + + +async def test_bench_pipelined_requests(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_plain_text_app, http_protocol_cls) + protocol.data_received(SIMPLE_GET_REQUEST * 3) + await protocol.loop.run_one() + await protocol.loop.run_one() + await protocol.loop.run_one() + + +async def test_bench_keepalive_reuse(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_plain_text_app, http_protocol_cls) + protocol.data_received(SIMPLE_GET_REQUEST) + await protocol.loop.run_one() + protocol.data_received(SIMPLE_GET_REQUEST) + await protocol.loop.run_one() + + +async def test_bench_chunked_response(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_chunked_app, http_protocol_cls) + protocol.data_received(SIMPLE_GET_REQUEST) + await protocol.loop.run_one() + + +async def test_bench_http10(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_plain_text_app, http_protocol_cls) + protocol.data_received(HTTP10_GET_REQUEST) + await protocol.loop.run_one() + + +async def test_bench_connection_close(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_plain_text_app, http_protocol_cls) + protocol.data_received(CONNECTION_CLOSE_REQUEST) + await protocol.loop.run_one() + + +async def test_bench_post_body_receive(http_protocol_cls: type[HTTPProtocol]) -> None: + protocol = get_connected_protocol(_body_echo_app, http_protocol_cls) + protocol.data_received(START_POST_REQUEST) + protocol.data_received(FINISH_POST_REQUEST) + await protocol.loop.run_one() diff --git a/uv.lock b/uv.lock index 039b554d..ba49c4bf 100644 --- a/uv.lock +++ b/uv.lock @@ -1284,6 +1284,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-codspeed" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/ab/eca41967d11c95392829a8b4bfa9220a51cffc4a33ec4653358000356918/pytest_codspeed-4.3.0.tar.gz", hash = "sha256:5230d9d65f39063a313ed1820df775166227ec5c20a1122968f85653d5efee48", size = 124745, upload-time = "2026-02-09T15:23:34.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/64/800bdaeabd3eb126aff7e3e22dc45b2826305f61cbfd093284caf8d9ca01/pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2acecc4126658abebc683b38121adec405a46e18a619d49d6154c6e60c5deb2", size = 347077, upload-time = "2026-02-09T15:23:17.2Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/d69707440829adab86d078d5f1c8c070df116b1624f8eae4ff36933ba612/pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:619120775e92a3f43fb4ff4c256a251b1554c904d95e2154a382484283f0388a", size = 342234, upload-time = "2026-02-09T15:23:18.407Z" }, + { url = "https://files.pythonhosted.org/packages/d9/15/ec0ac1f022173b3134c9638f2a35f21fbb3142c75da066d9e49e5a8bb4bd/pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbeff1eb2f2e36df088658b556fa993e6937bf64ffb07406de4db16fd2b26874", size = 347076, upload-time = "2026-02-09T15:23:19.989Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e8/1fe375794ad02b7835f378a7bcfa8fbac9acadefe600a782a7c4a7064db7/pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:878aad5e4bb7b401ad8d82f3af5186030cd2bd0d0446782e10dabb9db8827466", size = 342215, upload-time = "2026-02-09T15:23:20.954Z" }, + { url = "https://files.pythonhosted.org/packages/09/58/50df94e9a78e1c77818a492c90557eeb1309af025120c9a21e6375950c52/pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527a3a02eaa3e4d4583adc4ba2327eef79628f3e1c682a4b959439551a72588e", size = 347395, upload-time = "2026-02-09T15:23:21.986Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/7dfbd3eefd112a14e6fb65f9ff31dacf2e9c381cb94b27332b81d2b13f8d/pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9858c2a6e1f391d5696757e7b6e9484749a7376c46f8b4dd9aebf093479a9667", size = 342625, upload-time = "2026-02-09T15:23:23.035Z" }, + { url = "https://files.pythonhosted.org/packages/7f/53/7255f6a25bc56ff1745b254b21545dfe0be2268f5b91ce78f7e8a908f0ad/pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34f2fd8497456eefbd325673f677ea80d93bb1bc08a578c1fa43a09cec3d1879", size = 347325, upload-time = "2026-02-09T15:23:23.998Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f8/82ae570d8b9ad30f33c9d4002a7a1b2740de0e090540c69a28e4f711ebe2/pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df6a36a2a9da1406bc50428437f657f0bd8c842ae54bee5fb3ad30e01d50c0f5", size = 342558, upload-time = "2026-02-09T15:23:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e1/55cfe9474f91d174c7a4b04d257b5fc6d4d06f3d3680f2da672ee59ccc10/pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bec30f4fc9c4973143cd80f0d33fa780e9fa3e01e4dbe8cedf229e72f1212c62", size = 347383, upload-time = "2026-02-09T15:23:26.68Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/8fd781d959bbe789b3de8ce4c50d5706a684a0df377147dfb27b200c20c1/pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6584e641cadf27d894ae90b87c50377232a97cbfd76ee0c7ecd0c056fa3f7f4", size = 342481, upload-time = "2026-02-09T15:23:27.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0c/368045133c6effa2c665b1634b7b8a9c88b307f877fa31f1f8df47885b51/pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0d1f6ea594f29b745c634d66d5f5f1caa1c3abd2af82fea49d656038e8fc77", size = 353680, upload-time = "2026-02-09T15:23:28.726Z" }, + { url = "https://files.pythonhosted.org/packages/59/21/e543abcd72244294e25ae88ec3a9311ade24d6913f8c8f42569d671700bc/pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2f5bb6d8898bea7db45e3c8b916ee48e36905b929477bb511b79c5a3ccacda4", size = 347888, upload-time = "2026-02-09T15:23:30.443Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/b8a53c20cf5b41042c205bb9d36d37da00418d30fd1a94bf9eb147820720/pytest_codspeed-4.3.0-py3-none-any.whl", hash = "sha256:05baff2a61dc9f3e92b92b9c2ab5fb45d9b802438f5373073f5766a91319ed7a", size = 125224, upload-time = "2026-02-09T15:23:33.774Z" }, +] + [[package]] name = "pytest-mock" version = "3.15.1" @@ -1696,6 +1722,7 @@ dev = [ { name = "httpx" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-codspeed" }, { name = "pytest-mock" }, { name = "pytest-xdist", extra = ["psutil"] }, { name = "ruff" }, @@ -1724,7 +1751,7 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'standard'", specifier = ">=5.1" }, { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" }, { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' and extra == 'standard'", specifier = ">=0.15.1" }, - { name = "watchfiles", marker = "extra == 'standard'", specifier = ">=0.13" }, + { name = "watchfiles", marker = "extra == 'standard'", specifier = ">=0.20" }, { name = "websockets", marker = "extra == 'standard'", specifier = ">=10.4" }, ] provides-extras = ["standard"] @@ -1739,6 +1766,7 @@ dev = [ { name = "httpx", specifier = "==0.28.1" }, { name = "mypy", specifier = "==1.19.1" }, { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-codspeed", specifier = ">=4.1.1" }, { name = "pytest-mock", specifier = "==3.15.1" }, { name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" }, { name = "ruff", specifier = "==0.15.1" },