diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 00000000..1728bcad --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,43 @@ +name: CodSpeed + +# The repository is not connected on codspeed.io yet, so results cannot +# upload. Restore the push and pull_request triggers after the connection. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codspeed-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + benchmarks: + name: Credential helper benchmarks + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Python 3.12 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --locked --group dev --python 3.12 + + - name: Run benchmarks + uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 + with: + mode: simulation + run: uv run pytest benchmarks/ --codspeed + token: ${{ secrets.CODSPEED_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 17fba264..946ee09c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ Python `>=3.10` is required (CI tests 3.10–3.14). - Run the unit tests: `pytest -m "not integration"`. Run with coverage: `pytest --cov=cloudsmith_cli`. - Run the live-service tests: `pytest -m integration` (requires the `PYTEST_CLOUDSMITH_*` environment variables). Mark each test that calls the live Cloudsmith service with `@pytest.mark.integration`. - Run a single test: `pytest cloudsmith_cli/cli/tests/test_push.py::TestClass::test_name` or by node id / `-k `. +- Run the credential-helper benchmarks: `pytest benchmarks/ --codspeed`. `.github/workflows/codspeed.yml` runs them on CodSpeed (manual trigger until the repo is connected on codspeed.io); the default `pytest` run does not collect them. - Lint/format (all run via pre-commit): `pre-commit run --all-files`. Individual tools: `black .`, `isort .`, `flake8 --config=.flake8`, `pylint --rcfile=.pylintrc `, `pyupgrade --py310-plus `. - Release: `bumpversion ` then `git push origin `. The `VERSION` symlink in repo root points at `cloudsmith_cli/data/VERSION`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c08c2ba0..3938f0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed + +- Credential helper lookups against custom domains are now faster. The domain cache file is read once per process instead of on every request, and read-only lookups no longer create the cache directory. + ## [1.25.0] - 2026-08-24 ### Added diff --git a/benchmarks/test_credential_helpers.py b/benchmarks/test_credential_helpers.py new file mode 100644 index 00000000..2f67d527 --- /dev/null +++ b/benchmarks/test_credential_helpers.py @@ -0,0 +1,135 @@ +# Copyright 2026 Cloudsmith Ltd +"""CodSpeed benchmarks for the credential helper flows. + +Run with ``pytest benchmarks/ --codspeed``. Each benchmark measures one +in-process flow that a credential helper runs on each invocation. No +benchmark touches the network: the custom-domain flow is served from a +pre-warmed cache. +""" + +import io +import json + +import pytest + +from cloudsmith_cli.core.credentials.chain import CredentialProviderChain +from cloudsmith_cli.core.credentials.models import CredentialContext, CredentialResult +from cloudsmith_cli.credential_helpers import custom_domains +from cloudsmith_cli.credential_helpers.backends import BackendKind +from cloudsmith_cli.credential_helpers.cargo import runtime as cargo_runtime +from cloudsmith_cli.credential_helpers.common import is_cloudsmith_domain +from cloudsmith_cli.credential_helpers.custom_domains import CustomDomain, write_cache +from cloudsmith_cli.credential_helpers.default_domains import ( + DomainType, + load_default_domains, +) +from cloudsmith_cli.credential_helpers.docker import runtime as docker_runtime +from cloudsmith_cli.credential_helpers.pnpm import runtime as pnpm_runtime + +API_KEY = "0123456789abcdef0123456789abcdef" + +ORG = "acme" + +CARGO_GET_REQUEST = json.dumps( + { + "v": 1, + "kind": "get", + "operation": "read", + "registry": {"index-url": f"sparse+https://cargo.cloudsmith.io/{ORG}/repo/"}, + } +) + + +@pytest.fixture +def credential(): + return CredentialResult(api_key=API_KEY, source_name="env_var") + + +@pytest.fixture +def warm_custom_domain_cache(monkeypatch, tmp_path): + domain = CustomDomain( + host="cargo.example.com", + backend_kind=int(BackendKind.CARGO), + enabled=True, + validated=True, + org=ORG, + domain_type=DomainType.NATIVE_API, + ) + monkeypatch.setattr(custom_domains, "get_cache_dir", lambda: tmp_path) + write_cache(custom_domains.get_cache_path(ORG), [domain]) + return domain + + +def test_cargo_session(benchmark, credential): + def run_session(): + stdin = io.StringIO(CARGO_GET_REQUEST + "\n") + return cargo_runtime.execute(stdin, io.StringIO(), credential=credential) + + exit_code, stderr_text = benchmark(run_session) + assert exit_code == 0 + assert stderr_text is None + + +def test_cargo_handle_request(benchmark, credential): + request = json.loads(CARGO_GET_REQUEST) + response = benchmark(cargo_runtime.handle_request, request, credential=credential) + assert response["Ok"]["token"] == API_KEY + + +def test_docker_get(benchmark, credential): + def run_get(): + stdin = io.StringIO("https://docker.cloudsmith.io\n") + return docker_runtime.execute("get", stdin, credential=credential) + + exit_code, stdout_text, _ = benchmark(run_get) + assert exit_code == 0 + assert json.loads(stdout_text)["Secret"] == API_KEY + + +def test_pnpm_get(benchmark, credential): + exit_code, token, _ = benchmark( + pnpm_runtime.execute, + f"https://npm.cloudsmith.io/{ORG}/repo/", + credential=credential, + ) + assert exit_code == 0 + assert token == API_KEY + + +def test_standard_domain_match(benchmark, credential): + matched = benchmark( + is_cloudsmith_domain, + f"https://cargo.cloudsmith.io/{ORG}/repo/", + credential=credential, + backend_kind=BackendKind.CARGO, + org=ORG, + ) + assert matched is True + + +def test_custom_domain_match_from_cache( + benchmark, credential, warm_custom_domain_cache +): + matched = benchmark( + is_cloudsmith_domain, + f"https://{warm_custom_domain_cache.host}/{ORG}/repo/", + credential=credential, + backend_kind=BackendKind.CARGO, + org=ORG, + ) + assert matched is True + + +def test_load_default_domains(benchmark): + domains = benchmark(load_default_domains) + assert domains + + +def test_credential_chain_resolves_env_var(benchmark): + def resolve(): + chain = CredentialProviderChain() + return chain.resolve(CredentialContext(api_key_from_env=API_KEY)) + + result = benchmark(resolve) + assert result is not None + assert result.api_key == API_KEY diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 839f39ce..a0c143d0 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -2,17 +2,21 @@ import io import json +import os import time +from datetime import datetime, timedelta, timezone from unittest.mock import patch import httpretty import httpretty.core import pytest +from freezegun import freeze_time from ....cli.commands.credential_helper.docker import docker from ....core.api.exceptions import ApiException from ....core.api.init import initialise_api from ....core.credentials.models import CredentialResult +from ....credential_helpers import custom_domains from ....credential_helpers.backends import BackendKind from ....credential_helpers.common import is_cloudsmith_domain from ....credential_helpers.custom_domains import ( @@ -452,6 +456,7 @@ def test_get_custom_domains_cache_edge(tmp_path, monkeypatch, scenario, expected "cached_at": time.time(), } + cache_path.parent.mkdir(parents=True) cache_path.write_text(json.dumps(data), encoding="utf-8") assert read_cache(cache_path) == expected @@ -464,6 +469,7 @@ def test_cache_that_is_not_utf8_reads_as_a_miss(tmp_path, monkeypatch): ) cache_path = get_cache_path("acme") + cache_path.parent.mkdir(parents=True) cache_path.write_bytes(b"\xff\xfe not utf-8 at all") assert read_cache(cache_path) is None @@ -482,6 +488,239 @@ def test_unwritable_config_dir_reads_as_no_cached_domains(tmp_path, monkeypatch) assert not read_all_cached_domains() +# --------------------------------------------------------------------------- +# 7b. get_custom_domains — per-process memo and read-path filesystem behaviour +# --------------------------------------------------------------------------- + + +def test_get_custom_domains_reads_the_cache_file_once_per_process( + tmp_path, monkeypatch +): + """Repeated lookups serve the memo, not the cache file. + + A Cargo credential-provider session answers many requests, and each + request checks the same org's domains. + """ + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + write_cache( + get_cache_path("acme"), + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + domain_type=DomainType.NATIVE_API, + enabled=True, + validated=True, + org="acme", + ) + ], + ) + + reads = [] + real_read_cache = custom_domains.read_cache + + def counting_read_cache(cache_path): + reads.append(cache_path) + return real_read_cache(cache_path) + + monkeypatch.setattr(custom_domains, "read_cache", counting_read_cache) + + first = get_custom_domains("acme") + second = get_custom_domains("acme") + + assert [d.host for d in first] == ["docker.acme.com"] + assert second == first + assert len(reads) == 1 + + +@httpretty.activate(allow_net_connect=False) +def test_get_custom_domains_refresh_bypasses_the_memo(tmp_path, monkeypatch): + """refresh=True fetches from the API and replaces the memo.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + write_cache( + get_cache_path("acme"), + [ + CustomDomain( + host="old.acme.com", + backend_kind=6, + domain_type=DomainType.NATIVE_API, + enabled=True, + validated=True, + org="acme", + ) + ], + ) + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps( + [ + { + "host": "new.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": True, + } + ] + ), + status=200, + content_type="application/json", + ) + credential = CredentialResult(api_key="k_abc", source_name="test") + + assert [d.host for d in get_custom_domains("acme")] == ["old.acme.com"] + + refreshed = get_custom_domains( + "acme", credential=credential, api_host=API_HOST, refresh=True + ) + assert [d.host for d in refreshed] == ["new.acme.com"] + assert len(httpretty.latest_requests()) == 1 + + after = get_custom_domains("acme") + assert [d.host for d in after] == ["new.acme.com"] + assert len(httpretty.latest_requests()) == 1 + + +def test_memo_notices_an_external_cache_rewrite(tmp_path, monkeypatch): + """A lookup after another process rewrote the cache serves the new data.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + + def cache_domain(host): + return CustomDomain( + host=host, + backend_kind=6, + domain_type=DomainType.NATIVE_API, + enabled=True, + validated=True, + org="acme", + ) + + cache_path = get_cache_path("acme") + write_cache(cache_path, [cache_domain("old.acme.com")]) + assert [d.host for d in get_custom_domains("acme")] == ["old.acme.com"] + + write_cache(cache_path, [cache_domain("new.acme.com")]) + os.utime(cache_path, (time.time() + 1, time.time() + 1)) + + assert [d.host for d in get_custom_domains("acme")] == ["new.acme.com"] + + +@httpretty.activate(allow_net_connect=False) +def test_memo_does_not_outlive_the_cache_ttl(tmp_path, monkeypatch): + """A memoized entry expires with the cache file it came from.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + write_cache( + get_cache_path("acme"), + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + domain_type=DomainType.NATIVE_API, + enabled=True, + validated=True, + org="acme", + ) + ], + ) + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps({"detail": "error"}), + status=404, + content_type="application/json", + ) + credential = CredentialResult(api_key="k_abc", source_name="test") + + assert len(get_custom_domains("acme")) == 1 + + with freeze_time(datetime.now(timezone.utc) + timedelta(days=8)): + assert ( + get_custom_domains("acme", credential=credential, api_host=API_HOST) == [] + ) + + +@httpretty.activate(allow_net_connect=False) +def test_memo_does_not_outlive_the_cache_file(tmp_path, monkeypatch): + """A lookup after the cache file was deleted does not serve the memo.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + domain_type=DomainType.NATIVE_API, + enabled=True, + validated=True, + org="acme", + ) + ], + ) + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps({"detail": "error"}), + status=404, + content_type="application/json", + ) + credential = CredentialResult(api_key="k_abc", source_name="test") + + assert len(get_custom_domains("acme")) == 1 + + cache_path.unlink() + + assert get_custom_domains("acme", credential=credential, api_host=API_HOST) == [] + + +def test_read_path_does_not_create_the_cache_directory(tmp_path, monkeypatch): + """A lookup that only reads must not create the cache directory.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + + assert read_cache(get_cache_path("acme")) is None + assert not (tmp_path / "custom_domains_cache").exists() + + +def test_write_cache_creates_the_cache_directory(tmp_path, monkeypatch): + """write_cache creates the cache directory when it is absent.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + domain = CustomDomain( + host="docker.acme.com", + backend_kind=6, + domain_type=DomainType.NATIVE_API, + enabled=True, + validated=True, + org="acme", + ) + + write_cache(get_cache_path("acme"), [domain]) + + assert (tmp_path / "custom_domains_cache").is_dir() + assert read_cache(get_cache_path("acme")) == [domain] + + # --------------------------------------------------------------------------- # 8. get_format_domains # --------------------------------------------------------------------------- diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py index 1cdb53bc..1069c824 100644 --- a/cloudsmith_cli/credential_helpers/custom_domains.py +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -29,6 +29,11 @@ CACHE_FORMAT_VERSION = 2 +#: Per-process memo of cache reads, keyed by cache file path and stamped +#: with the file's mtime. A helper session checks the same org's domains on +#: every request; the memo replaces the full read with one stat call. +_domains_memo: dict[Path, tuple[float, list["CustomDomain"]]] = {} + @dataclass(frozen=True) class CustomDomain: @@ -56,17 +61,12 @@ def serves_repository(self, repository: str | None) -> bool: def get_cache_dir() -> Path: """ - Get the cache directory for custom domains, creating it where possible. + Get the cache directory for custom domains. - A directory that cannot be created is not an error: every read path then - resolves to a miss, and :func:`write_cache` already degrades on ``OSError``. + The directory is not created here: only :func:`write_cache` needs it to + exist, and the read paths run on every helper invocation. """ - cache_dir = Path(get_default_config_path()) / "custom_domains_cache" - try: - cache_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - except OSError as exc: - logger.debug("Could not create cache directory %s: %s", cache_dir, exc) - return cache_dir + return Path(get_default_config_path()) / "custom_domains_cache" def get_cache_path(org: str) -> Path: @@ -94,15 +94,11 @@ def is_cache_valid(cache_path: Path) -> bool: Returns: bool: True if cache exists and hasn't expired """ - if not cache_path.exists(): - return False - try: mtime = cache_path.stat().st_mtime - age = time.time() - mtime - return age < CACHE_TTL_SECONDS except OSError: return False + return time.time() - mtime < CACHE_TTL_SECONDS def _repository_slug(raw) -> str | None: @@ -206,6 +202,36 @@ def read_cache(cache_path: Path) -> list[CustomDomain] | None: return records +def _memoized_read(cache_path: Path) -> list[CustomDomain] | None: + """Return the valid cached domains at `cache_path`, or None for a miss. + + One stat call guards the memo: the mtime enforces the TTL and detects a + rewrite or a deletion by another process, so a long-lived process cannot + serve stale domains. + """ + try: + mtime = cache_path.stat().st_mtime + except OSError: + _domains_memo.pop(cache_path, None) + return None + + if time.time() - mtime >= CACHE_TTL_SECONDS: + _domains_memo.pop(cache_path, None) + return None + + memoized = _domains_memo.get(cache_path) + if memoized is not None and memoized[0] == mtime: + return memoized[1] + + domains = read_cache(cache_path) + if domains is None: + _domains_memo.pop(cache_path, None) + return None + + _domains_memo[cache_path] = (mtime, domains) + return domains + + def read_all_cached_domains() -> list[CustomDomain]: """Return every custom domain in a currently-valid cache entry. @@ -243,6 +269,7 @@ def write_cache(cache_path: Path, domains: list[CustomDomain]) -> None: "cached_at": time.time(), } try: + cache_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) atomic_write_json(cache_path, data) logger.debug("Wrote %d domains to cache: %s", len(domains), cache_path) except OSError as exc: @@ -261,7 +288,10 @@ def get_custom_domains( """ Fetch custom domains for a Cloudsmith organization. - Results are cached on the filesystem for 7 days to avoid excessive API calls. + Results are cached on the filesystem for 7 days to avoid excessive API + calls. Repeated lookups in one process are served from an in-process + memo. One stat call guards the memo, so an expired, rewritten or deleted + cache file is never served. Args: org: Organization slug @@ -269,8 +299,8 @@ def get_custom_domains( its own auth scheme (X-Api-Key vs Authorization: Bearer) api_host: Cloudsmith API host URL (including version). Taken from the SDK configuration default when not provided. - refresh: When ``True``, skip the cache read and always fetch from the API. - The fresh result is still written to the cache. + refresh: When ``True``, skip the memo and the cache read and always + fetch from the API. The fresh result is still written to both. strict: When ``True``, a failed lookup re-raises its ``ApiException`` instead of degrading to an empty list. Use it for callers that show results to a user, which must not render a typo'd org, a missing @@ -295,10 +325,11 @@ def get_custom_domains( ``strict=True`` to fail loudly instead. """ cache_path = get_cache_path(org) - cached = None if refresh else read_cache(cache_path) - if cached is not None: - logger.debug("Using cached custom domains for %s", org) - return cached + if not refresh: + cached = _memoized_read(cache_path) + if cached is not None: + logger.debug("Using cached custom domains for %s", org) + return list(cached) logger.debug("Fetching custom domains from API for %s", org) @@ -333,6 +364,7 @@ def get_custom_domains( logger.debug("Fetched %d custom domains for %s", len(records), org) write_cache(cache_path, records) + _domains_memo.pop(cache_path, None) return records diff --git a/pyproject.toml b/pyproject.toml index f65d1bcf..f7a3e2c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ dev = [ "boto3[crt]>=1.26.0", "pre-commit", "pylint", + "pytest-codspeed", "pytest-cov", "freezegun", "ty", diff --git a/uv.lock b/uv.lock index 9f5c6e66..d2fcbe20 100644 --- a/uv.lock +++ b/uv.lock @@ -567,6 +567,7 @@ dev = [ { name = "httpretty" }, { name = "pre-commit" }, { name = "pylint" }, + { name = "pytest-codspeed" }, { name = "pytest-cov" }, { name = "ty" }, ] @@ -617,6 +618,7 @@ dev = [ { name = "httpretty" }, { name = "pre-commit" }, { name = "pylint" }, + { name = "pytest-codspeed" }, { name = "pytest-cov" }, { name = "ty" }, ] @@ -1601,6 +1603,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] +[[package]] +name = "pytest-codspeed" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/b4/cf932fcd1960a2fd6d9b09eb403253a8709aeee975961afa6299239a830e/pytest_codspeed-5.0.3.tar.gz", hash = "sha256:91afef90e6a96b013495e4702ef5d6358614a449e71008cdc194ef668778b92f", size = 324571, upload-time = "2026-05-22T16:20:49.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f5/a8f70147216e4b84046ca406d03ecc8e83e3ea56ba1bdca0bb79cca79fee/pytest_codspeed-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:005348ea52ace3ede2e2f595913912ad2564cca7b124211a88dc78a9cb1fca63", size = 366249, upload-time = "2026-05-22T16:20:39.985Z" }, + { url = "https://files.pythonhosted.org/packages/f6/bd/7a4dbcf457fcc3ed788c55d402f3af2671e0e342b6098090fd590aa8712e/pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbe6a4a00b449b6ba2771f644cbc38bdf55acf5c812e60e5659110e19dd9f510", size = 932229, upload-time = "2026-05-22T16:20:37.283Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/414ea4c66559f24ec06aeb6db62bfc7079582dac1452e648affe1eb5cfb4/pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac4344f34bbcdd17f6f8c30dbac3da2f80d223dd112e568fd7f7c2cd4cbc693", size = 934647, upload-time = "2026-05-22T16:20:31.997Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ef/32ce60d42a4aa43e728d988e13eb6568fbc7b10a514517b459bafd3f2b94/pytest_codspeed-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f56d0339cd98d26f6e561987be25bdd2761a5d53d8f73493b1ebe02d0d451093", size = 366253, upload-time = "2026-05-22T16:21:10.013Z" }, + { url = "https://files.pythonhosted.org/packages/2a/15/c66ef90a793c5d2c039e63a1726a5e55c678be2618b0f5f1660d0f79e25f/pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c682f6645d4eb472f3bd95dbda1805e3af4243610572cb7d6bf94a88e8a0b6c", size = 932465, upload-time = "2026-05-22T16:20:34.265Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7b/d231279301967f05b7909160489e85ee3a1b9da76094ea25343faba1abc2/pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f852bee785a7a124cb1720b1915670c6742af87747dc4d838f3ffdbd365ce9d9", size = 934925, upload-time = "2026-05-22T16:20:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/c2/22/456c48160b761d5028c8afa119f085a9fc42855a783a13d73918078969f0/pytest_codspeed-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2eeb25fb1ac3f73c4de50e739e78fea396b89782bdb740bf2a7cd2df21f8d4ee", size = 366255, upload-time = "2026-05-22T16:20:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/74/33/ac7441fa937c9d9f158083a8c46920a5a5c81ed3c5f96240fc8d650db5c2/pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73c5c9d98a3372a42611989ccfa437cce3842431ac6d6b9ab42c4f0e59c070f7", size = 932325, upload-time = "2026-05-22T16:21:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/8b994adcb9e9016e7d9a808056a3dd9cca21441e432ef456eae2b697d7fe/pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2e0ab65df73e837666d12357280ca50ff6d6ac03ea5266703be518b68170edf", size = 934885, upload-time = "2026-05-22T16:21:01.444Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/e032451e9e0a06b0c4bff53105f62b693d9a54595dd8c024693741ce3380/pytest_codspeed-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6524c57fec279a22ffef6112af404036afc71b4704758ae9f0abda429b8478d4", size = 366253, upload-time = "2026-05-22T16:20:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7b/ae76fd8ac656b9695806a6aafd5f22ec32e6ce20e266a58f9112e01d3cd8/pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c383c9121deb58a69f174188e9e4488ffc0daced0ed276abf87747182511901", size = 932360, upload-time = "2026-05-22T16:20:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4a/dfd43d943fdb143be4fd62f34c2793ba349dc27aa188e521d19d629aa7ab/pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4bcdb4b6522738152885ef067e0c8524d5699828d780fb6f464cdb3db44369c", size = 934928, upload-time = "2026-05-22T16:20:38.62Z" }, + { url = "https://files.pythonhosted.org/packages/04/6a/fdcec19c7f267c195f147c51d3fd2245f6b8d09b80495ed0a90c008e0842/pytest_codspeed-5.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25464363c7f9b9bd5022e969c0addba616fa40ac9b8f0fc9e030c4538863b32d", size = 366259, upload-time = "2026-05-22T16:21:06.039Z" }, + { url = "https://files.pythonhosted.org/packages/6a/96/c6b03b81dcd21ae3d6b32cca0b3c10149fa378eb21b338d4b63c9eb8050b/pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efd43f82ea03ced8488a767ded9473f050791ab7783ea8654107e1e0ac66af40", size = 932395, upload-time = "2026-05-22T16:21:04.804Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/56ad8f1cc7d6962f8a680141b361e93467a2abc53d976cd9d5e1edd740e3/pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:782f9985b6f6b45b8bc20152d206d3a52b56dd088ba81cb70a71f0b39841be9e", size = 934994, upload-time = "2026-05-22T16:20:28.809Z" }, + { url = "https://files.pythonhosted.org/packages/0b/54/9096c4545f09da94b1b00f3be2fe4952949e86c9bcafca9a29b26aed1a75/pytest_codspeed-5.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9aa0815b90196f3c20d736ea8691381e97f12bbe8c7d87af10a351e434b452cb", size = 366311, upload-time = "2026-05-22T16:20:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3c/24c53f67a38ad48cb087105ac30a8aa0923223ee274ea9bf2dc705edaa59/pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85505c96a3477c346ec2d2b7dced8478f4c651e2b1666ee102d53a832b511853", size = 933169, upload-time = "2026-05-22T16:20:43.178Z" }, + { url = "https://files.pythonhosted.org/packages/d1/de/2213f868fa7694f743f96cccbc07e757f45c920c523cccc2da97bc8652df/pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20eba63765be9d1b6cacbbfad84b87d49eb04b357a7045a0899880da181f81e3", size = 935522, upload-time = "2026-05-22T16:21:03.398Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/5dfea1c031d6cccc11653464828edf205c30f798caf5b2a85375aacd914a/pytest_codspeed-5.0.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ec9fa6f0af0a9feb0e0bd517fb59ef28f806fbd50c0c6900ac26cbb4d080eba5", size = 366275, upload-time = "2026-05-22T16:20:59.463Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2b/af4d1b612f03b98a6cf3c7d5f62678917a60110a8bf380d49ab408b31137/pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8df77b3409f54f4a268f77f3ff74992fe1d995cdbaf2cecf8ad74d32db217ce7", size = 932537, upload-time = "2026-05-22T16:20:54.945Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a2/c7ec45e36a61b418efb2a3cccaa67a0c2fcf1f21d5880f64c33114f0c249/pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d8695a227ea1c3a41d25db5b3fe720bf1b4808bd38862be811a4efd902c792", size = 934153, upload-time = "2026-05-22T16:21:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c7/d5bada9618a0af56a5c8065fc61280849cab8e7c1e24025807a51c3157ce/pytest_codspeed-5.0.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bf4cc4178cbace8f4d2bd240408276bc4da3850ac5fcb5fb5f8a74ab417615bb", size = 366339, upload-time = "2026-05-22T16:20:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/fb27aeb40a81320e7349553b877a21333c897b27c8dfe215630452908f36/pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abe793da40f87295d33988673d34f06ea569848b44490b847552cd416816258a", size = 933055, upload-time = "2026-05-22T16:20:44.861Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d9/6f2d69e96deaf0475a695fc9195af59e7a3b5fab50782855e65c63a7bc28/pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3a9ed38dfa776443b86f4b49a982e8443d0953db4974bd2673d63cc904ae1ad", size = 934481, upload-time = "2026-05-22T16:20:58.264Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b2/1d2a993c532146dce9eca5b5942d51898021c3579ce18b2454f932a915f8/pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378", size = 74033, upload-time = "2026-05-22T16:20:26.814Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0"