From 55c8ca41b54ffe6a1812c002edb33e92ee418a06 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:25:22 -0700 Subject: [PATCH] ci: gate tests/e2e on zero basedpyright errors in pre-commit and lint CI --- .github/workflows/test-linting.yml | 12 ++++- Makefile | 13 +++-- pyproject.toml | 4 ++ scripts/pre_commit_lint.sh | 7 +++ tests/e2e/CLAUDE.md | 2 +- tests/e2e/CONTRIBUTING.md | 6 +-- tests/e2e/budgets/budget_client.py | 2 +- tests/e2e/budgets/test_budget_fallback_e2e.py | 6 +-- .../budgets/test_spend_counter_reseed_e2e.py | 14 +++--- tests/e2e/coverage_registry/collector.py | 19 +++++-- tests/e2e/coverage_registry/registry.py | 15 +++--- .../realtime/realtime_client.py | 8 +-- .../test_realtime_pipecat_audio_e2e.py | 6 +-- .../realtime/test_realtime_pipecat_e2e.py | 4 +- .../e2e/llm_translation/test_cache_control.py | 5 +- tests/e2e/management/management_client.py | 6 +-- tests/e2e/models.py | 4 ++ uv.lock | 49 ++++++++++++++++++- 18 files changed, 136 insertions(+), 46 deletions(-) diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml index c2fc3453261..0b5b0e9b976 100644 --- a/.github/workflows/test-linting.yml +++ b/.github/workflows/test-linting.yml @@ -48,7 +48,7 @@ jobs: - name: Install dependencies run: | - uv sync --frozen --group proxy-dev + uv sync --frozen --group proxy-dev --group e2e-dev # basedpyright resolves Prisma's generated client (litellm/proxy/schema.prisma) # only after `prisma generate` writes prisma/client.py et al. Without this the @@ -107,6 +107,16 @@ jobs: run: | (uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --base "$BASE_SHA" + - name: Check tests/e2e basedpyright (zero errors) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if git diff --name-only --diff-filter=ACMRD "$BASE_SHA"...HEAD -- 'tests/e2e/**/*.py' | grep -q .; then + uv run --no-sync basedpyright tests/e2e + else + echo "No changed tests/e2e Python files; skipping." + fi + - name: Check for circular imports run: | cd litellm diff --git a/Makefile b/Makefile index f2753b09ff5..965a3254616 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \ test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \ info lint lint-dev lint-checks format \ - lint-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \ + lint-basedpyright lint-e2e-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \ lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \ install-dev install-proxy-dev install-test-deps install-hooks \ install-helm-unittest check-circular-imports check-import-safety pre-commit \ @@ -27,6 +27,7 @@ help: @echo " make lint - Run all linting (Ruff, basedpyright, format check, circular imports, import safety)" @echo " make lint-ruff - Run Ruff linting only" @echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts" + @echo " make lint-e2e-basedpyright - Run basedpyright over tests/e2e (zero errors allowed)" @echo " make lint-basedpyright-budget-update - Ratchet basedpyright limits down by what this branch fixed" @echo " make lint-format - Check ruff format formatting (matches CI)" @echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its limit" @@ -54,6 +55,7 @@ UV := uv UV_RUN := $(UV) run --no-sync LINT_DEP_INSTALL ?= install-dev +LINT_E2E_DEP_INSTALL ?= lint-install LINT_DEP_BASE ?= lint-fetch-base LINT_JOBS := $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) LINT_OUTPUT_SYNC := $(if $(filter output-sync,$(.FEATURES)),--output-sync=target,) @@ -111,7 +113,7 @@ lint-fetch-base: # CI's). --inexact tops up the venv instead of pruning the proxy extras gen:api and the # running proxy need. lint-install: - $(UV) sync --inexact --frozen --group proxy-dev + $(UV) sync --inexact --frozen --group proxy-dev --group e2e-dev $(UV_RUN) python scripts/prisma_generate_if_needed.py # Diff-scoped format check, identical to test-linting.yml's "Check ruff format" step: @@ -164,6 +166,9 @@ lint-ruff-FULL-dev: install-dev lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) ($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --base origin/litellm_internal_staging +lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL) + $(UV_RUN) basedpyright tests/e2e + # Type-discipline budget (mutable collections / casts / type guards / kwargs / # unexplained suppressions), the test-linting.yml step `make lint` used to omit. lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) @@ -208,9 +213,9 @@ check-import-safety: $(LINT_DEP_INSTALL) # base fetch) runs once up front; the checks themselves are independent, so a sub-make # fans them out with -j and the fast ones finish under basedpyright's shadow. lint: lint-install lint-fetch-base - $(MAKE) -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_DEP_BASE= lint-checks + $(MAKE) -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks -lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-basedpyright check-circular-imports check-import-safety +lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety # Faster linting for local development (only checks changed code) lint-dev: lint-format-changed check-circular-imports check-import-safety diff --git a/pyproject.toml b/pyproject.toml index 4c70bf66031..effcd0c675f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,6 +184,10 @@ dev = [ "vcrpy==8.2.1", "pytest-recording==0.13.4", ] +e2e-dev = [ + "playwright==1.61.0", + "websockets>=15.0.1,<16.0", +] proxy-dev = [ "prisma==0.11.0", "hypercorn==0.17.3", diff --git a/scripts/pre_commit_lint.sh b/scripts/pre_commit_lint.sh index bd416ab814d..54d488b2440 100755 --- a/scripts/pre_commit_lint.sh +++ b/scripts/pre_commit_lint.sh @@ -4,6 +4,7 @@ # before `git commit`; it inspects your staged files and runs only the matching # gating CI checks, so a clean run means a green CI lint: # - litellm/ Python staged -> `make lint` (test-linting.yml's lint job) +# - tests/e2e Python staged -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step) # - dashboard staged -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint) # - proxy/types staged -> regenerate dashboard API types and fail on drift (check-ui-api-types.yml) # @@ -25,6 +26,7 @@ staged_match() { printf '%s\n' "$staged" | grep -E "$1" || true; } # scripts-only commit can't turn it red; scope the trigger there to skip the slow # make lint when it couldn't catch anything. litellm_py_files=$(staged_match '^litellm/.*\.py$') +e2e_py_files=$(staged_match '^tests/e2e/.*\.py$') # ruff format (and CI's format step) skip enterprise; the rest of make lint covers it. fmt_files=$(printf '%s\n' "$litellm_py_files" | grep -v '^litellm/enterprise/' || true) # check-ui-api-types.yml triggers on any file under litellm/proxy or litellm/types @@ -107,6 +109,11 @@ if [ -n "$litellm_py_files" ]; then fi fi +if [ -n "$e2e_py_files" ] && [ -z "$litellm_py_files" ]; then + echo "pre-commit: type-checking tests/e2e (make lint-e2e-basedpyright)" + make lint-e2e-basedpyright || { echo "✗ tests/e2e basedpyright failed. Fix the errors above, then re-run make pre-commit." >&2; status=1; } +fi + if [ -n "$ui_prettier_files" ] || [ -n "$ui_eslint_files" ]; then echo "pre-commit: linting dashboard (prettier + eslint + lint budgets)" lint_dashboard || { echo "✗ Dashboard lint failed. See above; format with: (cd ui/litellm-dashboard && npm run format)." >&2; status=1; } diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index 502c881c764..13bd0ea0cec 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -57,7 +57,7 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover ## Typing -The harness is fully typed and new code must not add `Any` or widen the basedpyright budgets. When a response field is untyped, model it in `models.py` (just the fields you read) and let pydantic validate it, rather than threading a `dict` or `Any` through the test +The harness is fully typed with no error budget: `make lint-e2e-basedpyright` must report zero basedpyright errors, and CI enforces that on any PR touching `tests/e2e/**/*.py`. When a response field is untyped, model it in `models.py` (just the fields you read) and let pydantic validate it, rather than threading a `dict` or `Any` through the test ## Coverage registry diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 4395d299c5b..2082f2c9de4 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -34,10 +34,10 @@ The suites run against a live proxy, so bring one up first. `docker-compose.yml` uv run pytest tests/e2e/llm_translation/ -v ``` - The browser tests in the `management/` suite drive the dashboard the proxy serves at `/ui` through playwright, an optional dependency behind `importorskip` (the suite's API tests run without it). Install it once into your environment along with its browser: + The browser tests in the `management/` suite drive the dashboard the proxy serves at `/ui` through playwright, an optional dependency behind `importorskip` (the suite's API tests run without it). It lives in the `e2e-dev` dependency group; install it along with its browser: ```bash - uv pip install playwright + uv sync --inexact --group e2e-dev uv run playwright install chromium ``` @@ -140,7 +140,7 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover Before you push -1. Run basedpyright over your changes; the harness is fully typed and new code must not add `Any` or widen the budgets +1. Run `make lint-e2e-basedpyright` (or `make pre-commit` with your changes staged); the harness is fully typed and the gate allows zero basedpyright errors, enforced in CI on any PR touching `tests/e2e/**/*.py` 2. Add the models your test needs to the inline config in `docker-compose.yml` diff --git a/tests/e2e/budgets/budget_client.py b/tests/e2e/budgets/budget_client.py index fe862b5c119..7b37c3af98e 100644 --- a/tests/e2e/budgets/budget_client.py +++ b/tests/e2e/budgets/budget_client.py @@ -342,7 +342,7 @@ class BudgetClient: case _: time.sleep(_TEAM_READY_SLEEP_SECONDS) assert last is not None - _ = unwrap(last) + raise AssertionError(last) def add_team_member(self, team_id: str, user_id: str, *, max_budget_in_team: float | None = None) -> None: last_body = "" diff --git a/tests/e2e/budgets/test_budget_fallback_e2e.py b/tests/e2e/budgets/test_budget_fallback_e2e.py index 6114bfe6fc9..adbda5ed767 100644 --- a/tests/e2e/budgets/test_budget_fallback_e2e.py +++ b/tests/e2e/budgets/test_budget_fallback_e2e.py @@ -4,7 +4,6 @@ blocking the caller with a `budget_exceeded` error. Coverage for the budget_fallbacks feature in litellm/proxy/hooks/model_max_budget_limiter.py. """ -import json import time import pytest @@ -12,6 +11,7 @@ import pytest from budget_client import BudgetClient, model_budget from e2e_config import unique_marker from lifecycle import ResourceManager +from models import AnthropicMessagesResponse pytestmark = pytest.mark.e2e @@ -42,8 +42,8 @@ def test_budget_fallback_reroutes_anthropic_messages_to_openai( "budget_fallbacks must reroute transparently, never surface a " f"block; status={result.status_code} body={result.body[:300]}" ) - served_by = json.loads(result.body)["model"] - if FALLBACK_MODEL in served_by: + served_by = AnthropicMessagesResponse.model_validate_json(result.body).model + if served_by is not None and FALLBACK_MODEL in served_by: break time.sleep(1) assert served_by is not None and FALLBACK_MODEL in served_by, ( diff --git a/tests/e2e/budgets/test_spend_counter_reseed_e2e.py b/tests/e2e/budgets/test_spend_counter_reseed_e2e.py index de868f4fd3c..132f99e9cdf 100644 --- a/tests/e2e/budgets/test_spend_counter_reseed_e2e.py +++ b/tests/e2e/budgets/test_spend_counter_reseed_e2e.py @@ -23,6 +23,7 @@ from threading import Barrier from typing import TYPE_CHECKING import pytest +from pydantic import TypeAdapter, ValidationError from budget_client import BudgetClient from e2e_config import unique_marker @@ -42,6 +43,8 @@ BURST = 6 # expires the counter; this waits out both. COLD_WAIT_SECONDS = 80 +_JSON_FLOAT: TypeAdapter[float] = TypeAdapter(float) + def _redis() -> "redis.Redis[str] | RedisCluster[str]": """The proxy's Redis. The deployed runner sets REDIS_HOST to the serverless @@ -75,12 +78,11 @@ def _parse_counter(raw: object) -> float | None: try: return float(text) except ValueError: - try: - import json - - return float(json.loads(text)) - except Exception: - return None + pass + try: + return _JSON_FLOAT.validate_json(text) + except ValidationError: + return None def _spend_counter(rds: "redis.Redis[str] | RedisCluster[str]", key: str) -> float | None: diff --git a/tests/e2e/coverage_registry/collector.py b/tests/e2e/coverage_registry/collector.py index f6e59ca4a88..50ef23bcb1a 100644 --- a/tests/e2e/coverage_registry/collector.py +++ b/tests/e2e/coverage_registry/collector.py @@ -17,8 +17,10 @@ import sys from argparse import ArgumentParser from dataclasses import dataclass from pathlib import Path +from typing import Literal import pytest +from pydantic import BaseModel from .registry import load_registry from .schema import MODULE_ORDER, Cell, Tier, dashboard_module, loki_module_label @@ -35,12 +37,13 @@ class _CoversSink: self.collection_errors: tuple[str, ...] = () def pytest_collection_finish(self, session: pytest.Session) -> None: - self.covered_ids = frozenset( - arg + marker_args: tuple[tuple[object, ...], ...] = tuple( + marker.args for item in session.items for marker in item.iter_markers(name="covers") - for arg in marker.args - if isinstance(arg, str) + ) + self.covered_ids = frozenset( + arg for args in marker_args for arg in args if isinstance(arg, str) ) def pytest_collectreport(self, report: pytest.CollectReport) -> None: @@ -257,6 +260,12 @@ def render_loki(report: CoverageReport) -> str: return "\n".join(lines) +class _CliArgs(BaseModel): + format: Literal["text", "json", "prometheus", "loki"] + strict: bool + fail_on_collection_errors: bool + + def main() -> int: parser = ArgumentParser() parser.add_argument( @@ -275,7 +284,7 @@ def main() -> int: action="store_true", help="Exit non-zero if pytest collection errors are found.", ) - args = parser.parse_args() + args = _CliArgs.model_validate(vars(parser.parse_args())) cells = load_registry() covered, errors = collect_covered_ids() report = compute_coverage(cells, covered, errors) diff --git a/tests/e2e/coverage_registry/registry.py b/tests/e2e/coverage_registry/registry.py index 7a472cfbf2f..baeea080a9d 100644 --- a/tests/e2e/coverage_registry/registry.py +++ b/tests/e2e/coverage_registry/registry.py @@ -6,20 +6,23 @@ from collections import Counter from pathlib import Path import yaml +from pydantic import TypeAdapter -from .schema import CELL_ADAPTER, Cell +from .schema import Cell REGISTRY_DIR = Path(__file__).resolve().parent +_CELLS_ADAPTER: TypeAdapter[tuple[Cell, ...]] = TypeAdapter(tuple[Cell, ...]) + + +def _load_cells(path: Path) -> tuple[Cell, ...]: + return _CELLS_ADAPTER.validate_python(yaml.safe_load(path.read_text()) or ()) + def load_registry(registry_dir: Path = REGISTRY_DIR) -> tuple[Cell, ...]: """Every cell across every `*.yaml`, validated. Raises on a schema violation or a duplicate id, since either would corrupt the coverage denominator.""" - cells = tuple( - CELL_ADAPTER.validate_python(row) - for path in sorted(registry_dir.glob("*.yaml")) - for row in (yaml.safe_load(path.read_text()) or ()) - ) + cells = tuple(cell for path in sorted(registry_dir.glob("*.yaml")) for cell in _load_cells(path)) duplicates = sorted(cid for cid, n in Counter(c.id for c in cells).items() if n > 1) if duplicates: raise ValueError(f"duplicate cell ids in registry: {duplicates}") diff --git a/tests/e2e/llm_translation/realtime/realtime_client.py b/tests/e2e/llm_translation/realtime/realtime_client.py index 51052abe227..b50160e3538 100644 --- a/tests/e2e/llm_translation/realtime/realtime_client.py +++ b/tests/e2e/llm_translation/realtime/realtime_client.py @@ -14,7 +14,7 @@ import time from collections.abc import Generator, Mapping from contextlib import contextmanager from dataclasses import dataclass -from typing import Any, TypeVar +from typing import TypeVar from urllib.parse import urlencode from pydantic import BaseModel, ConfigDict @@ -28,7 +28,7 @@ from models import LiteLLMParamsBody _M = TypeVar("_M", bound=BaseModel) -def _ws_base_url() -> str: +def ws_base_url() -> str: for scheme, ws_scheme in (("https://", "wss://"), ("http://", "ws://")): if PROXY_BASE_URL.startswith(scheme): return ws_scheme + PROXY_BASE_URL[len(scheme) :] @@ -36,7 +36,7 @@ def _ws_base_url() -> str: def realtime_ws_url(model: str) -> str: - return f"{_ws_base_url()}/v1/realtime?{urlencode({'model': model})}" + return f"{ws_base_url()}/v1/realtime?{urlencode({'model': model})}" @dataclass(frozen=True, slots=True) @@ -217,7 +217,7 @@ class OutputItemDone(BaseModel): class ResponsePayload(BaseModel): model_config = ConfigDict(extra="allow") - usage: dict[str, Any] | None = None + usage: dict[str, object] | None = None output: list[OutputItem] | None = None diff --git a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py index 0d290f6f64b..78955974cd5 100644 --- a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py +++ b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py @@ -30,7 +30,7 @@ import pytest from realtime_client import ( PROVIDERS, RealtimeProvider, - _ws_base_url, + ws_base_url, realtime_model, ) @@ -154,7 +154,7 @@ async def _run_pipeline( llm = LiteLLMRealtimeLLMService( api_key=key, - base_url=f"{_ws_base_url()}/v1/realtime", + base_url=f"{ws_base_url()}/v1/realtime", settings=OpenAIRealtimeLLMService.Settings( model=model, system_instruction=( @@ -276,7 +276,7 @@ async def _run_audio_input_pipeline( llm = LiteLLMRealtimeLLMService( api_key=key, - base_url=f"{_ws_base_url()}/v1/realtime", + base_url=f"{ws_base_url()}/v1/realtime", settings=OpenAIRealtimeLLMService.Settings( model=model, system_instruction=( diff --git a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_e2e.py b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_e2e.py index 799958ef4e3..40c008e21c7 100644 --- a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_e2e.py +++ b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_e2e.py @@ -28,7 +28,7 @@ import pytest from realtime_client import ( PROVIDERS, RealtimeProvider, - _ws_base_url, + ws_base_url, realtime_model, ) @@ -94,7 +94,7 @@ async def _run_pipeline(key: str, model: str) -> tuple[bool, bool]: await params.result_callback({"city": "Paris", "temperature_f": 72}) llm = LiteLLMRealtimeLLMService( - api_key=key, base_url=f"{_ws_base_url()}/v1/realtime", model=model + api_key=key, base_url=f"{ws_base_url()}/v1/realtime", model=model ) llm.register_function("get_weather", get_weather) diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index fda05cdc950..51ae6ccbc1d 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -111,12 +111,11 @@ def _assert_cache_read_on_second_call( first = unwrap(_cache_chat(client, key, model, prefix)) assert first.choices, f"{model}: first cache-priming call returned no choices: {first}" - read_tokens = 0 deadline = time.monotonic() + 30.0 - while time.monotonic() < deadline: + while True: second = unwrap(_cache_chat(client, key, model, prefix)) read_tokens = _cached_read_tokens(second.usage) - if read_tokens > 0: + if read_tokens > 0 or time.monotonic() >= deadline: break time.sleep(3.0) diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index bc60ce87c98..144a3c0c435 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -75,7 +75,7 @@ class ManagementClient: case _: break assert last is not None - _ = unwrap(last) + raise AssertionError(last) def delete_key_strict(self, key: str) -> None: """Strict delete for the act phase of a test: a failed delete is a hard @@ -147,7 +147,7 @@ class ManagementClient: case _: time.sleep(_TEAM_READY_SLEEP_SECONDS) assert last is not None - _ = unwrap(last) + raise AssertionError(last) def add_team_member(self, team_id: str, user_id: str) -> None: last: Result[NoBody] | None = None @@ -169,7 +169,7 @@ class ManagementClient: case _: break assert last is not None - _ = unwrap(last) + raise AssertionError(last) def delete_team_member(self, team_id: str, user_id: str) -> None: _ = unwrap( diff --git a/tests/e2e/models.py b/tests/e2e/models.py index ab2835d87c4..33bf79f2817 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -123,6 +123,10 @@ class AnthropicMessagesBody(BaseModel): max_tokens: int +class AnthropicMessagesResponse(BaseModel): + model: str | None = None + + class OutMessage(BaseModel): content: str | None = None reasoning_content: str | None = None diff --git a/uv.lock b/uv.lock index 692c1c2cb8f..38a662db07c 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-06T18:05:33.611729Z" +exclude-newer = "2026-07-08T17:10:15.525149Z" exclude-newer-span = "P3D" [manifest] @@ -2252,14 +2252,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/03/84359833f7e1d49a883e92777637c592306030e30cee5e2b1e6476f95c88/greenlet-3.5.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a", size = 283502, upload-time = "2026-04-27T12:20:55.213Z" }, { url = "https://files.pythonhosted.org/packages/25/ce/6f9f008266273aa14a2e011945797ac5802b97b8b40efe7afe1ee6c1afc9/greenlet-3.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f", size = 600508, upload-time = "2026-04-27T12:52:37.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/6d/b0f3272c2368ea2c1aa19a5ad70db0be8f8dff6e6d3d1eb82efa00cbcf19/greenlet-3.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb", size = 613283, upload-time = "2026-04-27T12:59:37.957Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ae/1db979ff6ae7958d80b288f63d5f6c30df96682700ea9fc340ce994d94a1/greenlet-3.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd", size = 619894, upload-time = "2026-04-27T13:02:35.13Z" }, { url = "https://files.pythonhosted.org/packages/ed/ac/0b509b6fb93551ce5a01612ee1acda7f7dda4bbb66c99aeb2ab403d205dc/greenlet-3.5.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb", size = 613418, upload-time = "2026-04-27T12:25:23.852Z" }, + { url = "https://files.pythonhosted.org/packages/ce/94/b0590e3d1978f02419f30502341c40d72f77eb0a2198119fe27df47714ee/greenlet-3.5.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243", size = 415681, upload-time = "2026-04-27T13:05:11.494Z" }, { url = "https://files.pythonhosted.org/packages/03/03/2b2b680ec87aaa97998fb5b8d76658d4d3560386864f17efab33ba7c2e24/greenlet-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977", size = 1572229, upload-time = "2026-04-27T12:53:23.509Z" }, { url = "https://files.pythonhosted.org/packages/61/e4/42b259e7a19aff1a270a4bd82caf6353109ed6860c9454e18f37162b83ae/greenlet-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0", size = 1639886, upload-time = "2026-04-27T12:25:22.325Z" }, { url = "https://files.pythonhosted.org/packages/6f/b4/733ca47b883b67c57f90d3ecb21055c9ec753597d10754ac201644061f9d/greenlet-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858", size = 237795, upload-time = "2026-04-27T12:21:40.118Z" }, { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, @@ -2267,7 +2271,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -2275,7 +2281,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, @@ -3453,6 +3461,10 @@ dev = [ { name = "types-setuptools" }, { name = "vcrpy" }, ] +e2e-dev = [ + { name = "playwright" }, + { name = "websockets" }, +] healthcheck = [ { name = "httpx" }, { name = "pyyaml" }, @@ -3622,6 +3634,10 @@ dev = [ { name = "types-setuptools", specifier = "==75.8.0.20250225" }, { name = "vcrpy", specifier = "==8.2.1" }, ] +e2e-dev = [ + { name = "playwright", specifier = "==1.61.0" }, + { name = "websockets", specifier = ">=15.0.1,<16.0" }, +] healthcheck = [ { name = "httpx", specifier = "==0.28.1" }, { name = "pyyaml", specifier = "==6.0.3" }, @@ -5343,6 +5359,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] +[[package]] +name = "playwright" +version = "1.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" }, + { url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -5915,6 +5950,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0"