9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
238 lines
9.6 KiB
Python
238 lines
9.6 KiB
Python
"""Cassette verification utilities for VCR and XAI proto cassettes.
|
|
|
|
This module provides a unified interface for verifying cassette contents across
|
|
different cassette formats (VCR HTTP cassettes and XAI protobuf cassettes).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from vcr.cassette import Cassette
|
|
|
|
|
|
def get_first_post_body(cassette: Cassette) -> dict[str, Any]:
|
|
"""Return the first POST request body in a VCR cassette, parsed as JSON.
|
|
|
|
Some VCR serializers (e.g. the project's custom JSON body serializer used for
|
|
huggingface cassettes) deserialize `request.body` to a dict ahead of time;
|
|
others leave it as raw bytes/str. Handle both shapes.
|
|
"""
|
|
for request in cassette.requests: # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
|
if request.method != 'POST': # pyright: ignore[reportUnknownMemberType]
|
|
continue
|
|
body = request.body # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
|
|
if not body:
|
|
continue # pragma: no cover
|
|
if isinstance(body, dict):
|
|
return body # pyright: ignore[reportUnknownVariableType]
|
|
parsed: dict[str, Any] = json.loads(body) # pyright: ignore[reportUnknownArgumentType]
|
|
return parsed
|
|
return {} # pragma: no cover
|
|
|
|
|
|
def single_request_body(cassette: Cassette) -> dict[str, Any]:
|
|
"""Decode the JSON body of the single recorded request in `cassette`.
|
|
|
|
Use this for cassette-backed tests that send exactly one request and want to
|
|
assert directly on the wire body (e.g. that a specific field survived
|
|
translation). Asserts the single-request invariant — tests with intentional
|
|
multi-request cassettes should access `cassette.requests` directly.
|
|
"""
|
|
requests = cassette.requests # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
|
assert len(requests) == 1, f'Expected 1 request, got {len(requests)}' # pyright: ignore[reportUnknownArgumentType]
|
|
return json.loads(requests[0].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
|
|
|
|
|
# Provider-specific cassette extractors — group new ones under this header so the module
|
|
# doesn't grow into a flat bag of `get_<provider>_*` helpers.
|
|
|
|
|
|
def get_bedrock_tool_config_from_cassette(cassette: Cassette) -> dict[str, Any]:
|
|
"""Return the `toolConfig` from the first POST request body in a Bedrock VCR cassette."""
|
|
return get_first_post_body(cassette).get('toolConfig', {})
|
|
|
|
|
|
def get_bedrock_tool_names_from_cassette(cassette: Cassette) -> list[str]:
|
|
"""Extract Bedrock tool definition names from the first recorded POST request body."""
|
|
tools: list[dict[str, Any]] = get_bedrock_tool_config_from_cassette(cassette).get('tools', [])
|
|
return [tool['toolSpec']['name'] for tool in tools if 'toolSpec' in tool]
|
|
|
|
|
|
def get_cohere_tool_names_from_cassette(cassette: Cassette) -> list[str]:
|
|
"""Extract Cohere tool definition names from the first recorded POST request body."""
|
|
tools: list[dict[str, Any]] = get_first_post_body(cassette).get('tools', [])
|
|
return [tool['function']['name'] for tool in tools if 'function' in tool]
|
|
|
|
|
|
def _get_cassette_request_bodies(cassette: Cassette) -> list[str]:
|
|
"""Get all request bodies from a VCR cassette as strings."""
|
|
bodies: list[str] = []
|
|
for request in cassette.requests: # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
|
|
raw_body = request.body # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
|
if raw_body:
|
|
body = raw_body.decode('utf-8', errors='ignore') if isinstance(raw_body, bytes) else raw_body # pyright: ignore[reportUnknownVariableType]
|
|
bodies.append(body) # pyright: ignore[reportUnknownArgumentType]
|
|
elif getattr(request, 'parsed_body', None): # pyright: ignore[reportUnknownArgumentType] # pragma: no cover
|
|
bodies.append(json.dumps(request.parsed_body)) # pyright: ignore[reportUnknownMemberType]
|
|
return bodies
|
|
|
|
|
|
def _get_cassette_bodies_from_yaml(path: Path) -> list[str]:
|
|
"""Read request bodies from a VCR cassette YAML file on disk.
|
|
|
|
Used as fallback when the VCR cassette object is not available (e.g. CI playback).
|
|
"""
|
|
import yaml
|
|
|
|
data: dict[str, Any] = yaml.safe_load(path.read_text(encoding='utf-8'))
|
|
bodies: list[str] = []
|
|
for interaction in data.get('interactions', []):
|
|
request = interaction.get('request', {})
|
|
parsed_body = request.get('parsed_body') or request.get('body')
|
|
if parsed_body is None:
|
|
continue
|
|
if isinstance(parsed_body, dict | list):
|
|
bodies.append(json.dumps(parsed_body))
|
|
elif isinstance(parsed_body, str) and parsed_body:
|
|
bodies.append(parsed_body)
|
|
return bodies
|
|
|
|
|
|
def _get_xai_cassette_request_bodies(cassette_path: Path) -> list[str]:
|
|
"""Get all request and response bodies from an XAI cassette as strings."""
|
|
from tests.models.xai_proto_cassettes import (
|
|
SampleInteraction,
|
|
StreamInteraction,
|
|
XaiProtoCassette,
|
|
xai_sdk_available,
|
|
)
|
|
|
|
if not xai_sdk_available():
|
|
return []
|
|
|
|
bodies: list[str] = []
|
|
cassette = XaiProtoCassette.load(cassette_path)
|
|
|
|
for interaction in cassette.interactions:
|
|
if interaction.request_json:
|
|
bodies.append(json.dumps(interaction.request_json))
|
|
|
|
if isinstance(interaction, SampleInteraction) and interaction.response_json:
|
|
bodies.append(json.dumps(interaction.response_json))
|
|
elif isinstance(interaction, StreamInteraction) and interaction.chunks_json:
|
|
for chunk in interaction.chunks_json:
|
|
bodies.append(json.dumps(chunk))
|
|
|
|
return bodies
|
|
|
|
|
|
def _sanitize_cassette_filename(name: str, max_length: int = 240) -> str:
|
|
"""Sanitize filename to be filesystem-safe."""
|
|
sanitized = re.sub(r'[<>:"/\\|?*]', '_', name)
|
|
return sanitized[:max_length]
|
|
|
|
|
|
def _pattern_in_bodies(pattern: str, bodies: list[str]) -> bool:
|
|
"""Check if pattern exists in any of the request bodies."""
|
|
return any(pattern in body for body in bodies)
|
|
|
|
|
|
@dataclass
|
|
class CassetteContext:
|
|
"""Unified cassette verification context for VCR and XAI cassettes.
|
|
|
|
Encapsulates provider-specific cassette handling (VCR vs XAI proto format)
|
|
and provides a uniform verification interface.
|
|
"""
|
|
|
|
provider: str
|
|
vcr: Cassette | None
|
|
test_name: str
|
|
test_module: str
|
|
test_dir: Path
|
|
|
|
def _vcr_cassette_path(self) -> Path:
|
|
return self.test_dir / 'cassettes' / self.test_module / f'{_sanitize_cassette_filename(self.test_name)}.yaml'
|
|
|
|
def _xai_cassette_path(self) -> Path:
|
|
return (
|
|
self.test_dir / 'cassettes' / self.test_module / f'{_sanitize_cassette_filename(self.test_name)}.xai.yaml'
|
|
)
|
|
|
|
def _get_bodies(self) -> list[str]:
|
|
"""Get request/response bodies from the appropriate cassette format."""
|
|
if self.provider == 'xai':
|
|
path = self._xai_cassette_path()
|
|
if path.exists():
|
|
return _get_xai_cassette_request_bodies(path)
|
|
return []
|
|
if self.vcr is not None:
|
|
bodies = _get_cassette_request_bodies(self.vcr)
|
|
if bodies: # pragma: no branch
|
|
return bodies
|
|
path = self._vcr_cassette_path()
|
|
if path.exists():
|
|
return _get_cassette_bodies_from_yaml(path)
|
|
return []
|
|
|
|
def verify_contains(self, *patterns: str | tuple[str, ...]) -> None:
|
|
"""Verify that all patterns appear in cassette request/response bodies.
|
|
|
|
Args:
|
|
patterns: Patterns to search for. Each pattern can be a string or a tuple
|
|
(where any one of the tuple elements matching is sufficient).
|
|
|
|
Raises:
|
|
AssertionError: If a pattern is not found.
|
|
"""
|
|
bodies = self._get_bodies()
|
|
if not bodies:
|
|
return
|
|
|
|
for pattern in patterns:
|
|
if isinstance(pattern, tuple):
|
|
assert any(_pattern_in_bodies(p, bodies) for p in pattern), (
|
|
f'Expected one of {pattern} in cassette but none found'
|
|
)
|
|
else:
|
|
assert _pattern_in_bodies(pattern, bodies), f'Expected "{pattern}" in cassette but not found'
|
|
|
|
def verify_ordering(self, *patterns: str | tuple[str, ...]) -> None:
|
|
"""Verify that patterns appear in cassette bodies in the given order.
|
|
|
|
Args:
|
|
patterns: Patterns that must appear in order. Each pattern can be a string
|
|
or a tuple (where any one of the tuple elements is used for position checking).
|
|
|
|
Raises:
|
|
AssertionError: If ordering is violated or a pattern is not found.
|
|
"""
|
|
bodies = self._get_bodies()
|
|
if not bodies:
|
|
return
|
|
|
|
content = ''.join(bodies)
|
|
last_index = -1
|
|
|
|
for pattern in patterns:
|
|
if isinstance(pattern, tuple):
|
|
indices = [content.find(p) for p in pattern]
|
|
valid_indices = [i for i in indices if i != -1]
|
|
assert valid_indices, f'Expected one of {pattern} in cassette but none found'
|
|
current_index = min(valid_indices)
|
|
else:
|
|
current_index = content.find(pattern)
|
|
assert current_index != -1, f'Expected "{pattern}" in cassette but not found'
|
|
|
|
assert current_index > last_index, (
|
|
f'Pattern "{pattern}" found at index {current_index}, '
|
|
f'but expected after index {last_index} (ordering violation)'
|
|
)
|
|
last_index = current_index
|