Skip to content

Commit 339abd8

Browse files
authored
Add pytest-recording/vcr and use it for a test (#969)
1 parent 5540bb9 commit 339abd8

File tree

9 files changed

+717
-29
lines changed

9 files changed

+717
-29
lines changed

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ testcov: test ## Run tests and generate a coverage report
6666
update-examples: ## Update documentation examples
6767
uv run -m pytest --update-examples tests/test_examples.py
6868

69+
.PHONY: update-vcr-tests
70+
update-vcr-tests: ## Update tests using VCR that hit LLM APIs; note you'll need to set API keys as appropriate
71+
uv run -m pytest --record-mode=rewrite tests
72+
6973
# `--no-strict` so you can build the docs without insiders packages
7074
.PHONY: docs
7175
docs: ## Build the documentation

pydantic_ai_slim/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dev = [
6363
"pytest-examples>=0.0.14",
6464
"pytest-mock>=3.14.0",
6565
"pytest-pretty>=1.2.0",
66+
"pytest-recording>=0.13.2",
6667
"diff-cover>=9.2.0",
6768
]
6869

tests/conftest.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,23 @@
1717
import pytest
1818
from _pytest.assertion.rewrite import AssertionRewritingHook
1919
from typing_extensions import TypeAlias
20+
from vcr import VCR
2021

2122
import pydantic_ai.models
2223

23-
__all__ = 'IsNow', 'IsFloat', 'TestEnv', 'ClientWithHandler', 'try_import'
24+
__all__ = 'IsDatetime', 'IsFloat', 'IsNow', 'IsStr', 'TestEnv', 'ClientWithHandler', 'try_import'
2425

2526

2627
pydantic_ai.models.ALLOW_MODEL_REQUESTS = False
2728

2829
if TYPE_CHECKING:
2930

30-
def IsNow(*args: Any, **kwargs: Any) -> datetime: ...
31+
def IsDatetime(*args: Any, **kwargs: Any) -> datetime: ...
3132
def IsFloat(*args: Any, **kwargs: Any) -> float: ...
33+
def IsNow(*args: Any, **kwargs: Any) -> datetime: ...
34+
def IsStr(*args: Any, **kwargs: Any) -> str: ...
3235
else:
33-
from dirty_equals import IsFloat, IsNow as _IsNow
36+
from dirty_equals import IsDatetime, IsFloat, IsNow as _IsNow, IsStr
3437

3538
def IsNow(*args: Any, **kwargs: Any):
3639
# Increase the default value of `delta` to 10 to reduce test flakiness on overburdened machines
@@ -179,3 +182,18 @@ def set_event_loop() -> Iterator[None]:
179182
asyncio.set_event_loop(new_loop)
180183
yield
181184
new_loop.close()
185+
186+
187+
def pytest_recording_configure(config: Any, vcr: VCR):
188+
from . import json_body_serializer
189+
190+
vcr.register_serializer('yaml', json_body_serializer)
191+
192+
193+
@pytest.fixture(scope='module')
194+
def vcr_config():
195+
return {
196+
# Note: additional header filtering is done inside the serializer
197+
'filter_headers': ['authorization', 'x-api-key'],
198+
'decode_compressed_response': True,
199+
}

tests/json_body_serializer.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
2+
import json
3+
from typing import TYPE_CHECKING, Any
4+
5+
import yaml
6+
7+
if TYPE_CHECKING:
8+
from yaml import Dumper, Loader
9+
else:
10+
try:
11+
from yaml import CDumper as Dumper, CLoader as Loader
12+
except ImportError:
13+
from yaml import Dumper, Loader
14+
15+
FILTERED_HEADER_PREFIXES = ['anthropic-', 'cf-', 'x-']
16+
FILTERED_HEADERS = {'authorization', 'date', 'request-id', 'server', 'user-agent', 'via'}
17+
18+
19+
class LiteralDumper(Dumper):
20+
"""
21+
A custom dumper that will represent multi-line strings using literal style.
22+
"""
23+
24+
25+
def str_presenter(dumper: Dumper, data: str):
26+
"""If the string contains newlines, represent it as a literal block."""
27+
if '\n' in data:
28+
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
29+
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
30+
31+
32+
# Register the custom presenter on our dumper
33+
LiteralDumper.add_representer(str, str_presenter)
34+
35+
36+
def deserialize(cassette_string: str):
37+
cassette_dict = yaml.load(cassette_string, Loader=Loader)
38+
for interaction in cassette_dict['interactions']:
39+
for kind, data in interaction.items():
40+
parsed_body = data.pop('parsed_body', None)
41+
if parsed_body is not None:
42+
dumped_body = json.dumps(parsed_body)
43+
data['body'] = {'string': dumped_body} if kind == 'response' else dumped_body
44+
return cassette_dict
45+
46+
47+
def serialize(cassette_dict: Any):
48+
for interaction in cassette_dict['interactions']:
49+
for _kind, data in interaction.items():
50+
headers: dict[str, list[str]] = data.get('headers', {})
51+
# make headers lowercase
52+
headers = {k.lower(): v for k, v in headers.items()}
53+
# filter headers by name
54+
headers = {k: v for k, v in headers.items() if k not in FILTERED_HEADERS}
55+
# filter headers by prefix
56+
headers = {
57+
k: v for k, v in headers.items() if not any(k.startswith(prefix) for prefix in FILTERED_HEADER_PREFIXES)
58+
}
59+
# update headers on source object
60+
data['headers'] = headers
61+
62+
content_type = headers.get('content-type', None)
63+
if content_type != ['application/json']:
64+
continue
65+
66+
# Parse the body as JSON
67+
body: Any = data.get('body', None)
68+
assert body is not None, data
69+
if isinstance(body, dict):
70+
# Responses will have the body under a field called 'string'
71+
body = body.get('string')
72+
if body is not None:
73+
data['parsed_body'] = json.loads(body)
74+
del data['body']
75+
76+
# Use our custom dumper
77+
return yaml.dump(cassette_dict, Dumper=LiteralDumper, allow_unicode=True, width=120)
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
interactions:
2+
- request:
3+
headers:
4+
accept:
5+
- application/json
6+
accept-encoding:
7+
- gzip, deflate
8+
connection:
9+
- keep-alive
10+
content-length:
11+
- '793'
12+
content-type:
13+
- application/json
14+
host:
15+
- api.anthropic.com
16+
method: POST
17+
parsed_body:
18+
max_tokens: 1024
19+
messages:
20+
- content:
21+
- text: Alice, Bob, Charlie and Daisy are a family. Who is the youngest?
22+
type: text
23+
role: user
24+
model: claude-3-5-haiku-latest
25+
stream: false
26+
system: "\n Use the `retrieve_entity_info` tool to get information about a specific person.\n If you need to use
27+
`retrieve_entity_info` to get information about multiple people, try\n to call them in parallel as much as possible.\n
28+
\ Think step by step and then provide a single most probable concise answer.\n "
29+
tool_choice:
30+
type: auto
31+
tools:
32+
- description: Get the knowledge about the given entity.
33+
input_schema:
34+
additionalProperties: false
35+
properties:
36+
name:
37+
title: Name
38+
type: string
39+
required:
40+
- name
41+
type: object
42+
name: retrieve_entity_info
43+
uri: https://api.anthropic.com/v1/messages
44+
response:
45+
headers:
46+
connection:
47+
- keep-alive
48+
content-length:
49+
- '835'
50+
content-type:
51+
- application/json
52+
transfer-encoding:
53+
- chunked
54+
parsed_body:
55+
content:
56+
- text: Let me retrieve the information about each family member to determine their ages.
57+
type: text
58+
- id: toolu_01B6ALG3PUWnoTbSG77WfHsW
59+
input:
60+
name: Alice
61+
name: retrieve_entity_info
62+
type: tool_use
63+
- id: toolu_018ar6FXUarSmz9v81ajKN5q
64+
input:
65+
name: Bob
66+
name: retrieve_entity_info
67+
type: tool_use
68+
- id: toolu_01DQDAMdPsj6Seitxpc29HZF
69+
input:
70+
name: Charlie
71+
name: retrieve_entity_info
72+
type: tool_use
73+
- id: toolu_01Vzma2bAahRtnZi69djzStJ
74+
input:
75+
name: Daisy
76+
name: retrieve_entity_info
77+
type: tool_use
78+
id: msg_01B6EyfNCSVqtFxbXvfmQXaX
79+
model: claude-3-5-haiku-20241022
80+
role: assistant
81+
stop_reason: tool_use
82+
stop_sequence: null
83+
type: message
84+
usage:
85+
cache_creation_input_tokens: 0
86+
cache_read_input_tokens: 0
87+
input_tokens: 429
88+
output_tokens: 186
89+
status:
90+
code: 200
91+
message: OK
92+
- request:
93+
headers:
94+
accept:
95+
- application/json
96+
accept-encoding:
97+
- gzip, deflate
98+
connection:
99+
- keep-alive
100+
content-length:
101+
- '1928'
102+
content-type:
103+
- application/json
104+
host:
105+
- api.anthropic.com
106+
method: POST
107+
parsed_body:
108+
max_tokens: 1024
109+
messages:
110+
- content:
111+
- text: Alice, Bob, Charlie and Daisy are a family. Who is the youngest?
112+
type: text
113+
role: user
114+
- content:
115+
- text: Let me retrieve the information about each family member to determine their ages.
116+
type: text
117+
- id: toolu_01B6ALG3PUWnoTbSG77WfHsW
118+
input:
119+
name: Alice
120+
name: retrieve_entity_info
121+
type: tool_use
122+
- id: toolu_018ar6FXUarSmz9v81ajKN5q
123+
input:
124+
name: Bob
125+
name: retrieve_entity_info
126+
type: tool_use
127+
- id: toolu_01DQDAMdPsj6Seitxpc29HZF
128+
input:
129+
name: Charlie
130+
name: retrieve_entity_info
131+
type: tool_use
132+
- id: toolu_01Vzma2bAahRtnZi69djzStJ
133+
input:
134+
name: Daisy
135+
name: retrieve_entity_info
136+
type: tool_use
137+
role: assistant
138+
- content:
139+
- content: alice is bob's wife
140+
is_error: false
141+
tool_use_id: toolu_01B6ALG3PUWnoTbSG77WfHsW
142+
type: tool_result
143+
- content: bob is alice's husband
144+
is_error: false
145+
tool_use_id: toolu_018ar6FXUarSmz9v81ajKN5q
146+
type: tool_result
147+
- content: charlie is alice's son
148+
is_error: false
149+
tool_use_id: toolu_01DQDAMdPsj6Seitxpc29HZF
150+
type: tool_result
151+
- content: daisy is bob's daughter and charlie's younger sister
152+
is_error: false
153+
tool_use_id: toolu_01Vzma2bAahRtnZi69djzStJ
154+
type: tool_result
155+
role: user
156+
model: claude-3-5-haiku-latest
157+
stream: false
158+
system: "\n Use the `retrieve_entity_info` tool to get information about a specific person.\n If you need to use
159+
`retrieve_entity_info` to get information about multiple people, try\n to call them in parallel as much as possible.\n
160+
\ Think step by step and then provide a single most probable concise answer.\n "
161+
tool_choice:
162+
type: auto
163+
tools:
164+
- description: Get the knowledge about the given entity.
165+
input_schema:
166+
additionalProperties: false
167+
properties:
168+
name:
169+
title: Name
170+
type: string
171+
required:
172+
- name
173+
type: object
174+
name: retrieve_entity_info
175+
uri: https://api.anthropic.com/v1/messages
176+
response:
177+
headers:
178+
connection:
179+
- keep-alive
180+
content-length:
181+
- '618'
182+
content-type:
183+
- application/json
184+
transfer-encoding:
185+
- chunked
186+
parsed_body:
187+
content:
188+
- text: |-
189+
Based on the retrieved information, we can see the family relationships:
190+
- Alice and Bob are married
191+
- Charlie is their son
192+
- Daisy is their daughter and Charlie's younger sister
193+
194+
Since Daisy is described as Charlie's younger sister, Daisy is the youngest in this family.
195+
196+
The answer is: Daisy is the youngest.
197+
type: text
198+
id: msg_018ApbFsve4yLKjivLPAzqvA
199+
model: claude-3-5-haiku-20241022
200+
role: assistant
201+
stop_reason: end_turn
202+
stop_sequence: null
203+
type: message
204+
usage:
205+
cache_creation_input_tokens: 0
206+
cache_read_input_tokens: 0
207+
input_tokens: 761
208+
output_tokens: 78
209+
status:
210+
code: 200
211+
message: OK
212+
version: 1

0 commit comments

Comments
 (0)