litellm/tests/claude_code/conftest.py
mateo-berri 0bf013f620 RALPH: tracer-bullet for Claude Code compatibility matrix (#26477, PRD #26476)
Slice 1 of the Claude Code Compatibility Matrix: the thinnest end-to-end
path through every layer for a single (feature, provider) cell, so a
future docs page can render a real green cell sourced from a real test.

What landed in this repo:

- tests/claude_code/manifest.yaml — feature manifest with one entry
  (basic_messaging_non_streaming) plus the v0 provider column order.
- tests/claude_code/cli_driver.py — Claude Code CLI Driver. One entry
  point (run_claude); handles subprocess assembly, env overlay, stream-JSON
  parsing, and structured failure modes. `runner=` is a unit-test seam.
- tests/claude_code/conftest.py — `compat_result` fixture (tagged-union
  recorder) + pytest_runtest_makereport hook that infers (feature, provider)
  from the file path and writes a structured compat-results.json artifact.
- tests/claude_code/basic_messaging_non_streaming/test_anthropic.py — the
  one cell, parametrized over Haiku/Sonnet/Opus per the PRD's per-cell
  model rule.
- tests/claude_code/matrix_builder.py — pure-function builder from
  (manifest, results, run-metadata) to the v1 JSON schema. Aggregates per-
  model results into one cell (pass iff all pass). build_from_paths is the
  thin I/O wrapper for the publisher.
- tests/claude_code/sample_compatibility-matrix.json — hand-authored sample
  of the v1 JSON; copied to the docs repo by hand as part of this slice.
- Unit tests: 10 driver tests (mocked subprocess), 9 compat_result tests,
  10 matrix-builder golden-file tests. 29/29 pass.

Key decisions:

- (feature, provider) is inferred from file path, not declared in metadata —
  mirrors the PRD's "no drift" goal.
- Driver injects subprocess via a `runner` kwarg so unit tests don't need
  the real `claude` CLI; production callers leave it default.
- Builder is a pure function on Mappings/Sequences; load/write live in a
  thin `build_from_paths` wrapper. Golden-file tests pin the schema.
- `_driver_unit_tests/` and `_builder_unit_tests/` are prefixed with `_`
  so the conftest's path-inference hook skips them and they don't
  pollute the matrix artifact.
- `compat-results.json` added to .gitignore (CI-only output).

Out of scope per CLAUDE.md (docs live in BerriAI/litellm-docs):
- The MDX page `docs/tutorials/claude-code-compatibility` and the
  `<CompatibilityMatrix />` React component. The hand-authored
  compatibility-matrix.json (`sample_compatibility-matrix.json` in this
  repo) is the artifact those docs files will consume; opening that doc
  PR is the next step in this slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 04:07:02 +00:00

164 lines
5.4 KiB
Python

"""Pytest plumbing for the Claude Code compatibility matrix.
Two responsibilities live here:
1. The `compat_result` fixture — the only API a test author needs to learn.
Tests call `compat_result.set({"status": "pass"})` (or fail / not_applicable)
to report their outcome as a tagged union. The fixture is per-test and
stores the last value reported.
2. The `pytest_runtest_makereport` hook — captures each test's reported result,
infers (feature, provider) from the file path, and writes a single
`compat-results.json` artifact next to JUnit XML. The Matrix JSON Builder
consumes this artifact to produce the published `compatibility-matrix.json`.
The (feature, provider) inference comes from the test file path: the parent
directory name is the feature_id (matching `manifest.yaml`), and the file
stem after the leading `test_` is the provider id. This avoids per-file
metadata that drifts.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
VALID_STATUSES = {"pass", "fail", "not_applicable", "not_tested"}
RESULTS_ARTIFACT_ENV = "COMPAT_RESULTS_PATH"
DEFAULT_ARTIFACT_PATH = "compat-results.json"
@dataclass
class CompatResult:
"""Per-test recorder for compatibility outcomes.
Tests interact only via `.set(...)`. `.value` is read by the
`pytest_runtest_makereport` hook after the test body finishes.
"""
value: Optional[Dict[str, Any]] = None
def set(self, result: Dict[str, Any]) -> None:
if not isinstance(result, dict):
raise TypeError("compat_result.set() requires a dict")
status = result.get("status")
if status not in VALID_STATUSES:
raise ValueError(
f"compat_result.set() status must be one of {sorted(VALID_STATUSES)}, "
f"got {status!r}"
)
if status == "fail" and not result.get("error"):
raise ValueError("compat_result.set({'status': 'fail'}) requires 'error'")
if status == "not_applicable" and not result.get("reason"):
raise ValueError(
"compat_result.set({'status': 'not_applicable'}) requires 'reason'"
)
self.value = dict(result)
@dataclass
class _CollectedResult:
feature_id: str
provider: str
nodeid: str
result: Dict[str, Any]
@dataclass
class _Collector:
items: List[_CollectedResult] = field(default_factory=list)
_COLLECTOR = _Collector()
@pytest.fixture
def compat_result() -> CompatResult:
"""Per-test recorder for the (feature, provider) outcome.
Tests should call `compat_result.set({"status": "pass"})` (or fail /
not_applicable) before returning. If a test exits without calling `.set()`
the harness records `status="fail"` with an explanatory error so that
every collected node maps to a real cell.
"""
return CompatResult()
def _infer_feature_and_provider(node_path: Path) -> Optional[tuple]:
"""Infer (feature_id, provider) from a test file path.
Path shape: tests/claude_code/<feature_id>/test_<provider>.py
Returns None if the file is not a per-feature test (e.g. unit tests
living under tests/claude_code/_driver_unit_tests/), so those don't
pollute the matrix artifact.
"""
name = node_path.name
if not name.startswith("test_") or not name.endswith(".py"):
return None
provider = name[len("test_") : -len(".py")]
feature_id = node_path.parent.name
if feature_id.startswith("_") or feature_id == "claude_code":
return None
return feature_id, provider
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""Capture compat_result.value at end-of-test and remember it for the artifact."""
outcome = yield
report = outcome.get_result()
if report.when != "call":
return
inferred = _infer_feature_and_provider(Path(str(item.path)))
if inferred is None:
return
feature_id, provider = inferred
fixture = item.funcargs.get("compat_result") if hasattr(item, "funcargs") else None
reported: Optional[Dict[str, Any]] = getattr(fixture, "value", None)
if reported is None:
if report.passed:
reported = {
"status": "fail",
"error": "test passed without calling compat_result.set(); "
"every compat test must report a status.",
}
else:
reported = {
"status": "fail",
"error": (str(report.longrepr) if report.longrepr else "test failed"),
}
_COLLECTOR.items.append(
_CollectedResult(
feature_id=feature_id,
provider=provider,
nodeid=report.nodeid,
result=reported,
)
)
def pytest_sessionfinish(session, exitstatus):
"""Write the structured results artifact at end of session."""
artifact_path = os.environ.get(RESULTS_ARTIFACT_ENV) or DEFAULT_ARTIFACT_PATH
payload = {
"schema_version": "1",
"results": [
{
"feature_id": item.feature_id,
"provider": item.provider,
"nodeid": item.nodeid,
"result": item.result,
}
for item in _COLLECTOR.items
],
}
Path(artifact_path).write_text(json.dumps(payload, indent=2, sort_keys=True))