diff --git a/.gitignore b/.gitignore index fd6c0aa..8894c05 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,4 @@ dev-docs/brainstorms/ dev-docs/plans/ .worktrees/ +.benchmarks/ diff --git a/pyproject.toml b/pyproject.toml index 6b2539f..3b929bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ dev = [ "netcdf4>=1.7.4", "pre-commit>=4.6.0", "pytest-asyncio>=0.24.0", + "pytest-benchmark>=5.1", "pytest-cov>=7.1.0", "pytest>=9.0.3", "pytest-xdist", diff --git a/tests/benchmarks/README.md b/tests/benchmarks/README.md new file mode 100644 index 0000000..7707189 --- /dev/null +++ b/tests/benchmarks/README.md @@ -0,0 +1,31 @@ +# Reader benchmarks + +In-process `XarrayReader` timings (pytest-benchmark) on a generated zarr +store, with the store optionally wrapped in zarr's `LatencyStore` so each +`get` costs a fixed round trip, as it would from S3. Every case also +records the number of store `get` calls one operation makes +(`extra_info.store_gets`), which is exact and branch-comparable. + +```sh +uv run pytest tests/benchmarks --benchmark-only --no-cov -q \ + --benchmark-json=bench.json --benchmark-columns=median,min,rounds +``` + +## Reporting a PR's performance + +Same machine, same interpreter, nothing else running. Build `main` in a +worktree so the branch stays checked out: + +```sh +git worktree add ../titiler-multidim-main main +(cd ../titiler-multidim-main && uv run pytest tests/benchmarks --benchmark-only --no-cov -q --benchmark-json=../before.json) +uv run pytest tests/benchmarks --benchmark-only --no-cov -q --benchmark-json=../after.json +uv run pytest-benchmark compare ../before.json ../after.json --columns=median,min,rounds --sort=name +``` + +Paste that table into the PR's **Performance** section, together with the +diff of `tests/test_perf_counts.py` if the PR changed any pinned count +(opens, storage requests or boto3 sessions per request). Medians under +~20 ms move by a few percent between runs; call a change real only when it +is well outside that. Do not pass `-n`: pytest-benchmark refuses +`--benchmark-only` under xdist. diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py new file mode 100644 index 0000000..bf9c9f4 --- /dev/null +++ b/tests/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Reader benchmarks (run with --benchmark-only).""" diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py new file mode 100644 index 0000000..b56c0d3 --- /dev/null +++ b/tests/benchmarks/conftest.py @@ -0,0 +1,19 @@ +"""Collect benchmarks only when pytest-benchmark is explicitly enabled. + +`pytest` alone runs the normal suite; `pytest tests/benchmarks +--benchmark-only` runs these (see README in this directory). +""" + +import pytest + + +def pytest_collection_modifyitems(config, items): + """Skip benchmark tests unless --benchmark-only / --benchmark-enable.""" + if config.getoption("benchmark_only", False) or config.getoption( + "benchmark_enable", False + ): + return + skip = pytest.mark.skip(reason="run with --benchmark-only") + for item in items: + if "benchmarks" in str(item.fspath): + item.add_marker(skip) diff --git a/tests/benchmarks/test_app_bench.py b/tests/benchmarks/test_app_bench.py new file mode 100644 index 0000000..cf10484 --- /dev/null +++ b/tests/benchmarks/test_app_bench.py @@ -0,0 +1,39 @@ +"""App-level benchmarks on a 17,228-step Icechunk store (TEMPO's time axis length). + +Each case is one request through `TestClient`, so it includes dependency +parsing, the mosaic backend, the reader and rendering. Compare runs with +`pytest-benchmark compare` (see README). +""" + +import pytest + +TIME = "2023-08-01T00:00:00" + + +@pytest.mark.parametrize( + ("path", "params"), + [ + ("/info", {"sel": f"time={TIME}"}), + # rio-tiler info() builds band metadata for every time step: Issue 4 + ("/info", {}), + # plus the times list (cheap since #194; kept so a regression shows) + ("/info", {"show_times": True}), + ("/tiles/WebMercatorQuad/2/0/1.png", {"sel": f"time={TIME}"}), + ("/point/-95,35", {"sel": f"time={TIME}"}), + ], + ids=["info_sel", "info_no_sel", "info_show_times", "tile_z2", "point"], +) +def test_request(benchmark, app, long_icechunk_store, path, params): + """One request, cold: every round opens the store again (as on main). + + The data chunks are inlined in the manifest (see the fixture), so the + tile and point cases measure the open and request path, not chunk + fetches; the chunk counts live in tests/test_perf_counts.py. + """ + query = {"url": long_icechunk_store, "variable": "data", **params} + + def run(): + response = app.get(path, params=query) + assert response.status_code == 200 + + benchmark(run) diff --git a/tests/benchmarks/test_reader_bench.py b/tests/benchmarks/test_reader_bench.py new file mode 100644 index 0000000..6bc5125 --- /dev/null +++ b/tests/benchmarks/test_reader_bench.py @@ -0,0 +1,167 @@ +"""XarrayReader benchmarks: open, tile and point. + +Cases are parametrized by store latency (0 and 30 ms per `get`) so the +difference between "reads the window" and "reads the whole slice", and +between one batched round trip and several, shows up as time and not +just as a `get` count. Values compared across branches must come from +the same machine, interpreter and store layout. +""" + +import numpy as np +import pytest +import xarray as xr +from zarr.abc.store import Store +from zarr.storage import LocalStore, WrapperStore +from zarr.testing.store import LatencyStore + +from titiler.multidim import mosaic, reader + +LATENCIES = (0.0, 0.03) +# WebMercatorQuad z/x/y inside the fixture's bounds +TILE = (3, 2, 3) +POINT = (-95.0, 35.0) + + +class CountingStore(WrapperStore[Store]): + """Count `get` calls on the wrapped store. + + Wrap a read-only store: zarr then uses this instance as-is rather than + deriving a read-only copy, with its own count, through `_with_store`. + """ + + gets = 0 + + async def get(self, key, prototype, byte_range=None): + """Count, then delegate.""" + self.gets += 1 + return await self._store.get(key, prototype, byte_range=byte_range) + + def get_sync(self, key, prototype, byte_range=None): + """Count, then delegate (zarr >= 3.2 sync read path).""" + self.gets += 1 + return self._store.get_sync(key, prototype=prototype, byte_range=byte_range) + + +@pytest.fixture(scope="session") +def store_path(tmp_path_factory) -> str: + """4 x 720 x 1440 float32 data, 2-D mask and flag, chunked 1 x 180 x 180.""" + rng = np.random.default_rng(0) + ny, nx, nt = 720, 1440, 4 + flag = rng.integers(0, 3, (ny, nx)).astype("float32") + flag[::7, ::11] = np.nan # fill pixels + ds = xr.Dataset( + { + "data": (("time", "lat", "lon"), rng.random((nt, ny, nx), dtype="float32")), + "mask2d": (("lat", "lon"), rng.random((ny, nx), dtype="float32")), + "flag": (("lat", "lon"), flag), + }, + coords={ + "time": np.arange(nt), + "lat": np.linspace(-89.875, 89.875, ny), + "lon": np.linspace(-179.875, 179.875, nx), + }, + ) + path = str(tmp_path_factory.mktemp("bench") / "store.zarr") + ds.to_zarr( + path, + consolidated=False, + encoding={ + "data": {"chunks": (1, 180, 180)}, + "mask2d": {"chunks": (180, 180)}, + "flag": {"chunks": (180, 180)}, + }, + ) + return path + + +@pytest.fixture(params=LATENCIES, ids=lambda v: f"latency={int(v * 1000)}ms") +def latency(request) -> float: + """Per-`get` store latency in seconds.""" + return request.param + + +@pytest.fixture +def counter(store_path, latency, monkeypatch) -> CountingStore: + """Route the reader's opener through counting + latency store wrappers.""" + counting = CountingStore(LocalStore(store_path, read_only=True)) + store: Store = LatencyStore(counting, get_latency=latency) if latency else counting + + def opener(src_path: str, **kwargs) -> xr.Dataset: + return xr.open_zarr(store, chunks=None, consolidated=False, decode_coords="all") + + monkeypatch.setattr(reader, "guess_opener", opener) + return counting + + +def _reader(): + return reader.XarrayReader( + src_path="bench://store", variable="data", sel=["time=0"] + ) + + +def _record(benchmark, counter, fn): + """Time `fn`; record the `get` count of one call as extra_info.""" + counter.gets = 0 + fn() + assert counter.gets, "gets went to a copy of the counting store" + benchmark.extra_info["store_gets"] = counter.gets + benchmark(fn) + + +def test_open(benchmark, counter): + """Reader construction: open and extract the variable (metadata only).""" + + def run(): + with _reader(): + pass + + _record(benchmark, counter, run) + + +def test_tile(benchmark, counter): + """Open + one z3 tile: the request path a map viewer exercises.""" + + def run(): + with _reader() as src: + return src.tile(TILE[1], TILE[2], TILE[0]) + + _record(benchmark, counter, run) + + +def test_point(benchmark, counter): + """Open + one point read.""" + + def run(): + with _reader() as src: + return src.point(*POINT) + + _record(benchmark, counter, run) + + +def _backend(): + """Single-URL request path: the backend validates, then rio-tiler reads.""" + return mosaic.XarrayMosaicBackend( + ["bench://store"], + reader=reader.XarrayReader, + reader_options={"variable": "data", "sel": ["time=0"]}, + ) + + +def test_backend_tile(benchmark, counter): + """Open through XarrayMosaicBackend + one z3 tile, as /tiles does.""" + + def run(): + with _backend() as src: + return src.tile(TILE[1], TILE[2], TILE[0]) + + _record(benchmark, counter, run) + + +def test_backend_point(benchmark, counter): + """Open through XarrayMosaicBackend + one point read, as /point does.""" + + def run(): + with _backend() as src: + return src.point(*POINT) + + _record(benchmark, counter, run) diff --git a/tests/conftest.py b/tests/conftest.py index 37d76b7..3c98f7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,3 +30,53 @@ def app(monkeypatch): with TestClient(app) as client: yield client + + +@pytest.fixture +def storage_requests(monkeypatch): + """Icechunk storage requests made by the test's local repositories, by kind.""" + from helpers import serve_counting + + server, counts = serve_counting(monkeypatch) + yield counts + server.shutdown() + server.server_close() + + +@pytest.fixture(scope="session") +def long_icechunk_store(tmp_path_factory) -> str: + """Icechunk store with TEMPO's time axis length (17,228 steps) on an 8x8 grid. + + Data is chunked coarsely: one chunk per step takes ~8 s to write and 17k + files, and nothing here depends on it. The `time` coordinate is the only + chunk object in the store (~18 KB stored): the all-zero data chunks + compress below icechunk's inline threshold and live in the manifest, so + tile and point requests on this store never fetch a data chunk. + """ + import icechunk + import numpy as np + import xarray as xr + + nt, ny, nx = 17228, 8, 8 + ds = xr.Dataset( + { + "data": ( + ("time", "latitude", "longitude"), + np.zeros((nt, ny, nx), "float32"), + ) + }, + coords={ + "time": np.datetime64("2023-08-01T00:00:00", "ns") + + np.arange(nt) * np.timedelta64(40, "m"), + "latitude": np.linspace(50, 15, ny), + "longitude": np.linspace(-125, -65, nx), + }, + ) + path = str(tmp_path_factory.mktemp("icechunk") / "long") + repo = icechunk.Repository.create(icechunk.local_filesystem_storage(path)) + session = repo.writable_session("main") + ds.to_zarr( + session.store, consolidated=False, encoding={"data": {"chunks": (1000, ny, nx)}} + ) + session.commit("17,228 time steps") + return path diff --git a/tests/helpers.py b/tests/helpers.py index 0afd1b4..b870b54 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,16 @@ """Functions to simplify writing tests.""" +import collections +import http.server +import os +import re +import socketserver +import threading + +import boto3.session import httpx2 as httpx +import icechunk +import xarray as xr def find_string_in_stream(response: httpx.Response, target: str) -> bool: @@ -21,3 +31,130 @@ def find_string_in_stream(response: httpx.Response, target: str) -> bool: -len(target) : ] # Keep only the tail end of the buffer with length equal to the target string return False + + +# --- performance counters ---------------------------------------------------- +# +# Each helper patches a third-party module (icechunk, xarray, boto3), never +# titiler.multidim.reader: the `app` fixture re-imports every titiler.multidim +# module, so a patch on a reader module imported earlier lands on a dead copy. + + +_COUNT_LOCK = threading.Lock() # icechunk reads in parallel; Counter += is not atomic + + +class _RangeHandler(http.server.SimpleHTTPRequestHandler): + """Serve any local file, honouring `Range` (icechunk reads manifests by range). + + `SimpleHTTPRequestHandler` ignores `Range`, and object_store then fails + the read, so this handler answers 206 with a `Content-Range`. + """ + + requests: collections.Counter + root: str # the store directory, set when a repository is opened + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory="/", **kwargs) + + def log_message(self, *args): # silence per-request stderr lines + pass + + def do_GET(self): + # classify by the icechunk key under the store root (repo, config.yaml, + # refs, snapshots, manifests, chunks), never by the checkout path + key = self.path.removeprefix(self.root).lstrip("/") + with _COUNT_LOCK: + self.requests[key.split("/", 1)[0]] += 1 + path = self.translate_path(self.path) + if not os.path.isfile(path): + self.send_error(404) + return + with open(path, "rb") as f: + body = f.read() + if match := re.fullmatch(r"bytes=(\d+)-(\d*)", self.headers.get("Range", "")): + start = int(match[1]) + end = int(match[2]) if match[2] else len(body) - 1 + part = body[start : end + 1] + self.send_response(206) + self.send_header( + "Content-Range", f"bytes {start}-{start + len(part) - 1}/{len(body)}" + ) + body = part + else: + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def serve_counting(monkeypatch) -> tuple[socketserver.TCPServer, collections.Counter]: + """Route local Icechunk repositories through a counting HTTP server. + + Returns the server and a Counter of icechunk storage requests keyed by + kind (`repo`, `config.yaml`, `refs`, `snapshots`, `manifests`, `chunks`), + the unit the performance roadmap's numbers are in. `opener_icechunk` + builds `file://` storage with `icechunk.local_filesystem_storage`; that + is patched to return `icechunk.http_storage` for the same directory, + served from the filesystem root so any local repository works. The + caller stops the server (`shutdown()`, then `server_close()`): the + `storage_requests` fixture in conftest.py does that on teardown. + """ + handler = type( + "CountingHandler", (_RangeHandler,), {"requests": collections.Counter()} + ) + server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), handler) + server.daemon_threads = True + threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True + ).start() + + def http_storage(path: str) -> icechunk.Storage: + handler.root = os.path.abspath(path) + return icechunk.http_storage( + f"http://127.0.0.1:{server.server_address[1]}{handler.root}/" + ) + + monkeypatch.setattr(icechunk, "local_filesystem_storage", http_storage) + return server, handler.requests + + +def count_opens(monkeypatch) -> collections.Counter: + """Count repository opens (`local_filesystem_storage`) and `xr.open_dataset`. + + Wraps whatever `icechunk.local_filesystem_storage` currently is, so it + composes with the `storage_requests` fixture (fixtures run first). + """ + counts: collections.Counter = collections.Counter() + storage, open_dataset = icechunk.local_filesystem_storage, xr.open_dataset + + def counting_storage(*args, **kwargs): + counts["repository"] += 1 + return storage(*args, **kwargs) + + def counting_open_dataset(*args, **kwargs): + counts["dataset"] += 1 + return open_dataset(*args, **kwargs) + + monkeypatch.setattr(icechunk, "local_filesystem_storage", counting_storage) + monkeypatch.setattr(xr, "open_dataset", counting_open_dataset) + return counts + + +def count_boto3_sessions(monkeypatch) -> collections.Counter: + """Count `boto3.Session()` constructions, as rasterio's `AWSSession` makes them. + + rasterio only builds one when `AWS_ACCESS_KEY_ID` and + `AWS_SECRET_ACCESS_KEY` are both in the environment (always on Lambda, + never in CI), so this also sets fake credentials. + """ + counts: collections.Counter = collections.Counter() + init = boto3.session.Session.__init__ + + def counting_init(self, *args, **kwargs): + counts["boto3.Session"] += 1 + init(self, *args, **kwargs) + + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setattr(boto3.session.Session, "__init__", counting_init) + return counts diff --git a/tests/test_perf_counts.py b/tests/test_perf_counts.py new file mode 100644 index 0000000..b12fa69 --- /dev/null +++ b/tests/test_perf_counts.py @@ -0,0 +1,104 @@ +"""Pin what each request type costs in opens, storage requests and boto3 sessions. + +These are `main`'s numbers, not targets. A PR that changes one updates the +assertion, and that diff is the PR's request-count report. The comments say +which performance-roadmap issue is expected to lower each number. +""" + +import shutil +import socket + +import pytest +from helpers import count_boto3_sessions, count_opens, serve_counting + +NATIVE = { + "url": "tests/fixtures/icechunk_native", + "variable": "CDD0", + "decode_times": False, +} +TIME = "2023-08-01T00:00:00" + + +@pytest.mark.parametrize( + ("path", "params", "repository", "dataset", "metadata", "chunks"), + [ + # Issue 5 keeps the dataset open across requests (-> 0, metadata -> 1 branch-ref check); + # Issue 8 serves repeated chunks from memory (chunks -> 0 on a repeat). + ("/tiles/WebMercatorQuad/2/1/1.png", {"sel": "time=0"}, 1, 1, 9, 9), + ("/point/-95,35", {"sel": "time=0"}, 1, 1, 9, 1), + ("/info", {"sel": "time=0"}, 1, 1, 8, 0), + ("/info", {"show_times": True}, 1, 1, 8, 0), + ], + ids=["tile", "point", "info", "info_show_times"], +) +def test_opens_and_storage_requests( + app, + monkeypatch, + storage_requests, + path, + params, + repository, + dataset, + metadata, + chunks, +): + """Opens and storage requests per request type, on `tests/fixtures/icechunk_native`.""" + opens = count_opens(monkeypatch) + + assert app.get(path, params={**NATIVE, **params}).status_code == 200 + + assert (opens["repository"], opens["dataset"]) == (repository, dataset) + assert storage_requests["chunks"] == chunks + assert sum(storage_requests.values()) - storage_requests["chunks"] == metadata + + +def test_coordinate_chunk_fetches_per_open(app, storage_requests, long_icechunk_store): + """Issue 6: icechunk's chunk cache is off, so the `time` chunk is read 3 times per open.""" + params = {"url": long_icechunk_store, "variable": "data", "sel": f"time={TIME}"} + + assert app.get("/info", params=params).status_code == 200 + + assert storage_requests["chunks"] == 3 + + +@pytest.mark.parametrize( + ("path", "sessions"), + [ + # Issue 7: rasterio's internal Envs build an AWSSession each (-> 0) + ("/tiles/WebMercatorQuad/2/1/1.png", 10), + ("/point/-95,35", 3), + ("/info", 2), + ], + ids=["tile", "point", "info"], +) +def test_boto3_sessions(app, monkeypatch, path, sessions): + """`boto3.Session()` constructions per request type, with AWS credentials in the environment.""" + counts = count_boto3_sessions(monkeypatch) + + assert app.get(path, params={**NATIVE, "sel": "time=0"}).status_code == 200 + + assert counts["boto3.Session"] == sessions + + +def test_storage_kinds_come_from_the_icechunk_key(app, storage_requests, tmp_path): + """A `chunks/` or `repo/` directory above the store must not be counted as reads.""" + store = tmp_path / "chunks" / "repo" + shutil.copytree("tests/fixtures/icechunk_native", store) + + params = {**NATIVE, "url": str(store), "sel": "time=0"} + assert app.get("/info", params=params).status_code == 200 + + assert storage_requests["chunks"] == 0 + assert sum(storage_requests.values()) == 8 + + +def test_counting_server_shuts_down(monkeypatch): + """`shutdown()` + `server_close()` stop the thread and free the port (fixture teardown).""" + server, _ = serve_counting(monkeypatch) + address = server.server_address + + server.shutdown() + server.server_close() + + with pytest.raises(ConnectionRefusedError): + socket.create_connection(address, timeout=1).close() diff --git a/uv.lock b/uv.lock index 5296dac..b34a42b 100644 --- a/uv.lock +++ b/uv.lock @@ -2742,6 +2742,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "py-cpuinfo2" +version = "10.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/97/a8b1ddada14c8280a047c0746f95cb05d94a31b1a331cea22bcdc2b2a82d/py_cpuinfo2-10.1.1.tar.gz", hash = "sha256:7861133863663f16e06eca63b12904ef100b5760415e92372dac0162799a4771", size = 100840, upload-time = "2026-03-25T21:49:40.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/0a/ba69d2dde1ae12ef1d389ea5a216384c5ff6ef7a1e7a48d1e9b6686f6790/py_cpuinfo2-10.1.1-py3-none-any.whl", hash = "sha256:adc53396bfb206e6498d078ec2ab407f85799ecd819584ac36a8f80a2d4d762d", size = 23791, upload-time = "2026-03-25T21:49:39.574Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2970,6 +2979,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo2" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8f/83a15e40dbc34a580ee56eb56983cae5394c6e94d50cf28fe268e457be25/pytest_benchmark-5.3.0.tar.gz", hash = "sha256:358444d4e89be901ee2b6404fb043ac3d7684002ad7f3563cc153fca6339c965", size = 375410, upload-time = "2026-08-23T17:45:08.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/42/7e80f7cfa191e0a766d1de99b4661847415ad5db34f8209d81fd42175b59/pytest_benchmark-5.3.0-py3-none-any.whl", hash = "sha256:920ab1dfcffa718d49aa15ba144c7e357bda59216a0dc308016cc1c7236f719d", size = 48401, upload-time = "2026-08-23T17:45:07.094Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" @@ -3474,6 +3496,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, @@ -3538,6 +3561,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-benchmark", specifier = ">=5.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-xdist" }, { name = "ruff", specifier = ">=0.15.12" },