mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
A test's JUnit report says whether it passed, never what it did or where a failing test died. This records that from the harness, so nothing about it is hand-written and it cannot drift from what the test actually ran
`@step("create team with a budget")` from the new tests/e2e/e2e_metadata.py goes on harness helpers, never on tests, and appends its label to the running test's step log in call order. The label is recorded before the wrapped call, so a helper that raises still leaves its own label last: a failing test's last step is where it died. Every public harness method that performs an action now carries one, 355 across the client modules, lifecycle, idp, the logging readers, migrations and the claude_code driver
Only the outermost step records, tracked per thread. Harness layers call each other (ResourceManager.key goes through ProxyClient.generate_key, a domain client wraps the shared ProxyClient), so every layer carries a label and the story still reads at the level the test called in at, one beat per action. A step above @contextmanager holds the guard through __enter__ and __exit__, so a context's cleanup never lands behind the step a test died on, and a bare generator function is refused at import because its body interleaves with its caller's. Consecutive duplicates collapse and the log caps at 50, so a poll loop is one beat rather than fifty. The wrapper is a frame, so the eight cleanup and retry warnings raised directly inside decorated helpers use stacklevel=2 + STEP_FRAMES to keep reporting at their caller
The log is emptied first thing in pytest_runtest_setup and attached from the existing pytest_runtest_makereport wrapper after setup and again after call, so a test that errors in a fixture keeps the steps recorded before the crash. Teardown does not attach: finalizer steps are cleanup. Each attach drops the item's earlier step entries, so the second attach and a --reruns 1 retry replace the story rather than doubling it
Steps ride out as repeated <property name="step"> entries behind the fixed package/covers/source prefix, which stays byte-identical. The project-releaser emitter already regroups them into the results JSON's steps array. test_junit_report.py runs real pytest with --junitxml against this conftest, in-process and under -n 2, and pins the passing, failing, setup-error, rerun and wide-scope-fixture cases on the parsed XML
120 lines
5.1 KiB
Python
120 lines
5.1 KiB
Python
"""Read-back for the s3 logging tests against the real S3 bucket the proxy
|
|
ships StandardLoggingPayload objects to (litellm_settings.callbacks: ["s3_v2"]).
|
|
|
|
Delivery is judged on what actually landed in the bucket: the proxy writes
|
|
with its own credentials exactly as in production, and the tests list and
|
|
download the objects back with boto3 (already a litellm proxy dependency, so
|
|
the e2e runner image carries it; it is an AWS SDK, not a raw HTTP client, so
|
|
the e2e_http-only transport rule is untouched). The bucket comes from
|
|
S3_LOGS_BUCKET_NAME - on the cluster the secret manager injects it, locally
|
|
tests/e2e/.env provides it. Missing configuration is a hard failure, never a
|
|
skip.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
import boto3
|
|
import pytest
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT
|
|
from e2e_metadata import step
|
|
|
|
if TYPE_CHECKING:
|
|
from types_boto3_s3.client import S3Client
|
|
|
|
#: How long to keep re-reading after the first match before trusting the
|
|
#: exactly-one assertion: past one full s3_v2 flush interval (~10s), so a
|
|
#: duplicate shipped by a LATER flush is seen, plus listing-latency margin.
|
|
#: The DataDog reader settles the same way (DD_SETTLE_SECONDS).
|
|
S3_SETTLE_SECONDS = 25.0
|
|
|
|
|
|
class S3LogRecord(BaseModel):
|
|
"""The StandardLoggingPayload fields the s3 scenarios pin."""
|
|
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
id: str
|
|
status: str
|
|
model_group: str | None = None
|
|
response_cost: float | None = None
|
|
total_tokens: int | None = None
|
|
error_str: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class S3LogReader:
|
|
bucket: str
|
|
client: S3Client
|
|
|
|
@step("list S3 log objects")
|
|
def list_keys(self, prefix: str) -> list[str]:
|
|
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=prefix)
|
|
return [obj["Key"] for obj in response.get("Contents", []) if "Key" in obj]
|
|
|
|
@step("read S3 log record")
|
|
def read_record(self, key: str) -> S3LogRecord:
|
|
body = self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
|
|
return S3LogRecord.model_validate_json(body)
|
|
|
|
@step("read matching S3 log records")
|
|
def records_matching(self, *, prefix: str, predicate: Callable[[S3LogRecord], bool]) -> list[S3LogRecord]:
|
|
return [record for record in map(self.read_record, self.list_keys(prefix)) if predicate(record)]
|
|
|
|
@step("poll S3 for matching log records")
|
|
def poll_records(self, *, prefix: str, predicate: Callable[[S3LogRecord], bool]) -> list[S3LogRecord]:
|
|
"""Poll until at least one matching object is listed (the s3_v2
|
|
callback flushes on a ~10s timer), then keep re-reading for
|
|
S3_SETTLE_SECONDS - past a full flush interval - so a duplicate
|
|
shipped by a later flush cannot hide from the exactly-one assertion.
|
|
One blind spot is inherent: a duplicate write that reuses the exact
|
|
same object key overwrites the first object and no listing can see
|
|
it; distinct-key duplicates are what this catches. At the deadline an
|
|
empty list is returned and the caller's assertion carries the failure
|
|
message."""
|
|
deadline = time.monotonic() + POLL_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
records = self.records_matching(prefix=prefix, predicate=predicate)
|
|
if records:
|
|
return self._settled_records(prefix=prefix, predicate=predicate, first=records)
|
|
time.sleep(POLL_INTERVAL)
|
|
return []
|
|
|
|
def _settled_records(
|
|
self, *, prefix: str, predicate: Callable[[S3LogRecord], bool], first: list[S3LogRecord]
|
|
) -> list[S3LogRecord]:
|
|
"""Re-read at every poll interval until the settle window closes; a
|
|
duplicate ends the watch early because more waiting cannot clear it.
|
|
A transiently empty re-read never downgrades what was already seen."""
|
|
settle_deadline = time.monotonic() + S3_SETTLE_SECONDS
|
|
latest = first
|
|
while time.monotonic() < settle_deadline and len(latest) <= 1:
|
|
time.sleep(POLL_INTERVAL)
|
|
latest = self.records_matching(prefix=prefix, predicate=predicate) or latest
|
|
return latest
|
|
|
|
|
|
def build_s3_reader() -> S3LogReader:
|
|
bucket = os.environ.get("S3_LOGS_BUCKET_NAME", "")
|
|
if not bucket:
|
|
pytest.fail(
|
|
"S3_LOGS_BUCKET_NAME must be set: the s3 tests read the proxy's s3_v2 "
|
|
"delivery back from the real bucket (the cluster secret manager injects "
|
|
"it; locally set it in tests/e2e/.env to the same bucket "
|
|
"s3_callback_params.s3_bucket_name names)"
|
|
)
|
|
region = os.environ.get("AWS_REGION_NAME") or os.environ.get("AWS_REGION") or "us-east-1"
|
|
return S3LogReader(
|
|
bucket=bucket,
|
|
# boto3.client's overload set covers every AWS service; the ones without
|
|
# installed stubs type as Unknown, so the member is "partially unknown"
|
|
# even though the s3 overload itself resolves to S3Client.
|
|
client=boto3.client("s3", region_name=region), # pyright: ignore[reportUnknownMemberType]
|
|
)
|