mirror of
https://github.com/pydantic/pydantic-ai.git
synced 2026-10-12 02:21:31 +00:00
Replace pytest-recording with cassetter for cassette recording (#8893)
This commit is contained in:
@@ -113,7 +113,7 @@ The project uses:
|
||||
- `pyright` via `make typecheck`
|
||||
- `pytest` in `tests/`, via `make test`, with:
|
||||
- `inline-snapshot` for inline assertions
|
||||
- `pytest-recording` and `vcrpy` for recording and playing back requests to model APIs
|
||||
- `cassetter` for recording and playing back requests to model APIs
|
||||
- Documentation is published by [pydantic/unified-docs](https://github.com/pydantic/unified-docs).
|
||||
`docs/navigation.yml` owns the Pydantic AI sidebar, routes, and redirects; `tests/test_examples.py`
|
||||
tests all code examples in the docs (including docstrings).
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@ Need deterministic, fast tests?
|
||||
└── Need specific tool call behavior?
|
||||
├── Yes → Use FunctionModel
|
||||
└── Testing against real API (integration)?
|
||||
└── Yes → Use pytest-recording with VCR cassettes
|
||||
└── Yes → Use cassetter with recorded cassettes
|
||||
```
|
||||
|
||||
## Comparison Tables
|
||||
|
||||
+2
-6
@@ -169,7 +169,6 @@ constraint-dependencies = [
|
||||
"joserfc>=1.6.7",
|
||||
"pydantic-settings>=2.14.2",
|
||||
"setuptools>=83.0.0",
|
||||
"vcrpy>=8.2.1",
|
||||
"starlette>=1.3.1",
|
||||
"aiohttp>=3.14.1",
|
||||
"python-multipart>=0.0.31",
|
||||
@@ -244,7 +243,6 @@ dev = [
|
||||
"pytest-examples>=0.0.18",
|
||||
"pytest-mock>=3.14.0",
|
||||
"pytest-pretty>=1.3.0",
|
||||
"pytest-recording>=0.13.2",
|
||||
"boto3-stubs[bedrock-runtime]>=1.42.63",
|
||||
"pytest-xdist>=3.6.1",
|
||||
"pytest-split>=0.11.0",
|
||||
@@ -272,9 +270,8 @@ dev = [
|
||||
# Storage backends `pydantic-ai-harness` supports, faked or run against a live server.
|
||||
"mongomock-motor>=0.0.36",
|
||||
"redis>=8.1.0",
|
||||
# `pydantic-clai2` records `httpx2` traffic with cassetter's context manager; its pytest plugin
|
||||
# stays disabled (`-p no:cassetter`) so `pytest-recording` keeps owning the `vcr` fixture.
|
||||
"cassetter>=0.12.0",
|
||||
# Records and replays provider traffic: its pytest plugin owns `@pytest.mark.vcr` and the `vcr` fixture.
|
||||
"cassetter>=0.12.1",
|
||||
]
|
||||
# `strict-no-cover` is only invoked by the final coverage audit, which runs on a GitHub-hosted
|
||||
# runner. Keeping it out of `dev` means the test matrix -- twenty cells of which run on Ubicloud --
|
||||
@@ -455,7 +452,6 @@ files = "tests/typed_agent.py"
|
||||
strict = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-p no:cassetter"
|
||||
# Every `async def` test runs via anyio; no `@pytest.mark.anyio` needed.
|
||||
anyio_mode = "auto"
|
||||
testpaths = ["tests", ".github/scripts/test_ci_duration.py"]
|
||||
|
||||
@@ -24,7 +24,7 @@ _FORBIDDEN_CHARS = r"""<>?%*:|"'/\\"""
|
||||
|
||||
|
||||
def _sanitize_cassette_name(name: str) -> str:
|
||||
"""Replicate pytest-recording's cassette name sanitization."""
|
||||
"""Replicate the cassette name sanitization of cassetter's pytest plugin."""
|
||||
for ch in _FORBIDDEN_CHARS:
|
||||
name = name.replace(ch, '-')
|
||||
return name
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""One-time script to apply serializer redaction to an existing cassette."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, 'tests')
|
||||
|
||||
from json_body_serializer import deserialize, serialize
|
||||
|
||||
cassette_path = sys.argv[1] if len(sys.argv) > 1 else (
|
||||
'tests/models/cassettes/test_multimodal_tool_returns/'
|
||||
'test_multimodal_tool_return_matrix[direct-uploaded_file-image-bedrock_nova].yaml'
|
||||
)
|
||||
|
||||
with open(cassette_path, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Round-trip through deserialize/serialize to apply redaction
|
||||
cassette_dict = deserialize(content)
|
||||
redacted = serialize(cassette_dict)
|
||||
|
||||
with open(cassette_path, 'w', encoding='utf-8') as f:
|
||||
f.write(redacted)
|
||||
|
||||
# Verify
|
||||
if 'SCRUBBED' in redacted and 'ASIAVEMKNXYDQ6ZF4HY7' not in redacted:
|
||||
print(f'Successfully redacted credentials in {cassette_path}')
|
||||
else:
|
||||
print('WARNING: Redaction may not have worked correctly')
|
||||
sys.exit(1)
|
||||
+6
-6
@@ -50,7 +50,7 @@ async def test_feature(model: Model, stream: bool):
|
||||
For cartesian product tests, use a dict to map parameter combinations to expected results:
|
||||
|
||||
```python
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai.models import Model
|
||||
|
||||
@@ -145,18 +145,18 @@ For detailed workflows see `.claude/skills/testing-skill/SKILL.md`.
|
||||
|
||||
Realtime cassettes live in `tests/realtime/cassettes/<module>/<test>.yaml`. Record them with
|
||||
`uv run --env-file .env pytest --record-mode=rewrite <test>`; normal test runs replay them offline.
|
||||
Unlike HTTP VCR cassettes, they contain raw WebSocket frames, with secrets scrubbed and audio payloads
|
||||
Unlike HTTP cassettes, they contain raw WebSocket frames, with secrets scrubbed and audio payloads
|
||||
truncated. The testing skill's `parse_cassette.py` is HTTP-only and does not apply to these cassettes.
|
||||
|
||||
### Asserting what goes out on the wire
|
||||
|
||||
Four mechanisms, and they are not interchangeable — pick by what the test's claim is about.
|
||||
|
||||
**An httpx request hook — the boundary tap, and the first reach.** Request the `request_capture` fixture from `tests/conftest.py`, route the provider through `request_capture.client`, and assert on `request_capture.body(path_suffix)`; see `test_anthropic_code_execution_tool_container_reuse` in `tests/models/test_anthropic.py`. For an Anthropic model the `anthropic_model` factory does the routing, so `anthropic_model('claude-sonnet-4-5', capture=True)` is the whole setup. The fixture's client is an `httpx2.AsyncClient` carrying an `event_hooks={'request': [...]}` recorder, and event hooks run inside `AsyncClient.send`, above the transport VCR patches, so the hook fires on replay too and observes the request the live code actually built — a field the code stops sending fails the assertion instead of hiding behind a frozen recording. It costs the cassette nothing: matching is untouched, so fields that legitimately vary stay free to vary, and it sees what a cassette will not, headers included (the cassette serializer strips `anthropic-*`, so the wire is the only place a test can assert beta gating). Snapshot a projection of the body rather than the body itself — `content_blocks`, `message_shape` and `cache_breakpoints`, in `tests/models/conftest.py`, pin the fields a claim rests on without churning on unrelated conversation-shape changes. The fixture is reachable from anywhere under `tests/`; those three projections are not. A test outside `tests/models/` imports them explicitly (`from tests.models.conftest import message_shape`) rather than re-implementing the comprehension inline. The shared fixture works only when the provider accepts `httpx2.AsyncClient` as its injected client. Check the provider signature before using it. `GroqProvider` requires legacy `httpx.AsyncClient`, and `HuggingFaceProvider` rejects `http_client` in favor of `hf_client`; capture those SDK boundaries with provider-specific clients. A provider on another transport taps its own send-time event for the same reason — see `_capture_bedrock_request_headers` in `tests/models/test_bedrock.py`, a botocore `before-send` handler.
|
||||
**An httpx request hook — the boundary tap, and the first reach.** Request the `request_capture` fixture from `tests/conftest.py`, route the provider through `request_capture.client`, and assert on `request_capture.body(path_suffix)`; see `test_anthropic_code_execution_tool_container_reuse` in `tests/models/test_anthropic.py`. For an Anthropic model the `anthropic_model` factory does the routing, so `anthropic_model('claude-sonnet-4-5', capture=True)` is the whole setup. The fixture's client is an `httpx2.AsyncClient` carrying an `event_hooks={'request': [...]}` recorder, and event hooks run inside `AsyncClient.send`, above cassetter's transport patches, so the hook fires on replay too and observes the request the live code actually built — a field the code stops sending fails the assertion instead of hiding behind a frozen recording. It costs the cassette nothing: matching is untouched, so fields that legitimately vary stay free to vary, and it sees what a cassette will not, headers included (the recording hooks in `tests/cassette_hooks.py` strip `anthropic-*`, so the wire is the only place a test can assert beta gating). Snapshot a projection of the body rather than the body itself — `content_blocks`, `message_shape` and `cache_breakpoints`, in `tests/models/conftest.py`, pin the fields a claim rests on without churning on unrelated conversation-shape changes. The fixture is reachable from anywhere under `tests/`; those three projections are not. A test outside `tests/models/` imports them explicitly (`from tests.models.conftest import message_shape`) rather than re-implementing the comprehension inline. The shared fixture works only when the provider accepts `httpx2.AsyncClient` as its injected client. Check the provider signature before using it. `GroqProvider` requires legacy `httpx.AsyncClient`, and `HuggingFaceProvider` rejects `http_client` in favor of `hf_client`; capture those SDK boundaries with provider-specific clients. A provider on another transport taps its own send-time event for the same reason — see `_capture_bedrock_request_headers` in `tests/models/test_bedrock.py`, a botocore `before-send` handler.
|
||||
|
||||
**Snapshot the request body — the review surface.** `single_request_body` in `tests/cassette_utils.py` decodes the request the *cassette* holds, so `assert single_request_body(vcr) == snapshot({...})` puts the whole outbound payload in the test body instead of leaving it buried in a long YAML. What it does not do is catch drift: the recording is frozen, so it keeps passing after the live code stops sending a field, and a recording can already disagree with what the code sends today. Its second job is at re-record time, where a changed payload surfaces as a snapshot diff. Where a hook is attached, snapshot what the hook captured instead — same review surface, and it is the request that is actually going out.
|
||||
|
||||
**A per-test matcher — the gate for when you cannot tap the boundary.** Making a field part of the cassette match also fails when the live code drifts: a request that no longer carries the recorded value cannot match its recording, so replay errors instead of quietly passing. Reach for it when the test doesn't build its own model (a shared or `indirect` fixture supplies it), when one guard should cover a whole family of tests, or when the provider isn't on httpx. Register the matcher via the `pytest_recording_configure(config, vcr)` hook and opt the test in with `@pytest.mark.vcr(additional_matchers=['<name>'])`, which adds to the defaults rather than replacing them; see `function_calling_mode` in `tests/models/google/conftest.py`. Match on the fields the test's claim rests on, not on every field you snapshot — matching a field that legitimately varies makes the cassette brittle and buys no information, which is the cost a hook doesn't have.
|
||||
**A per-test matcher — the gate for when you cannot tap the boundary.** Making a field part of the cassette match also fails when the live code drifts: a request that no longer carries the recorded value cannot match its recording, so replay errors instead of quietly passing. Reach for it when the test doesn't build its own model (a shared or `indirect` fixture supplies it), when one guard should cover a whole family of tests, or when the provider isn't on httpx. Opt the test in with `@pytest.mark.vcr(additional_matchers=['body'])`, which adds to the default `method` + `uri` match rather than replacing it; cassetter's matchers are `method`, `uri`, `headers`, `body` and `json_body`, and a module `vcr_config` override can set `match_on` for a whole directory (see `tests/harness/media/conftest.py`) or `ignore_json_paths` for fields that legitimately vary. Match on the fields the test's claim rests on, not on every field you snapshot — matching a field that legitimately varies makes the cassette brittle and buys no information, which is the cost a hook doesn't have.
|
||||
|
||||
**Capture the render — for claims no single exchange holds.** When the SDK client is mocked outright so nothing goes over the wire, or the claim is that two renderings are byte-identical rather than that one recorded request looked a certain way, capture what the adapter produces and assert against that; see `test_tool_availability_delta_and_the_tools_cache_section` in `tests/test_tool_availability_portability.py`, and `rendered_requests` in `tests/models/test_anthropic_mid_conversation_system.py` for the per-request form. Where the requests do go out over httpx, the boundary tap gets the same evidence without reaching into adapter internals.
|
||||
|
||||
@@ -274,7 +274,7 @@ Available to every test under `tests/models/`. See "Asserting what goes out on t
|
||||
- When testing prompt caching, assert prefix stability, not just a cache hit — `cache_read_tokens > 0` only proves that some prefix was reused, not that the prefix you intended stayed stable as history grows; pin it by asserting the cacheable region (serialized leading blocks up to the breakpoint) is byte-identical across consecutive requests and/or that the cache read covers the full prior prefix, since a per-request injection or serialization that moves with history length silently busts the cache with no error
|
||||
- When you deprecate a public symbol (add `@deprecated`), add a test asserting the warning fires — a `pytest.warns(PydanticAIDeprecationWarning, match=...)` block whose `match` pins both the symbol name and the migration guidance — so the deprecation is protected against accidental removal or a message rewrite, and any executable docstring/doc example that constructs the symbol is marked `{test="skip"}` (it would otherwise fail under `filterwarnings=["error"]`)
|
||||
- Don't blanket-suppress `reportDeprecated` at file scope — no whole-file `# pyright: ignore` (or `# pyright: basic`) at the top of a file to kill every deprecation static-warning at once, since that also silences the next *unintended* use of a deprecated symbol in the file. Put `# pyright: ignore[reportDeprecated]` on each intentional call instead. (The runtime side is different and conventional: a module-level `warnings.filterwarnings('ignore', message=...)` scoped to one deprecation message, in a file that pervasively constructs the deprecated symbol it tests — see `tests/durable_exec/temporal/test_agent.py` / `tests/durable_exec/test_prefect.py` — is fine, as long as a dedicated `pytest.warns` test still proves the deprecation fires.)
|
||||
- Gate on a missing optional dependency with `try_import()`, the default. It has two halves and they live in different files: each **test module** wraps its optional imports in `with try_import() as imports_successful:` and applies `pytestmark = pytest.mark.skipif(not imports_successful(), reason='<dep> not installed')` (see `tests/models/anthropic/test_native_tool_filtering.py`); a **`conftest.py`** wraps its own optional imports the same way but carries no skip of its own (see `tests/models/anthropic/conftest.py`) — a conftest's `pytestmark` is not inherited by the modules it serves, so the mark there would gate nothing while `try_import` still keeps the file importable. Reach for `pytest.skip(..., allow_module_level=True)` only when the module cannot import `tests/conftest.py` at all — the Temporal suite can't, because that would pull `vcr` into a module the workflow sandbox re-imports. In that case the gate goes in the test modules **and any non-test helper they import** (`tests/durable_exec/temporal/_shared.py`), never in the `conftest.py`, which instead stays importable without the dependency by deferring its fixtures' imports into the fixture bodies. Reason: pytest loads the conftest of every command-line argument's directory before collection, and `_importconftest` catches only `Exception` while `Skipped` is a `BaseException`, so a gate there escapes as a traceback with exit 1 the moment someone names that directory (or a node id inside it) instead of a parent. See `tests/durable_exec/temporal/conftest.py` and the guard in `tests/durable_exec/test_temporal_skip_gating.py`
|
||||
- Gate on a missing optional dependency with `try_import()`, the default. It has two halves and they live in different files: each **test module** wraps its optional imports in `with try_import() as imports_successful:` and applies `pytestmark = pytest.mark.skipif(not imports_successful(), reason='<dep> not installed')` (see `tests/models/anthropic/test_native_tool_filtering.py`); a **`conftest.py`** wraps its own optional imports the same way but carries no skip of its own (see `tests/models/anthropic/conftest.py`) — a conftest's `pytestmark` is not inherited by the modules it serves, so the mark there would gate nothing while `try_import` still keeps the file importable. Reach for `pytest.skip(..., allow_module_level=True)` only when the module cannot import `tests/conftest.py` at all — the Temporal suite can't, because that would pull `cassetter` into a module the workflow sandbox re-imports. In that case the gate goes in the test modules **and any non-test helper they import** (`tests/durable_exec/temporal/_shared.py`), never in the `conftest.py`, which instead stays importable without the dependency by deferring its fixtures' imports into the fixture bodies. Reason: pytest loads the conftest of every command-line argument's directory before collection, and `_importconftest` catches only `Exception` while `Skipped` is a `BaseException`, so a gate there escapes as a traceback with exit 1 the moment someone names that directory (or a node id inside it) instead of a parent. See `tests/durable_exec/temporal/conftest.py` and the guard in `tests/durable_exec/test_temporal_skip_gating.py`
|
||||
- Do not use subprocesses to test logic that can run in-process. Starting another interpreter and importing the project again makes the full suite materially slower. Reserve subprocesses for behavior that depends on the process boundary, such as CLI invocation, interpreter startup, or import isolation. Scrub `COVERAGE_*` from a required subprocess environment so `[tool.coverage.run] patch = ["subprocess"]` does not instrument the child.
|
||||
|
||||
## Directory Structure
|
||||
@@ -282,7 +282,7 @@ Available to every test under `tests/models/`. See "Asserting what goes out on t
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # shared fixtures
|
||||
├── json_body_serializer.py # custom VCR serializer
|
||||
├── cassette_hooks.py # scrubbing and header filtering applied while recording
|
||||
├── assets/ # binary test files
|
||||
├── cassettes/ # VCR recordings for root tests
|
||||
├── models/
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""What cassetter records, and how a request is matched, across the whole suite.
|
||||
|
||||
`before_record_request` / `before_record_response` run on live traffic before it is written to a cassette:
|
||||
they drop noisy headers, scrub credentials the built-in filters don't know about, and normalize the smart
|
||||
quotes LLM APIs return so cassettes and the snapshots built from them stay ASCII. `normalize_uri` runs on
|
||||
both recorded and incoming URIs at match time, so a cassette recorded against one AWS account or GCP
|
||||
project replays for another.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import zlib
|
||||
from typing import Any, cast
|
||||
|
||||
import brotli
|
||||
from cassetter import RawRequest, RawResponse, SkipRecording
|
||||
|
||||
from pydantic_ai._utils import is_str_dict
|
||||
|
||||
# Smart quote and special character normalization.
|
||||
# LLM APIs sometimes return smart quotes and special Unicode characters in responses.
|
||||
# These are captured in cassettes, which then populate snapshots
|
||||
# which in turn cause linter complaints about non-ASCII characters.
|
||||
# Fixing these manually in the snapshots doesn't help,
|
||||
# because the snapshots are asserted on test reruns against the cassettes.
|
||||
# Normalizing to ASCII equivalents ensures consistent, portable cassette files and stable snapshots.
|
||||
SMART_CHAR_MAP = {
|
||||
'\u2018': "'", # LEFT SINGLE QUOTATION MARK
|
||||
'\u2019': "'", # RIGHT SINGLE QUOTATION MARK
|
||||
'\u201c': '"', # LEFT DOUBLE QUOTATION MARK
|
||||
'\u201d': '"', # RIGHT DOUBLE QUOTATION MARK
|
||||
'\u2013': '-', # EN DASH
|
||||
'\u2014': '--', # EM DASH
|
||||
'\u2026': '...', # HORIZONTAL ELLIPSIS
|
||||
}
|
||||
SMART_CHAR_TRANS = str.maketrans(SMART_CHAR_MAP)
|
||||
|
||||
|
||||
def normalize_smart_chars(text: str) -> str:
|
||||
"""Normalize smart quotes and special characters to ASCII equivalents."""
|
||||
# First use the translation table for known characters
|
||||
text = text.translate(SMART_CHAR_TRANS)
|
||||
# Then apply NFKC normalization for any remaining special chars
|
||||
return unicodedata.normalize('NFKC', text)
|
||||
|
||||
|
||||
def normalize_body(obj: Any) -> Any:
|
||||
"""Recursively normalize smart characters in all strings within a data structure."""
|
||||
if isinstance(obj, str):
|
||||
return normalize_smart_chars(obj)
|
||||
elif isinstance(obj, dict):
|
||||
return {k: normalize_body(v) for k, v in cast('dict[Any, Any]', obj).items()}
|
||||
elif isinstance(obj, list):
|
||||
return [normalize_body(item) for item in cast('list[Any]', obj)]
|
||||
return obj
|
||||
|
||||
|
||||
FILTERED_HEADER_PREFIXES = ['anthropic-', 'cf-', 'x-']
|
||||
FILTERED_HEADERS = {
|
||||
'authorization',
|
||||
'chatgpt-account-id',
|
||||
'cookie',
|
||||
'date',
|
||||
'openai-organization',
|
||||
'openai-project',
|
||||
'request-id',
|
||||
'server',
|
||||
'user-agent',
|
||||
'via',
|
||||
'set-cookie',
|
||||
'api-key',
|
||||
}
|
||||
ALLOWED_HEADER_PREFIXES = {
|
||||
# required by huggingface_hub.file_download used by test_embeddings.py::TestSentenceTransformers
|
||||
'x-xet-',
|
||||
# required for Bedrock embeddings to preserve token count headers
|
||||
'x-amzn-bedrock-',
|
||||
}
|
||||
ALLOWED_HEADERS = {
|
||||
# required by huggingface_hub.file_download used by test_embeddings.py::TestSentenceTransformers
|
||||
'x-repo-commit',
|
||||
'x-linked-size',
|
||||
'x-linked-etag',
|
||||
# required for test_google_model_file_search_tool
|
||||
'x-goog-upload-url',
|
||||
'x-goog-upload-status',
|
||||
# recorded as `gen_ai.response.id` on TypeSafe's `decide` spans
|
||||
'x-typesafe-request-id',
|
||||
}
|
||||
|
||||
SCRUBBED_JSON_FIELDS = ('access_token', 'id_token', 'refresh_token', 'safety_identifier')
|
||||
SCRUBBED_FORM_FIELDS = (
|
||||
'assertion',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'code',
|
||||
'code_verifier',
|
||||
'refresh_token',
|
||||
'RoleArn',
|
||||
'RoleSessionName',
|
||||
)
|
||||
|
||||
# Token exchanges carry credentials in both directions and are never what a test is about.
|
||||
SKIPPED_ENDPOINTS = {
|
||||
('oauth2.googleapis.com', '/token'),
|
||||
('auth.openai.com', '/oauth/token'),
|
||||
}
|
||||
|
||||
_AWS_ACCOUNT_ID_IN_ARN = re.compile(r'(arn(?:%3A|:)aws(?:%3A|:)bedrock(?:%3A|:)[^:%]*(?:%3A|:))\d{12}((?:%3A|:))')
|
||||
_SCRUBBED_AWS_ACCOUNT_ID = r'\g<1>123456789012\2'
|
||||
_BEDROCK_HOST = re.compile(r'bedrock-runtime\.[a-z0-9-]+\.amazonaws\.com')
|
||||
_VERTEX_HOST = re.compile(r'[a-z0-9-]+-aiplatform\.googleapis\.com')
|
||||
_VERTEX_LOCATION = re.compile(r'/locations/[a-z0-9-]+/')
|
||||
_VERTEX_PROJECT = re.compile(r'/projects/[a-z0-9-]+/')
|
||||
_SAFETY_IDENTIFIER = re.compile(r'("safety_identifier"\s*:\s*)"(?:\\.|[^"\\])*"')
|
||||
|
||||
|
||||
def scrub_aws_account_id(uri: str) -> str:
|
||||
return _AWS_ACCOUNT_ID_IN_ARN.sub(_SCRUBBED_AWS_ACCOUNT_ID, uri)
|
||||
|
||||
|
||||
def normalize_uri(uri: str) -> str:
|
||||
"""Erase the region, project and account a cassette was recorded against before matching.
|
||||
|
||||
Bedrock cassettes are recorded in whatever region the recording developer's credentials use, and
|
||||
Vertex AI cassettes against their GCP project; neither is what the test asserts on.
|
||||
"""
|
||||
uri = scrub_aws_account_id(uri)
|
||||
uri = _BEDROCK_HOST.sub('bedrock-runtime.REGION.amazonaws.com', uri)
|
||||
uri = _VERTEX_HOST.sub('aiplatform.googleapis.com', uri)
|
||||
uri = _VERTEX_LOCATION.sub('/locations/REGION/', uri)
|
||||
return _VERTEX_PROJECT.sub('/projects/PROJECT/', uri)
|
||||
|
||||
|
||||
def filter_headers(headers: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||
"""Drop headers that carry credentials, request IDs or other per-run noise."""
|
||||
return {
|
||||
name: value
|
||||
for name, value in ((name.lower(), value) for name, value in headers.items())
|
||||
if name not in FILTERED_HEADERS
|
||||
and (
|
||||
not any(name.startswith(prefix) for prefix in FILTERED_HEADER_PREFIXES)
|
||||
or name in ALLOWED_HEADERS
|
||||
or any(name.startswith(prefix) for prefix in ALLOWED_HEADER_PREFIXES)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _content_type(headers: dict[str, list[str]]) -> str:
|
||||
return next(iter(headers.get('content-type', [])), '')
|
||||
|
||||
|
||||
def _decompress(body: bytes, headers: dict[str, list[str]]) -> bytes:
|
||||
"""Inflate a body so it can be scrubbed (botocore hands over the wire bytes).
|
||||
|
||||
`content-encoding` is only dropped once the body is actually inflated; anything left encoded
|
||||
is decoded by cassetter after the hook.
|
||||
"""
|
||||
encoding = headers.get('content-encoding', [])
|
||||
if 'br' in encoding:
|
||||
body = cast('bytes', brotli.decompress(body)) # pyright: ignore[reportUnknownMemberType]
|
||||
elif 'gzip' in encoding or body[:2] == b'\x1f\x8b':
|
||||
try:
|
||||
body = gzip.decompress(body)
|
||||
except (gzip.BadGzipFile, zlib.error):
|
||||
return body
|
||||
else:
|
||||
return body
|
||||
headers.pop('content-encoding', None)
|
||||
return body
|
||||
|
||||
|
||||
def scrub_json_body(body: bytes) -> bytes:
|
||||
"""Normalize smart characters and blank the credentials a JSON body may carry.
|
||||
|
||||
Some endpoints (e.g. resumable file uploads) send a non-JSON body under an `application/json`
|
||||
content-type; keep the raw body rather than crashing the hook.
|
||||
"""
|
||||
try:
|
||||
parsed: Any = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return body
|
||||
parsed = normalize_body(parsed)
|
||||
if is_str_dict(parsed):
|
||||
for field in SCRUBBED_JSON_FIELDS:
|
||||
if parsed.get(field) is not None:
|
||||
parsed[field] = 'scrubbed'
|
||||
return json.dumps(parsed).encode('utf-8')
|
||||
|
||||
|
||||
def scrub_form_credentials(body: bytes) -> bytes:
|
||||
"""Redact credentials from an `application/x-www-form-urlencoded` body."""
|
||||
query_params = urllib.parse.parse_qs(body.decode('utf-8'))
|
||||
for key in SCRUBBED_FORM_FIELDS:
|
||||
if key in query_params:
|
||||
query_params[key] = ['scrubbed']
|
||||
return urllib.parse.urlencode(query_params, doseq=True).encode('utf-8')
|
||||
|
||||
|
||||
def scrub_xml_credentials(body: bytes) -> bytes:
|
||||
"""Redact AWS STS credentials from a `text/xml` body."""
|
||||
text = body.decode('utf-8')
|
||||
if '<Credentials>' not in text:
|
||||
return body
|
||||
text = re.sub(r'<AccessKeyId>[^<]+</AccessKeyId>', '<AccessKeyId>SCRUBBED</AccessKeyId>', text)
|
||||
text = re.sub(r'<SecretAccessKey>[^<]+</SecretAccessKey>', '<SecretAccessKey>SCRUBBED</SecretAccessKey>', text)
|
||||
text = re.sub(r'<SessionToken>[^<]+</SessionToken>', '<SessionToken>SCRUBBED</SessionToken>', text)
|
||||
text = re.sub(r'<Expiration>[^<]+</Expiration>', '<Expiration>2099-01-01T00:00:00Z</Expiration>', text)
|
||||
text = re.sub(r'<AssumedRoleId>[^<]+</AssumedRoleId>', '<AssumedRoleId>SCRUBBED</AssumedRoleId>', text)
|
||||
text = re.sub(r'<Arn>[^<]+</Arn>', '<Arn>SCRUBBED</Arn>', text)
|
||||
return text.encode('utf-8')
|
||||
|
||||
|
||||
def scrub_body(body: bytes | None, headers: dict[str, list[str]]) -> bytes | None:
|
||||
if not body:
|
||||
return body
|
||||
body = _decompress(body, headers)
|
||||
content_type = _content_type(headers)
|
||||
if content_type.startswith('application/json'):
|
||||
return scrub_json_body(body)
|
||||
if content_type.startswith('application/x-www-form-urlencoded'):
|
||||
return scrub_form_credentials(body)
|
||||
if content_type == 'text/xml':
|
||||
return scrub_xml_credentials(body)
|
||||
# Codex SSE responses can omit the content-type header.
|
||||
if body.startswith((b'event:', b'data:')):
|
||||
return _SAFETY_IDENTIFIER.sub(r'\1"scrubbed"', body.decode('utf-8')).encode('utf-8')
|
||||
return body
|
||||
|
||||
|
||||
def before_record_request(request: RawRequest) -> RawRequest:
|
||||
parsed = urllib.parse.urlparse(request.uri)
|
||||
if (parsed.hostname, parsed.path) in SKIPPED_ENDPOINTS:
|
||||
raise SkipRecording
|
||||
request.uri = scrub_aws_account_id(request.uri)
|
||||
request.headers = filter_headers(request.headers)
|
||||
request.body = scrub_body(request.body, request.headers)
|
||||
return request
|
||||
|
||||
|
||||
def before_record_response(response: RawResponse) -> RawResponse:
|
||||
response.headers = filter_headers(response.headers)
|
||||
response.body = scrub_body(response.body, response.headers)
|
||||
return response
|
||||
+44
-37
@@ -26,7 +26,7 @@ except ImportError: # pragma: no cover
|
||||
from yaml import SafeLoader
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette, RecordedRequest
|
||||
|
||||
PrefixBlock = tuple[str, str]
|
||||
|
||||
@@ -240,8 +240,22 @@ def classify_prefix_pair(a: list[PrefixBlock], b: list[PrefixBlock]) -> tuple[st
|
||||
return f'{level}-divergent', divergent_index
|
||||
|
||||
|
||||
def recorded_request_body(request: dict[str, Any]) -> Any:
|
||||
"""The decoded body of a request mapping read straight from a cassette file.
|
||||
|
||||
Cassettes recorded under vcrpy carry the parsed JSON as `parsed_body` (and the raw text as `body`);
|
||||
cassetter writes a typed `body: {type: json | text | binary | none, content: ...}` mapping.
|
||||
"""
|
||||
if (parsed_body := request.get('parsed_body')) is not None:
|
||||
return parsed_body
|
||||
body = request.get('body')
|
||||
if is_str_dict(body) and 'type' in body:
|
||||
return body.get('content') if body['type'] in ('json', 'text') else None
|
||||
return body
|
||||
|
||||
|
||||
def iter_cassette_prefix_violations(cassette_path: Path) -> Iterator[CassettePrefixViolation]:
|
||||
"""Yield prompt-cache prefix violations from one VCR cassette.
|
||||
"""Yield prompt-cache prefix violations from one cassette.
|
||||
|
||||
Across 1,177 cassettes on 2026-07-15, this found 15 deliberately prefix-moving pairs in ten
|
||||
cassettes. Requests are grouped by host and provider shape so unrelated endpoints are not paired.
|
||||
@@ -265,7 +279,7 @@ def iter_cassette_prefix_violations(cassette_path: Path) -> Iterator[CassettePre
|
||||
method = request.get('method')
|
||||
if not isinstance(method, str) or method.upper() != 'POST':
|
||||
continue
|
||||
body = request.get('parsed_body')
|
||||
body = recorded_request_body(request)
|
||||
if not is_str_dict(body):
|
||||
continue
|
||||
uri = request.get('uri')
|
||||
@@ -324,25 +338,24 @@ def iter_cassette_prefix_violations(cassette_path: Path) -> Iterator[CassettePre
|
||||
|
||||
|
||||
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]
|
||||
"""Return the first POST request body in a cassette, parsed as JSON."""
|
||||
for request in cassette.requests:
|
||||
if request.method != 'POST':
|
||||
continue
|
||||
body = request.body # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
|
||||
body = request.body
|
||||
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]
|
||||
parsed: dict[str, Any] = json.loads(body)
|
||||
return parsed
|
||||
return {} # pragma: no cover
|
||||
|
||||
|
||||
def request_json(request: RecordedRequest) -> Any:
|
||||
"""Decode the JSON body of one recorded request."""
|
||||
assert request.body is not None, f'Expected the recorded {request.method} {request.uri} to carry a body'
|
||||
return json.loads(request.body)
|
||||
|
||||
|
||||
def single_request_body(cassette: Cassette) -> dict[str, Any]:
|
||||
"""Decode the JSON body of the single recorded request in `cassette`.
|
||||
|
||||
@@ -351,9 +364,9 @@ def single_request_body(cassette: Cassette) -> dict[str, Any]:
|
||||
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]
|
||||
requests = cassette.requests
|
||||
assert len(requests) == 1, f'Expected 1 request, got {len(requests)}'
|
||||
return request_json(requests[0])
|
||||
|
||||
|
||||
# Provider-specific cassette extractors — group new ones under this header so the module
|
||||
@@ -378,34 +391,28 @@ def get_cohere_tool_names_from_cassette(cassette: Cassette) -> list[str]:
|
||||
|
||||
|
||||
def _get_cassette_request_bodies(cassette: Cassette) -> list[str]:
|
||||
"""Get all request bodies from a VCR cassette as strings."""
|
||||
"""Get all request bodies from a cassette as strings."""
|
||||
bodies: list[str] = []
|
||||
for request in cassette.requests: # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
|
||||
raw_body = request.body # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
||||
for request in cassette.requests:
|
||||
raw_body = request.body
|
||||
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]
|
||||
bodies.append(raw_body.decode('utf-8', errors='ignore') if isinstance(raw_body, bytes) else raw_body)
|
||||
return bodies
|
||||
|
||||
|
||||
def _get_cassette_bodies_from_yaml(path: Path) -> list[str]:
|
||||
"""Read request bodies from a VCR cassette YAML file on disk.
|
||||
"""Read request bodies from a cassette YAML file on disk.
|
||||
|
||||
Used as fallback when the VCR cassette object is not available (e.g. CI playback).
|
||||
Used as fallback when the cassette object is not available (e.g. CI playback).
|
||||
"""
|
||||
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)
|
||||
body = recorded_request_body(interaction.get('request', {}))
|
||||
if isinstance(body, dict | list):
|
||||
bodies.append(json.dumps(body))
|
||||
elif isinstance(body, str) and body:
|
||||
bodies.append(body)
|
||||
return bodies
|
||||
|
||||
|
||||
@@ -450,9 +457,9 @@ def _pattern_in_bodies(pattern: str, bodies: list[str]) -> bool:
|
||||
|
||||
@dataclass
|
||||
class CassetteContext:
|
||||
"""Unified cassette verification context for VCR and XAI cassettes.
|
||||
"""Unified cassette verification context for HTTP and XAI cassettes.
|
||||
|
||||
Encapsulates provider-specific cassette handling (VCR vs XAI proto format)
|
||||
Encapsulates provider-specific cassette handling (cassetter vs XAI proto format)
|
||||
and provides a uniform verification interface.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Isolate settings and provider access for every CLAI test."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import keyring
|
||||
@@ -24,7 +23,6 @@ def anyio_backend() -> str:
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Redirect default databases, including subprocesses, away from user data."""
|
||||
monkeypatch.setenv('PYTEST_ADDOPTS', f'{os.getenv("PYTEST_ADDOPTS", "")} -p no:cassetter')
|
||||
monkeypatch.setenv('XDG_CONFIG_HOME', str(tmp_path / 'config'))
|
||||
monkeypatch.setenv('HOME', str(tmp_path / 'home'))
|
||||
monkeypatch.delenv('CLAI_MODEL', raising=False)
|
||||
|
||||
+11
-95
@@ -13,7 +13,7 @@ from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from functools import cache, cached_property
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast, overload
|
||||
@@ -22,10 +22,9 @@ import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from _pytest.assertion.rewrite import AssertionRewritingHook
|
||||
from cassetter import Cassette, RecordMode
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from pytest_mock import MockerFixture
|
||||
from vcr import VCR, request as vcr_request
|
||||
from vcr.record_mode import RecordMode
|
||||
|
||||
import pydantic_ai._http
|
||||
import pydantic_ai.models
|
||||
@@ -51,6 +50,7 @@ from pydantic_ai.messages import (
|
||||
from pydantic_ai.models import DEFAULT_HTTP_TIMEOUT, Model
|
||||
from pydantic_ai.usage import RequestUsage, RunUsage
|
||||
|
||||
from . import cassette_hooks
|
||||
from ._inline_snapshot import Builder, Custom, customize
|
||||
from .cassette_utils import check_cache_prefix_stability
|
||||
|
||||
@@ -93,12 +93,6 @@ __all__ = (
|
||||
'message_part',
|
||||
)
|
||||
|
||||
# Configure VCR logger to WARNING as it is too verbose by default
|
||||
# specifically, it logs every request and response including binary
|
||||
# content in Cassette.append, which is causing log downloads from
|
||||
# GitHub action to fail.
|
||||
logging.getLogger('vcr.cassette').setLevel(logging.WARNING)
|
||||
|
||||
pydantic_ai.models.ALLOW_MODEL_REQUESTS = False
|
||||
|
||||
os.environ.setdefault('HF_HUB_DISABLE_PROGRESS_BARS', '1')
|
||||
@@ -118,7 +112,6 @@ def pytest_configure(config: pytest.Config) -> None:
|
||||
if TYPE_CHECKING:
|
||||
from blockbuster import BlockBuster
|
||||
from pluggy import Result
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai.providers.bedrock import BedrockProvider
|
||||
from pydantic_ai.providers.xai import XaiProvider
|
||||
@@ -703,66 +696,6 @@ def raise_if_exception(e: Any) -> None:
|
||||
raise e
|
||||
|
||||
|
||||
_AWS_ACCOUNT_ID_IN_ARN = re.compile(r'(arn(?:%3A|:)aws(?:%3A|:)bedrock(?:%3A|:)[^:%]*(?:%3A|:))\d{12}((?:%3A|:))')
|
||||
_SCRUBBED_AWS_ACCOUNT_ID = r'\g<1>123456789012\2'
|
||||
|
||||
|
||||
def pytest_recording_configure(config: Any, vcr: VCR):
|
||||
from . import json_body_serializer
|
||||
|
||||
vcr.register_serializer('yaml', json_body_serializer)
|
||||
|
||||
def method_matcher(r1: vcr_request.Request, r2: vcr_request.Request) -> None:
|
||||
if r1.method.upper() != r2.method.upper():
|
||||
raise AssertionError(f'{r1.method} != {r2.method}')
|
||||
|
||||
def path_matcher(r1: vcr_request.Request, r2: vcr_request.Request) -> None:
|
||||
"""Match URL paths after scrubbing AWS account IDs from ARNs."""
|
||||
path1 = _AWS_ACCOUNT_ID_IN_ARN.sub(_SCRUBBED_AWS_ACCOUNT_ID, r1.path)
|
||||
path2 = _AWS_ACCOUNT_ID_IN_ARN.sub(_SCRUBBED_AWS_ACCOUNT_ID, r2.path)
|
||||
# Normalize Vertex AI paths by replacing region and project (cassettes may be recorded
|
||||
# against a different GCP project than the fixture default)
|
||||
path1 = re.sub(r'/locations/[a-z0-9-]+/', '/locations/REGION/', path1)
|
||||
path2 = re.sub(r'/locations/[a-z0-9-]+/', '/locations/REGION/', path2)
|
||||
path1 = re.sub(r'/projects/[a-z0-9-]+/', '/projects/PROJECT/', path1)
|
||||
path2 = re.sub(r'/projects/[a-z0-9-]+/', '/projects/PROJECT/', path2)
|
||||
if path1 != path2:
|
||||
raise AssertionError(f'{path1} != {path2}')
|
||||
|
||||
vcr.register_matcher('method', method_matcher)
|
||||
vcr.register_matcher('path', path_matcher)
|
||||
|
||||
def scrub_request(request: vcr_request.Request) -> vcr_request.Request | None:
|
||||
if (request.host, request.path) in {
|
||||
('oauth2.googleapis.com', '/token'),
|
||||
('auth.openai.com', '/oauth/token'),
|
||||
}:
|
||||
return None
|
||||
request.uri = _AWS_ACCOUNT_ID_IN_ARN.sub(_SCRUBBED_AWS_ACCOUNT_ID, request.uri)
|
||||
return request
|
||||
|
||||
vcr.before_record_request = scrub_request
|
||||
|
||||
# Normalize Bedrock hostnames to ignore region differences
|
||||
# e.g., bedrock-runtime.us-east-1.amazonaws.com == bedrock-runtime.us-east-2.amazonaws.com
|
||||
bedrock_host_pattern = re.compile(r'bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com')
|
||||
|
||||
def host_matcher(r1: vcr_request.Request, r2: vcr_request.Request) -> None:
|
||||
host1 = r1.host # pyright: ignore[reportUnknownVariableType]
|
||||
host2 = r2.host # pyright: ignore[reportUnknownVariableType]
|
||||
# Normalize Bedrock hosts by removing region
|
||||
host1_normalized = bedrock_host_pattern.sub('bedrock-runtime.REGION.amazonaws.com', host1)
|
||||
host2_normalized = bedrock_host_pattern.sub('bedrock-runtime.REGION.amazonaws.com', host2)
|
||||
# Normalize Vertex AI hosts by removing region prefix
|
||||
vertex_host_pattern = re.compile(r'^[a-z0-9-]+-aiplatform\.googleapis\.com$')
|
||||
host1_normalized = vertex_host_pattern.sub('aiplatform.googleapis.com', host1_normalized)
|
||||
host2_normalized = vertex_host_pattern.sub('aiplatform.googleapis.com', host2_normalized)
|
||||
if host1_normalized != host2_normalized:
|
||||
raise AssertionError(f'{host1} != {host2}')
|
||||
|
||||
vcr.register_matcher('host', host_matcher)
|
||||
|
||||
|
||||
def pytest_addoption(parser: Any) -> None:
|
||||
parser.addoption(
|
||||
'--anyio-backend',
|
||||
@@ -799,30 +732,14 @@ def pytest_runtest_makereport(
|
||||
setattr(item, f'rep_{report.when}', report)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_vcr_aiohttp_content(mocker: MockerFixture):
|
||||
try:
|
||||
from vcr.stubs import aiohttp_stubs
|
||||
except ImportError: # pragma: lax no cover
|
||||
return
|
||||
|
||||
# google-genai calls `self.response_stream.content.readline()` where `self.response_stream` is a `MockClientResponse`,
|
||||
# which creates a new `MockStream` each time instead of returning the same one, resulting in the readline cursor not being respected.
|
||||
# So we turn `content` into a cached property to return the same one each time.
|
||||
# VCR issue: https://github.com/kevin1024/vcrpy/issues/927. Once that's is resolved, we can remove this patch.
|
||||
cached_content = cached_property(aiohttp_stubs.MockClientResponse.content.fget) # pyright: ignore[reportArgumentType, reportUnknownVariableType]
|
||||
cached_content.__set_name__(aiohttp_stubs.MockClientResponse, 'content')
|
||||
mocker.patch('vcr.stubs.aiohttp_stubs.MockClientResponse.content', new=cached_content)
|
||||
mocker.patch('vcr.stubs.aiohttp_stubs.MockStream.set_exception', return_value=None)
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config():
|
||||
def vcr_config() -> dict[str, Any]:
|
||||
"""Module `conftest.py` overrides should extend this (`{**vcr_config, ...}`) so the hooks stay on."""
|
||||
return {
|
||||
'ignore_localhost': True,
|
||||
# Note: additional header filtering is done inside the serializer
|
||||
'filter_headers': ['authorization', 'x-api-key', 'cookie'],
|
||||
'decode_compressed_response': True,
|
||||
'before_record_request': cassette_hooks.before_record_request,
|
||||
'before_record_response': cassette_hooks.before_record_response,
|
||||
'uri_normalizer': cassette_hooks.normalize_uri,
|
||||
}
|
||||
|
||||
|
||||
@@ -833,7 +750,7 @@ def check_vcr_cassette_usage(vcr: Cassette, strict_usage: bool) -> None:
|
||||
unused_indexes = [index for index in range(len(vcr)) if vcr.play_counts.get(index, 0) == 0]
|
||||
if unused_indexes:
|
||||
pytest.fail(
|
||||
f'Cassette {getattr(vcr, "_path", "<unknown>")} did not play all interactions: '
|
||||
f'Cassette {vcr.path} did not play all interactions: '
|
||||
f'played {vcr.play_count}/{len(vcr)}; unused indexes: {unused_indexes}'
|
||||
)
|
||||
|
||||
@@ -867,8 +784,7 @@ def fail_cache_prefix_violations(request: pytest.FixtureRequest, vcr: Cassette |
|
||||
if vcr is None or vcr.record_mode != RecordMode.NONE:
|
||||
return
|
||||
|
||||
cassette_path_value = getattr(vcr, '_path', None)
|
||||
if cassette_path_value is None or not (cassette_path := Path(cassette_path_value)).is_file():
|
||||
if not (cassette_path := Path(vcr.path)).is_file():
|
||||
return
|
||||
check_cache_prefix_stability(request.node, cassette_path)
|
||||
|
||||
@@ -1292,7 +1208,7 @@ async def xai_provider(request: pytest.FixtureRequest) -> AsyncIterator[XaiProvi
|
||||
cassette_path = Path(request.node.fspath).parent / 'cassettes' / test_module / f'{cassette_name}.xai.yaml'
|
||||
record_mode: str | None
|
||||
try:
|
||||
# Provided by `pytest-recording` as `--record-mode=...` (dest is typically `record_mode`).
|
||||
# Provided by cassetter's pytest plugin as `--record-mode=...`.
|
||||
record_mode = cast(Any, request.config).getoption('record_mode')
|
||||
except Exception: # pragma: no cover
|
||||
record_mode = None
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
@@ -73,12 +72,6 @@ def blockbuster_enabled() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def recording_plugin_in_subprocesses(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep child pytest processes on the same recording plugin as this suite."""
|
||||
monkeypatch.setenv('PYTEST_ADDOPTS', f'{os.getenv("PYTEST_ADDOPTS", "")} -p no:cassetter')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_model() -> TestModel:
|
||||
"""A fresh `TestModel` instance for each test."""
|
||||
|
||||
@@ -7,25 +7,23 @@ without R2 / AWS credentials. Recording is opt-in via
|
||||
|
||||
Sanitisation policy: every recorded cassette is rewritten on disk to
|
||||
swap the real R2 account-id subdomain and bucket name for fixed
|
||||
placeholders, and drop the `Authorization` header. The test setup uses
|
||||
the placeholder endpoint + bucket so request matching still succeeds on
|
||||
replay. See `_rewrite_request` / `_rewrite_response` below.
|
||||
placeholders; the repo-wide hooks drop the `Authorization` header. The
|
||||
test setup uses the placeholder endpoint + bucket so request matching
|
||||
still succeeds on replay. See `_rewrite_request` / `_rewrite_response` below.
|
||||
"""
|
||||
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cassetter import RawRequest, RawResponse
|
||||
|
||||
from pydantic_ai_harness.media import S3MediaStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vcr.request import Request as VcrRequest
|
||||
from tests import cassette_hooks
|
||||
|
||||
# `pymongo` is gated on the `mongodb` extra, so an install without it can't import
|
||||
# the Mongo store tests. Ignore them at collection then. A conditional expression
|
||||
@@ -60,101 +58,67 @@ def _real_bucket_pattern() -> re.Pattern[str] | None: # pragma: lax no cover
|
||||
return re.compile(r'/' + re.escape(bucket) + r'/')
|
||||
|
||||
|
||||
def _rewrite_request(request: VcrRequest) -> VcrRequest: # pragma: lax no cover
|
||||
def _rewrite_request(request: RawRequest) -> RawRequest: # pragma: lax no cover
|
||||
"""Strip account-id, bucket name, and credentials from recorded request."""
|
||||
request = cassette_hooks.before_record_request(request)
|
||||
host_pat = _real_account_host_pattern()
|
||||
if host_pat is not None:
|
||||
request.uri = host_pat.sub(SANITIZED_HOST, request.uri)
|
||||
bucket_pat = _real_bucket_pattern()
|
||||
if bucket_pat is not None:
|
||||
request.uri = bucket_pat.sub(f'/{SANITIZED_BUCKET}/', request.uri)
|
||||
# VCR already drops Authorization via `filter_headers`, but Host is set
|
||||
# by httpx independently — overwrite it so cassettes never carry the
|
||||
# real account subdomain.
|
||||
# The repo-wide hooks drop `authorization` and `x-amz-date`, but `host` is set by httpx
|
||||
# independently — overwrite it so cassettes never carry the real account subdomain.
|
||||
if 'host' in request.headers:
|
||||
request.headers['host'] = SANITIZED_HOST
|
||||
request.headers['host'] = [SANITIZED_HOST]
|
||||
return request
|
||||
|
||||
|
||||
_DROP_RESPONSE_HEADERS = frozenset(
|
||||
{
|
||||
'cf-ray',
|
||||
'cf-cache-status',
|
||||
'x-amz-version-id',
|
||||
'x-amz-request-id',
|
||||
'x-amz-id-2',
|
||||
'x-amz-checksum-crc64nvme',
|
||||
'x-amz-checksum-crc32',
|
||||
'x-amz-checksum-crc32c',
|
||||
'x-amz-checksum-sha1',
|
||||
'x-amz-checksum-sha256',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _rewrite_response(response: dict[str, Any]) -> dict[str, Any]: # pragma: lax no cover
|
||||
def _rewrite_response(response: RawResponse) -> RawResponse: # pragma: lax no cover
|
||||
"""Sanitise the response: drop noisy / identifying headers and any error body.
|
||||
|
||||
For non-2xx responses (typically the gzipped XML R2/AWS error envelope,
|
||||
which can mention the bucket) we blank the body entirely and strip
|
||||
`Content-Encoding`. Our `S3MediaStore` only inspects `status_code` for
|
||||
4xx and `response.text[:200]` for 5xx — no test in this module relies
|
||||
on the error body shape.
|
||||
The repo-wide hooks already drop the `cf-*` and `x-amz-*` request ids and checksums. For non-2xx
|
||||
responses (typically the gzipped XML R2/AWS error envelope, which can mention the bucket) we blank
|
||||
the body entirely and strip `Content-Encoding`. Our `S3MediaStore` only inspects `status_code` for
|
||||
4xx and `response.text[:200]` for 5xx — no test in this module relies on the error body shape.
|
||||
"""
|
||||
response = cassette_hooks.before_record_response(response)
|
||||
host_pat = _real_account_host_pattern()
|
||||
bucket_pat = _real_bucket_pattern()
|
||||
headers = response.get('headers', {})
|
||||
for header_name in list(headers.keys()):
|
||||
if header_name.lower() in _DROP_RESPONSE_HEADERS:
|
||||
del headers[header_name]
|
||||
continue
|
||||
values = headers[header_name]
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for header_name, values in response.headers.items():
|
||||
new_values: list[str] = []
|
||||
for v in values:
|
||||
if not isinstance(v, str):
|
||||
new_values.append(v)
|
||||
continue
|
||||
if host_pat is not None:
|
||||
v = host_pat.sub(SANITIZED_HOST, v)
|
||||
if bucket_pat is not None:
|
||||
v = bucket_pat.sub(f'/{SANITIZED_BUCKET}/', v)
|
||||
new_values.append(v)
|
||||
headers[header_name] = new_values
|
||||
response.headers[header_name] = new_values
|
||||
|
||||
status = response.get('status', {})
|
||||
code = status.get('code') if isinstance(status, dict) else None
|
||||
is_success = isinstance(code, int) and 200 <= code < 300
|
||||
body = response.get('body', {})
|
||||
if not is_success and isinstance(body, dict):
|
||||
if not 200 <= response.status < 300:
|
||||
# Drop any provider error envelope — it can name the bucket inside
|
||||
# the gzipped XML. Tests only read the status code on this path.
|
||||
body['string'] = b''
|
||||
for header_name in list(headers.keys()):
|
||||
if header_name.lower() in ('content-encoding', 'content-length', 'transfer-encoding'):
|
||||
del headers[header_name]
|
||||
response.body = b''
|
||||
for header_name in ('content-encoding', 'content-length', 'transfer-encoding'):
|
||||
response.headers.pop(header_name, None)
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config() -> dict[str, Any]:
|
||||
"""Per-module VCR configuration. Cassettes live next to the tests.
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Per-module cassette configuration. Cassettes live next to the tests.
|
||||
|
||||
Matching: method + scheme + host + path + body. Headers (including
|
||||
SigV4 `authorization` and `x-amz-date`) are NOT part of matching —
|
||||
they regenerate per replay and would otherwise miss every time.
|
||||
Matching: method + URI + body. Headers (including SigV4 `authorization`
|
||||
and `x-amz-date`) are NOT part of matching — they regenerate per replay
|
||||
and would otherwise miss every time.
|
||||
|
||||
Record mode is whatever `--record-mode` says (default `none`).
|
||||
"""
|
||||
return {
|
||||
'filter_headers': [
|
||||
('authorization', 'REDACTED'),
|
||||
('x-amz-date', 'REDACTED'),
|
||||
],
|
||||
**vcr_config,
|
||||
'before_record_request': _rewrite_request,
|
||||
'before_record_response': _rewrite_response,
|
||||
'match_on': ['method', 'scheme', 'host', 'path', 'body'],
|
||||
'match_on': ['method', 'uri', 'body'],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from dirty_equals import IsDatetime, IsInstance, IsStr
|
||||
from inline_snapshot.plugin import customize
|
||||
|
||||
from pydantic_ai.usage import RequestUsage
|
||||
from tests import cassette_hooks
|
||||
|
||||
|
||||
class InlineSnapshotPlugin:
|
||||
@@ -24,14 +25,15 @@ class InlineSnapshotPlugin:
|
||||
return builder.create_call(IsStr, [], {'regex': pattern})
|
||||
|
||||
|
||||
_IP_LITERAL_HOST = re.compile(r'://(?:(?:\d{1,3}\.){3}\d{1,3}|\[[0-9a-fA-F:]+\])(?::\d+)?/')
|
||||
|
||||
|
||||
def _normalize_resolved_host(uri: str) -> str:
|
||||
# `safe_download` connects to a resolved IPv4 or IPv6 address, which may differ
|
||||
# between recording and replay for the same URL.
|
||||
return _IP_LITERAL_HOST.sub('://RESOLVED_IP/', cassette_hooks.normalize_uri(uri))
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config() -> dict[str, Any]:
|
||||
return {
|
||||
'filter_headers': [
|
||||
('authorization', 'REDACTED'),
|
||||
('x-api-key', 'REDACTED'),
|
||||
],
|
||||
# `safe_download` connects to a resolved IP address, which may differ
|
||||
# between recording and replay for the same URL.
|
||||
'match_on': ['method', 'path', 'query'],
|
||||
}
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**vcr_config, 'uri_normalizer': _normalize_resolved_host}
|
||||
|
||||
@@ -19,8 +19,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from _pytest.mark import ParameterSet
|
||||
from pydantic import BaseModel
|
||||
from vcr.serializers import yamlserializer
|
||||
from cassetter import Cassette, RecordMode
|
||||
|
||||
_ROOT = Path(__file__).parents[2]
|
||||
|
||||
@@ -46,23 +45,6 @@ def _optional_encodings(header_value: str) -> list[str]:
|
||||
return optional
|
||||
|
||||
|
||||
class _Request(BaseModel):
|
||||
uri: str
|
||||
|
||||
|
||||
class _Response(BaseModel):
|
||||
headers: dict[str, list[str]]
|
||||
|
||||
|
||||
class _Interaction(BaseModel):
|
||||
request: _Request
|
||||
response: _Response
|
||||
|
||||
|
||||
class _Cassette(BaseModel):
|
||||
interactions: list[_Interaction]
|
||||
|
||||
|
||||
def _cassettes() -> Iterable[ParameterSet]:
|
||||
for directory in ('tests/harness', 'src/pydantic_ai_harness/integration_tests'):
|
||||
for path in sorted((_ROOT / directory).glob('**/cassettes/**/*.yaml')):
|
||||
@@ -76,13 +58,15 @@ def test_cassettes_discovered() -> None:
|
||||
|
||||
@pytest.mark.parametrize('path', list(_cassettes()))
|
||||
def test_cassette_replays_without_an_optional_decompressor(path: Path) -> None:
|
||||
# `vcr`'s own loader, because a cassette can carry tags `yaml.safe_load` rejects.
|
||||
document = yamlserializer.deserialize(path.read_text(encoding='utf-8')) # pyright: ignore[reportUnknownMemberType]
|
||||
cassette = _Cassette.model_validate(document)
|
||||
# `cassetter`'s own loader, because a cassette can carry tags `yaml.safe_load` rejects.
|
||||
cassette = Cassette(path, record_mode=RecordMode.NONE)
|
||||
cassette.load()
|
||||
needs = [
|
||||
f'{interaction.request.uri} responds `Content-Encoding: {encoding}`'
|
||||
for interaction in cassette.interactions
|
||||
for value in interaction.response.headers.get('content-encoding', [])
|
||||
for name, values in interaction.response.headers.items()
|
||||
if name.lower() == 'content-encoding'
|
||||
for value in values
|
||||
for encoding in _optional_encodings(value)
|
||||
]
|
||||
assert not needs, (
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import zlib
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import brotli
|
||||
import yaml
|
||||
|
||||
# Smart quote and special character normalization.
|
||||
# LLM APIs sometimes return smart quotes and special Unicode characters in responses.
|
||||
# These are captured in cassettes, which then populate snapshots
|
||||
# which in turn cause linter complaints about non-ASCII characters.
|
||||
# Fixing these manually in the snapshots doesn't help,
|
||||
# because the snapshots are asserted on test reruns against the cassettes.
|
||||
# Normalizing to ASCII equivalents ensures consistent, portable cassette files and stable snapshots.
|
||||
SMART_CHAR_MAP = {
|
||||
'\u2018': "'", # LEFT SINGLE QUOTATION MARK
|
||||
'\u2019': "'", # RIGHT SINGLE QUOTATION MARK
|
||||
'\u201c': '"', # LEFT DOUBLE QUOTATION MARK
|
||||
'\u201d': '"', # RIGHT DOUBLE QUOTATION MARK
|
||||
'\u2013': '-', # EN DASH
|
||||
'\u2014': '--', # EM DASH
|
||||
'\u2026': '...', # HORIZONTAL ELLIPSIS
|
||||
}
|
||||
SMART_CHAR_TRANS = str.maketrans(SMART_CHAR_MAP)
|
||||
|
||||
|
||||
def normalize_smart_chars(text: str) -> str:
|
||||
"""Normalize smart quotes and special characters to ASCII equivalents."""
|
||||
# First use the translation table for known characters
|
||||
text = text.translate(SMART_CHAR_TRANS)
|
||||
# Then apply NFKC normalization for any remaining special chars
|
||||
return unicodedata.normalize('NFKC', text)
|
||||
|
||||
|
||||
def normalize_body(obj: Any) -> Any:
|
||||
"""Recursively normalize smart characters in all strings within a data structure."""
|
||||
if isinstance(obj, str):
|
||||
return normalize_smart_chars(obj)
|
||||
elif isinstance(obj, dict):
|
||||
return {k: normalize_body(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [normalize_body(item) for item in obj]
|
||||
return obj # pragma: no cover
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from yaml import Dumper, SafeLoader
|
||||
else:
|
||||
try:
|
||||
from yaml import CDumper as Dumper, CSafeLoader as SafeLoader
|
||||
except ImportError: # pragma: no cover
|
||||
from yaml import Dumper, SafeLoader
|
||||
|
||||
FILTERED_HEADER_PREFIXES = ['anthropic-', 'cf-', 'x-']
|
||||
FILTERED_HEADERS = {
|
||||
'authorization',
|
||||
'chatgpt-account-id',
|
||||
'cookie',
|
||||
'date',
|
||||
'openai-organization',
|
||||
'openai-project',
|
||||
'request-id',
|
||||
'server',
|
||||
'user-agent',
|
||||
'via',
|
||||
'set-cookie',
|
||||
'api-key',
|
||||
}
|
||||
ALLOWED_HEADER_PREFIXES = {
|
||||
# required by huggingface_hub.file_download used by test_embeddings.py::TestSentenceTransformers
|
||||
'x-xet-',
|
||||
# required for Bedrock embeddings to preserve token count headers
|
||||
'x-amzn-bedrock-',
|
||||
}
|
||||
ALLOWED_HEADERS = {
|
||||
# required by huggingface_hub.file_download used by test_embeddings.py::TestSentenceTransformers
|
||||
'x-repo-commit',
|
||||
'x-linked-size',
|
||||
'x-linked-etag',
|
||||
# required for test_google_model_file_search_tool
|
||||
'x-goog-upload-url',
|
||||
'x-goog-upload-status',
|
||||
# recorded as `gen_ai.response.id` on TypeSafe's `decide` spans
|
||||
'x-typesafe-request-id',
|
||||
}
|
||||
|
||||
|
||||
class LiteralDumper(Dumper):
|
||||
"""
|
||||
A custom dumper that will represent multi-line strings using literal style.
|
||||
"""
|
||||
|
||||
|
||||
def str_presenter(dumper: Dumper, data: str):
|
||||
"""If the string contains newlines, represent it as a literal block."""
|
||||
if '\n' in data:
|
||||
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
|
||||
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
|
||||
|
||||
|
||||
# Register the custom presenter on our dumper
|
||||
LiteralDumper.add_representer(str, str_presenter)
|
||||
|
||||
|
||||
def deserialize(cassette_string: str):
|
||||
cassette_dict = yaml.load(cassette_string, Loader=SafeLoader)
|
||||
for interaction in cassette_dict['interactions']:
|
||||
for kind, data in interaction.items():
|
||||
parsed_body = data.pop('parsed_body', None)
|
||||
if parsed_body is not None:
|
||||
dumped_body = json.dumps(parsed_body)
|
||||
data['body'] = {'string': dumped_body} if kind == 'response' else dumped_body
|
||||
return cassette_dict
|
||||
|
||||
|
||||
def _content_type_startswith(content_type: Sequence[str | bytes], prefix: str) -> bool:
|
||||
return any(
|
||||
(h if isinstance(h, str) else h.decode('utf-8') if isinstance(h, bytes) else '').startswith(prefix)
|
||||
for h in content_type
|
||||
)
|
||||
|
||||
|
||||
def scrub_form_credentials(data: dict[str, Any], content_type: list[str]) -> None: # pragma: lax no cover
|
||||
"""Redact credentials from application/x-www-form-urlencoded request bodies."""
|
||||
if not _content_type_startswith(content_type, 'application/x-www-form-urlencoded'):
|
||||
return
|
||||
query_params = urllib.parse.parse_qs(data['body'])
|
||||
for key in [
|
||||
'assertion',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'code',
|
||||
'code_verifier',
|
||||
'refresh_token',
|
||||
'RoleArn',
|
||||
'RoleSessionName',
|
||||
]:
|
||||
if key in query_params:
|
||||
query_params[key] = ['scrubbed']
|
||||
data['body'] = urllib.parse.urlencode(query_params, doseq=True)
|
||||
|
||||
|
||||
def scrub_xml_credentials(
|
||||
data: dict[str, Any], headers: dict[str, list[str]], content_type: list[str]
|
||||
) -> None: # pragma: lax no cover
|
||||
"""Redact AWS STS credentials from text/xml response bodies."""
|
||||
if content_type != ['text/xml']:
|
||||
return
|
||||
body = data.get('body', None)
|
||||
if isinstance(body, dict):
|
||||
body = body.get('string', '')
|
||||
if not isinstance(body, str) or '<Credentials>' not in body:
|
||||
return
|
||||
body = re.sub(r'<AccessKeyId>[^<]+</AccessKeyId>', '<AccessKeyId>SCRUBBED</AccessKeyId>', body)
|
||||
body = re.sub(r'<SecretAccessKey>[^<]+</SecretAccessKey>', '<SecretAccessKey>SCRUBBED</SecretAccessKey>', body)
|
||||
body = re.sub(r'<SessionToken>[^<]+</SessionToken>', '<SessionToken>SCRUBBED</SessionToken>', body)
|
||||
body = re.sub(r'<Expiration>[^<]+</Expiration>', '<Expiration>2099-01-01T00:00:00Z</Expiration>', body)
|
||||
body = re.sub(r'<AssumedRoleId>[^<]+</AssumedRoleId>', '<AssumedRoleId>SCRUBBED</AssumedRoleId>', body)
|
||||
body = re.sub(r'<Arn>[^<]+</Arn>', '<Arn>SCRUBBED</Arn>', body)
|
||||
data['body'] = {'string': body}
|
||||
if 'content-length' in headers:
|
||||
headers['content-length'] = [str(len(body.encode('utf-8')))]
|
||||
|
||||
|
||||
def _store_json_body(
|
||||
kind: str, data: dict[str, Any], body: str, headers: dict[str, list[str]]
|
||||
) -> None: # pragma: lax no cover
|
||||
"""Replace an `application/json` body with a normalized, scrubbed `parsed_body`.
|
||||
|
||||
Some endpoints (e.g. resumable file uploads) send a non-JSON body under an `application/json`
|
||||
content-type; keep the raw body rather than crashing the serializer.
|
||||
"""
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
data['body'] = {'string': body} if kind == 'response' else body
|
||||
if 'content-length' in headers:
|
||||
headers['content-length'] = [str(len(body.encode('utf-8')))]
|
||||
return
|
||||
# Normalize smart quotes and special characters
|
||||
data['parsed_body'] = normalize_body(parsed)
|
||||
if 'access_token' in data['parsed_body']:
|
||||
data['parsed_body']['access_token'] = 'scrubbed'
|
||||
if 'id_token' in data['parsed_body']:
|
||||
data['parsed_body']['id_token'] = 'scrubbed'
|
||||
if 'refresh_token' in data['parsed_body']:
|
||||
data['parsed_body']['refresh_token'] = 'scrubbed'
|
||||
if 'safety_identifier' in data['parsed_body'] and data['parsed_body']['safety_identifier'] is not None:
|
||||
data['parsed_body']['safety_identifier'] = 'scrubbed'
|
||||
del data['body']
|
||||
# Update content-length to match the body that will be produced during deserialize.
|
||||
# This is necessary because decompression changes the body size, and botocore
|
||||
# verifies content-length against the actual body during cassette replay.
|
||||
if 'content-length' in headers:
|
||||
new_body = json.dumps(data['parsed_body'])
|
||||
headers['content-length'] = [str(len(new_body.encode('utf-8')))]
|
||||
|
||||
|
||||
def serialize(cassette_dict: Any): # pragma: lax no cover
|
||||
for interaction in cassette_dict['interactions']:
|
||||
for kind, data in interaction.items():
|
||||
headers: dict[str, list[str]] = data.get('headers', {})
|
||||
# make headers lowercase
|
||||
headers = {k.lower(): v for k, v in headers.items()}
|
||||
# filter headers by name
|
||||
headers = {k: v for k, v in headers.items() if k not in FILTERED_HEADERS}
|
||||
# filter headers by prefix
|
||||
headers = {
|
||||
k: v
|
||||
for k, v in headers.items()
|
||||
if not any(k.startswith(prefix) for prefix in FILTERED_HEADER_PREFIXES)
|
||||
or k in ALLOWED_HEADERS
|
||||
or any(k.startswith(prefix) for prefix in ALLOWED_HEADER_PREFIXES)
|
||||
}
|
||||
# update headers on source object
|
||||
data['headers'] = headers
|
||||
|
||||
content_type = headers.get('content-type', [])
|
||||
if any(isinstance(header, str) and header.startswith('application/json') for header in content_type):
|
||||
# Parse the body as JSON
|
||||
body = data.get('body', None)
|
||||
assert body is not None, data
|
||||
if isinstance(body, dict):
|
||||
# Responses will have the body under a field called 'string'
|
||||
body = body.get('string')
|
||||
if body:
|
||||
if isinstance(body, bytes):
|
||||
content_encoding = headers.get('content-encoding', [])
|
||||
# Decompress the body and remove the content-encoding header.
|
||||
# Otherwise httpx will try to decompress again on cassette replay.
|
||||
if 'br' in content_encoding:
|
||||
body = brotli.decompress(body)
|
||||
headers.pop('content-encoding', None)
|
||||
elif 'gzip' in content_encoding or (len(body) > 2 and body[:2] == b'\x1f\x8b'):
|
||||
try:
|
||||
body = gzip.decompress(body)
|
||||
headers.pop('content-encoding', None)
|
||||
except (gzip.BadGzipFile, zlib.error):
|
||||
pass
|
||||
body = body.decode('utf-8')
|
||||
_store_json_body(kind, data, body, headers) # pyright: ignore[reportUnknownArgumentType]
|
||||
elif kind == 'response':
|
||||
# Codex SSE responses can omit the content-type header.
|
||||
body = data.get('body', {}).get('string')
|
||||
if isinstance(body, bytes) and body.startswith((b'event:', b'data:')):
|
||||
body = body.decode('utf-8')
|
||||
if isinstance(body, str) and body.startswith(('event:', 'data:')):
|
||||
body = re.sub(r'("safety_identifier"\s*:\s*)"(?:\\.|[^"\\])*"', r'\1"scrubbed"', body)
|
||||
data['body']['string'] = body
|
||||
if 'content-length' in headers:
|
||||
headers['content-length'] = [str(len(body.encode('utf-8')))]
|
||||
scrub_form_credentials(data, content_type)
|
||||
scrub_xml_credentials(data, headers, content_type)
|
||||
|
||||
# Use our custom dumper
|
||||
return yaml.dump(cassette_dict, Dumper=LiteralDumper, allow_unicode=True, width=120)
|
||||
@@ -30,7 +30,7 @@ from ..test_anthropic import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
with try_import() as imports_successful:
|
||||
from anthropic import (
|
||||
|
||||
@@ -8,7 +8,7 @@ interaction between `bedrock_cache_tool_definitions` and `toolChoice` shapes.
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import pytest
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai import Agent, ModelRequest, RunContext, ToolReturnPart
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
-44
@@ -1,48 +1,4 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: grant_type=refresh_token&client_id=scrubbed&client_secret=scrubbed&refresh_token=scrubbed
|
||||
headers:
|
||||
accept:
|
||||
- '*/*'
|
||||
accept-encoding:
|
||||
- gzip, deflate, br, zstd
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '268'
|
||||
content-type:
|
||||
- application/x-www-form-urlencoded
|
||||
method: POST
|
||||
uri: https://oauth2.googleapis.com/token
|
||||
response:
|
||||
headers:
|
||||
alt-svc:
|
||||
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
|
||||
cache-control:
|
||||
- no-cache, no-store, max-age=0, must-revalidate
|
||||
content-length:
|
||||
- '258'
|
||||
content-type:
|
||||
- application/json; charset=utf-8
|
||||
expires:
|
||||
- Mon, 01 Jan 1990 00:00:00 GMT
|
||||
pragma:
|
||||
- no-cache
|
||||
transfer-encoding:
|
||||
- chunked
|
||||
vary:
|
||||
- Origin
|
||||
- X-Origin
|
||||
- Referer
|
||||
parsed_body:
|
||||
access_token: scrubbed
|
||||
expires_in: 3599
|
||||
id_token: scrubbed
|
||||
scope: https://www.googleapis.com/auth/sqlservice.login https://www.googleapis.com/auth/userinfo.email openid https://www.googleapis.com/auth/cloud-platform
|
||||
token_type: Bearer
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
- request:
|
||||
headers:
|
||||
accept:
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import JsonValue
|
||||
from ..conftest import RequestCapture, try_import
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai.models.anthropic import AnthropicModel
|
||||
from pydantic_ai.providers.google import GoogleProvider
|
||||
|
||||
@@ -11,7 +11,7 @@ from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai import (
|
||||
Agent,
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeVar, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
@@ -34,7 +34,7 @@ from ..cassette_utils import single_request_body
|
||||
from ..conftest import TestEnv, try_import
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
with try_import() as imports_successful:
|
||||
from anthropic import NOT_GIVEN, AsyncAnthropicBedrock, BadRequestError, omit as OMIT
|
||||
@@ -126,7 +126,7 @@ async def test_anthropic_bedrock_count_tokens_real_api(
|
||||
|
||||
assert result.input_tokens == snapshot(18)
|
||||
|
||||
assert vcr.requests[0].path == snapshot('/model/anthropic.claude-sonnet-4-20250514-v1:0/count-tokens') # pyright: ignore[reportUnknownMemberType]
|
||||
assert vcr.requests[0].path == snapshot('/model/anthropic.claude-sonnet-4-20250514-v1:0/count-tokens')
|
||||
envelope = single_request_body(vcr)
|
||||
body = json.loads(base64.b64decode(envelope['input']['invokeModel']['body']))
|
||||
assert body == snapshot(
|
||||
|
||||
@@ -19,7 +19,6 @@ profile flag, the client-transport gate, and the cache breakpoint that now lands
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -45,11 +44,11 @@ from pydantic_ai.exceptions import UserError
|
||||
from pydantic_ai.tools import ToolDefinition
|
||||
|
||||
from .._inline_snapshot import snapshot
|
||||
from ..cassette_utils import single_request_body
|
||||
from ..cassette_utils import request_json, single_request_body
|
||||
from ..conftest import try_import
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
with try_import() as imports_successful:
|
||||
from anthropic import AsyncAnthropicBedrock, AsyncAnthropicFoundry
|
||||
@@ -1188,7 +1187,7 @@ async def test_inline_system_prompt_cache_prefix_is_reused(
|
||||
first = await agent.run(message_history=history)
|
||||
second = await agent.run(message_history=history)
|
||||
|
||||
recorded_bodies = [json.loads(request.body) for request in vcr.requests] # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType]
|
||||
recorded_bodies = [request_json(request) for request in vcr.requests]
|
||||
assert rendered_requests == [{'system': body['system'], 'messages': body['messages']} for body in recorded_bodies]
|
||||
assert rendered_requests[0] == rendered_requests[1]
|
||||
assert rendered_requests[0]['messages'][-2:] == snapshot(
|
||||
|
||||
@@ -71,7 +71,7 @@ from pydantic_ai.tools import ToolDefinition
|
||||
from pydantic_ai.usage import RunUsage, UsageLimits
|
||||
|
||||
from .._inline_snapshot import snapshot
|
||||
from ..cassette_utils import single_request_body
|
||||
from ..cassette_utils import request_json, single_request_body
|
||||
from ..conftest import IsDatetime, IsInstance, IsNow, IsStr, TestEnv, try_import
|
||||
|
||||
with try_import() as imports_successful:
|
||||
@@ -84,9 +84,9 @@ with try_import() as imports_successful:
|
||||
ReadTimeoutError,
|
||||
)
|
||||
from botocore.hooks import HierarchicalEmitter
|
||||
from cassetter import Cassette
|
||||
from mypy_boto3_bedrock_runtime import BedrockRuntimeClient
|
||||
from mypy_boto3_bedrock_runtime.type_defs import MessageUnionTypeDef, SystemContentBlockTypeDef, ToolTypeDef
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai.models.bedrock import BedrockConverseModel, BedrockModelName, BedrockModelSettings
|
||||
from pydantic_ai.models.openai import OpenAIResponsesModel, OpenAIResponsesModelSettings
|
||||
@@ -1978,8 +1978,8 @@ def _bedrock_tool_result_media_kinds(cassette: Cassette) -> set[str]:
|
||||
sibling-splitting it (a placeholder `text` in the `toolResult` plus a separate file block).
|
||||
"""
|
||||
kinds: set[str] = set()
|
||||
for request in cassette.requests: # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
||||
data: dict[str, Any] = json.loads(request.body) # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType]
|
||||
for request in cassette.requests:
|
||||
data: dict[str, Any] = request_json(request)
|
||||
messages: list[dict[str, Any]] = data.get('messages', [])
|
||||
for message in messages:
|
||||
content: list[dict[str, Any]] = message.get('content', [])
|
||||
@@ -2032,8 +2032,8 @@ async def test_bedrock_media_kind_delivered_in_tool_result(
|
||||
|
||||
# The file rode inside the `toolResult`, and no sibling-split placeholder was emitted.
|
||||
assert file_kind in _bedrock_tool_result_media_kinds(vcr)
|
||||
for request in vcr.requests: # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
||||
assert 'See file' not in json.dumps(json.loads(request.body)) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
for request in vcr.requests:
|
||||
assert 'See file' not in json.dumps(request_json(request))
|
||||
|
||||
|
||||
async def test_bedrock_model_thinking_part_deepseek(allow_model_requests: None, bedrock_provider: BedrockProvider):
|
||||
@@ -3886,7 +3886,7 @@ async def test_bedrock_thinking_high_qwen_variant(
|
||||
# `<think>` tags, leaving empty visible text and triggering pydantic-ai's
|
||||
# output-validation retry — the cassette captures two recorded interactions. We
|
||||
# only need to assert on the wire shape of the first one.
|
||||
sent = json.loads(vcr.requests[0].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
sent = request_json(vcr.requests[0])
|
||||
assert sent['additionalModelRequestFields'] == {'reasoning_config': 'high'}
|
||||
# Loose response-shape pin: at least one ThinkingPart survives the
|
||||
# validation-retry roundtrip and reaches the final response.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast, get_args
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from inline_snapshot import snapshot
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import Agent, ModelRequest, ModelResponse, TextPart, ThinkingPart
|
||||
from pydantic_ai._warnings import PydanticAIDeprecationWarning
|
||||
@@ -15,6 +14,7 @@ from pydantic_ai.profiles import DEFAULT_THINKING_TAGS
|
||||
from pydantic_ai.settings import ServiceTier
|
||||
from pydantic_ai.tools import ToolDefinition
|
||||
|
||||
from ..cassette_utils import request_json
|
||||
from ..conftest import RequestCapture, iter_message_parts, try_import
|
||||
|
||||
with try_import() as imports_successful:
|
||||
@@ -106,8 +106,8 @@ async def test_cerebras_accepts_every_service_tier(
|
||||
Tiers are in Private Preview, so whether a request *gets* that tier is gated. Acceptance is
|
||||
not: `auto` / `default` / `flex` / `priority` all 200 rather than 400.
|
||||
|
||||
`request_capture` pins the four values on the live outgoing body; `vcr.responses` pins the
|
||||
recorded HTTP 200s. Cassette matching ignores the body, so asserting on `vcr.requests` would
|
||||
`request_capture` pins the four values on the live outgoing body; the cassette's interactions pin
|
||||
the recorded HTTP 200s. Cassette matching ignores the body, so asserting on `vcr.requests` would
|
||||
keep passing after the code stopped sending `service_tier`.
|
||||
"""
|
||||
provider = CerebrasProvider(api_key=cerebras_api_key, http_client=request_capture.client)
|
||||
@@ -120,8 +120,7 @@ async def test_cerebras_accepts_every_service_tier(
|
||||
|
||||
sent = [body.get('service_tier') for body in request_capture.bodies('/chat/completions')]
|
||||
assert sent == list(tiers)
|
||||
recorded_responses = vcr.responses # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
|
||||
assert [response['status']['code'] for response in recorded_responses] == [200] * len(tiers) # pyright: ignore[reportUnknownVariableType]
|
||||
assert [interaction.response.status for interaction in vcr.interactions] == [200] * len(tiers)
|
||||
|
||||
|
||||
async def test_cerebras_disable_reasoning_setting(allow_model_requests: None, cerebras_api_key: str, vcr: Cassette):
|
||||
@@ -141,7 +140,7 @@ async def test_cerebras_disable_reasoning_setting(allow_model_requests: None, ce
|
||||
text_part = cast(TextPart, response.parts[0])
|
||||
assert '4' in text_part.content
|
||||
|
||||
body = json.loads(vcr.requests[0].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
body = request_json(vcr.requests[0])
|
||||
assert body.get('reasoning_effort') == 'none'
|
||||
assert 'disable_reasoning' not in body
|
||||
# zai replays prior reasoning as `<think>` tags, so `clear_thinking=false` is injected by default.
|
||||
@@ -173,7 +172,7 @@ async def test_cerebras_thinking_part_survives_multiturn(
|
||||
assert any(p.content == turn1_thinking[0].content for p in preserved)
|
||||
|
||||
# On the wire, the decorative thinking is replayed as the assistant message's `reasoning` field.
|
||||
turn2_body = json.loads(vcr.requests[1].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
turn2_body = request_json(vcr.requests[1])
|
||||
assistant_messages = [m for m in turn2_body['messages'] if m.get('role') == 'assistant']
|
||||
assert any(m.get('reasoning') == turn1_thinking[0].content for m in assistant_messages)
|
||||
|
||||
@@ -198,7 +197,7 @@ async def test_cerebras_zai_reasoning_replayed_as_think_tags(
|
||||
|
||||
await agent.run('Now divide that by 2.', message_history=result1.all_messages())
|
||||
|
||||
turn2_body = json.loads(vcr.requests[1].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
turn2_body = request_json(vcr.requests[1])
|
||||
assistant_messages = [m for m in turn2_body['messages'] if m.get('role') == 'assistant']
|
||||
start_tag, end_tag = model.profile.get('thinking_tags', DEFAULT_THINKING_TAGS)
|
||||
assert any(
|
||||
|
||||
+19
-13
@@ -13,6 +13,7 @@ import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cassetter import RawResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pydantic_ai import (
|
||||
@@ -28,6 +29,7 @@ from pydantic_ai import (
|
||||
)
|
||||
from pydantic_ai.usage import RequestUsage
|
||||
|
||||
from .. import cassette_hooks
|
||||
from .._inline_snapshot import snapshot
|
||||
from ..conftest import IsDatetime, IsStr, try_import
|
||||
|
||||
@@ -44,27 +46,31 @@ pytestmark = [
|
||||
_INTERNAL_SERVING_ADDRESSES = re.compile(rb'___prefill_addr_[\d.]+:\d+___decode_addr_[\d.]+:\d+_')
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
def scrub_serving_addresses(response: RawResponse) -> RawResponse:
|
||||
"""Keep Crusoe's internal serving-cluster addresses out of recorded responses.
|
||||
|
||||
Crusoe builds the completion id out of the prefill and decode pod addresses
|
||||
(`chatcmpl-___prefill_addr_10.x.x.x:PORT___decode_addr_10.x.x.x:PORT_<id>`). Nothing here
|
||||
depends on the value, so there's no reason to publish their internal topology.
|
||||
"""
|
||||
response = cassette_hooks.before_record_response(response)
|
||||
if response.body is not None:
|
||||
response.body = _INTERNAL_SERVING_ADDRESSES.sub(b'', response.body)
|
||||
return response
|
||||
|
||||
def scrub_response(response: dict[str, Any]) -> dict[str, Any]:
|
||||
body: dict[str, Any] = response['body']
|
||||
body.update(
|
||||
{
|
||||
key: _INTERNAL_SERVING_ADDRESSES.sub(b'', value)
|
||||
for key, value in body.items()
|
||||
if isinstance(value, bytes)
|
||||
}
|
||||
)
|
||||
return response
|
||||
|
||||
return {**vcr_config, 'before_record_response': scrub_response}
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**vcr_config, 'before_record_response': scrub_serving_addresses}
|
||||
|
||||
|
||||
def test_scrub_serving_addresses():
|
||||
body = b'{"id": "chatcmpl-___prefill_addr_10.0.0.1:8000___decode_addr_10.0.0.2:8000_abc"}'
|
||||
response = scrub_serving_addresses(
|
||||
RawResponse(status=200, headers={'content-type': ['application/json']}, body=body)
|
||||
)
|
||||
assert response.body == b'{"id": "chatcmpl-abc"}'
|
||||
assert scrub_serving_addresses(RawResponse(status=200, headers={}, body=None)).body is None
|
||||
|
||||
|
||||
async def test_crusoe_model_simple(allow_model_requests: None, crusoe_api_key: str):
|
||||
|
||||
@@ -15,6 +15,7 @@ from enum import Enum
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from httpx import Timeout
|
||||
from httpx2 import (
|
||||
AsyncClient as HTTPX2AsyncClient,
|
||||
@@ -25,7 +26,6 @@ from httpx2 import (
|
||||
from pydantic import BaseModel, Field
|
||||
from pytest_mock import MockerFixture
|
||||
from typing_extensions import TypedDict
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import (
|
||||
AgentRunResult,
|
||||
@@ -89,7 +89,7 @@ from pydantic_ai.tools import ToolDefinition
|
||||
from pydantic_ai.usage import RequestUsage, RunUsage, UsageLimits
|
||||
|
||||
from .._inline_snapshot import Is, snapshot
|
||||
from ..cassette_utils import single_request_body
|
||||
from ..cassette_utils import request_json, single_request_body
|
||||
from ..conftest import IsDatetime, IsInstance, IsNow, IsStr, RequestCapture, try_import
|
||||
from ..parts_from_messages import part_types_from_messages
|
||||
|
||||
@@ -3305,9 +3305,9 @@ async def test_google_vertexai_count_tokens_forwards_native_tools(
|
||||
usage_limits=UsageLimits(input_tokens_limit=999_999, count_tokens_before_request=True),
|
||||
)
|
||||
|
||||
count_requests = [request for request in vcr.requests if 'countTokens' in request.uri] # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
assert len(count_requests) == 1 # pyright: ignore[reportUnknownArgumentType]
|
||||
assert json.loads(count_requests[0].body)['tools'] == snapshot([{'googleSearch': {}}]) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
count_requests = [request for request in vcr.requests if 'countTokens' in request.uri]
|
||||
assert len(count_requests) == 1
|
||||
assert request_json(count_requests[0])['tools'] == snapshot([{'googleSearch': {}}])
|
||||
assert result.output == snapshot('The capital of France is Paris.')
|
||||
|
||||
|
||||
@@ -4083,8 +4083,8 @@ async def test_google_vertex_tool_combination_omits_include_server_side_tool_inv
|
||||
|
||||
result = await agent.run('Look up the city I live in, then search the web for its weather today.')
|
||||
|
||||
generate_requests = [request for request in vcr.requests if 'generateContent' in request.uri] # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
request_bodies = [json.loads(request.body) for request in generate_requests] # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType, reportUnknownVariableType]
|
||||
generate_requests = [request for request in vcr.requests if 'generateContent' in request.uri]
|
||||
request_bodies = [request_json(request) for request in generate_requests]
|
||||
# On the Gemini Developer API these requests carry `toolConfig.includeServerSideToolInvocations`;
|
||||
# on Vertex the field is skipped, so it is absent from every request Vertex actually accepted.
|
||||
assert [body.get('toolConfig', {}) for body in request_bodies] == snapshot(
|
||||
|
||||
@@ -13,9 +13,9 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import (
|
||||
BinaryContent,
|
||||
@@ -43,6 +43,7 @@ from pydantic_ai.settings import ThinkingLevel
|
||||
from pydantic_ai.usage import RequestUsage, RunUsage
|
||||
|
||||
from .._inline_snapshot import snapshot
|
||||
from ..cassette_utils import request_json
|
||||
from ..conftest import IsDatetime, IsInstance, IsNow, IsStr, RequestCapture, raise_if_exception, try_import
|
||||
from .mock_async_stream import MockAsyncStream
|
||||
|
||||
@@ -450,7 +451,7 @@ async def test_mistral_history_uses_prompt_cache(allow_model_requests: None, mis
|
||||
model_settings=settings,
|
||||
)
|
||||
|
||||
second_request = json.loads(vcr.requests[1].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
second_request = request_json(vcr.requests[1])
|
||||
assert second_request['messages'][2]['content'] == [{'text': first.output, 'type': 'text'}]
|
||||
assert second.usage.cache_read_tokens >= 64
|
||||
|
||||
@@ -3063,7 +3064,7 @@ async def test_mistral_forwards_penalties(allow_model_requests: None, mistral_ap
|
||||
result = await agent.run('hello')
|
||||
|
||||
assert result.output
|
||||
sent = json.loads(vcr.requests[0].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
sent = request_json(vcr.requests[0])
|
||||
assert sent['presence_penalty'] == 0.5
|
||||
assert sent['frequency_penalty'] == 0.25
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from functools import partial
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
import httpx
|
||||
@@ -46,24 +45,12 @@ pytestmark = [
|
||||
]
|
||||
|
||||
|
||||
def modify_response(response: dict[str, Any], filter_headers: list[str]) -> dict[str, Any]: # pragma: lax no cover
|
||||
for header in response['headers'].copy():
|
||||
assert isinstance(header, str)
|
||||
if header.lower() in filter_headers:
|
||||
del response['headers'][header]
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config(): # pragma: lax no cover
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]: # pragma: lax no cover
|
||||
"""Re-record the provider model lists whenever a developer with the keys runs the module."""
|
||||
if os.getenv('CI') or not os.getenv('CEREBRAS_API_KEY'):
|
||||
return {'record_mode': 'none'}
|
||||
|
||||
return {
|
||||
'record_mode': 'rewrite',
|
||||
'filter_headers': ['accept-encoding'],
|
||||
'before_record_response': partial(modify_response, filter_headers=['cache-control', 'connection']),
|
||||
}
|
||||
return vcr_config
|
||||
return {**vcr_config, 'record_mode': 'rewrite'}
|
||||
|
||||
|
||||
_PROVIDER_TO_MODEL_NAMES = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -139,18 +141,14 @@ def test_ollama_provider_name_routes_through_ollama_model(monkeypatch: pytest.Mo
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config():
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Override the repo-wide `vcr_config` fixture so that localhost traffic is recorded.
|
||||
|
||||
The local Ollama tests need to replay `http://localhost:11434` cassettes in CI where
|
||||
there is no running daemon; the repo default ignores localhost so those requests
|
||||
would pass through live.
|
||||
"""
|
||||
return {
|
||||
'ignore_localhost': False,
|
||||
'filter_headers': ['authorization', 'x-api-key'],
|
||||
'decode_compressed_response': True,
|
||||
}
|
||||
return {**vcr_config, 'ignore_localhost': False}
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
|
||||
@@ -6,8 +6,7 @@ from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from vcr.cassette import Cassette
|
||||
from vcr.record_mode import RecordMode
|
||||
from cassetter import Cassette, RecordMode
|
||||
|
||||
from pydantic_ai import (
|
||||
Agent,
|
||||
|
||||
@@ -14,13 +14,12 @@ the real APIs.
|
||||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal, cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai import Agent, BinaryContent, CachePoint, ImageUrl
|
||||
from pydantic_ai.exceptions import UserError
|
||||
@@ -28,6 +27,7 @@ from pydantic_ai.messages import ModelMessagesTypeAdapter
|
||||
from pydantic_ai.usage import RunUsage
|
||||
|
||||
from .._inline_snapshot import snapshot
|
||||
from ..cassette_utils import request_json
|
||||
from ..conftest import try_import
|
||||
from .mock_openai import (
|
||||
MockOpenAI,
|
||||
@@ -717,8 +717,7 @@ _STABLE_PREFIX = 'Reference catalogue for the prompt cache test corpus.\n' + '\n
|
||||
|
||||
|
||||
def _request_body(cassette: Cassette, index: int) -> dict[str, Any]:
|
||||
body = cast('Any', cassette.requests)[index].body # pyright: ignore[reportUnknownMemberType]
|
||||
return cast('dict[str, Any]', json.loads(body))
|
||||
return cast('dict[str, Any]', request_json(cassette.requests[index]))
|
||||
|
||||
|
||||
def _assert_cache_usage(first: RunUsage, second: RunUsage) -> None:
|
||||
|
||||
@@ -13,10 +13,9 @@ from typing import Any, Literal, cast
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from cassetter import Cassette, RecordMode
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
from vcr.cassette import Cassette
|
||||
from vcr.record_mode import RecordMode
|
||||
|
||||
from pydantic_ai import (
|
||||
BinaryContent,
|
||||
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from inline_snapshot import snapshot
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import (
|
||||
CompactionPart,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterable, Sequence
|
||||
from copy import deepcopy
|
||||
@@ -9,8 +8,8 @@ from typing import Any, Literal, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import (
|
||||
Agent,
|
||||
@@ -1576,7 +1575,7 @@ async def test_openrouter_web_search_tool_usage(
|
||||
response = result.all_messages()[-1]
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.provider_details is not None
|
||||
raw_response = json.loads(vcr.responses[0]['body']['string']) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
raw_response = vcr.interactions[0].response.body.content
|
||||
assert raw_response['usage']['server_tool_use_details'] == {'web_search_requests': 1}
|
||||
assert response.provider_details['server_tool_use'] == {'web_search_requests': 1}
|
||||
assert 'annotations' not in response.provider_details
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import BaseModel
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.messages import ThinkingPart
|
||||
@@ -14,6 +13,7 @@ from pydantic_ai.models import ModelRequestParameters, infer_model
|
||||
from pydantic_ai.output import NativeOutput
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
from ..cassette_utils import request_json
|
||||
from ..conftest import IsStr, TestEnv, try_import
|
||||
|
||||
with try_import() as imports_successful:
|
||||
@@ -236,13 +236,13 @@ async def test_snowflake_thinking(allow_model_requests: None, live_provider: Sno
|
||||
{'format': 'anthropic-claude-v1', 'index': 0, 'type': 'reasoning.text'}
|
||||
)
|
||||
|
||||
first_request = json.loads(vcr.requests[0].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
first_request = request_json(vcr.requests[0])
|
||||
assert first_request['reasoning'] == {'effort': 'low'}
|
||||
# Cortex applies a non-1 default temperature server-side, which Claude rejects with thinking enabled.
|
||||
assert first_request['temperature'] == 1
|
||||
|
||||
# The second request replays the thinking as `reasoning_details` on the assistant message.
|
||||
second_request = json.loads(vcr.requests[1].body) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType]
|
||||
second_request = request_json(vcr.requests[1])
|
||||
assistant_message = next(m for m in second_request['messages'] if m['role'] == 'assistant')
|
||||
assert assistant_message['reasoning_details'][0]['type'] == 'reasoning.text'
|
||||
assert assistant_message['reasoning_details'][0]['signature']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Proto cassette utilities for xAI SDK (gRPC) tests.
|
||||
|
||||
Why this exists:
|
||||
- `pytest-recording`/VCR only records HTTP. The xAI SDK uses gRPC, so VCR can't record/replay model calls.
|
||||
- The HTTP cassettes only record HTTP. The xAI SDK uses gRPC, so they can't record/replay model calls.
|
||||
- However, xAI responses are protobuf messages. We can serialize them and store them in YAML cassettes.
|
||||
|
||||
This is intentionally minimal for now:
|
||||
@@ -144,7 +144,7 @@ def _truthy_env(name: str) -> bool:
|
||||
|
||||
|
||||
def _normalize_record_mode(mode: str | None) -> ProtoCassetteRecordMode | None:
|
||||
"""Normalize pytest-recording/VCR-ish record modes to a small supported set.
|
||||
"""Normalize cassetter/VCR-ish record modes to a small supported set.
|
||||
|
||||
Notes:
|
||||
- VCR uses: `none`, `once`, `new_episodes`, `all`
|
||||
|
||||
@@ -293,13 +293,9 @@ def gateway_api_key():
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config():
|
||||
return {
|
||||
'ignore_localhost': False,
|
||||
# Note: additional header filtering is done inside the serializer
|
||||
'filter_headers': ['authorization', 'x-api-key'],
|
||||
'decode_compressed_response': True,
|
||||
}
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The gateway cassettes were recorded against a local gateway, so localhost traffic must replay."""
|
||||
return {**vcr_config, 'ignore_localhost': False}
|
||||
|
||||
|
||||
@patch.dict(
|
||||
|
||||
@@ -4,15 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from urllib.parse import parse_qs
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
import anyio
|
||||
import httpx2
|
||||
import pytest
|
||||
from typing_extensions import TypedDict
|
||||
from cassetter import RawRequest, RawResponse
|
||||
|
||||
from pydantic_ai.exceptions import UserError
|
||||
|
||||
from .. import cassette_hooks
|
||||
from ..conftest import try_import
|
||||
|
||||
with try_import() as imports_successful:
|
||||
@@ -34,33 +36,61 @@ DEVICE = {
|
||||
TOKEN = {'access_token': 'secret-access-token', 'token_type': 'bearer', 'scope': 'read:user'}
|
||||
|
||||
|
||||
class RecordedBody(TypedDict):
|
||||
string: bytes | str
|
||||
def scrub_device_grant_request(request: RawRequest) -> RawRequest:
|
||||
"""Keep the device grant's one-time codes out of recorded requests."""
|
||||
request = cassette_hooks.before_record_request(request)
|
||||
if request.body is not None:
|
||||
form = parse_qs(request.body.decode())
|
||||
for key in ('device_code', 'refresh_token'):
|
||||
if key in form:
|
||||
form[key] = ['scrubbed']
|
||||
request.body = urlencode(form, doseq=True).encode()
|
||||
return request
|
||||
|
||||
|
||||
class RecordedResponse(TypedDict):
|
||||
body: RecordedBody
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config() -> dict[str, object]:
|
||||
def scrub_response(response: RecordedResponse) -> RecordedResponse:
|
||||
body = response['body']['string']
|
||||
data: dict[str, object] = json.loads(body)
|
||||
def scrub_device_grant_response(response: RawResponse) -> RawResponse:
|
||||
"""Keep the device grant's codes and tokens out of recorded responses."""
|
||||
response = cassette_hooks.before_record_response(response)
|
||||
if response.body is not None:
|
||||
data: dict[str, object] = json.loads(response.body)
|
||||
for key in ('device_code', 'user_code', 'access_token', 'refresh_token'):
|
||||
if key in data:
|
||||
data[key] = 'scrubbed'
|
||||
response['body']['string'] = json.dumps(data).encode()
|
||||
return response
|
||||
response.body = json.dumps(data).encode()
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
'filter_headers': ['authorization', 'cookie'],
|
||||
'filter_post_data_parameters': ['device_code', 'refresh_token'],
|
||||
'before_record_response': scrub_response,
|
||||
'decode_compressed_response': True,
|
||||
**vcr_config,
|
||||
'before_record_request': scrub_device_grant_request,
|
||||
'before_record_response': scrub_device_grant_response,
|
||||
}
|
||||
|
||||
|
||||
def test_device_grant_hooks_scrub_codes_and_tokens():
|
||||
request = scrub_device_grant_request(
|
||||
RawRequest(
|
||||
method='POST',
|
||||
uri='https://github.com/login/oauth/access_token',
|
||||
headers={'content-type': ['application/x-www-form-urlencoded']},
|
||||
body=b'client_id=abc&device_code=secret-device-code&grant_type=urn',
|
||||
)
|
||||
)
|
||||
# `client_id` is one of the fields the shared hook already blanks.
|
||||
assert request.body == b'client_id=scrubbed&device_code=scrubbed&grant_type=urn'
|
||||
response = scrub_device_grant_response(
|
||||
RawResponse(status=200, headers={'content-type': ['application/json']}, body=json.dumps(DEVICE).encode())
|
||||
)
|
||||
assert json.loads(response.body or b'') == {**DEVICE, 'device_code': 'scrubbed', 'user_code': 'scrubbed'}
|
||||
assert (
|
||||
scrub_device_grant_request(RawRequest(method='GET', uri='https://github.com/', headers={}, body=None)).body
|
||||
is None
|
||||
)
|
||||
assert scrub_device_grant_response(RawResponse(status=204, headers={}, body=None)).body is None
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
async def test_start_against_github() -> None:
|
||||
"""Record only initiation, using the public GitHub CLI client; no user approves this grant."""
|
||||
|
||||
+19
-24
@@ -11,7 +11,9 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from cassetter import RawRequest, RawResponse
|
||||
|
||||
from .. import cassette_hooks
|
||||
from ..conftest import sanitize_filename, try_import
|
||||
from .ws_cassettes import ProviderName, RealtimeCassette, patched_ws_connect, realtime_cassette_plan
|
||||
|
||||
@@ -90,22 +92,19 @@ a=setup:actpass""".strip().splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _scrub_ephemeral_secret(response: dict[str, Any]) -> dict[str, Any]:
|
||||
def _scrub_ephemeral_secret(response: RawResponse) -> RawResponse:
|
||||
"""Redact the short-lived WebRTC client secret from recorded `/realtime/client_secrets` responses.
|
||||
|
||||
The mint response body carries `{"value": "ek_..."}` — the ephemeral browser token. It expires in
|
||||
seconds and is useless offline, but replacing it keeps recorded cassettes free of anything
|
||||
secret-shaped. (The api-key / Entra bearer used to mint it are filtered out via `filter_headers`.)
|
||||
secret-shaped. (The api-key / Entra bearer used to mint it are filtered out by the repo-wide hooks.)
|
||||
"""
|
||||
try:
|
||||
raw = response['body']['string']
|
||||
except (KeyError, TypeError): # non-body responses
|
||||
return response
|
||||
if not raw: # empty body
|
||||
response = cassette_hooks.before_record_response(response)
|
||||
if not response.body: # empty body
|
||||
return response
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError): # non-JSON body
|
||||
data = json.loads(response.body)
|
||||
except ValueError: # non-JSON body
|
||||
return response
|
||||
if not isinstance(data, dict): # non-object JSON body
|
||||
return response
|
||||
@@ -113,8 +112,7 @@ def _scrub_ephemeral_secret(response: dict[str, Any]) -> dict[str, Any]:
|
||||
value = body_data.get('value')
|
||||
if isinstance(value, str) and value.startswith('ek_'):
|
||||
body_data['value'] = 'ek_scrubbed'
|
||||
body = json.dumps(body_data)
|
||||
response['body']['string'] = body.encode() if isinstance(raw, bytes) else body
|
||||
response.body = json.dumps(body_data).encode()
|
||||
return response
|
||||
|
||||
|
||||
@@ -123,7 +121,7 @@ def _scrub_ephemeral_secret(response: dict[str, Any]) -> dict[str, Any]:
|
||||
_SDP_ADDRESS_RE = re.compile(rb'^(?P<prefix>c=IN IP[46] |a=candidate:\S+ \d+ \S+ \d+ )(?P<address>\S+)', re.MULTILINE)
|
||||
|
||||
|
||||
def _zero_sdp_addresses(request: Any) -> Any:
|
||||
def _zero_sdp_addresses(request: RawRequest) -> RawRequest:
|
||||
"""Zero out the network addresses in a recorded SDP offer.
|
||||
|
||||
A cassette recorded against a *live* WebRTC peer (see `_webrtc_media_peer` — the only way to get
|
||||
@@ -131,29 +129,26 @@ def _zero_sdp_addresses(request: Any) -> Any:
|
||||
replays or matches on a recorded request body, so blanking them costs nothing, and it keeps
|
||||
hand-zeroing them (as `REAL_SDP_OFFER` above was) from being a step someone has to remember.
|
||||
"""
|
||||
body = request.body
|
||||
if isinstance(body, bytes):
|
||||
request = cassette_hooks.before_record_request(request)
|
||||
if request.body is not None:
|
||||
# Zero every address the regex finds, not just when an ICE candidate is present: an SDP whose
|
||||
# only address is the `c=IN IP4/IP6` connection line (no `a=candidate:` lines) would otherwise
|
||||
# be recorded with the recorder's real address intact.
|
||||
request.body = _SDP_ADDRESS_RE.sub(
|
||||
lambda match: match['prefix'] + (b'0.0.0.0' if b'.' in match['address'] else b'::'), body
|
||||
lambda match: match['prefix'] + (b'0.0.0.0' if b'.' in match['address'] else b'::'), request.body
|
||||
)
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def vcr_config() -> dict[str, Any]:
|
||||
"""VCR config for realtime HTTP (WebRTC signaling) cassettes.
|
||||
def vcr_config(vcr_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Cassette config for realtime HTTP (WebRTC signaling) cassettes.
|
||||
|
||||
Extends the repo default with Azure's `api-key` header (the WebSocket cassettes never record HTTP,
|
||||
so the default set omits it), scrubs the minted ephemeral client secret from response bodies, and
|
||||
zeroes the network addresses in a recorded SDP offer.
|
||||
Extends the repo default to scrub the minted ephemeral client secret from response bodies and zero
|
||||
the network addresses in a recorded SDP offer.
|
||||
"""
|
||||
return {
|
||||
'ignore_localhost': True,
|
||||
'filter_headers': ['authorization', 'x-api-key', 'api-key', 'cookie'],
|
||||
'decode_compressed_response': True,
|
||||
**vcr_config,
|
||||
'before_record_request': _zero_sdp_addresses,
|
||||
'before_record_response': _scrub_ephemeral_secret,
|
||||
}
|
||||
@@ -186,7 +181,7 @@ def _realtime_api_keys(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _record_mode(request: pytest.FixtureRequest) -> str | None:
|
||||
try:
|
||||
return cast('Any', request.config).getoption('record_mode')
|
||||
# Depends on pytest-recording being active.
|
||||
# Depends on cassetter's pytest plugin being active.
|
||||
except (ValueError, AttributeError): # pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Any
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
from cassetter import RawRequest, RawResponse
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.agent import WrapperAgent
|
||||
@@ -224,26 +225,29 @@ def test_parse_call_id(location: str | None, expected: str | None) -> None:
|
||||
|
||||
|
||||
def test_scrub_ephemeral_secret_redacts_client_secret() -> None:
|
||||
"""The VCR `before_record_response` hook redacts the minted `ek_...` client secret from recorded bodies.
|
||||
"""The `before_record_response` hook redacts the minted `ek_...` client secret from recorded bodies.
|
||||
|
||||
A unit test because the hook only runs while *recording* a cassette; offline replay never invokes it,
|
||||
so a cassette test can't reach it — yet it's the guard that keeps recorded signaling cassettes free of
|
||||
anything secret-shaped.
|
||||
"""
|
||||
minted = {'body': {'string': json.dumps({'value': 'ek_live_secret', 'expires_at': 1}).encode()}}
|
||||
assert json.loads(_scrub_ephemeral_secret(minted)['body']['string'])['value'] == 'ek_scrubbed'
|
||||
|
||||
def scrub(body: bytes | None) -> bytes | None:
|
||||
return _scrub_ephemeral_secret(RawResponse(status=200, headers={}, body=body)).body
|
||||
|
||||
minted = json.dumps({'value': 'ek_live_secret', 'expires_at': 1}).encode()
|
||||
assert json.loads(scrub(minted) or b'')['value'] == 'ek_scrubbed'
|
||||
# A non-secret JSON body is returned unchanged.
|
||||
other = {'body': {'string': b'{"foo": "bar"}'}}
|
||||
assert _scrub_ephemeral_secret(other)['body']['string'] == b'{"foo": "bar"}'
|
||||
assert scrub(b'{"foo": "bar"}') == b'{"foo": "bar"}'
|
||||
# The defensive guards pass non-body, empty, non-JSON, and non-object bodies through untouched.
|
||||
assert _scrub_ephemeral_secret({}) == {}
|
||||
assert _scrub_ephemeral_secret({'body': {'string': b''}})['body']['string'] == b''
|
||||
assert _scrub_ephemeral_secret({'body': {'string': b'not json'}})['body']['string'] == b'not json'
|
||||
assert _scrub_ephemeral_secret({'body': {'string': b'[1, 2]'}})['body']['string'] == b'[1, 2]'
|
||||
assert scrub(None) is None
|
||||
assert scrub(b'') == b''
|
||||
assert scrub(b'not json') == b'not json'
|
||||
assert scrub(b'[1, 2]') == b'[1, 2]'
|
||||
|
||||
|
||||
def test_zero_sdp_addresses_blanks_offer_addresses() -> None:
|
||||
"""The VCR `before_record_request` hook keeps the recorder's own addresses out of a cassette.
|
||||
"""The `before_record_request` hook keeps the recorder's own addresses out of a cassette.
|
||||
|
||||
A unit test for the same reason as the one above: the hook only runs while recording. It matters
|
||||
for the sideband audio cassette, whose offer comes from a live `aiortc` peer rather than the
|
||||
@@ -251,9 +255,8 @@ def test_zero_sdp_addresses_blanks_offer_addresses() -> None:
|
||||
addresses.
|
||||
"""
|
||||
|
||||
class _Request:
|
||||
def __init__(self, body: Any) -> None:
|
||||
self.body = body
|
||||
def zero(body: bytes | None) -> bytes | None:
|
||||
return _zero_sdp_addresses(RawRequest(method='POST', uri='https://example.com/', headers={}, body=body)).body
|
||||
|
||||
offer = (
|
||||
b'--boundary\r\nContent-Type: application/sdp\r\n\r\n'
|
||||
@@ -262,7 +265,7 @@ def test_zero_sdp_addresses_blanks_offer_addresses() -> None:
|
||||
b'a=candidate:2 1 udp 2130706431 fd7a:115c:a1e0::1 57945 typ host\r\n'
|
||||
b'c=IN IP6 fd7a:115c:a1e0::1\r\na=ice-ufrag:creB\r\n'
|
||||
)
|
||||
assert _zero_sdp_addresses(_Request(offer)).body == (
|
||||
assert zero(offer) == (
|
||||
b'--boundary\r\nContent-Type: application/sdp\r\n\r\n'
|
||||
b'v=0\r\nc=IN IP4 0.0.0.0\r\n'
|
||||
b'a=candidate:1 1 udp 2130706431 0.0.0.0 46294 typ host\r\n'
|
||||
@@ -270,13 +273,13 @@ def test_zero_sdp_addresses_blanks_offer_addresses() -> None:
|
||||
b'c=IN IP6 ::\r\na=ice-ufrag:creB\r\n'
|
||||
)
|
||||
# An SDP whose only address is the `c=` connection line (no `a=candidate:` lines) is still zeroed:
|
||||
# the substitution is gated on the body being bytes, not on an ICE candidate being present.
|
||||
assert _zero_sdp_addresses(_Request(b'v=0\r\nc=IN IP4 192.168.1.5\r\na=ice-ufrag:creB\r\n')).body == (
|
||||
# the substitution is gated on the body being present, not on an ICE candidate being present.
|
||||
assert zero(b'v=0\r\nc=IN IP4 192.168.1.5\r\na=ice-ufrag:creB\r\n') == (
|
||||
b'v=0\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:creB\r\n'
|
||||
)
|
||||
# Bodies with no address fields at all — every other recorded request — are unchanged.
|
||||
assert _zero_sdp_addresses(_Request(b'{"model": "gpt-realtime"}')).body == b'{"model": "gpt-realtime"}'
|
||||
assert _zero_sdp_addresses(_Request(None)).body is None
|
||||
assert zero(b'{"model": "gpt-realtime"}') == b'{"model": "gpt-realtime"}'
|
||||
assert zero(None) is None
|
||||
|
||||
|
||||
# --- client secret minting --------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""WebSocket cassette utilities for realtime provider tests.
|
||||
|
||||
Realtime providers talk over a persistent WebSocket rather than the request/response HTTP that
|
||||
`pytest-recording` / VCR captures, so VCR can't record their traffic. These helpers record and
|
||||
the HTTP cassettes capture, so those can't record their traffic. These helpers record and
|
||||
replay the actual JSON frames exchanged with the provider, letting cassette-backed tests exercise
|
||||
the *real* protocol offline:
|
||||
|
||||
@@ -237,7 +237,7 @@ CassettePlan = Literal['replay', 'record', 'error_missing']
|
||||
|
||||
|
||||
def realtime_cassette_plan(*, cassette_exists: bool, record_mode: str | None) -> CassettePlan:
|
||||
"""Decide replay vs. record, mirroring the repo's `pytest-recording` record modes."""
|
||||
"""Decide replay vs. record, mirroring the repo's `--record-mode` values."""
|
||||
mode = (record_mode or 'none').strip().lower()
|
||||
if mode in {'rewrite', 'all'}:
|
||||
return 'record'
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from vcr.record_mode import RecordMode
|
||||
from cassetter import RecordMode
|
||||
|
||||
from .cassette_utils import (
|
||||
canonical_prefix_blocks,
|
||||
@@ -95,7 +95,7 @@ def test_cache_prefix_fixture_skips_uncheckable_cassettes(call_report: Any, cass
|
||||
"""Failed tests and missing cassette files must not produce a second teardown failure."""
|
||||
node = SimpleNamespace(rep_setup=SimpleNamespace(skipped=False, failed=False), rep_call=call_report)
|
||||
request = SimpleNamespace(node=node)
|
||||
vcr = SimpleNamespace(record_mode=RecordMode.NONE, _path=cassette_path)
|
||||
vcr = SimpleNamespace(record_mode=RecordMode.NONE, path=cassette_path)
|
||||
fixture = cast(Callable[[Any, Any], Iterator[None]], getattr(fail_cache_prefix_violations, '__wrapped__'))
|
||||
iterator = fixture(cast(Any, request), cast(Any, vcr))
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ bigger bill and higher latency (the PR #4338 class of bug).
|
||||
Each test records a real conversation, round-trips the resulting history through an adapter,
|
||||
then sends the same follow-up prompt twice — once with the original history, once with the
|
||||
round-tripped one — and compares the two recorded request bodies. Note the comparison is over
|
||||
the re-serialized JSON, not the raw wire bytes: the project's cassette serializer stores each
|
||||
JSON body as a parsed structure and replays it via `json.dumps` (see `tests/json_body_serializer`),
|
||||
so whitespace-only differences are normalized away. What survives — and what these tests guard —
|
||||
the re-serialized JSON, not the raw wire bytes: cassetter stores each JSON body as a parsed
|
||||
structure and `tests/cassette_utils.py` reads it back through `json.dumps`, so whitespace-only
|
||||
differences are normalized away. What survives — and what these tests guard —
|
||||
is the structural and field-value drift a lossy round-trip actually produces: dropped or reordered
|
||||
blocks, a changed tool-arg representation, a missing thinking signature. Coverage targets the
|
||||
PR #5873 wire-fidelity risks: tool-call arg serialization and thinking signatures.
|
||||
@@ -27,7 +27,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.messages import ModelMessage
|
||||
@@ -54,9 +54,9 @@ with try_import() as anthropic_imports_successful:
|
||||
pytestmark = [pytest.mark.vcr]
|
||||
|
||||
|
||||
def _post_bodies(vcr: Cassette) -> list[bytes | str]:
|
||||
def _post_bodies(vcr: Cassette) -> list[bytes | str | None]:
|
||||
"""Bodies of the POST requests recorded in `vcr`, in order."""
|
||||
return [request.body for request in vcr.requests if request.method == 'POST'] # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
return [request.body for request in vcr.requests if request.method == 'POST']
|
||||
|
||||
|
||||
def _vercel_roundtrip(history: list[ModelMessage]) -> list[ModelMessage]:
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""The recording hooks only run while a cassette is being recorded, so replay never reaches them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
import brotli
|
||||
import pytest
|
||||
from cassetter import RawRequest, RawResponse, SkipRecording
|
||||
|
||||
from ._inline_snapshot import snapshot
|
||||
from .cassette_hooks import (
|
||||
before_record_request,
|
||||
before_record_response,
|
||||
filter_headers,
|
||||
normalize_uri,
|
||||
scrub_xml_credentials,
|
||||
)
|
||||
|
||||
|
||||
def _request(uri: str = 'https://api.example.com/v1', body: bytes | None = None, **headers: str) -> RawRequest:
|
||||
return RawRequest(method='POST', uri=uri, headers={k: [v] for k, v in headers.items()}, body=body)
|
||||
|
||||
|
||||
def _response(body: bytes | None = None, **headers: str) -> RawResponse:
|
||||
return RawResponse(status=200, headers={k: [v] for k, v in headers.items()}, body=body)
|
||||
|
||||
|
||||
def test_filtered_headers_removed_and_lowercased():
|
||||
headers = filter_headers(
|
||||
{
|
||||
'Content-Type': ['application/json'],
|
||||
'Authorization': ['some-token'],
|
||||
'Date': ['some-date-string'],
|
||||
'X-Test': ['some-token'],
|
||||
'cf-ray': ['abc'],
|
||||
'anthropic-ratelimit-requests-remaining': ['5'],
|
||||
'Other-Header': ['test-value'],
|
||||
'x-xet-hash': ['keep'],
|
||||
'X-Amzn-Bedrock-Input-Token-Count': ['keep'],
|
||||
'x-goog-upload-url': ['keep'],
|
||||
}
|
||||
)
|
||||
assert headers == snapshot(
|
||||
{
|
||||
'content-type': ['application/json'],
|
||||
'other-header': ['test-value'],
|
||||
'x-xet-hash': ['keep'],
|
||||
'x-amzn-bedrock-input-token-count': ['keep'],
|
||||
'x-goog-upload-url': ['keep'],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['https://oauth2.googleapis.com/token', 'https://auth.openai.com/oauth/token'])
|
||||
def test_oauth_token_exchanges_are_not_recorded(url: str):
|
||||
with pytest.raises(SkipRecording):
|
||||
before_record_request(_request(url))
|
||||
|
||||
|
||||
def test_oauth_credentials_are_scrubbed():
|
||||
request = before_record_request(
|
||||
_request(
|
||||
body=b'code=oauth-code&code_verifier=oauth-verifier&refresh_token=oauth-refresh',
|
||||
**{
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Bearer oauth-access',
|
||||
'ChatGPT-Account-ID': 'oauth-account',
|
||||
},
|
||||
)
|
||||
)
|
||||
response = before_record_response(
|
||||
_response(
|
||||
body=b'{"access_token":"oauth-access","refresh_token":"oauth-refresh","id_token":"oauth-id"}',
|
||||
**{'Content-Type': 'application/json'},
|
||||
)
|
||||
)
|
||||
assert b'oauth-' not in (request.body or b'') + (response.body or b'')
|
||||
assert (request.headers, request.body) == snapshot(
|
||||
(
|
||||
{'content-type': ['application/x-www-form-urlencoded']},
|
||||
b'code=scrubbed&code_verifier=scrubbed&refresh_token=scrubbed',
|
||||
)
|
||||
)
|
||||
assert (response.headers, response.body) == snapshot(
|
||||
(
|
||||
{'content-type': ['application/json']},
|
||||
b'{"access_token": "scrubbed", "refresh_token": "scrubbed", "id_token": "scrubbed"}',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('content_type', ['application/json', 'text/event-stream', None])
|
||||
def test_safety_identifier_is_scrubbed(content_type: str | None):
|
||||
body = '{"safety_identifier":"synthetic-user-id","output":[]}'
|
||||
if content_type != 'application/json':
|
||||
body = f'data: {{"response":{body}}}\n\ndata: [DONE]\n\n'
|
||||
headers = {'content-type': content_type} if content_type is not None else {}
|
||||
response = before_record_response(_response(body=body.encode(), **headers))
|
||||
assert response.body is not None
|
||||
assert b'synthetic-user-id' not in response.body
|
||||
assert b'scrubbed' in response.body
|
||||
|
||||
|
||||
@pytest.mark.parametrize('encoding', ['gzip', 'br'])
|
||||
def test_compressed_bodies_are_inflated_before_scrubbing(encoding: str):
|
||||
raw = b'{"access_token": "secret", "ok": true}'
|
||||
compressed = gzip.compress(raw) if encoding == 'gzip' else cast('bytes', brotli.compress(raw)) # pyright: ignore[reportUnknownMemberType]
|
||||
response = before_record_response(
|
||||
_response(body=compressed, **{'content-type': 'application/json', 'content-encoding': encoding})
|
||||
)
|
||||
assert 'content-encoding' not in response.headers
|
||||
assert response.body is not None
|
||||
assert json.loads(response.body) == {'access_token': 'scrubbed', 'ok': True}
|
||||
|
||||
|
||||
def test_undecodable_body_keeps_its_content_encoding():
|
||||
response = before_record_response(
|
||||
_response(body=b'\x1f\x8bnot really gzip', **{'content-type': 'application/json', 'content-encoding': 'gzip'})
|
||||
)
|
||||
assert response.headers['content-encoding'] == ['gzip']
|
||||
assert response.body == b'\x1f\x8bnot really gzip'
|
||||
|
||||
|
||||
def test_smart_characters_are_normalized():
|
||||
response = before_record_response(
|
||||
# Escaped so the `fix-smartquotes` pre-commit hook leaves the fixture alone.
|
||||
_response(
|
||||
body='{"text": "\u201cquoted\u201d \u2014 it\u2019s\u2026"}'.encode(),
|
||||
**{'content-type': 'application/json'},
|
||||
)
|
||||
)
|
||||
assert response.body == snapshot(b'{"text": "\\"quoted\\" -- it\'s..."}')
|
||||
|
||||
|
||||
def test_json_array_body_is_normalized_without_scrubbing():
|
||||
response = before_record_response(
|
||||
_response(body='[{"access_token": "kept"}, "\u2019"]'.encode(), **{'content-type': 'application/json'})
|
||||
)
|
||||
assert response.body == b'[{"access_token": "kept"}, "\'"]'
|
||||
|
||||
|
||||
def test_non_json_body_under_json_content_type_is_kept():
|
||||
response = before_record_response(_response(body=b'\x00\x01 not json', **{'content-type': 'application/json'}))
|
||||
assert response.body == b'\x00\x01 not json'
|
||||
|
||||
|
||||
def test_empty_bodies_pass_through():
|
||||
assert before_record_request(_request(body=None)).body is None
|
||||
assert before_record_response(_response(body=b'')).body == b''
|
||||
assert before_record_response(_response(body=b'plain', **{'content-type': 'text/plain'})).body == b'plain'
|
||||
|
||||
|
||||
def test_aws_account_id_is_scrubbed_from_request_uri():
|
||||
request = before_record_request(
|
||||
_request(
|
||||
'https://bedrock-runtime.us-east-1.amazonaws.com/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A111122223333%3Ainference-profile%2Fus.anthropic.claude/converse'
|
||||
)
|
||||
)
|
||||
assert request.uri == snapshot(
|
||||
'https://bedrock-runtime.us-east-1.amazonaws.com/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Ainference-profile%2Fus.anthropic.claude/converse'
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_uri_erases_region_project_and_account():
|
||||
assert normalize_uri(
|
||||
'https://bedrock-runtime.eu-west-2.amazonaws.com/model/arn:aws:bedrock:eu-west-2:111122223333:inference-profile/x/converse'
|
||||
) == snapshot(
|
||||
'https://bedrock-runtime.REGION.amazonaws.com/model/arn:aws:bedrock:eu-west-2:123456789012:inference-profile/x/converse'
|
||||
)
|
||||
assert normalize_uri(
|
||||
'https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini:generateContent'
|
||||
) == snapshot(
|
||||
'https://aiplatform.googleapis.com/v1/projects/PROJECT/locations/REGION/publishers/google/models/gemini:generateContent'
|
||||
)
|
||||
assert normalize_uri('https://api.openai.com/v1/responses') == 'https://api.openai.com/v1/responses'
|
||||
|
||||
|
||||
def test_scrub_xml_credentials_redacts_sts_tokens():
|
||||
xml_body = (
|
||||
b'<AssumeRoleWithWebIdentityResponse>'
|
||||
b'<Credentials>'
|
||||
b'<AccessKeyId>ASIA1234</AccessKeyId>'
|
||||
b'<SecretAccessKey>secret123</SecretAccessKey>'
|
||||
b'<SessionToken>token456</SessionToken>'
|
||||
b'<Expiration>2026-01-01T00:00:00Z</Expiration>'
|
||||
b'</Credentials>'
|
||||
b'</AssumeRoleWithWebIdentityResponse>'
|
||||
)
|
||||
assert scrub_xml_credentials(xml_body) == snapshot(
|
||||
b'<AssumeRoleWithWebIdentityResponse><Credentials><AccessKeyId>SCRUBBED</AccessKeyId><SecretAccessKey>SCRUBBED</SecretAccessKey><SessionToken>SCRUBBED</SessionToken><Expiration>2099-01-01T00:00:00Z</Expiration></Credentials></AssumeRoleWithWebIdentityResponse>'
|
||||
)
|
||||
|
||||
|
||||
def test_scrub_xml_credentials_only_touches_credentials():
|
||||
assert scrub_xml_credentials(b'<Response>ok</Response>') == b'<Response>ok</Response>'
|
||||
# Dispatch is on the content type, so JSON carrying a `<Credentials>` tag is left to the JSON scrubber.
|
||||
response = before_record_response(
|
||||
_response(body=b'{"xml": "<Credentials>secret</Credentials>"}', **{'content-type': 'application/json'})
|
||||
)
|
||||
assert response.body == b'{"xml": "<Credentials>secret</Credentials>"}'
|
||||
response = before_record_response(
|
||||
_response(body=b'<Credentials>secret</Credentials>', **{'content-type': 'text/xml'})
|
||||
)
|
||||
assert response.body == snapshot(b'<Credentials>secret</Credentials>')
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from cassetter import Cassette, RecordMode
|
||||
|
||||
from tests.cassette_utils import (
|
||||
CassetteContext,
|
||||
@@ -77,17 +78,25 @@ class TestGetCassetteBodiesFromYaml:
|
||||
assert len(bodies) == 1
|
||||
assert '"role": "user"' in bodies[0]
|
||||
|
||||
def test_cassetter_typed_bodies(self, tmp_path: Path) -> None:
|
||||
"""Cassettes written by cassetter carry a typed body mapping instead of `parsed_body`."""
|
||||
cassette_data: dict[str, Any] = {
|
||||
'interactions': [
|
||||
{'request': {'body': {'type': 'json', 'content': {'model': 'gpt-4'}}}},
|
||||
{'request': {'body': {'type': 'text', 'content': 'raw request body'}}},
|
||||
{'request': {'body': {'type': 'binary', 'content': 'AAE='}}},
|
||||
{'request': {'body': {'type': 'none'}}},
|
||||
]
|
||||
}
|
||||
path = tmp_path / 'cassette.yaml'
|
||||
path.write_text(yaml.dump(cassette_data), encoding='utf-8')
|
||||
assert _get_cassette_bodies_from_yaml(path) == ['{"model": "gpt-4"}', 'raw request body']
|
||||
|
||||
|
||||
def test_get_first_post_body_skips_non_post_request() -> None:
|
||||
from vcr.cassette import Cassette
|
||||
from vcr.request import Request
|
||||
|
||||
cassette = Cassette('fake.yaml')
|
||||
cassette.append(Request('GET', 'https://example.com', None, dict[str, str]()), {}) # pyright: ignore[reportUnknownMemberType]
|
||||
cassette.append( # pyright: ignore[reportUnknownMemberType]
|
||||
Request('POST', 'https://example.com', b'{"key": "value"}', dict[str, str]()),
|
||||
{},
|
||||
)
|
||||
cassette = Cassette('fake.yaml', record_mode=RecordMode.NONE)
|
||||
cassette.record('GET', 'https://example.com', {}, None, 200, {}, b'')
|
||||
cassette.record('POST', 'https://example.com', {}, b'{"key": "value"}', 200, {}, b'')
|
||||
|
||||
assert get_first_post_body(cassette) == {'key': 'value'}
|
||||
|
||||
|
||||
@@ -339,9 +339,8 @@ async def test_anthropic_code_execution_files_rejected_container_is_dropped_and_
|
||||
|
||||
assert '100' in result.output
|
||||
|
||||
recorded_error = vcr.responses[1]
|
||||
recorded_error_body = json.loads(recorded_error['body']['string'])
|
||||
assert (recorded_error['status']['code'], recorded_error_body['error']['type']) == snapshot((500, 'api_error'))
|
||||
recorded_error = vcr.interactions[1].response
|
||||
assert (recorded_error.status, recorded_error.body.content['error']['type']) == snapshot((500, 'api_error'))
|
||||
|
||||
# Both attempts are on the wire, and only the first carries the dead id.
|
||||
bodies = request_capture.bodies('/v1/messages')
|
||||
|
||||
+12
-34
@@ -4,17 +4,14 @@ import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette, RecordMode
|
||||
from coverage.python import get_python_source
|
||||
from vcr.cassette import Cassette
|
||||
from vcr.record_mode import RecordMode
|
||||
from vcr.request import Request
|
||||
|
||||
from . import conftest
|
||||
from .conftest import BLOCKBUSTER_EXEMPTIONS, check_vcr_cassette_usage, pytest_recording_configure
|
||||
from .conftest import BLOCKBUSTER_EXEMPTIONS, check_vcr_cassette_usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from blockbuster import BlockBuster, BlockingError
|
||||
@@ -36,30 +33,15 @@ def blockbuster_types() -> tuple[type[BlockBuster], type[BlockingError]]:
|
||||
return BlockBuster, BlockingError
|
||||
|
||||
|
||||
class RecordingVCR:
|
||||
before_record_request: Callable[[Request], Request | None] | None = None
|
||||
|
||||
def register_serializer(self, name: str, serializer: object) -> None:
|
||||
pass
|
||||
|
||||
def register_matcher(self, name: str, matcher: Callable[[Request, Request], None]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _blocking_stat() -> None:
|
||||
os.stat(__file__)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['https://oauth2.googleapis.com/token', 'https://auth.openai.com/oauth/token'])
|
||||
def test_pytest_recording_configure_drops_oauth_token_requests(url: str) -> None:
|
||||
vcr = RecordingVCR()
|
||||
pytest_recording_configure(None, vcr) # pyright: ignore[reportArgumentType]
|
||||
|
||||
before_record_request = vcr.before_record_request
|
||||
assert before_record_request is not None
|
||||
request = Request('POST', url, None, dict[str, str]())
|
||||
|
||||
assert before_record_request(request) is None
|
||||
def _recorded_cassette(*uris: str) -> Cassette:
|
||||
cassette = Cassette('fake.yaml', record_mode=RecordMode.NONE)
|
||||
for uri in uris:
|
||||
cassette.record('POST', uri, {}, b'{}', 200, {}, b'{}')
|
||||
return cassette
|
||||
|
||||
|
||||
def test_check_vcr_cassette_usage_allows_loaded_unused_cassette_by_default() -> None:
|
||||
@@ -69,21 +51,17 @@ def test_check_vcr_cassette_usage_allows_loaded_unused_cassette_by_default() ->
|
||||
|
||||
|
||||
def test_check_vcr_cassette_usage_reports_unused_interactions() -> None:
|
||||
cassette = Cassette('fake.yaml', record_mode=RecordMode.NONE)
|
||||
cassette.append(Request('POST', 'https://example.com/one', b'{}', dict[str, str]()), {}) # pyright: ignore[reportUnknownMemberType]
|
||||
cassette.append(Request('POST', 'https://example.com/two', b'{}', dict[str, str]()), {}) # pyright: ignore[reportUnknownMemberType]
|
||||
cassette.play_counts[0] = 1 # pyright: ignore[reportUnknownMemberType]
|
||||
cassette = _recorded_cassette('https://example.com/one', 'https://example.com/two')
|
||||
cassette.play('POST', 'https://example.com/one', {}, b'{}')
|
||||
|
||||
with pytest.raises(pytest.fail.Exception, match=r'played 1/2; unused indexes: \[1\]'):
|
||||
check_vcr_cassette_usage(cassette, strict_usage=False)
|
||||
|
||||
|
||||
def test_check_vcr_cassette_usage_allows_fully_used_cassette() -> None:
|
||||
cassette = Cassette('fake.yaml', record_mode=RecordMode.NONE)
|
||||
cassette.append(Request('POST', 'https://example.com/one', b'{}', dict[str, str]()), {}) # pyright: ignore[reportUnknownMemberType]
|
||||
cassette.append(Request('POST', 'https://example.com/two', b'{}', dict[str, str]()), {}) # pyright: ignore[reportUnknownMemberType]
|
||||
cassette.play_counts[0] = 1 # pyright: ignore[reportUnknownMemberType]
|
||||
cassette.play_counts[1] = 1 # pyright: ignore[reportUnknownMemberType]
|
||||
cassette = _recorded_cassette('https://example.com/one', 'https://example.com/two')
|
||||
cassette.play('POST', 'https://example.com/one', {}, b'{}')
|
||||
cassette.play('POST', 'https://example.com/two', {}, b'{}')
|
||||
|
||||
check_vcr_cassette_usage(cassette, strict_usage=False)
|
||||
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from ._inline_snapshot import snapshot
|
||||
from .json_body_serializer import deserialize, scrub_xml_credentials, serialize
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cassette_dict_base():
|
||||
"""
|
||||
A fixture providing a base cassette dictionary with:
|
||||
- A request containing a multi-line JSON string in `body`.
|
||||
- A response containing a multi-line JSON string in `body['string']`.
|
||||
- Various headers to test filtering & lowercasing.
|
||||
"""
|
||||
return {
|
||||
'interactions': [
|
||||
{
|
||||
'request': {
|
||||
'headers': {
|
||||
'Content-Type': ['application/json'],
|
||||
'Authorization': ['some-token'], # Should be filtered out
|
||||
'X-Test': ['some-token'], # Should be filtered out
|
||||
'Other-Header': ['test-value'], # Should remain, but become lowercase (other-header)
|
||||
},
|
||||
'body': '{"message": "line1\\nline2"}', # multi-line JSON
|
||||
},
|
||||
},
|
||||
{
|
||||
'response': {
|
||||
'headers': {
|
||||
'Content-Type': ['application/json'],
|
||||
'Date': ['some-date-string'], # Should be filtered out
|
||||
},
|
||||
'body': {'string': '{"response": "line3\\nline4"}'},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_filtered_headers_removed(cassette_dict_base: dict[str, Any]):
|
||||
"""
|
||||
Ensure that headers in FILTERED_HEADERS (e.g. 'Authorization', 'Date', etc.)
|
||||
are removed from the serialized output.
|
||||
"""
|
||||
output = serialize(cassette_dict_base).lower()
|
||||
|
||||
assert 'authorization:' not in output, "Expected 'Authorization' to be filtered out."
|
||||
assert 'date:' not in output, "Expected 'Date' to be filtered out."
|
||||
assert 'x-test:' not in output, "Expected 'X-Test' to be filtered out."
|
||||
|
||||
|
||||
def test_oauth_credentials_are_scrubbed():
|
||||
cassette: dict[str, object] = {
|
||||
'interactions': [
|
||||
{
|
||||
'request': {
|
||||
'headers': {
|
||||
'Content-Type': ['application/x-www-form-urlencoded'],
|
||||
'Authorization': ['Bearer oauth-access'],
|
||||
'ChatGPT-Account-ID': ['oauth-account'],
|
||||
},
|
||||
'body': 'code=oauth-code&code_verifier=oauth-verifier&refresh_token=oauth-refresh',
|
||||
},
|
||||
'response': {
|
||||
'headers': {'Content-Type': ['application/json']},
|
||||
'body': {
|
||||
'string': '{"access_token":"oauth-access","refresh_token":"oauth-refresh","id_token":"oauth-id"}'
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
serialized = serialize(cassette)
|
||||
assert 'oauth-' not in serialized
|
||||
assert deserialize(serialized) == snapshot(
|
||||
{
|
||||
'interactions': [
|
||||
{
|
||||
'request': {
|
||||
'headers': {'content-type': ['application/x-www-form-urlencoded']},
|
||||
'body': 'code=scrubbed&code_verifier=scrubbed&refresh_token=scrubbed',
|
||||
},
|
||||
'response': {
|
||||
'headers': {'content-type': ['application/json']},
|
||||
'body': {
|
||||
'string': '{"access_token": "scrubbed", "id_token": "scrubbed", "refresh_token": "scrubbed"}'
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('content_type', ['application/json', 'text/event-stream', None])
|
||||
@pytest.mark.parametrize('as_bytes', [False, True])
|
||||
def test_safety_identifier_is_scrubbed(content_type: str | None, as_bytes: bool):
|
||||
body = '{"safety_identifier":"synthetic-user-id","output":[]}'
|
||||
if content_type != 'application/json':
|
||||
body = f'data: {{"response":{body}}}\n\ndata: [DONE]\n\n'
|
||||
headers: dict[str, list[str]] = {'content-length': [str(len(body))]}
|
||||
if content_type is not None:
|
||||
headers['content-type'] = [content_type]
|
||||
cassette: dict[str, object] = {
|
||||
'interactions': [
|
||||
{
|
||||
'response': {
|
||||
'headers': headers,
|
||||
'body': {'string': body.encode() if as_bytes else body},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
serialized = serialize(cassette)
|
||||
assert 'synthetic-user-id' not in serialized
|
||||
response = deserialize(serialized)['interactions'][0]['response']
|
||||
assert 'scrubbed' in response['body']['string']
|
||||
assert response['headers']['content-length'] == [str(len(response['body']['string'].encode()))]
|
||||
|
||||
|
||||
def test_headers_are_lowercased(cassette_dict_base: dict[str, Any]):
|
||||
"""
|
||||
Ensure that the remaining headers are written in all-lowercase form.
|
||||
"""
|
||||
output = serialize(cassette_dict_base)
|
||||
|
||||
# 'Other-Header' should appear as 'other-header:' in the YAML
|
||||
assert 'other-header:' in output.lower(), "Expected 'Other-Header' to become 'other-header' in the serialized YAML."
|
||||
# Ensure we don't see the uppercase form
|
||||
assert 'Other-Header:' not in output, (
|
||||
f'Found uppercase header name in the serialized YAML; expected it to be lowercase.\nOutput:\n{output}'
|
||||
)
|
||||
|
||||
|
||||
def test_multiline_strings_are_literal(cassette_dict_base: dict[str, Any]):
|
||||
"""
|
||||
Ensure that multi-line JSON strings are emitted as literal blocks in YAML.
|
||||
"""
|
||||
output = serialize(cassette_dict_base)
|
||||
|
||||
# The custom LiteralDumper uses style='|' when it finds '\n' in a string.
|
||||
# Often you'll see '|\n' or '|-' in the YAML depending on your exact presenter.
|
||||
assert '|\n' in output or '|-' in output, (
|
||||
"Expected multi-line string to be represented in literal style ('|' or '|-'), "
|
||||
"but didn't find it.\n\nSerialized output:\n" + output
|
||||
)
|
||||
|
||||
|
||||
def test_serialization_includes_parsed_body_excludes_body(cassette_dict_base: dict[str, Any]):
|
||||
"""
|
||||
After calling `serialize(...)`, the in-memory dict is transformed such that
|
||||
JSON content is moved into `parsed_body` (and `body` is removed) for readability.
|
||||
We then confirm the *YAML text* also contains `parsed_body`.
|
||||
"""
|
||||
output_yaml = serialize(cassette_dict_base)
|
||||
|
||||
# 1) Check the YAML text for "parsed_body" key
|
||||
assert 'parsed_body:' in output_yaml, (
|
||||
f"Expected the YAML output to contain 'parsed_body' for JSON content.\nGot:\n{output_yaml}"
|
||||
)
|
||||
|
||||
# 2) Double-check by parsing the YAML text back into a dict (just to confirm structure)
|
||||
parsed = yaml.safe_load(output_yaml)
|
||||
interactions = parsed.get('interactions', [])
|
||||
assert len(interactions) == 2, 'Expected two interactions in the serialized output.'
|
||||
|
||||
# request
|
||||
req = interactions[0]['request']
|
||||
assert 'parsed_body' in req, "Expected 'parsed_body' to be present for the request in the YAML dictionary."
|
||||
assert 'body' not in req, "Expected 'body' to be removed from the request after serialization."
|
||||
# response
|
||||
resp = interactions[1]['response']
|
||||
assert 'parsed_body' in resp, "Expected 'parsed_body' to be present for the response in the YAML dictionary."
|
||||
assert 'body' not in resp, "Expected 'body' to be removed from the response after serialization."
|
||||
|
||||
|
||||
def test_deserialization_restores_body_removes_parsed_body(cassette_dict_base: dict[str, Any]):
|
||||
"""
|
||||
After we deserialize the YAML, we want `body` to be restored for VCR usage,
|
||||
and `parsed_body` should be removed.
|
||||
"""
|
||||
# First, get the YAML
|
||||
output_yaml = serialize(cassette_dict_base)
|
||||
|
||||
# Now, deserialize it
|
||||
deserialized = deserialize(output_yaml)
|
||||
interactions = deserialized.get('interactions', [])
|
||||
|
||||
assert len(interactions) == 2, 'Expected exactly two interactions after deserialization.'
|
||||
|
||||
# Check request
|
||||
req = interactions[0]['request']
|
||||
assert 'parsed_body' not in req, "Expected 'parsed_body' to NOT be present in the request after deserialization."
|
||||
assert 'body' in req, "Expected 'body' to be restored in the request after deserialization."
|
||||
# The original code for a request sets `body` to a string
|
||||
assert req['body'] == '{"message": "line1\\nline2"}', (
|
||||
'Expected the request body to contain the JSON string from parsed_body.'
|
||||
)
|
||||
|
||||
# Check response
|
||||
resp = interactions[1]['response']
|
||||
assert 'parsed_body' not in resp, "Expected 'parsed_body' to NOT be present in the response after deserialization."
|
||||
assert 'body' in resp, "Expected 'body' to be restored in the response after deserialization."
|
||||
# The original code for a response sets `body` to a dict with {'string': ...}
|
||||
assert resp['body'] == {'string': '{"response": "line3\\nline4"}'}, (
|
||||
"Expected the response body to be a dict with 'string' after deserialization."
|
||||
)
|
||||
|
||||
|
||||
def test_round_trip_data_integrity(cassette_dict_base: dict[str, Any]):
|
||||
"""
|
||||
Checks that going from an original cassette_dict -> serialize -> deserialize
|
||||
yields the data structure that VCR needs (i.e., final form has 'body',
|
||||
not 'parsed_body'), while still having the original JSON strings.
|
||||
"""
|
||||
# Original dictionary:
|
||||
original_request_body = cassette_dict_base['interactions'][0]['request']['body']
|
||||
original_response_body = cassette_dict_base['interactions'][1]['response']['body']['string']
|
||||
|
||||
# Serialize -> Deserialize
|
||||
output_yaml = serialize(cassette_dict_base)
|
||||
deserialized = deserialize(output_yaml)
|
||||
|
||||
# Final data structure should have 'body' again
|
||||
interactions = deserialized.get('interactions', [])
|
||||
assert len(interactions) == 2
|
||||
|
||||
req: Any = interactions[0]['request']
|
||||
resp: Any = interactions[1]['response']
|
||||
|
||||
# Ensure the final request body matches the original
|
||||
assert req['body'] == original_request_body, (
|
||||
'The final request body after round-trip should match the original JSON string.'
|
||||
)
|
||||
|
||||
# Ensure the final response body['string'] matches the original
|
||||
assert resp['body'] == {'string': original_response_body}, (
|
||||
'The final response body after round-trip should match the original JSON string.'
|
||||
)
|
||||
|
||||
# No 'parsed_body' in the final dictionary
|
||||
assert 'parsed_body' not in req
|
||||
assert 'parsed_body' not in resp
|
||||
|
||||
|
||||
def test_scrub_xml_credentials_redacts_sts_tokens():
|
||||
"""Test that scrub_xml_credentials redacts AWS STS credentials from XML bodies."""
|
||||
xml_body = (
|
||||
'<AssumeRoleWithWebIdentityResponse>'
|
||||
'<Credentials>'
|
||||
'<AccessKeyId>ASIA1234</AccessKeyId>'
|
||||
'<SecretAccessKey>secret123</SecretAccessKey>'
|
||||
'<SessionToken>token456</SessionToken>'
|
||||
'<Expiration>2026-01-01T00:00:00Z</Expiration>'
|
||||
'</Credentials>'
|
||||
'</AssumeRoleWithWebIdentityResponse>'
|
||||
)
|
||||
data: dict[str, Any] = {'body': {'string': xml_body}}
|
||||
headers: dict[str, list[str]] = {'content-type': ['text/xml'], 'content-length': ['999']}
|
||||
|
||||
scrub_xml_credentials(data, headers, ['text/xml'])
|
||||
|
||||
body = data['body']['string']
|
||||
assert body == snapshot(
|
||||
'<AssumeRoleWithWebIdentityResponse><Credentials><AccessKeyId>SCRUBBED</AccessKeyId><SecretAccessKey>SCRUBBED</SecretAccessKey><SessionToken>SCRUBBED</SessionToken><Expiration>2099-01-01T00:00:00Z</Expiration></Credentials></AssumeRoleWithWebIdentityResponse>'
|
||||
)
|
||||
assert headers['content-length'] == [str(len(body.encode('utf-8')))]
|
||||
|
||||
|
||||
def test_scrub_xml_credentials_string_body_no_content_length():
|
||||
"""Test scrub_xml_credentials with a plain string body and no content-length header."""
|
||||
xml_body = '<Credentials><AccessKeyId>ASIA1234</AccessKeyId></Credentials>'
|
||||
data: dict[str, Any] = {'body': xml_body}
|
||||
headers: dict[str, list[str]] = {'content-type': ['text/xml']}
|
||||
|
||||
scrub_xml_credentials(data, headers, ['text/xml'])
|
||||
|
||||
assert data['body'] == snapshot({'string': '<Credentials><AccessKeyId>SCRUBBED</AccessKeyId></Credentials>'})
|
||||
assert 'content-length' not in headers
|
||||
|
||||
|
||||
def test_scrub_xml_credentials_skips_non_xml():
|
||||
"""Test that scrub_xml_credentials is a no-op for non-XML content types."""
|
||||
data: dict[str, Any] = {'body': {'string': '<Credentials>secret</Credentials>'}}
|
||||
headers: dict[str, list[str]] = {'content-type': ['application/json']}
|
||||
|
||||
scrub_xml_credentials(data, headers, ['application/json'])
|
||||
|
||||
assert data['body']['string'] == '<Credentials>secret</Credentials>'
|
||||
|
||||
|
||||
def test_scrub_xml_credentials_skips_non_credentials_xml():
|
||||
"""Test that scrub_xml_credentials is a no-op for XML without <Credentials>."""
|
||||
data: dict[str, Any] = {'body': {'string': '<Response>ok</Response>'}}
|
||||
headers: dict[str, list[str]] = {'content-type': ['text/xml']}
|
||||
|
||||
scrub_xml_credentials(data, headers, ['text/xml'])
|
||||
|
||||
assert data['body']['string'] == '<Response>ok</Response>'
|
||||
@@ -35,8 +35,8 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from pydantic import BaseModel, Field, JsonValue
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import Agent, StructuredDict
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.capabilities import NativeTool
|
||||
|
||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import pytest
|
||||
from vcr.cassette import Cassette
|
||||
from cassetter import Cassette
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.settings import ModelSettings, ThinkingLevel
|
||||
|
||||
@@ -8,9 +8,9 @@ from dataclasses import dataclass, replace
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import pytest
|
||||
from cassetter import Cassette
|
||||
from inline_snapshot import snapshot
|
||||
from pytest_mock import MockerFixture
|
||||
from vcr.cassette import Cassette
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai._utils import TOOL_CALL_ID_PREFIX
|
||||
|
||||
@@ -84,7 +84,6 @@ constraints = [
|
||||
{ name = "torch", specifier = ">=2.13.0" },
|
||||
{ name = "transformers", specifier = ">=5.5.0" },
|
||||
{ name = "urllib3", specifier = ">=2.7.0" },
|
||||
{ name = "vcrpy", specifier = ">=8.2.1" },
|
||||
{ name = "xgrammar", specifier = ">=0.1.32" },
|
||||
]
|
||||
excludes = ["browser-use"]
|
||||
@@ -1146,52 +1145,52 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cassetter"
|
||||
version = "0.12.0"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/90/87e5a89d726a8407ef394dd46870eae50a3a89d309c348d6698da71538e4/cassetter-0.12.0.tar.gz", hash = "sha256:b0207a762cc9727280cbbc62f84023543d00b756da30a33373173a319aa1825e", size = 105365, upload-time = "2026-09-27T12:22:05.16Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/12/c76b175d0a8668cb268dffb640dfbc95c103c119c2682f89be54dbd3d301/cassetter-0.12.1.tar.gz", hash = "sha256:16b2d00a25cac79ead1dd50d39b2d72cf7b8c6521ba18a8c811a68b1d39f49dd", size = 105553, upload-time = "2026-09-28T13:12:53.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ae/88d643a5c15870adc524359c2d687bbd3c4eda0d99ade8c83fe426dbf870/cassetter-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4f428b56b9077c9b11e364601118d83f67d144ec9e3609d87b16443fd3383927", size = 1985571, upload-time = "2026-09-27T12:20:53.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/47/6c0c7230cbcb133a1f3620fb3f2e4d094236009da50ade3044a62f33f46a/cassetter-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c8b1b9ee266139b6e8b67b2e63e6b79059dfd9379d3e7df0a58958dd0bd96bfe", size = 1863309, upload-time = "2026-09-27T12:20:56.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/2d/2e81bf3c881d22cd53f872fac7dadfd26eb9642ed63b7d99b8f9f5ab6f50/cassetter-0.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c0aae7c766209fdec96372dc39b690faf54850d0a35d0426cb63db41c49ace", size = 1929948, upload-time = "2026-09-27T12:20:58.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e6/55e342e9133b15d20f179745e4d1279ca9a8b32ad5e5e69103d466fe3b15/cassetter-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e354d5dd36e9acc8ea3e8c31e89fe11280e3307b4a89fc27874280f23268045", size = 2081630, upload-time = "2026-09-27T12:20:59.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/e8/a497c4f50f13edc06d71d006110a574f6d23571e6cd955af12824d44fb0a/cassetter-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7125eb238cd3e6e80a4552ba4e7dd3b607e5d1e6f2883ca9686050ef6bd0ada4", size = 2113941, upload-time = "2026-09-27T12:21:01.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/b7/8e9c96e2bc50d0f2739d4bf384e518d91631a26e0a7a0f7de1becfc90ced/cassetter-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e94cb033b24b9b62be520c5f2974c97de2b2a27a2faf57b371a6bc08442e5142", size = 2308402, upload-time = "2026-09-27T12:21:03.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/33/f9c776ab137d3f9974d1d168f3b777bf10aa2004d8ea920473160104feee/cassetter-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:4bbaebc4ca81bee93477c0f3433e3aca609a6ed1193bc3735b73952309adc94e", size = 2029370, upload-time = "2026-09-27T12:21:04.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/80/36567939ff79fba5dc730c8da3401b5ce6644923609041f2bdbb2166f58c/cassetter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6547034028941b1160ee127bf5e3e0f6141ebbe6a57be087b59b1b3b910d219f", size = 1985610, upload-time = "2026-09-27T12:21:06.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/b84d913b31a1f2ed33df12abea687a203be95c11d8f27e8a68111c835d2d/cassetter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb2b22afd959cfc95b94607dc3aeb743bde9d4a3a5ac45de461a85711c3efa84", size = 1863413, upload-time = "2026-09-27T12:21:07.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/de/087a06f6f472f97b6f5637c625faf4b30125194213769c5fb096b62f945e/cassetter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e31970719299df1e4602de9728fd1d4c8dab27a004845d2382b034a30b8fb10", size = 1930562, upload-time = "2026-09-27T12:21:09.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/cd/69c6024aeaba663cc4b3dcde18c022c7feafcd708fd99cf82365bfba3f0b/cassetter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae93650fac389d0727582a76f46449c4e3d948ad69a9eb6be50ea8ea4aa12ed3", size = 2081525, upload-time = "2026-09-27T12:21:11.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a3/5b04fb8f574699b0df7fefe23697e015392f6340940cfc1c91dbacf48315/cassetter-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:de67bab4712a510b87f4db2c79e8a74f600b702ee5ab938913b67dfbae2729fa", size = 2113960, upload-time = "2026-09-27T12:21:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/9c/82b69fcc37db7dc94932578e43a77bf028facbe302521eadcfc20f08297a/cassetter-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:16d0725e79721dc673f6e12a71ece47041d7bff4aa7cb9f34c0b70376fa00fe2", size = 2308357, upload-time = "2026-09-27T12:21:14.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c0/1ee5c7d875775b892651edfc4358bc416be513b0d276e7157d3813099e1d/cassetter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:5deb24f5e10017e06df9b251dcc9b258470e0ba987eac168e9cb2c0cd9215bab", size = 2029475, upload-time = "2026-09-27T12:21:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a7/a50690b3b2bb254765e3a4c96956ef33f9cbfb65eb38cbfacd38ff94b8df/cassetter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ec4655845f7c932fe07c6e5490ae5c4e6e52963ac951c5573921438211cd14ac", size = 1996630, upload-time = "2026-09-27T12:21:18.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c8/42182e46f3f74533cc96ff0946d74606b7224981939fcd1a0ee45cde9e94/cassetter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abb2a85eb471bc4615942d3c22fbbeab39fc912ede5aae1604e6989e6e6eea5", size = 1859136, upload-time = "2026-09-27T12:21:19.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/87/4664bbab642f91c83ff4c404389e8cad26a97ddc0df8cccacb5ae56c930e/cassetter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dde7fa38ad5422c5465966893bfc3362ca8fc5d2fa88fbcff197da9d1169be26", size = 1926244, upload-time = "2026-09-27T12:21:21.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/3b/821c80cfe7950d15047bd6151ab8582d22fe3d8253915744ee055b4a0e9a/cassetter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ee1edb1e00cd91cf35af6b8c73881a11af442e7c0c9d6b70f7eba1a8ea8b2f0", size = 2081412, upload-time = "2026-09-27T12:21:22.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d6/19f4993db3e56fad6e092cfd294eb5b092ec6b719526d183318e7a02c9cc/cassetter-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a420b85b085faf619b4df59af0da4c9039b7144b7a5f554e56fae6d79ad0f29b", size = 2112577, upload-time = "2026-09-27T12:21:24.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4d/6fdc02102ae6aa515321350b0d4d3137b54b7ee09470df52b114da351cec/cassetter-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16cca8d71faab41754996476d6b84451a45b37f3fdf5ec3c28ac07e556d1f911", size = 2308145, upload-time = "2026-09-27T12:21:25.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/0a/18f6f82147f884961af793a16e26cfea2404c44999c47b865f7dbed92038/cassetter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:f7438677bc28e5fcf3bb57ff702fc0fea32bd1e6f0176ced2c8ef942c1564da3", size = 2026248, upload-time = "2026-09-27T12:21:27.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/0b/e6b44a6e3ac099274cb54450570f67ffec68c36bab4d0370c1fa2590fb28/cassetter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e237ea2f5cff93373093ba41051af386079d4065e73009755fdc67e551b550ce", size = 1996509, upload-time = "2026-09-27T12:21:29.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/36/d31e5370afb30711814ff5a29110731b18b8e1e8a9a7707dc6d66cfec6c3/cassetter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b9820fb6b33b222ecf02c79c90583df4e4bb0518d7e7d07f8269064d15342626", size = 1859283, upload-time = "2026-09-27T12:21:30.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/42/c7ea558c1497fd8f7088f6347c3247740c52fb8bac91ec12e85f25f362a0/cassetter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5335495366d9700585231efcee7bb0fee528fd19a680e569bfccfd18c7ce4113", size = 1925965, upload-time = "2026-09-27T12:21:32.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e4/6162537e19eb7000965796ca0bf03335390f2f599e00b34f46c647560778/cassetter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07536e25f8d3c2b64671496983b13e0c7a413c5e8b4d30c7a9f25b3753121c31", size = 2081228, upload-time = "2026-09-27T12:21:33.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/9f/c53c936d3e79c8b0a78676a38f1eae1a655b36e292613f4f6b5fdc48731d/cassetter-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cb862cdbba541b44ba9cad7ae57ec80b7d80e3f0432cfe755d46843e46b05fb8", size = 2112718, upload-time = "2026-09-27T12:21:35.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/39/0d846bd520b985f7082b171980f741ab56dfb32177cadd55eabccc83662a/cassetter-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40d47252aa2e8f98128057318ebd6755ba61973e3a52a6599dfd22f918aca5b5", size = 2308031, upload-time = "2026-09-27T12:21:36.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1f/3eb13b62acf00c4acc1fed38804bb497accc728055792546bb4a13b42341/cassetter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:c5d51a0701824ef8d4ae57d855b3c984dc2f351a81bd20c424029ec2b6350e5c", size = 2026562, upload-time = "2026-09-27T12:21:38.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/12/3aba8904ab9132f61733891062f1c70a2e43af547c040243a20d241507b9/cassetter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b856a0f653e85fd236987e40fae3d2f13124cc21e0b73ae035a1ecdc60bfa623", size = 1997892, upload-time = "2026-09-27T12:21:40.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1c/ec182d501cc3d54aeb349c52d23997bd9c5e7c0a46177ff52d8815912359/cassetter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:123257350f099ae74ea69e260560a1353c5b37fc91646fa0b4413d56ed6db8bf", size = 1859820, upload-time = "2026-09-27T12:21:42.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/f6/701c6d7141d2cb09f3b623ecac45d7a2ce63325d20073af492edbc2f9a8a/cassetter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02757cb141e7fe1871d3006a96551715e79e1b04894423931ee5ac4c685d5ca9", size = 1928143, upload-time = "2026-09-27T12:21:44.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/da/1b2c71d8eb7875f10af25ac8dffd2fba0b07a994d25b80413a22bc75a6c5/cassetter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832a5ae5b25ac3663de1b0b166bff7b1501aec519d2b8f2aad3b167a8edb3e4b", size = 2082401, upload-time = "2026-09-27T12:21:45.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d0/c6acfe2c52f6bc89683a6cf190ff4145e2db1155dd43efe7a3dd6d222fd1/cassetter-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:157f061b68fe8143636779646688fccf468d2ebf3f5bd7caa4b2f1397fac23bb", size = 2112985, upload-time = "2026-09-27T12:21:47.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/29/e6f246fb87df2d07b9631a07f0ceee5a38f35c4aa9c5d1e9c2c0109224ec/cassetter-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:536f9e9d8c79240e31f57af56a8523ef90d6907dbb874e2e64d11c6b1c4d6a7a", size = 2309142, upload-time = "2026-09-27T12:21:49.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a1/30b82a181847823ca0ff678d8d9c76c674a6bc99becec8f8c52e7560994d/cassetter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7e7e0178d357b3a19aaa9818185612dd14504d0f1b5f3c15c3e4138f32b8e7f2", size = 2027611, upload-time = "2026-09-27T12:21:51.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/10/85d9fcefa018c480054026fa950c53831444a43af2bc6ca96e53867f63a4/cassetter-0.12.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1ab6532378ffe2a2f8fdfe3719ee082af2684f696136c32f2538944aacc08886", size = 1992886, upload-time = "2026-09-27T12:21:52.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/3b/9dfedf9fb7001b383cf220548f1b4a2f577384b7c40b34d187c230e9e76a/cassetter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4a51140841a75e1992d8335e2e851e7e7d8964c415347faf5c257375f30488b3", size = 1853954, upload-time = "2026-09-27T12:21:54.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/86/da964d0b98f20b1c44778713962d1250d501d632a67399e858409dcae95c/cassetter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d91352146edb0dc22846e6af30102178215fc7e75b4c8cd5939d2ec3c416d11", size = 1920516, upload-time = "2026-09-27T12:21:56.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b1/dfbace925cbebf7e1910753e32a03aa56ad748d339428b2f5ef72d243164/cassetter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e7f3c3b9d6d07ea68f494b07bd1e373541606453bef7dcd1e5fefe81fbdd5b0", size = 2076385, upload-time = "2026-09-27T12:21:58.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/7d/080596bbcfbe5af0c9201166cb24dd06dc19edad02666900339e63f0cc1b/cassetter-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aac3b6e5f773facc277bbcad55e1d2ff305916e904a0e6b85233157e387b155d", size = 2107432, upload-time = "2026-09-27T12:21:59.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/8b/e5f78a3fe7e4256278d27c5f6fa9181cf012a8fb928d0e3eedd13038a42d/cassetter-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d8885f6c0eb8f7ef36569c78ae4da7579054327d32735da804ce0caee68e7b6", size = 2304131, upload-time = "2026-09-27T12:22:01.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a0/e179e3c15aba9d89478123e2ad73740bc9de6b47a48791f82bb37361f3e2/cassetter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:628b681feefcfda06375d4afa9fb01ab9b7d6991d2756f1b706553aff02a3562", size = 2025958, upload-time = "2026-09-27T12:22:03.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/61ac471f51c2b114c74e493a461c991b464c3010d056fb3879db095c1c60/cassetter-0.12.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:8b44cced1b667fb21588bc78135f90783bbff926dd153d4b227fa8ef80364a51", size = 1985660, upload-time = "2026-09-28T13:11:34.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ee/33dac9cfb830bb31241cd4ffb7b024affdb3d69cc84f2c516dde03305374/cassetter-0.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9dbd6638b283b7aa7399b1ab8ce1e4190e57c8ece518f4652094bbf0653d4b26", size = 1863545, upload-time = "2026-09-28T13:11:37.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/d1/bb0d7fd88e535b6aecc972f2ed924847b32120479de8fba5c523ba11743f/cassetter-0.12.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade71ef5ecc0a513562d2f09c0f8e0036f3badad42868c7d3d7607e6e5881ad7", size = 1930195, upload-time = "2026-09-28T13:11:38.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a2/450b82f8e6feb5d605d182bb5d18c62ec0c3fc7d80939c15647c810d2b01/cassetter-0.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6178c82555b940eacb8548a8afc1b61f8cca735175c2898fb016a02d7713bc08", size = 2081745, upload-time = "2026-09-28T13:11:40.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bb/b53f4df7dbae79b5184967ef24e8c93a8cebf589d89ad7b16a9077c1ba9d/cassetter-0.12.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b0444946ad7f2a26669d21f733e1ef09d9ded77dc24aed5bb3a148ed6063d1ff", size = 2114179, upload-time = "2026-09-28T13:11:43.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/f0/36ce38773c2e4126c0e98dadd76c67ce43cfb13b003d8aea272765033aef/cassetter-0.12.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:42b3de4aa5083ed0a1ef4871b0399dbd924d2fd5536bc4d2f2165b121212e507", size = 2308620, upload-time = "2026-09-28T13:11:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ac/84c5b9a12ca917b43bc8b346c5ee71b7dbb3a40058e17831c330d6e8993c/cassetter-0.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:27cd9fa3f2a001e6995d5af8d275f186048901c4f7a870f35ee4e347d5de95f7", size = 2029628, upload-time = "2026-09-28T13:11:47.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/19/dd19b02e69403ab22708807351dcbdc885dfe372a7643655eaa25efaff38/cassetter-0.12.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:49f47eb9087cfc0e48e60794e511d7cf300d275e4c2bfe9e7251337a4f9eb678", size = 1985703, upload-time = "2026-09-28T13:11:49.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/2a/45b16e50b5d9644de4d13aed687f6cabf42736b1f96033addef0273c4e92/cassetter-0.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:653971e231a9844be7af156a2128b07eb7709aad649a9c0337010fb3e8a5c8c2", size = 1863619, upload-time = "2026-09-28T13:11:51.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c7/b42300f299a39a89dfe8ad84e67c2a4a60510fca7f6528abe78fbe6a4a85/cassetter-0.12.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:181c2b94ff2ffa3a6be678a397f71a59234fe4ac47ab1659e4bdabf5e65f7bde", size = 1930799, upload-time = "2026-09-28T13:11:52.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/73/c71d13d349bc44fc1b9b69a04b74ff06c6216a52389979176e695fe9a54c/cassetter-0.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee4bcce8cf523c9140524f55acfc3cc5265b109089493e34fbd3cd98919ef761", size = 2081686, upload-time = "2026-09-28T13:11:55.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/d9/8c1f6db81ae22f57825310adcc93875184927be25b1a3384596c5c0d5dbf/cassetter-0.12.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1fb4d628ba9d41c12764f97f8d3f51960278dce13dc8ab522020332de5baf279", size = 2114200, upload-time = "2026-09-28T13:11:57.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/f0/282649d7b2a17c402b82056ab2966a9b23710a8e85406ff12ab32b4b7e31/cassetter-0.12.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e73d0d953613ec703d21234e44058c6d09b03fbe36698f0cde13d028613ce585", size = 2308593, upload-time = "2026-09-28T13:11:59.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/eb/8c41957ab7f77fea27ea7684c799ba2ec71631e30b926e5db0f46e6f8ae0/cassetter-0.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:668ba906e4df214906614d4d99edcf9460f34b5a3e3d8681884e5c5533cc3098", size = 2029689, upload-time = "2026-09-28T13:12:01.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1b/ea163e74935dbe205a1c7639493e9027b198ce7314708332c5fe6b0a9579/cassetter-0.12.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1cab9ccd315abe3dc3d74b259656136e0e85c1454606c71d88d247312d9e41ad", size = 1996914, upload-time = "2026-09-28T13:12:03.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/59/e065750cac9d2c82e2240f9c88c6229bf222fb55a6cfac69ffc5bb7047c0/cassetter-0.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f752d39661e93627d3788f5cf574804c7e636b643e8bb27a31bacfe9f30a943f", size = 1859316, upload-time = "2026-09-28T13:12:04.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/94/cd59462cbd44ba7bf66fffa996ed1251180d62d84c8bf35ccba071c5ead5/cassetter-0.12.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06e7cb897731277d5a71c47f004cfc959e5abae728364abf25dfabee314185ce", size = 1926498, upload-time = "2026-09-28T13:12:06.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/10/b61924f695f557294bb67ae3f18b07bc9787d88641b781d228cdedd0466d/cassetter-0.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d62ed80c7ec62f04042148cee80107e7a86c1360f009aca658b5ead044a556b", size = 2081395, upload-time = "2026-09-28T13:12:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/23/044b797f8d6ef968d12e1288d63951f4626f74437ead76a8ae20466878a7/cassetter-0.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:06af5c2bc2ff86b30f5595924376539255170c531168b68575b4f7b3fc65b34d", size = 2112812, upload-time = "2026-09-28T13:12:09.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/1c/3ae433c2be6fa7a8e1355793c4449b286d22a4fbc96067e9b05f9b427650/cassetter-0.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a45ed29380932b0f654ff0f0d24255068ad4100cd9301cdc016d77298331edcf", size = 2308325, upload-time = "2026-09-28T13:12:11.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d3/7f3b76b8759f0ecd189927b8f3d752e82ab0e11df020b22e5d7435122081/cassetter-0.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:43e1a75e0f187d92002181b5da99c854f4001dd62e8bc961ac26bffc5e004ae5", size = 2026470, upload-time = "2026-09-28T13:12:13.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/44/7a8299647c4786a7428e8ede2288ad5cdf48bb57094cf6afbea3d61d3242/cassetter-0.12.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:de2771ccb49bde3db7a5eabc8133784096c394ffbd4d85fef7a3507051d75b31", size = 1996813, upload-time = "2026-09-28T13:12:15.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/80/e36aafef296ee7b69cb347fb34c7d24c118ee47f07ca257bd242a2fb2bc8/cassetter-0.12.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e99d035bbab030854794bb5e3a5a361e12b980e461020a0a6f2dffde77333fee", size = 1859447, upload-time = "2026-09-28T13:12:17.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6c/b865ebb74d2ca0a6714cc84eaea519232d2e45645248cb2900abfd3ecba1/cassetter-0.12.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e549a2ac4521a464f45f23df61d0f1dc5e141368561383daf51d2057c7fedd73", size = 1926207, upload-time = "2026-09-28T13:12:18.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1e/fd82a9f3230273a7ab848166fdce2749d00bf861278246a56c082cdef883/cassetter-0.12.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b4af555415acc01485ce615cf28c3e6a12d6a4b4cec8f8d731bc8ca36401dd", size = 2081486, upload-time = "2026-09-28T13:12:20.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/af/0ad858fc4b5287c6a714d0f3804c1d0c2e7a983c97a657840cec5b7d8c31/cassetter-0.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45135c0e5ec29c81ecff232db745f6d6ba30caa9d8f11d3ce069fc4fe9e6fd76", size = 2112955, upload-time = "2026-09-28T13:12:21.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/0d/92bfa4ab47a22a1efe35f14131f65593e2902f2544c97cb2bfdf3d4058f2/cassetter-0.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c99453b0129494dc035ea220f0916a548f71bd68161161c569d5a76710317e5", size = 2308192, upload-time = "2026-09-28T13:12:23.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b1/318c1f1fc19af145123dcf270c2eff3ead57eebd3ec0d385fbca18e5357d/cassetter-0.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:6034808822b1f0e6f36e28dcf569a3134c47df4efe7b1ad2c3db12ac48b537d2", size = 2026664, upload-time = "2026-09-28T13:12:25.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c0/fa2c1ccdacc5c75cb900616c01d9fceb6dc538dd4e4705056f2914f103ae/cassetter-0.12.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a19998424e878ba1324555ccf9e41ec88a7b3da4123942b654b3e059c3c13e7d", size = 1998188, upload-time = "2026-09-28T13:12:27.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/731f6753169019837f06164c9d5844873ebc2da2a3b2d12f5eaa636c4557/cassetter-0.12.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2ebedcd2d3ce8e86adfbe614aade6cd88a157f125180273d039772546fc36f8d", size = 1860053, upload-time = "2026-09-28T13:12:29.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/7d/0035f7f272e5cab2aec7ce59fd0442b8053c492d50c8b1b9d6f7cca30f5d/cassetter-0.12.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efe85dff70222cb28af1559aaa81907d4b3e35643dd7d422e972336236824645", size = 1928369, upload-time = "2026-09-28T13:12:31.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/62/ba5c5c21b5ed6e3f03c656d739f48c6ac2e35cf1ab3dce318eeb73c5b033/cassetter-0.12.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2bbbb56fc071063bd282a7f66cb0f880257db013e2e7d6c08472903cc3c918", size = 2082460, upload-time = "2026-09-28T13:12:33.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ef/4e5b99fdfa5bbd671ee764fd0846e07ab388a137c55c016323e78614dbee/cassetter-0.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ef9e529b3807bf60c70bcd4665deaa7af7848322a6b873ec1fb3f4ee06e09b96", size = 2113231, upload-time = "2026-09-28T13:12:35.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/1b/7cdc406098fdac279dd5b383e5d01f3d4c97504143912eae04fc5f8ae55e/cassetter-0.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ac82710d0262a71f4ec805f18615f2b0f2e14768429ca61f3fe5f050bd869334", size = 2309562, upload-time = "2026-09-28T13:12:37.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/4b/4435ed4c0db0bca6402050504e786099b0b323a9d62f42d2cbe2de632a06/cassetter-0.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:956d7c20d02456aa35ab2c479a4d1f77b49076e96ff4a68ea3168f04d3878fe9", size = 2027850, upload-time = "2026-09-28T13:12:39.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/38/63665191878d4ffff77ac3aa37f713a360dc8a2f89f12f37e220a6a078a0/cassetter-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6a779796e2004ce054b390403878ccffb36ce7613b4bcbe657e945e67307348b", size = 1993147, upload-time = "2026-09-28T13:12:41.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/1f/a88ebfdab6b4b5045dc3554d6d8657ebbd89f42bdecf1fb5b4c9102ff912/cassetter-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1659aa625ae8c84f756812ac47f44bddc4133c68e2dceb72847533bd0c0350ec", size = 1854004, upload-time = "2026-09-28T13:12:42.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/1d/f0e8a1ab527382e11974598cd3d454d01cabcc468d86dff8a527f6ea1a8c/cassetter-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aafb1f00d6a284fbb711d90cef93c90b6187c0ae4d58d764612b345df03e8a5", size = 1920759, upload-time = "2026-09-28T13:12:44.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a4/7159a161d5b0ab7da2a68a261e4fdb7b08ab44897400c15b367d17ab40cf/cassetter-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3fadcd15cbec75b018bd05b7141f15de446c2e777f817843b945b65bac2d0bd", size = 2076515, upload-time = "2026-09-28T13:12:46.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/20/5f0fa1c683421c116321ebafa8260669e47c77dac7b096c54c25e03a9345/cassetter-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6d5e77eeec39a54fc111f539d10ed63472d4f3a777cc37ed08a312aa290d338e", size = 2107672, upload-time = "2026-09-28T13:12:48.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9e/d7108d0cfba6370e278f30900b0d4fb5c80d71bf484bc54207c53bd1ec1d/cassetter-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:de3d31c053775a6ca93c3840ba50752fd7cf29d7e53339472549b6ab8c32c4ce", size = 2304441, upload-time = "2026-09-28T13:12:50.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/93/f4990d76c8dddddda86a96f25daa57d4894b070e7d140d4c2fbd70b00c8d/cassetter-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9d1bf9a9536c62db4212cfaa27d692665e00d7ceb85dedd98a79e3ebbd20f5f3", size = 2026168, upload-time = "2026-09-28T13:12:51.78Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6134,7 +6133,6 @@ dev = [
|
||||
{ name = "pytest-examples" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-pretty" },
|
||||
{ name = "pytest-recording" },
|
||||
{ name = "pytest-split" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "redis" },
|
||||
@@ -6192,7 +6190,7 @@ dev = [
|
||||
{ name = "blockbuster", specifier = ">=1.5.26,<1.6" },
|
||||
{ name = "boto3-stubs", extras = ["bedrock-runtime"], specifier = ">=1.42.63" },
|
||||
{ name = "brotli", specifier = ">=1.2.0" },
|
||||
{ name = "cassetter", specifier = ">=0.12.0" },
|
||||
{ name = "cassetter", specifier = ">=0.12.1" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=7.10.7" },
|
||||
{ name = "devtools", specifier = ">=0.12.2" },
|
||||
{ name = "dirty-equals", specifier = ">=0.9.0" },
|
||||
@@ -6214,7 +6212,6 @@ dev = [
|
||||
{ name = "pytest-examples", specifier = ">=0.0.18" },
|
||||
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
||||
{ name = "pytest-pretty", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-recording", specifier = ">=0.13.2" },
|
||||
{ name = "pytest-split", specifier = ">=0.11.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.6.1" },
|
||||
{ name = "redis", specifier = ">=8.1.0" },
|
||||
@@ -7507,19 +7504,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/85/2f97a1b65178b0f11c9c77c35417a4cc5b99a80db90dad4734a129844ea5/pytest_pretty-1.3.0-py3-none-any.whl", hash = "sha256:074b9d5783cef9571494543de07e768a4dda92a3e85118d6c7458c67297159b7", size = 5620, upload-time = "2025-06-04T12:54:36.229Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-recording"
|
||||
version = "0.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "vcrpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/9c/f4027c5f1693847b06d11caf4b4f6bb09f22c1581ada4663877ec166b8c6/pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c", size = 26576, upload-time = "2025-05-08T10:41:11.231Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/c2/ce34735972cc42d912173e79f200fe66530225190c06655c5632a9d88f1e/pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439", size = 13723, upload-time = "2025-05-08T10:41:09.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-split"
|
||||
version = "0.11.0"
|
||||
@@ -9457,19 +9441,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcrpy"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/db/08183b845b0040bb877dad2bd7e4e0976fc232bb3476d7ee369c6c4f8b5a/vcrpy-8.2.1.tar.gz", hash = "sha256:d73a6e4eb6dae8148e659764b7a00e68cc51ba29ba9e6a85e1f0790ad96b97df", size = 90511, upload-time = "2026-06-16T13:20:52.906Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7c/0e812ab83f5289404c674f3461ba783250b967d34b5ab034d361236ec042/vcrpy-8.2.1-py3-none-any.whl", hash = "sha256:7ce58c9e2792b246f79d6f4b3e9660676cc6f853be17e1547305b4437ab1ff85", size = 44925, upload-time = "2026-06-16T13:20:51.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "voyageai"
|
||||
version = "0.3.7"
|
||||
|
||||
Reference in New Issue
Block a user