From dad2902ce3ad4fe6657deb98ab3ad4f634778ede Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Thu, 24 Sep 2026 17:22:55 +0000 Subject: [PATCH] Add CodSpeed performance benchmarks for backend hot paths --- .github/workflows/codspeed.yml | 65 +++++ README.md | 1 + backend/README.md | 16 ++ backend/pyproject.toml | 1 + backend/tests/benchmarks/__init__.py | 0 backend/tests/benchmarks/test_bench_core.py | 254 ++++++++++++++++++++ backend/uv.lock | 67 ++++++ 7 files changed, 404 insertions(+) create mode 100644 .github/workflows/codspeed.yml create mode 100644 backend/tests/benchmarks/__init__.py create mode 100644 backend/tests/benchmarks/test_bench_core.py diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 000000000..57e1be7f4 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,65 @@ +name: CodSpeed Benchmarks + +on: + pull_request: + branches: + - develop + - master + - "release/**" + push: + branches: + - develop + - master + # `workflow_dispatch` allows CodSpeed to trigger backtest + # performance analysis in order to generate initial data. + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codspeed-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for OIDC authentication with CodSpeed + env: + DISABLE_BACKGROUND_WORKERS: "1" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.14" + working-directory: backend + + - name: Install backend dependencies + run: | + cd backend + uv sync --frozen --group dev + + - name: Run benchmarks + uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 + with: + mode: simulation + working-directory: backend + run: uv run --frozen pytest tests/benchmarks --codspeed diff --git a/README.md b/README.md index ff64840e9..74b30b674 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Application CI](https://github.com/ContextualWisdomLab/naruon/actions/workflows/app-ci.yml/badge.svg)](https://github.com/ContextualWisdomLab/naruon/actions/workflows/app-ci.yml) [![Bandit Security Scan](https://github.com/ContextualWisdomLab/naruon/actions/workflows/bandit.yml/badge.svg)](https://github.com/ContextualWisdomLab/naruon/actions/workflows/bandit.yml) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/naruon) +[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://app.codspeed.io/ContextualWisdomLab/naruon?utm_source=badge) Full-stack AI workspace with a FastAPI backend, Next.js frontend, vector search, AI summaries, hardened email threading, and relay/proxy contracts for external diff --git a/backend/README.md b/backend/README.md index 3fc6e3fa7..765ba7511 100644 --- a/backend/README.md +++ b/backend/README.md @@ -90,6 +90,22 @@ the authenticated-user query scope. Before claiming production multi-user safety, audit and backfill historical `emails.user_id` values against verified mailbox owners. +## Performance benchmarks + +CPU-bound hot paths (email parsing, HTML stripping, threading references, +dedupe fingerprints, hybrid retrieval fusion, calendar conflicts, and access +policy decisions) are benchmarked with `pytest-codspeed` in +`tests/benchmarks/`. The `CodSpeed Benchmarks` workflow runs them on every pull +request and push to `develop`/`master`. To run them locally: + +```bash +uv sync --group dev +uv run pytest tests/benchmarks --codspeed +``` + +The benchmark module is skipped automatically when `pytest-codspeed` is not +installed, so the regular `pytest -q` run is unaffected. + ## Warning classification Backend evidence should be collected with warnings promoted to errors whenever diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c156d9776..f3b1a5e78 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -45,5 +45,6 @@ dev = [ "coverage==7.15.1", "pytest==9.1.1", "pytest-asyncio==1.4.0", + "pytest-codspeed==5.0.3", "ruff==0.15.21", ] diff --git a/backend/tests/benchmarks/__init__.py b/backend/tests/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/benchmarks/test_bench_core.py b/backend/tests/benchmarks/test_bench_core.py new file mode 100644 index 000000000..16d74bc9e --- /dev/null +++ b/backend/tests/benchmarks/test_bench_core.py @@ -0,0 +1,254 @@ +"""CodSpeed performance benchmarks for CPU-bound backend hot paths. + +These benchmarks require ``pytest-codspeed`` (backend dev dependency group) and +are skipped when the plugin is not installed. Run them with: + + uv run pytest tests/benchmarks --codspeed +""" + +import datetime +from email.message import EmailMessage +from pathlib import Path + +import pytest + +pytest.importorskip("pytest_codspeed") + +from rankweave import FusionSettings # noqa: E402 + +from services.access_policy import ( # noqa: E402 + AccessRequest, + ResourcePolicy, + evaluate_access, +) +from services.calendar_conflict_ics import ( # noqa: E402 + evaluate_calendar_conflicts_from_ics, +) +from services.email_dedupe_service import ( # noqa: E402 + EmailDedupeCandidate, + candidate_strong_fingerprint, +) +from services.email_parser import parse_eml_bytes # noqa: E402 +from services.hybrid_retrieval import ( # noqa: E402 + fuse_channel_scores, + normalize_search_text, +) +from services.text_safety import contains_html_markup, strip_html_markup # noqa: E402 +from services.threading_service import extract_reference_ids # noqa: E402 + +FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" +CALENDAR_FIXTURES_DIR = FIXTURES_DIR / "calendar" + + +def _build_multipart_email(paragraphs: int) -> bytes: + msg = EmailMessage() + msg["Message-ID"] = "" + msg["From"] = "Alice Example " + msg["To"] = "Bob Example , Carol " + msg["Subject"] = "Re: Quarterly plan and budget review" + msg["Date"] = "Mon, 27 Apr 2026 10:05:00 +0000" + msg["In-Reply-To"] = "" + msg["References"] = " ".join(f"" for i in range(20)) + plain = "\n\n".join( + f"Paragraph {i}: discussing the quarterly roadmap, budget and hiring plan." + for i in range(paragraphs) + ) + html = "".join( + f"

Paragraph {i}: discussing the " + "quarterly roadmap, budget & hiring plan.

" + for i in range(paragraphs) + ) + msg.set_content(plain) + msg.add_alternative(f"{html}", subtype="html") + msg.add_attachment( + "name,value\n" + "\n".join(f"row{i},{i}" for i in range(50)), + subtype="csv", + filename="report.csv", + ) + return msg.as_bytes() + + +MULTIPART_EMAIL = _build_multipart_email(paragraphs=40) +THREAD_REPLY_EMAIL = (FIXTURES_DIR / "threading-basic-02-reply.eml").read_bytes() + +HTML_DOCUMENT = ( + "" + "" + + "".join( + f"

Hello user {i} <user{i}@example.com> " + f"& welcome to Naruon.

" + "
" + for i in range(100) + ) + + "" +) +PLAIN_TEXT_DOCUMENT = "\n".join( + f"Line {i}: plain text body without any markup at all." for i in range(200) +) + +REFERENCES_HEADER = "\r\n ".join( + f"" for i in range(300) +) + + +@pytest.mark.benchmark +def test_parse_multipart_email(benchmark): + result = benchmark(parse_eml_bytes, MULTIPART_EMAIL) + assert result["message_id"] + + +@pytest.mark.benchmark +def test_parse_thread_reply_email(benchmark): + result = benchmark(parse_eml_bytes, THREAD_REPLY_EMAIL) + assert result["subject"] == "Re: Quarterly plan" + + +@pytest.mark.benchmark +def test_strip_html_markup(benchmark): + text = benchmark(strip_html_markup, HTML_DOCUMENT) + assert "Naruon" in text + + +@pytest.mark.benchmark +def test_strip_html_markup_plain_text(benchmark): + text = benchmark(strip_html_markup, PLAIN_TEXT_DOCUMENT) + assert text + + +@pytest.mark.benchmark +def test_contains_html_markup(benchmark): + assert benchmark(contains_html_markup, HTML_DOCUMENT) is True + + +@pytest.mark.benchmark +def test_extract_reference_ids(benchmark): + refs = benchmark(extract_reference_ids, REFERENCES_HEADER) + assert len(refs) == 150 + + +@pytest.mark.benchmark +def test_email_strong_fingerprints(benchmark): + base_date = datetime.datetime(2026, 4, 27, 10, 0, tzinfo=datetime.timezone.utc) + candidates = [ + EmailDedupeCandidate( + candidate_key=f"candidate-{i}", + message_id=f"", + sender=f"sender{i}@example.com", + recipients="team@example.com", + subject=f"Status update {i}", + date=base_date + datetime.timedelta(minutes=i), + body="Body content for the status update. " * 30, + ) + for i in range(200) + ] + + def run(): + return [candidate_strong_fingerprint(c) for c in candidates] + + fingerprints = benchmark(run) + assert len(set(fingerprints)) == len(candidates) + + +@pytest.mark.benchmark +def test_hybrid_retrieval_fusion(benchmark): + settings = FusionSettings() + queries = [ + " Quarterly PLAN review\tfor Naruon workspace ", + "예산 검토 회의 일정", + "Budget & hiring — Q3 roadmap", + ] * 20 + channel_rows = [ + (i / 500, 0.2 + (i % 50) / 100, {"dense": i + 1, "lexical": (i * 7) % 500 + 1}) + for i in range(500) + ] + + def run(): + normalized = [normalize_search_text(q) for q in queries] + scores = [ + fuse_channel_scores( + word_similarity_score=ws, + cosine_distance=cd, + channel_ranks=ranks, + settings=settings, + ) + for ws, cd, ranks in channel_rows + ] + return normalized, scores + + normalized, scores = benchmark(run) + assert len(scores) == len(channel_rows) + assert normalized[0] + + +def _build_existing_calendar(events: int) -> str: + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Naruon//Calendar Benchmark//EN", + ] + base = datetime.datetime(2026, 8, 1, 8, 0, tzinfo=datetime.timezone.utc) + statuses = ("CONFIRMED", "TENTATIVE", "CANCELLED") + for i in range(events): + start = base + datetime.timedelta(hours=i * 3) + end = start + datetime.timedelta(hours=1) + lines.extend( + [ + "BEGIN:VEVENT", + f"UID:bench-event-{i}", + "DTSTAMP:20260801T000000Z", + f"DTSTART:{start:%Y%m%dT%H%M%SZ}", + f"DTEND:{end:%Y%m%dT%H%M%SZ}", + f"SUMMARY:Benchmark event {i}", + f"STATUS:{statuses[i % len(statuses)]}", + "END:VEVENT", + ] + ) + lines.append("END:VCALENDAR") + return "\r\n".join(lines) + "\r\n" + + +@pytest.mark.benchmark +def test_calendar_conflicts_from_ics(benchmark): + proposed = (CALENDAR_FIXTURES_DIR / "proposed-confirmed-1000z.ics").read_text() + existing = _build_existing_calendar(events=100) + decision = benchmark(evaluate_calendar_conflicts_from_ics, proposed, existing) + assert decision is not None + + +@pytest.mark.benchmark +def test_evaluate_access_policy(benchmark): + roles = ( + "member", + "group_admin", + "organization_admin", + "tenant_admin", + "platform_admin", + "system_admin", + ) + requests = [ + AccessRequest( + user_id=f"user-{i}", + role=roles[i % len(roles)], + organization_id=f"org-{i % 3}", + group_ids=(f"group-{i % 5}", f"group-{i % 7}"), + data_region="kr" if i % 4 else "eu", + consent_scopes=("email.read", "calendar.read"), + workspace_id=f"ws-{i % 2}", + ) + for i in range(300) + ] + resource = ResourcePolicy( + owner_id="user-1", + organization_id="org-1", + permitted_roles=("member", "group_admin", "organization_admin"), + permitted_group_ids=("group-1", "group-3"), + data_region="kr", + required_consent_scopes=("email.read",), + workspace_id="ws-1", + ) + + def run(): + return [evaluate_access(request, resource) for request in requests] + + decisions = benchmark(run) + assert any(d.allowed for d in decisions) diff --git a/backend/uv.lock b/backend/uv.lock index 2f0d04a76..f59d7386c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -912,6 +912,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -975,6 +987,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "naruon-backend" version = "0.14.4" @@ -1021,6 +1042,7 @@ dev = [ { name = "coverage" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-codspeed" }, { name = "ruff" }, ] @@ -1067,6 +1089,7 @@ dev = [ { name = "coverage", specifier = "==7.15.1" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, { name = "ruff", specifier = "==0.15.21" }, ] @@ -1585,6 +1608,37 @@ 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-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/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 = "python-dateutil" version = "2.9.0.post0" @@ -1798,6 +1852,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.21"