diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..a9acc422 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,66 @@ +name: Annotation Benchmark + +on: + workflow_dispatch: + pull_request: + branches: + - main + +jobs: + benchmark: + runs-on: ubuntu-latest + timeout-minutes: 30 + container: python:3.11.9 + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + GOOGLE_API_KEY: TEST + GOOGLE_CSE_ID: TEST + PROFILE_DIR: profiles + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv and set the python version + uses: astral-sh/setup-uv@v5 + + - name: Install the project + run: uv sync --locked --group dev + + - name: Create profiles directory + run: mkdir -p profiles + + - name: Run benchmark tests + run: | + uv run pytest tests/automated/integration/benchmark \ + -m "manual and benchmark" \ + --benchmark-json=benchmark-results.json \ + -v + + - name: Post benchmark summary + run: uv run python scripts/post_benchmark_summary.py + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark-results-${{ github.sha }} + path: | + benchmark-results.json + profiles/ + retention-days: 90 diff --git a/alembic/versions/2026_02_26_0000-c8e4f1a2b3d5_materialize_annotation_views.py b/alembic/versions/2026_02_26_0000-c8e4f1a2b3d5_materialize_annotation_views.py new file mode 100644 index 00000000..20fbb7d3 --- /dev/null +++ b/alembic/versions/2026_02_26_0000-c8e4f1a2b3d5_materialize_annotation_views.py @@ -0,0 +1,246 @@ +"""Materialize url_annotation_count_view and url_annotation_flags + +Revision ID: c8e4f1a2b3d5 +Revises: 759ce7d0772b +Create Date: 2026-02-26 00:00:00.000000 + +""" +from typing import Optional, Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'c8e4f1a2b3d5' +down_revision: Optional[str] = '1fb2286a016c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_URL_ANNOTATION_COUNT_VIEW_SQL = """ + WITH + auto_location_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__location__auto__subtasks anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , auto_agency_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__agency__auto__subtasks anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , auto_url_type_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__url_type__auto anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , auto_record_type_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__record_type__auto anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , user_location_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__location__user anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , user_agency_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__agency__user anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , user_url_type_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__url_type__user anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , user_record_type_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__record_type__user anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , anon_location_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__location__anon anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , anon_agency_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__agency__anon anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , anon_url_type_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__url_type__anon anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) + , anon_record_type_count AS ( + SELECT + u_1.id, + count(anno.url_id) AS cnt + FROM + urls u_1 + JOIN annotation__record_type__anon anno + ON u_1.id = anno.url_id + GROUP BY + u_1.id + ) +SELECT + u.id AS url_id, + COALESCE(auto_ag.cnt, 0::bigint) AS auto_agency_count, + COALESCE(auto_loc.cnt, 0::bigint) AS auto_location_count, + COALESCE(auto_rec.cnt, 0::bigint) AS auto_record_type_count, + COALESCE(auto_typ.cnt, 0::bigint) AS auto_url_type_count, + COALESCE(user_ag.cnt, 0::bigint) AS user_agency_count, + COALESCE(user_loc.cnt, 0::bigint) AS user_location_count, + COALESCE(user_rec.cnt, 0::bigint) AS user_record_type_count, + COALESCE(user_typ.cnt, 0::bigint) AS user_url_type_count, + COALESCE(anon_ag.cnt, 0::bigint) AS anon_agency_count, + COALESCE(anon_loc.cnt, 0::bigint) AS anon_location_count, + COALESCE(anon_rec.cnt, 0::bigint) AS anon_record_type_count, + COALESCE(anon_typ.cnt, 0::bigint) AS anon_url_type_count, + COALESCE(auto_ag.cnt, 0::bigint) + COALESCE(auto_loc.cnt, 0::bigint) + COALESCE(auto_rec.cnt, 0::bigint) + + COALESCE(auto_typ.cnt, 0::bigint) + COALESCE(user_ag.cnt, 0::bigint) + COALESCE(user_loc.cnt, 0::bigint) + + COALESCE(user_rec.cnt, 0::bigint) + COALESCE(user_typ.cnt, 0::bigint) + COALESCE(anon_ag.cnt, 0::bigint) + + COALESCE(anon_loc.cnt, 0::bigint) + COALESCE(anon_rec.cnt, 0::bigint) + COALESCE(anon_typ.cnt, 0::bigint) AS total_anno_count + +FROM + urls u + LEFT JOIN auto_agency_count auto_ag + ON auto_ag.id = u.id + LEFT JOIN auto_location_count auto_loc + ON auto_loc.id = u.id + LEFT JOIN auto_record_type_count auto_rec + ON auto_rec.id = u.id + LEFT JOIN auto_url_type_count auto_typ + ON auto_typ.id = u.id + LEFT JOIN user_agency_count user_ag + ON user_ag.id = u.id + LEFT JOIN user_location_count user_loc + ON user_loc.id = u.id + LEFT JOIN user_record_type_count user_rec + ON user_rec.id = u.id + LEFT JOIN user_url_type_count user_typ + ON user_typ.id = u.id + LEFT JOIN anon_agency_count anon_ag + ON anon_ag.id = u.id + LEFT JOIN anon_location_count anon_loc + ON anon_loc.id = u.id + LEFT JOIN anon_record_type_count anon_rec + ON anon_rec.id = u.id + LEFT JOIN anon_url_type_count anon_typ + ON anon_typ.id = u.id +""" + +_URL_ANNOTATION_FLAGS_SQL = """ +SELECT u.id as url_id, + EXISTS (SELECT 1 FROM public.annotation__record_type__auto a WHERE a.url_id = u.id) AS has_auto_record_type_suggestion, + EXISTS (SELECT 1 FROM public.annotation__url_type__auto a WHERE a.url_id = u.id) AS has_auto_relevant_suggestion, + EXISTS (SELECT 1 FROM public.annotation__agency__auto__subtasks a WHERE a.url_id = u.id) AS has_auto_agency_suggestion, + EXISTS (SELECT 1 FROM public.annotation__location__auto__subtasks a WHERE a.url_id = u.id) AS has_auto_location_suggestion, + EXISTS (SELECT 1 FROM public.annotation__record_type__user a WHERE a.url_id = u.id) AS has_user_record_type_suggestion, + EXISTS (SELECT 1 FROM public.annotation__url_type__user a WHERE a.url_id = u.id) AS has_user_relevant_suggestion, + EXISTS (SELECT 1 FROM public.annotation__agency__user a WHERE a.url_id = u.id) AS has_user_agency_suggestion, + EXISTS (SELECT 1 FROM public.annotation__location__user a WHERE a.url_id = u.id) AS has_user_location_suggestion, + EXISTS (SELECT 1 FROM public.link_agencies__urls a WHERE a.url_id = u.id) AS has_confirmed_agency, + EXISTS (SELECT 1 FROM public.reviewing_user_url a WHERE a.url_id = u.id) AS was_reviewed +FROM urls u +""" + + +def upgrade() -> None: + """Convert url_annotation_count_view and url_annotation_flags to materialized views.""" + # Drop regular views + op.execute("DROP VIEW IF EXISTS url_annotation_count_view") + op.execute("DROP VIEW IF EXISTS url_annotation_flags") + + # Recreate as materialized views + op.execute( + f"CREATE MATERIALIZED VIEW url_annotation_count_view AS {_URL_ANNOTATION_COUNT_VIEW_SQL}" + ) + op.execute( + f"CREATE MATERIALIZED VIEW url_annotation_flags AS {_URL_ANNOTATION_FLAGS_SQL}" + ) + + # Unique indexes required for REFRESH MATERIALIZED VIEW CONCURRENTLY + op.execute("CREATE UNIQUE INDEX ON url_annotation_count_view (url_id)") + op.execute("CREATE UNIQUE INDEX ON url_annotation_flags (url_id)") + + +def downgrade() -> None: + """Revert url_annotation_count_view and url_annotation_flags to regular views.""" + op.execute("DROP MATERIALIZED VIEW IF EXISTS url_annotation_count_view") + op.execute("DROP MATERIALIZED VIEW IF EXISTS url_annotation_flags") + + # Recreate as regular views + op.execute( + f"CREATE VIEW url_annotation_count_view AS {_URL_ANNOTATION_COUNT_VIEW_SQL}" + ) + op.execute( + f"CREATE OR REPLACE VIEW url_annotation_flags AS ({_URL_ANNOTATION_FLAGS_SQL})" + ) diff --git a/alembic/versions/2026_03_09_1709-f831e447b1cb_merge_heads.py b/alembic/versions/2026_03_09_1709-f831e447b1cb_merge_heads.py new file mode 100644 index 00000000..bfce8b23 --- /dev/null +++ b/alembic/versions/2026_03_09_1709-f831e447b1cb_merge_heads.py @@ -0,0 +1,25 @@ +"""merge_heads + +Revision ID: f831e447b1cb +Revises: c8e4f1a2b3d5, 94e2b850fb30, c2f46d1af640 +Create Date: 2026-03-09 17:09:11.129775 + +""" +from typing import Optional, Sequence + + +# revision identifiers, used by Alembic. +revision: str = 'f831e447b1cb' +down_revision: Optional[tuple[str, ...]] = ('c8e4f1a2b3d5', '94e2b850fb30', 'c2f46d1af640') +branch_labels: Optional[str | Sequence[str]] = None +depends_on: Optional[str | Sequence[str]] = None + + +def upgrade() -> None: + """Merge multiple heads.""" + pass + + +def downgrade() -> None: + """Downgrade merge.""" + pass diff --git a/docs/development.md b/docs/development.md index 7ea62cc6..2c5b975a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -35,14 +35,14 @@ At minimum, you need the database connection variables: ```dotenv POSTGRES_USER=test_source_collector_user -POSTGRES_PASSWORD=HanviliciousHamiltonHilltops +POSTGRES_PASSWORD= POSTGRES_DB=source_collector_test_db POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 DEV=true ``` -These match the defaults in `local_database/docker-compose.yml`. +The password and other defaults are defined in `local_database/docker-compose.yml`. ### API Keys diff --git a/pyproject.toml b/pyproject.toml index 415094ff..2c6fd9c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,14 @@ dev = [ "pytest>=7.2.2", "pytest-asyncio~=0.25.2", "pytest-mock==3.12.0", + "pytest-benchmark~=5.2", "pytest-timeout~=2.3.1", "vulture>=2.14", + "flake8>=7.3.0", + "flake8-docstrings>=1.7.0", + "flake8-simplify>=0.30.0", + "flake8-unused-arguments>=0.0.14", + "flake8-annotations>=3.2.0", + "pyinstrument>=4.6.0", ] diff --git a/pytest.ini b/pytest.ini index 5c39d47c..4b80fd72 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,5 @@ timeout = 300 asyncio_default_fixture_loop_scope=function markers = manual: mark test as manual-only (excluded from default test runs) + benchmark: mark test as a performance benchmark (subset of manual) asyncio_mode = auto \ No newline at end of file diff --git a/scripts/post_benchmark_summary.py b/scripts/post_benchmark_summary.py new file mode 100644 index 00000000..f4b5f7b4 --- /dev/null +++ b/scripts/post_benchmark_summary.py @@ -0,0 +1,55 @@ +"""Write benchmark results to the GitHub Actions job summary. + +Reads benchmark-results.json (always present) and appends +a markdown summary to $GITHUB_STEP_SUMMARY. +""" +import json +import os +import pathlib +import sys + + +def _benchmark_table(data: dict) -> list[str]: + lines = [ + "## Benchmark Results\n", + "| Test | Mean (ms) | Min (ms) | Max (ms) | Rounds |", + "|------|-----------|----------|----------|--------|", + ] + for b in data["benchmarks"]: + s = b["stats"] + lines.append( + f"| {b['name']} " + f"| {s['mean'] * 1000:.2f} " + f"| {s['min'] * 1000:.2f} " + f"| {s['max'] * 1000:.2f} " + f"| {s['rounds']} |" + ) + return lines + + +def main() -> None: + """Build and write the job summary.""" + benchmark_path = pathlib.Path("benchmark-results.json") + if not benchmark_path.exists(): + print( + "benchmark-results.json not found — skipping summary.", + file=sys.stderr, + ) + return + + with benchmark_path.open() as f: + data = json.load(f) + + lines = _benchmark_table(data) + + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + print("\n".join(lines)) + return + + with open(summary_file, "a") as f: + f.write("\n".join(lines)) + + +if __name__ == "__main__": + main() diff --git a/src/api/endpoints/annotate/_shared/extract.py b/src/api/endpoints/annotate/_shared/extract.py index 4a7517eb..0abb3b9f 100644 --- a/src/api/endpoints/annotate/_shared/extract.py +++ b/src/api/endpoints/annotate/_shared/extract.py @@ -25,21 +25,24 @@ async def extract_and_format_get_annotation_result( url: URL, batch_id: int | None = None ) -> GetNextURLForAllAnnotationResponse: + """Extract and format the annotation result for a URL.""" html_response_info = DTOConverter.html_content_list_to_html_response_info( url.html_content ) # URL Types - url_type_suggestions: list[URLTypeAnnotationSuggestion] = \ + url_type_suggestions: list[URLTypeAnnotationSuggestion] = ( convert_user_url_type_suggestion_to_url_type_annotation_suggestion( url.user_url_type_suggestions, - url.anon_url_type_suggestions + url.anon_url_type_suggestions, ) + ) # Record Types - record_type_suggestions: RecordTypeAnnotationResponseOuterInfo = \ + record_type_suggestions: RecordTypeAnnotationResponseOuterInfo = ( convert_user_record_type_suggestion_to_record_type_annotation_suggestion( url.user_record_type_suggestions, - url.anon_record_type_suggestions + url.anon_record_type_suggestions, ) + ) # Agencies agency_suggestions: AgencyAnnotationResponseOuterInfo = \ await GetAgencySuggestionsQueryBuilder(url_id=url.id).run(session) @@ -49,6 +52,12 @@ async def extract_and_format_get_annotation_result( # Names name_suggestions: NameAnnotationResponseOuterInfo = \ await GetNameSuggestionsQueryBuilder(url_id=url.id).run(session) + batch_info = await GetAnnotationBatchInfoQueryBuilder( + batch_id=batch_id, + models=[ + AnnotationAgencyUser, + ] + ).run(session) return GetNextURLForAllAnnotationResponse( next_annotation=GetNextURLForAllAnnotationInnerResponse( url_info=SimpleURLMapping( @@ -59,12 +68,7 @@ async def extract_and_format_get_annotation_result( url_type_suggestions=url_type_suggestions, record_type_suggestions=record_type_suggestions, agency_suggestions=agency_suggestions, - batch_info=await GetAnnotationBatchInfoQueryBuilder( - batch_id=batch_id, - models=[ - AnnotationAgencyUser, - ] - ).run(session), + batch_info=batch_info, location_suggestions=location_suggestions, name_suggestions=name_suggestions ) diff --git a/src/api/endpoints/annotate/_shared/queries/helper.py b/src/api/endpoints/annotate/_shared/queries/helper.py index 57370c36..58e05e55 100644 --- a/src/api/endpoints/annotate/_shared/queries/helper.py +++ b/src/api/endpoints/annotate/_shared/queries/helper.py @@ -19,11 +19,11 @@ def add_joins(query: Select) -> Select: query .join( URLAnnotationFlagsView, - URLAnnotationFlagsView.url_id == URL.id + URLAnnotationFlagsView.url_id == URL.id, ) .join( URLAnnotationCount, - URLAnnotationCount.url_id == URL.id + URLAnnotationCount.url_id == URL.id, ) ) return query diff --git a/src/db/client/async_.py b/src/db/client/async_.py index 6377fa60..66eb3dcb 100644 --- a/src/db/client/async_.py +++ b/src/db/client/async_.py @@ -907,4 +907,10 @@ async def refresh_materialized_views(self): ) await self.execute( text("REFRESH MATERIALIZED VIEW mat_view__html_duplicate_url") - ) \ No newline at end of file + ) + await self.execute( + text("REFRESH MATERIALIZED VIEW CONCURRENTLY url_annotation_count_view") + ) + await self.execute( + text("REFRESH MATERIALIZED VIEW CONCURRENTLY url_annotation_flags") + ) diff --git a/tests/automated/integration/api/annotate/all/test_sorting.py b/tests/automated/integration/api/annotate/all/test_sorting.py index 2f9f7b2a..90287c9d 100644 --- a/tests/automated/integration/api/annotate/all/test_sorting.py +++ b/tests/automated/integration/api/annotate/all/test_sorting.py @@ -42,6 +42,8 @@ async def test_annotate_sorting( db_data_creator=ath.db_data_creator, include_user_annotations=True ) + # Refresh so that the new annotation counts are reflected in the materialized view + await dbc.refresh_materialized_views() get_response_2 = await ath.request_validator.get_next_url_for_all_annotations() assert get_response_2.next_annotation is not None assert get_response_2.next_annotation.url_info.url_id == setup_info_high_annotations.url_mapping.url_id diff --git a/tests/automated/integration/benchmark/README.md b/tests/automated/integration/benchmark/README.md new file mode 100644 index 00000000..2b963e51 --- /dev/null +++ b/tests/automated/integration/benchmark/README.md @@ -0,0 +1,55 @@ +# Annotation Load Time Benchmarks + +Benchmarks for the `GET /annotate/all` endpoint. +Run manually or via the [`benchmark` GHA workflow](../../../../.github/workflows/benchmark.yml). + +## What is measured + +Each benchmark hits `GET /annotate/all` and records either the total HTTP round-trip +or a pyinstrument call-tree profile. + +Two fixture sizes are used: + +| Fixture | Description | +|---|---| +| `benchmark_readonly_helper` | Small realistic dataset | +| `scale_seeder` | 10 000-URL dataset to surface query scaling behaviour | + +## Tests + +| Test | What it measures | +|---|---| +| `test_benchmark_annotate_all_http_roundtrip` | Total HTTP round-trip time (small dataset) | +| `test_benchmark_annotate_all_profiled` | pyinstrument flamegraph (small dataset) | +| `test_benchmark_annotate_all_scale_http_roundtrip` | Total HTTP round-trip time (10k-URL dataset) | +| `test_benchmark_annotate_all_scale_profiled` | pyinstrument flamegraph (10k-URL dataset) | + +## Profiled tests + +The `_profiled` tests wrap each benchmark round with a `pyinstrument.Profiler` +(`async_mode="enabled"`) so that time is attributed correctly across `await` boundaries. +After all rounds complete, they write a self-contained interactive HTML flamegraph to +`$PROFILE_DIR` (defaults to the current directory): + +- `profile_readonly.html` +- `profile_scale_{url_count}.html` + +Open these files in any browser — no extra tooling required. + +## Running locally + +Set the required environment variables (see [`ENV.md`](../../../../ENV.md) for values), +then run: + +```bash +PROFILE_DIR=. uv run pytest tests/automated/integration/benchmark \ + -m "manual and benchmark" \ + --benchmark-json=benchmark-results.json \ + -v +``` + +## Comparing runs + +```bash +uv run pytest-benchmark compare baseline.json new-results.json --sort=mean +``` diff --git a/tests/automated/integration/benchmark/__init__.py b/tests/automated/integration/benchmark/__init__.py new file mode 100644 index 00000000..3b9a88ba --- /dev/null +++ b/tests/automated/integration/benchmark/__init__.py @@ -0,0 +1 @@ +"""Benchmark tests for the GET /annotate/all endpoint.""" diff --git a/tests/automated/integration/benchmark/conftest.py b/tests/automated/integration/benchmark/conftest.py new file mode 100644 index 00000000..4d4e4aac --- /dev/null +++ b/tests/automated/integration/benchmark/conftest.py @@ -0,0 +1,45 @@ +"""Fixtures for annotation benchmark tests.""" +import pytest_asyncio +from sqlalchemy import Engine +from starlette.testclient import TestClient + +from tests.automated.integration.api._helpers.RequestValidator import RequestValidator +from tests.automated.integration.benchmark.scale_seed import ScaleSeedResult, create_scale_data +from tests.automated.integration.readonly.helper import ReadOnlyTestHelper +from tests.automated.integration.readonly.setup.core import setup_readonly_data +from tests.helpers.api_test_helper import APITestHelper +from tests.helpers.data_creator.core import DBDataCreator +from tests.helpers.setup.wipe import wipe_database + + +@pytest_asyncio.fixture(scope="module", loop_scope="module") +async def benchmark_readonly_helper( + client: TestClient, engine: Engine +) -> ReadOnlyTestHelper: + """Set up small realistic dataset for readonly benchmarks.""" + wipe_database(engine) + db_data_creator = DBDataCreator() + api_test_helper = APITestHelper( + request_validator=RequestValidator(client=client), + async_core=client.app.state.async_core, + db_data_creator=db_data_creator, + ) + return await setup_readonly_data(api_test_helper=api_test_helper) + + +@pytest_asyncio.fixture(scope="module", loop_scope="module") +async def scale_seeder(client: TestClient, engine: Engine) -> ScaleSeedResult: + """Seed 10k URLs once per session for scale benchmarks.""" + wipe_database(engine) + db_data_creator = DBDataCreator() + api_test_helper = APITestHelper( + request_validator=RequestValidator(client=client), + async_core=client.app.state.async_core, + db_data_creator=db_data_creator, + ) + adb_client = db_data_creator.adb_client + return await create_scale_data( + adb_client=adb_client, + db_data_creator=db_data_creator, + api_test_helper=api_test_helper, + ) diff --git a/tests/automated/integration/benchmark/scale_seed.py b/tests/automated/integration/benchmark/scale_seed.py new file mode 100644 index 00000000..ab60f9a3 --- /dev/null +++ b/tests/automated/integration/benchmark/scale_seed.py @@ -0,0 +1,164 @@ +"""Scale seeding utilities for annotation load benchmarks.""" +from dataclasses import dataclass + +from src.db.client.async_ import AsyncDatabaseClient +from src.db.enums import TaskType +from src.db.models.impl.annotation.agency.auto.subtask.enum import AutoAgencyIDSubtaskType +from src.db.models.impl.annotation.agency.auto.subtask.pydantic import URLAutoAgencyIDSubtaskPydantic +from src.db.models.impl.annotation.agency.auto.suggestion.pydantic import AgencyIDSubtaskSuggestionPydantic +from src.db.models.impl.annotation.location.auto.subtask.enums import LocationIDSubtaskType +from src.db.models.impl.annotation.location.auto.subtask.pydantic import AutoLocationIDSubtaskPydantic +from src.db.models.impl.annotation.location.auto.suggestion.pydantic import LocationIDSubtaskSuggestionPydantic +from src.db.models.impl.annotation.name.suggestion.enums import NameSuggestionSource +from src.db.models.impl.annotation.name.suggestion.pydantic import URLNameSuggestionPydantic +from tests.helpers.api_test_helper import APITestHelper +from tests.helpers.data_creator.core import DBDataCreator +from tests.helpers.data_creator.generate import generate_urls, generate_batch_url_links + + +@dataclass +class ScaleSeedResult: + """Result of seeding the database with a large URL dataset.""" + + api_test_helper: APITestHelper + url_count: int + + +async def create_scale_data( + adb_client: AsyncDatabaseClient, + db_data_creator: DBDataCreator, + api_test_helper: APITestHelper, + url_count: int = 10_000, +) -> ScaleSeedResult: + """ + Seed the database with url_count URLs plus supporting data so that + GET /annotate/all exercises the query planner under realistic load. + + All URLs are left unvalidated (not in flag_url_validated) so they appear + in unvalidated_url_view and are returned by the endpoint on every request. + No user annotations are created, so all URLs remain eligible each round. + + All expensive steps use bulk inserts — the per-URL helper commands + (agency_auto_suggestions, add_location_suggestion) each make 4+ DB round- + trips; at 5k scale that blows through the 300s pytest timeout. Instead, + each suggestion type is seeded in exactly 3 round-trips: + 1. initiate_task (1 row) + 2. bulk_insert subtasks (N rows, returns IDs) + 3. bulk_insert suggestions (N rows) + """ + + # 1. Geographic hierarchy — needed before agency-location links + us_state = await db_data_creator.create_us_state(name="California", iso="CA") + county_1 = await db_data_creator.create_county( + state_id=us_state.us_state_id, name="Los Angeles County" + ) + county_2 = await db_data_creator.create_county( + state_id=us_state.us_state_id, name="San Francisco County" + ) + county_3 = await db_data_creator.create_county( + state_id=us_state.us_state_id, name="Sacramento County" + ) + + counties = [county_1, county_2, county_3] + locality_location_ids: list[int] = [] + for i in range(10): + county = counties[i % len(counties)] + locality = await db_data_creator.create_locality( + state_id=us_state.us_state_id, + county_id=county.county_id, + name=f"Scale City {i}", + ) + locality_location_ids.append(locality.location_id) + + # 2. 20 agencies linked to localities — makes location-agency joins non-trivial + agency_ids: list[int] = await db_data_creator.create_agencies(count=20) + for i, agency_id in enumerate(agency_ids): + await db_data_creator.link_agencies_to_location( + agency_ids=[agency_id], + location_id=locality_location_ids[i % len(locality_location_ids)], + ) + + # 3. 10k URLs — single bulk round-trip, returns all IDs + urls = generate_urls(count=url_count) + url_ids: list[int] = await adb_client.bulk_insert(urls, return_ids=True) + + # 4. Batch + links — URLs appear in the standard query context + batch_id: int = await db_data_creator.create_batch() + links = generate_batch_url_links(url_ids=url_ids, batch_id=batch_id) + await adb_client.bulk_insert(links) + + # 5. Name suggestions for 70% of URLs — single bulk insert. + # Makes GetNameSuggestionsQueryBuilder non-trivial. + name_count = int(url_count * 0.7) + name_suggestions = [ + URLNameSuggestionPydantic( + url_id=url_id, + suggestion=f"Scale suggestion {url_id}"[:100], + source=NameSuggestionSource.HTML_METADATA_TITLE, + ) + for url_id in url_ids[:name_count] + ] + await adb_client.bulk_insert(name_suggestions) + + # 6. Auto agency suggestions for 50% of URLs — 3 bulk round-trips total. + # Makes GetAgencySuggestionsQueryBuilder non-trivial. + agency_subset = url_ids[:int(url_count * 0.5)] + agency_task_id: int = await adb_client.initiate_task( + task_type=TaskType.AGENCY_IDENTIFICATION + ) + agency_subtask_models = [ + URLAutoAgencyIDSubtaskPydantic( + task_id=agency_task_id, + url_id=url_id, + type=AutoAgencyIDSubtaskType.HOMEPAGE_MATCH, + agencies_found=True, + ) + for url_id in agency_subset + ] + agency_subtask_ids: list[int] = await adb_client.bulk_insert( + agency_subtask_models, return_ids=True + ) + seed_agency_id = agency_ids[0] + agency_suggestion_models = [ + AgencyIDSubtaskSuggestionPydantic( + subtask_id=subtask_id, + agency_id=seed_agency_id, + confidence=50, + ) + for subtask_id in agency_subtask_ids + ] + await adb_client.bulk_insert(agency_suggestion_models) + + # 7. Auto location suggestions for 50% of URLs — 3 bulk round-trips total. + # Makes GetLocationSuggestionsQueryBuilder non-trivial. + location_subset = url_ids[:int(url_count * 0.5)] + location_task_id: int = await adb_client.initiate_task( + task_type=TaskType.HTML + ) + primary_location_id = locality_location_ids[0] + location_subtask_models = [ + AutoLocationIDSubtaskPydantic( + task_id=location_task_id, + url_id=url_id, + type=LocationIDSubtaskType.NLP_LOCATION_FREQUENCY, + locations_found=True, + ) + for url_id in location_subset + ] + location_subtask_ids: list[int] = await adb_client.bulk_insert( + location_subtask_models, return_ids=True + ) + location_suggestion_models = [ + LocationIDSubtaskSuggestionPydantic( + subtask_id=subtask_id, + location_id=primary_location_id, + confidence=1.0, + ) + for subtask_id in location_subtask_ids + ] + await adb_client.bulk_insert(location_suggestion_models) + + return ScaleSeedResult( + api_test_helper=api_test_helper, + url_count=url_count, + ) diff --git a/tests/automated/integration/benchmark/test_annotate_all_benchmark.py b/tests/automated/integration/benchmark/test_annotate_all_benchmark.py new file mode 100644 index 00000000..daeba786 --- /dev/null +++ b/tests/automated/integration/benchmark/test_annotate_all_benchmark.py @@ -0,0 +1,80 @@ +"""Benchmarks for the GET /annotate/all endpoint.""" +import os + +import pytest +from pyinstrument import Profiler +from pytest_benchmark.fixture import BenchmarkFixture + +from tests.automated.integration.benchmark.scale_seed import ScaleSeedResult +from tests.automated.integration.readonly.helper import ReadOnlyTestHelper + +_PROFILE_DIR = os.environ.get("PROFILE_DIR", ".") + + +def _save_profile(profiler: Profiler, name: str) -> None: + """Write profiler HTML flamegraph to PROFILE_DIR.""" + path = os.path.join(_PROFILE_DIR, f"{name}.html") + with open(path, "w") as f: + f.write(profiler.output_html()) + + +@pytest.mark.manual +@pytest.mark.benchmark +def test_benchmark_annotate_all_http_roundtrip( + benchmark: BenchmarkFixture, + benchmark_readonly_helper: ReadOnlyTestHelper, +) -> None: + """Total HTTP round-trip time for GET /annotate/all.""" + rv = benchmark_readonly_helper + benchmark( + lambda: rv.api_test_helper.request_validator.get(url="/annotate/all") + ) + + +@pytest.mark.manual +@pytest.mark.benchmark +def test_benchmark_annotate_all_profiled( + benchmark: BenchmarkFixture, + benchmark_readonly_helper: ReadOnlyTestHelper, +) -> None: + """Pyinstrument call-tree profile of GET /annotate/all (small dataset).""" + rv = benchmark_readonly_helper + profiler = Profiler(async_mode="enabled") + + def one_request() -> None: + with profiler: + rv.api_test_helper.request_validator.get(url="/annotate/all") + + benchmark(one_request) + _save_profile(profiler, "profile_readonly") + + +@pytest.mark.manual +@pytest.mark.benchmark +def test_benchmark_annotate_all_scale_http_roundtrip( + benchmark: BenchmarkFixture, + scale_seeder: ScaleSeedResult, +) -> None: + """Total HTTP round-trip time for GET /annotate/all at 10k-URL scale.""" + rv = scale_seeder + benchmark( + lambda: rv.api_test_helper.request_validator.get(url="/annotate/all") + ) + + +@pytest.mark.manual +@pytest.mark.benchmark +def test_benchmark_annotate_all_scale_profiled( + benchmark: BenchmarkFixture, + scale_seeder: ScaleSeedResult, +) -> None: + """Pyinstrument profile of GET /annotate/all (10k-URL dataset).""" + rv = scale_seeder + profiler = Profiler(async_mode="enabled") + + def one_request() -> None: + with profiler: + rv.api_test_helper.request_validator.get(url="/annotate/all") + + benchmark(one_request) + _save_profile(profiler, f"profile_scale_{rv.url_count}") diff --git a/tests/helpers/setup/annotation/core.py b/tests/helpers/setup/annotation/core.py index 10bc67b7..d285a785 100644 --- a/tests/helpers/setup/annotation/core.py +++ b/tests/helpers/setup/annotation/core.py @@ -19,4 +19,5 @@ async def setup_for_get_next_url_for_annotation( url.url_id for url in insert_urls_info.url_mappings ] ) + await db_data_creator.adb_client.refresh_materialized_views() return AnnotationSetupInfo(batch_id=batch_id, insert_urls_info=insert_urls_info) diff --git a/tests/helpers/setup/final_review/core.py b/tests/helpers/setup/final_review/core.py index 20c0f8df..f6178ea6 100644 --- a/tests/helpers/setup/final_review/core.py +++ b/tests/helpers/setup/final_review/core.py @@ -71,6 +71,8 @@ async def add_relevant_suggestion(relevant: bool): else: user_agency_id = None + await db_data_creator.adb_client.refresh_materialized_views() + return FinalReviewSetupInfo( batch_id=batch_id, url_mapping=url_mapping, diff --git a/uv.lock b/uv.lock index 0a04d3a2..36e88352 100644 --- a/uv.lock +++ b/uv.lock @@ -537,8 +537,10 @@ dev = [ { name = "flake8-unused-arguments" }, { name = "pendulum" }, { name = "prek" }, + { name = "pyinstrument" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, { name = "pytest-mock" }, { name = "pytest-timeout" }, { name = "vulture" }, @@ -593,11 +595,15 @@ dev = [ { name = "flake8-annotations", specifier = ">=3.2.0" }, { name = "flake8-docstrings", specifier = ">=1.7.0" }, { name = "flake8-simplify", specifier = ">=0.22.0" }, + { name = "flake8-simplify", specifier = ">=0.30.0" }, { name = "flake8-unused-arguments", specifier = ">=0.0.13" }, + { name = "flake8-unused-arguments", specifier = ">=0.0.14" }, { name = "pendulum", specifier = ">=3.1.0" }, { name = "prek", specifier = ">=0.3.1" }, + { name = "pyinstrument", specifier = ">=4.6.0" }, { name = "pytest", specifier = ">=7.2.2" }, { name = "pytest-asyncio", specifier = "~=0.25.2" }, + { name = "pytest-benchmark", specifier = "~=5.2" }, { name = "pytest-mock", specifier = "==3.12.0" }, { name = "pytest-timeout", specifier = "~=2.3.1" }, { name = "vulture", specifier = ">=2.14" }, @@ -2097,6 +2103,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pyarrow" version = "20.0.0" @@ -2302,6 +2317,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pyinstrument" +version = "5.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889, upload-time = "2026-01-04T18:38:58.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/ef/0288edd620fb0cf2074d8c8e3567007a6bac66307b839d99988563de4eb8/pyinstrument-5.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3739a05583ea6312c385eb59fe985cd20d9048e95f9eeeb6a2f6c35202e2d36e", size = 131284, upload-time = "2026-01-04T18:37:35.01Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4e/2a90a6997d9f7a39a6998d56de72e52673ebf5a9169a1c39dbf173e95105/pyinstrument-5.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c9ee05dc75ac5fb18498c311e624f77f7f321f7ff325b251aa09e52e46f1d6a", size = 124468, upload-time = "2026-01-04T18:37:36.628Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/7bfd403e81f9b5ec523f60cced8f516ee52312752bb2e0fafabfd90bbd78/pyinstrument-5.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a49a55ca5b75218767e29cacbe515d0b66fc18cb48a937bca0f77b8dafc7202", size = 148057, upload-time = "2026-01-04T18:37:37.998Z" }, + { url = "https://files.pythonhosted.org/packages/50/3a/7205d7c199947d18edcd013af4ddf4d3cca85c5488fbe493050035947f7c/pyinstrument-5.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c45c14974ff04b1bfdc6c2a448627c6da7409c7800d0eb7bd03fb435dcb41d7", size = 146526, upload-time = "2026-01-04T18:37:39.642Z" }, + { url = "https://files.pythonhosted.org/packages/24/e8/f6864172e7ebe4bc5209bafbc574a619b4c511b9506b941789b11441be7c/pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:22b9c04b3982c41c04b1c5ed05d1bc3a2ba26533450084058119f6dc160e70a3", size = 147179, upload-time = "2026-01-04T18:37:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/6d/04/89ef2d1c34767bfdbcc74ab0c7e0d021d7fac5e79873239e4ca26e97d6da/pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c4995ee0774801790c138f0dfec17d4e7a7ef09a6d56d53cbcbf0578a711021", size = 146354, upload-time = "2026-01-04T18:37:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/64441547ec12391b92c739a3b0685059e7dfa088d928df8364676ef7abc7/pyinstrument-5.1.2-cp311-cp311-win32.whl", hash = "sha256:fe449e4a8ee60a2a27cf509350a584670f4c3704649601be7937598f09dbe7ca", size = 125790, upload-time = "2026-01-04T18:37:44.141Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8b/0a5f6b239294decb0ecd932711f3470bfbd42fc2e08a94cd5c1f4f6da7f1/pyinstrument-5.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:3fb839429671a42bf349335af4c1ce5cf83386ac11f04df0bc40720d4cb7d77d", size = 126578, upload-time = "2026-01-04T18:37:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/26/d9/8fa5571ddd21b2b7189bd8b0bb4e90be1659a54dda5af51c7f6bf2b5666f/pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2519865d4bf58936f2506c1c46a82d29a20f3239aa50c941df1ca9618c7da5f0", size = 131419, upload-time = "2026-01-04T18:37:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/0512adb83cadfeaa1a215dc9784defff5043c5aa052d15015e3d8013af75/pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:059442106b8b5de29ae5ac1bdc20d044fed4da534b8caba434b6ffb119037bf5", size = 124446, upload-time = "2026-01-04T18:37:48.572Z" }, + { url = "https://files.pythonhosted.org/packages/9b/78/c45f0b668fb3c8c0d32058a451a8e1d34737cd7586387982185e12df1977/pyinstrument-5.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd51f2d54fc39a4cfd73ba6be27cd0187123132ce3f445b639bff5e1b23d7e26", size = 149694, upload-time = "2026-01-04T18:37:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/91/4d/2ca3ca9906ce6e05070f431c54d54ccbaf57a980cfa58032d35b0b0ac1f8/pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12af1e83795b6c640d657d339014dd1ff718b182dec736d7d1f1d8a97534eb53", size = 148461, upload-time = "2026-01-04T18:37:51.544Z" }, + { url = "https://files.pythonhosted.org/packages/18/d2/bfe84a4326172ef68655b65b49fd041eeb94c8e59ee47258589b8b79dd3b/pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2565513658e742c5eb691a779cb29d19d01bc9ee951d0eb76482e9f343c38c2e", size = 148560, upload-time = "2026-01-04T18:37:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/db7f5def351e869230b0165828c4edacbf3fdda8d66aff30dd73a62082c2/pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5afd0ba788a1d112da49fb77966918e01df1f9e7d62e72894d82f7acb0996c2d", size = 148178, upload-time = "2026-01-04T18:37:54.278Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bc/aea3329576e20b987d205027b8e6442ece845d681b9f9d8682d5404f81f3/pyinstrument-5.1.2-cp312-cp312-win32.whl", hash = "sha256:554077b031b278593cb2301f0057be771ea62a729878c69aaf29fcdfb7b71281", size = 125927, upload-time = "2026-01-04T18:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/d928434ec3a840478e95fd0d73b0dfc0b8060a07b06f4b45e9df30444e9a/pyinstrument-5.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:55a905384ba43efc924b8863aa6cfd276f029e4aa70c4a0e3b7389e27b191e45", size = 126675, upload-time = "2026-01-04T18:37:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/b9aea969eec67c129652000446384d550a0df45c297adc9fd74da2f8482c/pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29", size = 131418, upload-time = "2026-01-04T18:37:58.642Z" }, + { url = "https://files.pythonhosted.org/packages/8f/62/76418eb29b5591f3e5500369a6777ce928135c3aa6ccdb0c861a9c6ca93b/pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3", size = 124448, upload-time = "2026-01-04T18:37:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/07/73/874bccc04bcf6f4babc3de1a9568e209e7e40998563974f5030b0fb4d3e0/pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a", size = 149853, upload-time = "2026-01-04T18:38:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/268446c4388d77ff4abdeaff202356e1527b3ff9576f5587443a24980bec/pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0", size = 148641, upload-time = "2026-01-04T18:38:03.086Z" }, + { url = "https://files.pythonhosted.org/packages/fc/15/4f8dea3381483e68d00582a9b823a21a088acfe77a847a7991a1a8feed76/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5", size = 148674, upload-time = "2026-01-04T18:38:04.805Z" }, + { url = "https://files.pythonhosted.org/packages/fa/61/72c180454b6511d5b90166f8828e1bab3b45d0489952a1fe48c5c585233d/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e", size = 148315, upload-time = "2026-01-04T18:38:06.114Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f0/4c27cebddf22a8840bd8b419366bb321ce41f921ca1893e309c932ab28bf/pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0", size = 125926, upload-time = "2026-01-04T18:38:07.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/20/6b1bee88ddef065b0df3a3ba4ba60ed8a9ca443d5cded7152a8a9750914f/pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b", size = 126678, upload-time = "2026-01-04T18:38:08.826Z" }, + { url = "https://files.pythonhosted.org/packages/66/0f/7d5154c92904bdf25be067a7fe4cad4ba48919f16ccbb51bb953d9ae1a20/pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e", size = 131388, upload-time = "2026-01-04T18:38:10.491Z" }, + { url = "https://files.pythonhosted.org/packages/17/28/bf83231a3f951e11b4dfaf160e1eeba1ce29377eab30e3d2eb6ee22ff3ba/pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568", size = 124456, upload-time = "2026-01-04T18:38:11.792Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/762cf10896d907268629e1db08a48f128984a53e8d92b99ea96f862597e5/pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4", size = 149594, upload-time = "2026-01-04T18:38:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/48580e16e623d89af58b89c552c95a2ae65f70a1f4fab1d97879f34791db/pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5", size = 148339, upload-time = "2026-01-04T18:38:14.767Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/38157a8a6ec67789d8ee109fd09877ea3340df44e1a7add8f249e30a8ade/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f", size = 148485, upload-time = "2026-01-04T18:38:16.121Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/31ee72b19cfc48a82801024b5d653f07982154a11381a3ae65bbfdbf2c7b/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6", size = 148106, upload-time = "2026-01-04T18:38:17.623Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b4/7ab20243187262d66ab062778b1ccac4ca55090752f32a83f603f4e5e3a2/pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b", size = 126593, upload-time = "2026-01-04T18:38:18.968Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a0/db6a8ae3182546227f5a043b1be29b8d5f98bf973e20d922981ef206de85/pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e", size = 127358, upload-time = "2026-01-04T18:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/59/d2/719f439972b3f80e35fb5b1bcd888c3218d60dbc91957b99ffafd7ac9221/pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2", size = 132317, upload-time = "2026-01-04T18:38:21.669Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1c/0ebfef69ae926665fae635424c5647411235c3689c9a9ad69fd68de6cae2/pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d", size = 124917, upload-time = "2026-01-04T18:38:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/5599f769f515a0f1c97443edc7394fe2b9829bf39f404c046499c1a62378/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee", size = 157407, upload-time = "2026-01-04T18:38:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/fd/40/32aa865252288caef301237488ee309bd6701125888bf453d23ab764e357/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d", size = 155068, upload-time = "2026-01-04T18:38:26.112Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/0b56a1540fe1c357dfcda82d4f5b52c87fada5962cbf18703ea39ccbbe69/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee", size = 155186, upload-time = "2026-01-04T18:38:27.914Z" }, + { url = "https://files.pythonhosted.org/packages/7a/48/7ef84abfc3e41148cf993095214f104e75ecff585e94c6e8be001e672573/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6", size = 153979, upload-time = "2026-01-04T18:38:29.236Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cf/a28ad117d58b33c1d74bcdfbbcf1603b67346883800ac7d510cff8d3bcee/pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8", size = 127267, upload-time = "2026-01-04T18:38:30.619Z" }, + { url = "https://files.pythonhosted.org/packages/8e/97/03635143a12a5d941f545548b00f8ac39d35565321a2effb4154ed267338/pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db", size = 128164, upload-time = "2026-01-04T18:38:32.281Z" }, +] + [[package]] name = "pyjwt" version = "2.10.1" @@ -2347,6 +2410,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467, upload-time = "2025-01-28T18:37:56.798Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + [[package]] name = "pytest-mock" version = "3.12.0"