litellm/tests/integration/conftest.py
yuneng-jiang 9b53ee878f
perf(ci): isolate and shard integration suites (#44146)
* perf(ci): distribute unit cases and reuse security sweep connections

* revert(ci): reject first speed trial after new failures

* perf(ci): avoid repeated test discovery and parsing

* perf(ci): isolate parallel security workers

* revert(ci): restore measured security execution isolation

* perf(tests): reuse source scans and deduplicate cache cleanup

* perf(ci): restore parallel security with safe log batching

* test(ci): isolate fixture state and schema comparisons

* test(ci): make schema and scheduler fixtures deterministic

* test(ci): drain queued logging before fixture teardown

* test(ci): preserve logging delivery without idle worker restarts

* test(ci): keep guardrail sinks available during parallel bursts

* test(ci): isolate full proxy lifecycle from gateway imports

* test(ci): preserve first failure responses and sweep evidence

* test(ci): restore documentation URL settings between cases

* test(ci): isolate open MCP tools in private gateways

* test(ci): group private MCP cases and handle exited workers

* test(ci): retain proxy exits and MCP failure details

* test(ci): repair merge interactions with main

Key the schema shard's full clone on artifact-name, since main removed the
unit-flag input. Give each owned Redis its own log file again. Hand the
realtime transcription tests main added a logging object whose dispatch
handlers are awaitable, so the per-test logging drain no longer waits on a
MagicMock task forever

* perf(ci): shard owned integration cases and close lifecycle leaks

* fix(ci): qualify shard execution and harden interrupted cleanup

* fix(ci): bound worker supervision and own fault traffic

* perf(ci): reuse unparametrized fixtures across bounded case groups

* fix(ci): preserve boot fault allowances and complete exit records

* test(ci): tolerate startup retries and retain readiness details

* perf(ci): balance integration shards by recorded duration

* fix(ci): load migration database helpers only for workers
2026-10-10 11:15:19 -07:00

243 lines
10 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
from collections.abc import Iterator, Mapping, Sequence
from importlib.metadata import version
from pathlib import Path
from typing import Final, Protocol, runtime_checkable
import httpx
import pytest
from pydantic import TypeAdapter
from redis import Redis
from tests.integration._support.client import Gateway, eventually, gateway_from_environment
from tests.integration._support.generation import LIFECYCLE_SETTINGS
from tests.integration._support.manifest import OWNED_DIRECTORIES
from tests.integration._support.ordering import CaseTiming, case_shards, collected_case, fixture_groups, parse_timings
from tests.integration._support.provider import SharedProvider, shared_provider
from tests.integration._support.routing import RoutingPlugin
from tests.integration.run import GITHUB_FILES
COLLECTED: Final = pytest.StashKey[tuple[str, ...]]()
INVENTORY: Final = pytest.StashKey[tuple[str, ...]]()
REPORTS: Final = pytest.StashKey[list[pytest.TestReport]]()
WORKER_INVENTORY: Final = TypeAdapter(tuple[str, ...])
TIMING_PATH: Final[TypeAdapter[Path | None]] = TypeAdapter(Path | None)
INTEGER_OPTION: Final = TypeAdapter(int)
@runtime_checkable
class WorkerConfig(Protocol):
@property
def workeroutput(self) -> dict[str, object]: ...
class WorkerNode(Protocol):
@property
def workeroutput(self) -> Mapping[str, object]: ...
def _worker_output(config: object) -> dict[str, object] | None:
return config.workeroutput if isinstance(config, WorkerConfig) else None
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption("--integration-order-seed", type=int, default=0)
parser.addoption("--integration-shard-count", type=int, default=1)
parser.addoption("--integration-shard-index", type=int, default=0)
parser.addoption("--integration-timings", type=Path)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "integration: owned real-service integration contracts")
config.addinivalue_line("markers", "covers(*ids): legacy contract IDs kept for existing tests, not enforced")
config.stash[REPORTS] = []
config.pluginmanager.register(IntegrationReportPlugin(config))
if os.environ.get("INTEGRATION_ROUTING"):
config.pluginmanager.register(RoutingPlugin(config))
class IntegrationReportPlugin:
def __init__(self, config: pytest.Config) -> None:
self.config = config
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
self.config.stash[REPORTS].append(report)
@pytest.hookimpl(optionalhook=True)
def pytest_xdist_node_collection_finished(self, node: object, ids: Sequence[str]) -> None:
self.config.stash[COLLECTED] = tuple(nodeid for nodeid in ids if _owned(nodeid))
@pytest.hookimpl(optionalhook=True)
def pytest_testnodedown(self, node: WorkerNode, error: object) -> None:
if error is not None:
return
inventory: Final = WORKER_INVENTORY.validate_python(node.workeroutput.get("integration_inventory"))
previous: Final = self.config.stash.get(INVENTORY, inventory)
if inventory != previous:
raise pytest.UsageError("Integration worker collection inventories disagree")
self.config.stash[INVENTORY] = inventory
def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
output: Final = _worker_output(self.config)
if output is not None:
output["integration_inventory"] = self.config.stash.get(INVENTORY, ())
def _owned(nodeid: str) -> bool:
parts: Final = Path(nodeid.split("::", 1)[0]).parts
return parts[:2] == ("tests", "integration") and len(parts) > 3 and parts[2] in OWNED_DIRECTORIES
def _digest(seed: int, identity: str) -> bytes:
return hashlib.sha256(f"{seed}:{identity}".encode()).digest()
def _order_key(seed: int, nodeid: str, group: str = "") -> tuple[bytes, bytes, bytes]:
return _digest(seed, nodeid.split("::", 1)[0]), _digest(seed, group or nodeid), _digest(seed, nodeid)
def _timing_content(path: Path | None) -> str:
if path is None:
return "[]"
try:
return path.read_text()
except (OSError, UnicodeError):
return "[]"
@pytest.hookimpl(wrapper=True)
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> Iterator[None]:
yield
order_seed: Final = INTEGER_OPTION.validate_python(config.getoption("integration_order_seed"))
if order_seed:
groups: Final = fixture_groups(tuple(collected_case(item) for item in items))
items.sort(key=lambda item: _order_key(order_seed, item.nodeid, groups[item.nodeid]))
root: Final = Path(__file__).parent
owned: Final = tuple(
item
for item in items
if item.path.is_relative_to(root) and item.path.relative_to(root).parts[0] in OWNED_DIRECTORIES
)
circleci_only: Final = tuple(
item for item in owned if item.path.relative_to(root.parents[1]).as_posix() not in GITHUB_FILES
)
if circleci_only and os.environ.get("GITHUB_ACTIONS") == "true":
raise pytest.UsageError("Integration contracts are owned by CircleCI")
for item in owned:
item.add_marker(pytest.mark.integration)
config.stash[INVENTORY] = tuple(item.nodeid for item in owned)
count: Final = INTEGER_OPTION.validate_python(config.getoption("integration_shard_count"))
index: Final = INTEGER_OPTION.validate_python(config.getoption("integration_shard_index"))
if count < 1 or not 0 <= index < count:
raise pytest.UsageError("Integration shard index must be within the positive shard count")
if count > 1:
timing_path: Final = TIMING_PATH.validate_python(config.getoption("integration_timings"))
shards: Final = case_shards(
tuple(collected_case(item) for item in owned), count, parse_timings(_timing_content(timing_path))
)
selected: Final = tuple(item for item in items if item not in owned or item.nodeid in shards[index])
deselected: Final = tuple(item for item in owned if item.nodeid not in shards[index])
config.hook.pytest_deselected(items=deselected)
items[:] = selected
config.stash[COLLECTED] = tuple(item.nodeid for item in items if item in owned)
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
if hasattr(session.config, "workerinput"):
return
destination: Final = os.environ.get("INTEGRATION_RESULTS_DIR")
if destination is None:
return
collected: Final = session.config.stash.get(COLLECTED, ())
reports: Final = tuple(report for report in session.config.stash[REPORTS] if report.nodeid in collected)
timings: Final = tuple(
CaseTiming(nodeid=nodeid, seconds=sum(report.duration for report in reports if report.nodeid == nodeid))
for nodeid in collected
)
passed: Final = tuple(report.nodeid for report in reports if report.when == "call" and report.passed)
skipped: Final = tuple(report.nodeid for report in reports if report.skipped)
complete: Final = (
exitstatus == 0
and bool(collected)
and sorted(collected) == sorted(passed + skipped)
and not any(report.failed for report in reports)
)
output: Final = Path(destination)
output.mkdir(parents=True, exist_ok=True)
(output / "execution.json").write_text(
json.dumps(
{
"collected": collected,
"inventory": session.config.stash.get(INVENTORY, collected),
"shard_count": session.config.getoption("integration_shard_count"),
"shard_index": session.config.getoption("integration_shard_index"),
"passed": passed,
"skipped": skipped,
"timings": tuple({"nodeid": timing.nodeid, "seconds": timing.seconds} for timing in timings),
"complete": complete,
"exitstatus": exitstatus,
"hypothesis_version": version("hypothesis"),
"hypothesis_seed": session.config.getoption("hypothesis_seed"),
"order_seed": session.config.getoption("integration_order_seed"),
"generation": {
"max_examples": LIFECYCLE_SETTINGS.max_examples,
"stateful_step_count": LIFECYCLE_SETTINGS.stateful_step_count,
"database": str(LIFECYCLE_SETTINGS.database),
"phases": [phase.name for phase in LIFECYCLE_SETTINGS.phases],
},
},
indent=2,
)
+ "\n"
)
if not complete and exitstatus == 0:
session.exitstatus = pytest.ExitCode.TESTS_FAILED
@pytest.fixture
def gateway() -> Iterator[Gateway]:
with gateway_from_environment() as value:
yield value
@pytest.fixture(scope="session")
def shared_provider_server() -> Iterator[SharedProvider]:
if os.environ.get("PYTEST_XDIST_WORKER"):
pytest.fail("the shared fake provider needs tests to run one at a time; this group runs under pytest-xdist")
with shared_provider() as server:
yield server
late: Final = server.received()
assert late == (), (
f"the shared fake provider got {[item.target for item in late]} after {server.last_test} finished"
)
@pytest.fixture
def provider(shared_provider_server: SharedProvider, request: pytest.FixtureRequest) -> Iterator[SharedProvider]:
stray: Final = shared_provider_server.received()
shared_provider_server.replies.clear()
assert stray == (), (
f"the shared fake provider got {[item.target for item in stray]} "
f"after {shared_provider_server.last_test} finished"
)
yield shared_provider_server
shared_provider_server.last_test = request.node.nodeid
unused: Final = len(shared_provider_server.replies)
unread: Final = shared_provider_server.received()
shared_provider_server.replies.clear()
assert unused == 0, f"{unused} queued provider replies were never requested"
assert unread == (), f"the test never read the provider requests {[item.target for item in unread]}"
@pytest.fixture
def peer(gateway: Gateway) -> Iterator[Gateway]:
url: Final = os.environ["INTEGRATION_PEER_URL"]
assert url.rstrip("/") != str(gateway.client.base_url).rstrip("/")
with Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) as cache:
eventually(lambda: cache.pubsub_numsub("litellm_proxy.auth_cache_invalidation")[0][1], lambda count: count >= 2)
with httpx.Client(base_url=url, timeout=15, trust_env=False) as client:
yield Gateway(client, gateway.key, gateway.upstream_url)