braintrust 0.3.15__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- braintrust/_generated_types.py +737 -672
- braintrust/audit.py +2 -2
- braintrust/bt_json.py +178 -19
- braintrust/cli/eval.py +6 -7
- braintrust/cli/push.py +11 -11
- braintrust/context.py +12 -17
- braintrust/contrib/temporal/__init__.py +16 -27
- braintrust/contrib/temporal/test_temporal.py +8 -3
- braintrust/devserver/auth.py +8 -8
- braintrust/devserver/cache.py +3 -4
- braintrust/devserver/cors.py +8 -7
- braintrust/devserver/dataset.py +3 -5
- braintrust/devserver/eval_hooks.py +7 -6
- braintrust/devserver/schemas.py +22 -19
- braintrust/devserver/server.py +19 -12
- braintrust/devserver/test_cached_login.py +4 -4
- braintrust/framework.py +139 -142
- braintrust/framework2.py +88 -87
- braintrust/functions/invoke.py +66 -59
- braintrust/functions/stream.py +3 -2
- braintrust/generated_types.py +3 -1
- braintrust/git_fields.py +11 -11
- braintrust/gitutil.py +2 -3
- braintrust/graph_util.py +10 -10
- braintrust/id_gen.py +2 -2
- braintrust/logger.py +373 -471
- braintrust/merge_row_batch.py +10 -9
- braintrust/oai.py +21 -20
- braintrust/otel/__init__.py +49 -49
- braintrust/otel/context.py +16 -30
- braintrust/otel/test_distributed_tracing.py +14 -11
- braintrust/otel/test_otel_bt_integration.py +32 -31
- braintrust/parameters.py +8 -8
- braintrust/prompt.py +14 -14
- braintrust/prompt_cache/disk_cache.py +5 -4
- braintrust/prompt_cache/lru_cache.py +3 -2
- braintrust/prompt_cache/prompt_cache.py +13 -14
- braintrust/queue.py +4 -4
- braintrust/score.py +4 -4
- braintrust/serializable_data_class.py +4 -4
- braintrust/span_identifier_v1.py +1 -2
- braintrust/span_identifier_v2.py +3 -4
- braintrust/span_identifier_v3.py +23 -20
- braintrust/span_identifier_v4.py +34 -25
- braintrust/test_bt_json.py +644 -0
- braintrust/test_framework.py +72 -6
- braintrust/test_helpers.py +5 -5
- braintrust/test_id_gen.py +2 -3
- braintrust/test_logger.py +211 -107
- braintrust/test_otel.py +61 -53
- braintrust/test_queue.py +0 -1
- braintrust/test_score.py +1 -3
- braintrust/test_span_components.py +29 -44
- braintrust/util.py +9 -8
- braintrust/version.py +2 -2
- braintrust/wrappers/_anthropic_utils.py +4 -4
- braintrust/wrappers/agno/__init__.py +3 -4
- braintrust/wrappers/agno/agent.py +1 -2
- braintrust/wrappers/agno/function_call.py +1 -2
- braintrust/wrappers/agno/model.py +1 -2
- braintrust/wrappers/agno/team.py +1 -2
- braintrust/wrappers/agno/utils.py +12 -12
- braintrust/wrappers/anthropic.py +7 -8
- braintrust/wrappers/claude_agent_sdk/__init__.py +3 -4
- braintrust/wrappers/claude_agent_sdk/_wrapper.py +29 -27
- braintrust/wrappers/dspy.py +15 -17
- braintrust/wrappers/google_genai/__init__.py +17 -30
- braintrust/wrappers/langchain.py +22 -24
- braintrust/wrappers/litellm.py +4 -3
- braintrust/wrappers/openai.py +15 -15
- braintrust/wrappers/pydantic_ai.py +225 -110
- braintrust/wrappers/test_agno.py +0 -1
- braintrust/wrappers/test_dspy.py +0 -1
- braintrust/wrappers/test_google_genai.py +64 -4
- braintrust/wrappers/test_litellm.py +0 -1
- braintrust/wrappers/test_pydantic_ai_integration.py +819 -22
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/METADATA +3 -2
- braintrust-0.4.1.dist-info/RECORD +121 -0
- braintrust-0.3.15.dist-info/RECORD +0 -120
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/WHEEL +0 -0
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/entry_points.txt +0 -0
- {braintrust-0.3.15.dist-info → braintrust-0.4.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
# pyright: reportUnknownVariableType=false
|
|
2
|
+
# pyright: reportUnknownArgumentType=false
|
|
3
|
+
# pyright: reportPrivateUsage=false
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
from unittest import TestCase
|
|
7
|
+
|
|
8
|
+
from braintrust.bt_json import bt_dumps, bt_safe_deep_copy
|
|
9
|
+
from braintrust.logger import Attachment, ExternalAttachment
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestBTJson(TestCase):
|
|
13
|
+
def testdeep_copy_event_basic(self):
|
|
14
|
+
original = {
|
|
15
|
+
"input": {"foo": "bar", "null": None, "empty": {}},
|
|
16
|
+
"output": [1, 2, "3", None, {}],
|
|
17
|
+
}
|
|
18
|
+
copy = bt_safe_deep_copy(original)
|
|
19
|
+
self.assertEqual(copy, original)
|
|
20
|
+
self.assertIsNot(copy, original)
|
|
21
|
+
self.assertIsNot(copy["input"], original["input"])
|
|
22
|
+
self.assertIsNot(copy["output"], original["output"])
|
|
23
|
+
|
|
24
|
+
def test_deep_copy_mutation_independence(self):
|
|
25
|
+
"""Test that mutating the copy doesn't affect the original (true dereferencing)."""
|
|
26
|
+
original = {
|
|
27
|
+
"top_level": "value",
|
|
28
|
+
"nested_dict": {"inner": "data", "deep": {"level": 3}},
|
|
29
|
+
"nested_list": [1, 2, [3, 4]],
|
|
30
|
+
"nested_in_list": [{"key": "val"}],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
copy = bt_safe_deep_copy(original)
|
|
34
|
+
|
|
35
|
+
# Mutate the copy at various levels
|
|
36
|
+
copy["top_level"] = "MODIFIED"
|
|
37
|
+
copy["nested_dict"]["inner"] = "MODIFIED"
|
|
38
|
+
copy["nested_dict"]["deep"]["level"] = 999
|
|
39
|
+
copy["nested_list"][0] = 999
|
|
40
|
+
copy["nested_list"][2][0] = 999
|
|
41
|
+
copy["nested_in_list"][0]["key"] = "MODIFIED"
|
|
42
|
+
|
|
43
|
+
# Verify original is unchanged
|
|
44
|
+
self.assertEqual(original["top_level"], "value")
|
|
45
|
+
self.assertEqual(original["nested_dict"]["inner"], "data")
|
|
46
|
+
self.assertEqual(original["nested_dict"]["deep"]["level"], 3)
|
|
47
|
+
self.assertEqual(original["nested_list"][0], 1)
|
|
48
|
+
self.assertEqual(original["nested_list"][2][0], 3)
|
|
49
|
+
self.assertEqual(original["nested_in_list"][0]["key"], "val")
|
|
50
|
+
|
|
51
|
+
# Add new keys to copy
|
|
52
|
+
copy["new_key"] = "new_value"
|
|
53
|
+
copy["nested_dict"]["new_inner"] = "new"
|
|
54
|
+
|
|
55
|
+
# Verify original doesn't have these keys
|
|
56
|
+
self.assertNotIn("new_key", original)
|
|
57
|
+
self.assertNotIn("new_inner", original["nested_dict"])
|
|
58
|
+
|
|
59
|
+
def testdeep_copy_event_with_attachments(self):
|
|
60
|
+
attachment1 = Attachment(
|
|
61
|
+
data=b"data",
|
|
62
|
+
filename="filename",
|
|
63
|
+
content_type="text/plain",
|
|
64
|
+
)
|
|
65
|
+
attachment2 = Attachment(
|
|
66
|
+
data=b"data2",
|
|
67
|
+
filename="filename2",
|
|
68
|
+
content_type="text/plain",
|
|
69
|
+
)
|
|
70
|
+
attachment3 = ExternalAttachment(
|
|
71
|
+
url="s3://bucket/path/to/key.pdf",
|
|
72
|
+
filename="filename3",
|
|
73
|
+
content_type="application/pdf",
|
|
74
|
+
)
|
|
75
|
+
date = "2024-10-23T05:02:48.796Z"
|
|
76
|
+
|
|
77
|
+
original = {
|
|
78
|
+
"input": "Testing",
|
|
79
|
+
"output": {
|
|
80
|
+
"span": "<span>",
|
|
81
|
+
"myIllegalObjects": ["<experiment>", "<dataset>", "<logger>"],
|
|
82
|
+
"myOtherWeirdObjects": [None, date, None, None],
|
|
83
|
+
"attachment": attachment1,
|
|
84
|
+
"another_attachment": attachment3,
|
|
85
|
+
"attachmentList": [attachment1, attachment2, "string", attachment3],
|
|
86
|
+
"nestedAttachment": {
|
|
87
|
+
"attachment": attachment2,
|
|
88
|
+
"another_attachment": attachment3,
|
|
89
|
+
},
|
|
90
|
+
"fake": {
|
|
91
|
+
"_bt_internal_saved_attachment": "not a number",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
copy = bt_safe_deep_copy(original)
|
|
97
|
+
|
|
98
|
+
self.assertEqual(
|
|
99
|
+
copy,
|
|
100
|
+
{
|
|
101
|
+
"input": "Testing",
|
|
102
|
+
"output": {
|
|
103
|
+
"span": "<span>",
|
|
104
|
+
"myIllegalObjects": ["<experiment>", "<dataset>", "<logger>"],
|
|
105
|
+
"myOtherWeirdObjects": [None, date, None, None],
|
|
106
|
+
"attachment": attachment1,
|
|
107
|
+
"another_attachment": attachment3,
|
|
108
|
+
"attachmentList": [attachment1, attachment2, "string", attachment3],
|
|
109
|
+
"nestedAttachment": {
|
|
110
|
+
"attachment": attachment2,
|
|
111
|
+
"another_attachment": attachment3,
|
|
112
|
+
},
|
|
113
|
+
"fake": {
|
|
114
|
+
"_bt_internal_saved_attachment": "not a number",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
self.assertIsNot(copy, original)
|
|
121
|
+
|
|
122
|
+
self.assertIs(copy["output"]["attachment"], attachment1)
|
|
123
|
+
self.assertIs(copy["output"]["another_attachment"], attachment3)
|
|
124
|
+
self.assertIs(copy["output"]["nestedAttachment"]["attachment"], attachment2)
|
|
125
|
+
self.assertIs(copy["output"]["nestedAttachment"]["another_attachment"], attachment3)
|
|
126
|
+
self.assertIs(copy["output"]["attachmentList"][0], attachment1)
|
|
127
|
+
self.assertIs(copy["output"]["attachmentList"][1], attachment2)
|
|
128
|
+
self.assertIs(copy["output"]["attachmentList"][3], attachment3)
|
|
129
|
+
|
|
130
|
+
def test_bt_dumps_circular_references_raises(self):
|
|
131
|
+
"""Test that bt_dumps raises on circular references in raw data.
|
|
132
|
+
|
|
133
|
+
Note: bt_dumps without bt_safe_deep_copy will raise ValueError on circular refs.
|
|
134
|
+
Use bt_safe_deep_copy first to handle circular references gracefully.
|
|
135
|
+
"""
|
|
136
|
+
data: dict[str, Any] = {"a": "b"}
|
|
137
|
+
data["self"] = data
|
|
138
|
+
|
|
139
|
+
with self.assertRaises(ValueError) as ctx:
|
|
140
|
+
bt_dumps(data)
|
|
141
|
+
self.assertIn("Circular reference", str(ctx.exception))
|
|
142
|
+
|
|
143
|
+
def test_deep_copy_binary_types(self):
|
|
144
|
+
"""Test current handling of bytes, bytearray, memoryview through bt_dumps/bt_loads roundtrip."""
|
|
145
|
+
data = {
|
|
146
|
+
"bytes": b"hello world",
|
|
147
|
+
"bytearray": bytearray(b"test data"),
|
|
148
|
+
"memoryview": memoryview(b"memory"),
|
|
149
|
+
"nested": {"embedded": b"\x00\x01\x02\x03"},
|
|
150
|
+
}
|
|
151
|
+
result = bt_safe_deep_copy(data)
|
|
152
|
+
|
|
153
|
+
# The function uses bt_dumps/bt_loads for non-container types, so binary
|
|
154
|
+
# gets JSON-serialized. Check what actually comes back:
|
|
155
|
+
self.assertIn("bytes", result)
|
|
156
|
+
self.assertIn("bytearray", result)
|
|
157
|
+
self.assertIn("memoryview", result)
|
|
158
|
+
self.assertIn("nested", result)
|
|
159
|
+
self.assertIn("embedded", result["nested"])
|
|
160
|
+
|
|
161
|
+
# Verify it's JSON-serializable (main goal of the function)
|
|
162
|
+
json_str = json.dumps(result)
|
|
163
|
+
self.assertIsInstance(json_str, str)
|
|
164
|
+
|
|
165
|
+
def test_deep_copy_frozenset(self):
|
|
166
|
+
"""Test current frozenset handling through JSON roundtrip."""
|
|
167
|
+
data = {"frozen": frozenset([1, 2, 3])}
|
|
168
|
+
result = bt_safe_deep_copy(data)
|
|
169
|
+
|
|
170
|
+
# frozenset goes through bt_dumps/bt_loads - it becomes a string representation
|
|
171
|
+
# since frozenset is not JSON-serializable, bt_dumps converts it to str
|
|
172
|
+
self.assertIn("frozen", result)
|
|
173
|
+
self.assertIsInstance(result["frozen"], str)
|
|
174
|
+
self.assertIn("frozenset", result["frozen"])
|
|
175
|
+
|
|
176
|
+
def test_deep_copy_empty_containers(self):
|
|
177
|
+
"""Test handling of empty containers."""
|
|
178
|
+
data = {
|
|
179
|
+
"empty_list": [],
|
|
180
|
+
"empty_dict": {},
|
|
181
|
+
"empty_set": set(),
|
|
182
|
+
"nested": {"also_empty": {}},
|
|
183
|
+
}
|
|
184
|
+
result = bt_safe_deep_copy(data)
|
|
185
|
+
|
|
186
|
+
self.assertEqual(result["empty_list"], [])
|
|
187
|
+
self.assertEqual(result["empty_dict"], {})
|
|
188
|
+
# empty set becomes empty list via JSON roundtrip
|
|
189
|
+
self.assertEqual(result["empty_set"], [])
|
|
190
|
+
self.assertEqual(result["nested"]["also_empty"], {})
|
|
191
|
+
|
|
192
|
+
def test_deep_copy_exactly_max_depth(self):
|
|
193
|
+
"""Test behavior at exactly MAX_DEPTH (200)."""
|
|
194
|
+
# Create nested structure at depth 199 (just under limit)
|
|
195
|
+
nested = {"level": 0}
|
|
196
|
+
current = nested
|
|
197
|
+
for i in range(1, 199):
|
|
198
|
+
current["child"] = {"level": i}
|
|
199
|
+
current = current["child"]
|
|
200
|
+
|
|
201
|
+
result = bt_safe_deep_copy(nested)
|
|
202
|
+
|
|
203
|
+
# Should succeed - verify structure is preserved
|
|
204
|
+
self.assertEqual(result["level"], 0)
|
|
205
|
+
self.assertIn("child", result)
|
|
206
|
+
|
|
207
|
+
# Walk down and verify depth
|
|
208
|
+
current_result = result
|
|
209
|
+
depth_reached = 0
|
|
210
|
+
while "child" in current_result:
|
|
211
|
+
current_result = current_result["child"]
|
|
212
|
+
depth_reached += 1
|
|
213
|
+
self.assertEqual(depth_reached, 198) # 199 levels total (0 to 198)
|
|
214
|
+
|
|
215
|
+
def test_deep_copy_exceeds_max_depth(self):
|
|
216
|
+
"""Test behavior exceeding MAX_DEPTH (200)."""
|
|
217
|
+
# Create nested structure at depth 201 (exceeds limit)
|
|
218
|
+
nested = {"level": 0}
|
|
219
|
+
current = nested
|
|
220
|
+
for i in range(1, 201):
|
|
221
|
+
current["child"] = {"level": i}
|
|
222
|
+
current = current["child"]
|
|
223
|
+
|
|
224
|
+
result = bt_safe_deep_copy(nested)
|
|
225
|
+
|
|
226
|
+
# Should have root level preserved
|
|
227
|
+
self.assertEqual(result["level"], 0)
|
|
228
|
+
|
|
229
|
+
# Walk down until we find the truncation marker
|
|
230
|
+
current_result = result
|
|
231
|
+
depth_reached = 0
|
|
232
|
+
truncation_found = False
|
|
233
|
+
while isinstance(current_result, dict) and "child" in current_result:
|
|
234
|
+
current_result = current_result["child"]
|
|
235
|
+
depth_reached += 1
|
|
236
|
+
if current_result == "<max depth exceeded>":
|
|
237
|
+
truncation_found = True
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
self.assertTrue(truncation_found, f"Expected truncation marker at depth {depth_reached}")
|
|
241
|
+
self.assertLessEqual(depth_reached, 200) # Should truncate at or before MAX_DEPTH
|
|
242
|
+
|
|
243
|
+
def test_deep_copy_non_stringifiable_keys(self):
|
|
244
|
+
"""Test dict with keys that can't be converted to string."""
|
|
245
|
+
|
|
246
|
+
class BadKey:
|
|
247
|
+
def __str__(self):
|
|
248
|
+
raise RuntimeError("Cannot stringify")
|
|
249
|
+
|
|
250
|
+
data = {BadKey(): "value"}
|
|
251
|
+
result = bt_safe_deep_copy(data)
|
|
252
|
+
|
|
253
|
+
# Should have fallback key from exception handler
|
|
254
|
+
keys = list(result.keys())
|
|
255
|
+
self.assertEqual(len(keys), 1)
|
|
256
|
+
|
|
257
|
+
# The fallback should contain type name and indicate it's non-stringifiable
|
|
258
|
+
key = keys[0]
|
|
259
|
+
self.assertTrue("non-stringifiable" in key.lower() or "BadKey" in key)
|
|
260
|
+
self.assertEqual(result[key], "value")
|
|
261
|
+
|
|
262
|
+
def test_deep_copy_numeric_and_special_keys(self):
|
|
263
|
+
"""Test dict with various key types that need coercion."""
|
|
264
|
+
data = {
|
|
265
|
+
1: "int_key",
|
|
266
|
+
2.5: "float_key",
|
|
267
|
+
True: "bool_key",
|
|
268
|
+
(1, 2): "tuple_key",
|
|
269
|
+
None: "none_key",
|
|
270
|
+
}
|
|
271
|
+
result = bt_safe_deep_copy(data)
|
|
272
|
+
|
|
273
|
+
# All keys should be coerced to strings
|
|
274
|
+
self.assertTrue(all(isinstance(k, str) for k in result.keys()))
|
|
275
|
+
|
|
276
|
+
# Verify values are preserved
|
|
277
|
+
# bool True coerces to "True", int 1 to "1" - they may conflict
|
|
278
|
+
self.assertTrue("1" in result or "True" in result)
|
|
279
|
+
self.assertIn("2.5", result)
|
|
280
|
+
# tuple str representation
|
|
281
|
+
self.assertTrue("(1, 2)" in result or "1, 2" in result)
|
|
282
|
+
self.assertIn("None", result)
|
|
283
|
+
|
|
284
|
+
def test_to_bt_safe_special_objects(self):
|
|
285
|
+
"""Test _to_bt_safe handling of Span, Experiment, Dataset, Logger objects."""
|
|
286
|
+
from braintrust import init, init_dataset, init_logger
|
|
287
|
+
|
|
288
|
+
# Create actual objects
|
|
289
|
+
exp = init(project="test", experiment="test")
|
|
290
|
+
dataset = init_dataset(project="test", name="test")
|
|
291
|
+
logger = init_logger(project="test")
|
|
292
|
+
span = exp.start_span()
|
|
293
|
+
|
|
294
|
+
# Import _to_bt_safe
|
|
295
|
+
from braintrust.bt_json import _to_bt_safe
|
|
296
|
+
|
|
297
|
+
# Test each special object
|
|
298
|
+
self.assertEqual(_to_bt_safe(span), "<span>")
|
|
299
|
+
self.assertEqual(_to_bt_safe(exp), "<experiment>")
|
|
300
|
+
self.assertEqual(_to_bt_safe(dataset), "<dataset>")
|
|
301
|
+
self.assertEqual(_to_bt_safe(logger), "<logger>")
|
|
302
|
+
|
|
303
|
+
# Clean up
|
|
304
|
+
exp.flush()
|
|
305
|
+
dataset.flush()
|
|
306
|
+
logger.flush()
|
|
307
|
+
|
|
308
|
+
def test_to_bt_safe_attachments(self):
|
|
309
|
+
"""Test _to_bt_safe preserves BaseAttachment and converts ReadonlyAttachment to reference."""
|
|
310
|
+
from braintrust.bt_json import _to_bt_safe
|
|
311
|
+
|
|
312
|
+
# Test BaseAttachment preservation
|
|
313
|
+
attachment = Attachment(data=b"test", filename="test.txt", content_type="text/plain")
|
|
314
|
+
result = _to_bt_safe(attachment)
|
|
315
|
+
self.assertIs(result, attachment)
|
|
316
|
+
|
|
317
|
+
# Test ExternalAttachment preservation
|
|
318
|
+
ext_attachment = ExternalAttachment(
|
|
319
|
+
url="s3://bucket/key", filename="ext.pdf", content_type="application/pdf"
|
|
320
|
+
)
|
|
321
|
+
result_ext = _to_bt_safe(ext_attachment)
|
|
322
|
+
self.assertIs(result_ext, ext_attachment)
|
|
323
|
+
|
|
324
|
+
# Test ReadonlyAttachment conversion to reference
|
|
325
|
+
from braintrust.logger import ReadonlyAttachment
|
|
326
|
+
|
|
327
|
+
reference = {
|
|
328
|
+
"type": "braintrust_attachment",
|
|
329
|
+
"key": "test-key",
|
|
330
|
+
"filename": "readonly.txt",
|
|
331
|
+
"content_type": "text/plain",
|
|
332
|
+
}
|
|
333
|
+
readonly = ReadonlyAttachment(reference)
|
|
334
|
+
result_readonly = _to_bt_safe(readonly)
|
|
335
|
+
self.assertEqual(result_readonly, reference)
|
|
336
|
+
self.assertIsNot(result_readonly, readonly)
|
|
337
|
+
|
|
338
|
+
def test_to_bt_safe_pydantic_models(self):
|
|
339
|
+
"""Test _to_bt_safe handling of Pydantic v1 and v2 models."""
|
|
340
|
+
from braintrust.bt_json import _to_bt_safe
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
from pydantic import BaseModel
|
|
344
|
+
|
|
345
|
+
class TestModel(BaseModel):
|
|
346
|
+
name: str
|
|
347
|
+
value: int
|
|
348
|
+
|
|
349
|
+
model = TestModel(name="test", value=42)
|
|
350
|
+
result = _to_bt_safe(model)
|
|
351
|
+
|
|
352
|
+
# Should convert to dict
|
|
353
|
+
self.assertIsInstance(result, dict)
|
|
354
|
+
self.assertEqual(result["name"], "test")
|
|
355
|
+
self.assertEqual(result["value"], 42)
|
|
356
|
+
except ImportError:
|
|
357
|
+
self.skipTest("Pydantic not available")
|
|
358
|
+
|
|
359
|
+
def test_to_bt_safe_dataclasses(self):
|
|
360
|
+
"""Test _to_bt_safe handling of dataclasses with attachment fields."""
|
|
361
|
+
from dataclasses import dataclass
|
|
362
|
+
|
|
363
|
+
from braintrust.bt_json import _to_bt_safe
|
|
364
|
+
|
|
365
|
+
@dataclass
|
|
366
|
+
class SimpleData:
|
|
367
|
+
text: str
|
|
368
|
+
number: int
|
|
369
|
+
|
|
370
|
+
@dataclass
|
|
371
|
+
class DataWithAttachment:
|
|
372
|
+
name: str
|
|
373
|
+
file: Attachment
|
|
374
|
+
|
|
375
|
+
# Test simple dataclass
|
|
376
|
+
simple = SimpleData(text="hello", number=123)
|
|
377
|
+
result = _to_bt_safe(simple)
|
|
378
|
+
self.assertIsInstance(result, dict)
|
|
379
|
+
self.assertEqual(result["text"], "hello")
|
|
380
|
+
self.assertEqual(result["number"], 123)
|
|
381
|
+
|
|
382
|
+
# Test dataclass with attachment field
|
|
383
|
+
attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
|
|
384
|
+
with_attachment = DataWithAttachment(name="test", file=attachment)
|
|
385
|
+
result_with_att = _to_bt_safe(with_attachment)
|
|
386
|
+
|
|
387
|
+
self.assertIsInstance(result_with_att, dict)
|
|
388
|
+
self.assertEqual(result_with_att["name"], "test")
|
|
389
|
+
# The attachment should be preserved in the dict
|
|
390
|
+
self.assertIs(result_with_att["file"], attachment)
|
|
391
|
+
|
|
392
|
+
def test_to_bt_safe_special_floats(self):
|
|
393
|
+
"""Test _to_bt_safe handling of NaN, Infinity, -Infinity."""
|
|
394
|
+
from braintrust.bt_json import _to_bt_safe
|
|
395
|
+
|
|
396
|
+
self.assertEqual(_to_bt_safe(float("nan")), "NaN")
|
|
397
|
+
self.assertEqual(_to_bt_safe(float("inf")), "Infinity")
|
|
398
|
+
self.assertEqual(_to_bt_safe(float("-inf")), "-Infinity")
|
|
399
|
+
self.assertEqual(_to_bt_safe(1.5), 1.5)
|
|
400
|
+
self.assertEqual(_to_bt_safe(0.0), 0.0)
|
|
401
|
+
|
|
402
|
+
def test_to_bt_safe_fallback_exceptions(self):
|
|
403
|
+
"""Test _to_bt_safe graceful handling when serialization fails in bt_safe_deep_copy."""
|
|
404
|
+
|
|
405
|
+
class UnserializableObject:
|
|
406
|
+
def __init__(self):
|
|
407
|
+
self.data = "test"
|
|
408
|
+
|
|
409
|
+
obj = UnserializableObject()
|
|
410
|
+
|
|
411
|
+
# When called through bt_safe_deep_copy, exceptions are caught
|
|
412
|
+
result = bt_safe_deep_copy({"key": obj})
|
|
413
|
+
|
|
414
|
+
# The object should be in the result (after roundtrip through bt_dumps/bt_loads)
|
|
415
|
+
self.assertIn("key", result)
|
|
416
|
+
# The value might be stringified or converted depending on fallback behavior
|
|
417
|
+
self.assertIsNotNone(result["key"])
|
|
418
|
+
|
|
419
|
+
def test_bt_safe_deep_copy_attachment_identity(self):
|
|
420
|
+
"""Test bt_safe_deep_copy preserves attachment object identity."""
|
|
421
|
+
attachment1 = Attachment(data=b"data1", filename="file1.txt", content_type="text/plain")
|
|
422
|
+
attachment2 = ExternalAttachment(
|
|
423
|
+
url="s3://bucket/key", filename="file2.pdf", content_type="application/pdf"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
original = {
|
|
427
|
+
"field1": attachment1,
|
|
428
|
+
"nested": {"field2": attachment2},
|
|
429
|
+
"list": [attachment1, "string", attachment2],
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
result = bt_safe_deep_copy(original)
|
|
433
|
+
|
|
434
|
+
# Verify attachment identity is preserved (same object)
|
|
435
|
+
self.assertIs(result["field1"], attachment1)
|
|
436
|
+
self.assertIs(result["nested"]["field2"], attachment2)
|
|
437
|
+
self.assertIs(result["list"][0], attachment1)
|
|
438
|
+
self.assertIs(result["list"][2], attachment2)
|
|
439
|
+
|
|
440
|
+
# But container objects are copied
|
|
441
|
+
self.assertIsNot(result, original)
|
|
442
|
+
self.assertIsNot(result["nested"], original["nested"])
|
|
443
|
+
self.assertIsNot(result["list"], original["list"])
|
|
444
|
+
|
|
445
|
+
def test_bt_safe_deep_copy_mixed_attachment_types(self):
|
|
446
|
+
"""Test bt_safe_deep_copy with BaseAttachment and ReadonlyAttachment together."""
|
|
447
|
+
from braintrust.logger import ReadonlyAttachment
|
|
448
|
+
|
|
449
|
+
base_attachment = Attachment(data=b"base", filename="base.txt", content_type="text/plain")
|
|
450
|
+
|
|
451
|
+
reference = {
|
|
452
|
+
"type": "braintrust_attachment",
|
|
453
|
+
"key": "readonly-key",
|
|
454
|
+
"filename": "readonly.txt",
|
|
455
|
+
"content_type": "text/plain",
|
|
456
|
+
}
|
|
457
|
+
readonly_attachment = ReadonlyAttachment(reference)
|
|
458
|
+
|
|
459
|
+
original = {
|
|
460
|
+
"base": base_attachment,
|
|
461
|
+
"readonly": readonly_attachment,
|
|
462
|
+
"mixed_list": [base_attachment, readonly_attachment],
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
result = bt_safe_deep_copy(original)
|
|
466
|
+
|
|
467
|
+
# BaseAttachment preserved as-is
|
|
468
|
+
self.assertIs(result["base"], base_attachment)
|
|
469
|
+
self.assertIs(result["mixed_list"][0], base_attachment)
|
|
470
|
+
|
|
471
|
+
# ReadonlyAttachment converted to reference dict
|
|
472
|
+
self.assertEqual(result["readonly"], reference)
|
|
473
|
+
self.assertIsInstance(result["readonly"], dict)
|
|
474
|
+
self.assertEqual(result["mixed_list"][1], reference)
|
|
475
|
+
|
|
476
|
+
def test_bt_safe_deep_copy_circular_with_attachments(self):
|
|
477
|
+
"""Test circular reference detection with attachments in the structure."""
|
|
478
|
+
attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
|
|
479
|
+
|
|
480
|
+
# Create circular structure with attachment
|
|
481
|
+
circular: dict[str, Any] = {"attachment": attachment, "data": "test"}
|
|
482
|
+
circular["self"] = circular
|
|
483
|
+
|
|
484
|
+
result = bt_safe_deep_copy(circular)
|
|
485
|
+
|
|
486
|
+
# Attachment should be preserved
|
|
487
|
+
self.assertIs(result["attachment"], attachment)
|
|
488
|
+
self.assertEqual(result["data"], "test")
|
|
489
|
+
|
|
490
|
+
# Circular reference should be detected
|
|
491
|
+
self.assertEqual(result["self"], "<circular reference>")
|
|
492
|
+
|
|
493
|
+
def test_bt_safe_deep_copy_containers_with_attachments(self):
|
|
494
|
+
"""Test tuple, set, and nested containers with attachments."""
|
|
495
|
+
attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
|
|
496
|
+
|
|
497
|
+
original = {
|
|
498
|
+
"tuple_with_attachment": (attachment, "string", 123),
|
|
499
|
+
"set_with_attachment": {attachment, "value"},
|
|
500
|
+
"nested": {"inner_tuple": (1, 2, attachment)},
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
result = bt_safe_deep_copy(original)
|
|
504
|
+
|
|
505
|
+
# Tuples and sets are converted to lists
|
|
506
|
+
self.assertIsInstance(result["tuple_with_attachment"], list)
|
|
507
|
+
self.assertIsInstance(result["set_with_attachment"], list)
|
|
508
|
+
|
|
509
|
+
# Attachment preserved in converted list
|
|
510
|
+
self.assertIs(result["tuple_with_attachment"][0], attachment)
|
|
511
|
+
self.assertIn(attachment, result["set_with_attachment"])
|
|
512
|
+
|
|
513
|
+
# Nested tuple also converted
|
|
514
|
+
self.assertIsInstance(result["nested"]["inner_tuple"], list)
|
|
515
|
+
self.assertIs(result["nested"]["inner_tuple"][2], attachment)
|
|
516
|
+
|
|
517
|
+
def test_bt_safe_deep_copy_pydantic_with_attachments(self):
|
|
518
|
+
"""Test Pydantic model with attachment field."""
|
|
519
|
+
try:
|
|
520
|
+
from pydantic import BaseModel
|
|
521
|
+
|
|
522
|
+
attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
|
|
523
|
+
|
|
524
|
+
class ModelWithAttachment(BaseModel):
|
|
525
|
+
name: str
|
|
526
|
+
file: Any # Pydantic doesn't have built-in type for our Attachment
|
|
527
|
+
|
|
528
|
+
model = ModelWithAttachment(name="test", file=attachment)
|
|
529
|
+
|
|
530
|
+
result = bt_safe_deep_copy(model)
|
|
531
|
+
|
|
532
|
+
# Model should be converted to dict
|
|
533
|
+
self.assertIsInstance(result, dict)
|
|
534
|
+
self.assertEqual(result["name"], "test")
|
|
535
|
+
|
|
536
|
+
# Attachment should be preserved
|
|
537
|
+
self.assertIs(result["file"], attachment)
|
|
538
|
+
except ImportError:
|
|
539
|
+
self.skipTest("Pydantic not available")
|
|
540
|
+
|
|
541
|
+
def test_bt_safe_deep_copy_dataclass_with_attachments(self):
|
|
542
|
+
"""Test that dataclasses with attachments are handled correctly."""
|
|
543
|
+
from dataclasses import dataclass
|
|
544
|
+
|
|
545
|
+
attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
|
|
546
|
+
|
|
547
|
+
@dataclass
|
|
548
|
+
class DataWithAttachment:
|
|
549
|
+
name: str
|
|
550
|
+
value: int
|
|
551
|
+
file: Attachment
|
|
552
|
+
|
|
553
|
+
data = DataWithAttachment(name="test", value=42, file=attachment)
|
|
554
|
+
|
|
555
|
+
result = bt_safe_deep_copy({"data": data})
|
|
556
|
+
|
|
557
|
+
# Dataclasses with Attachment fields are now properly converted by
|
|
558
|
+
# recursively applying _to_bt_safe to each field instead of using
|
|
559
|
+
# dataclasses.asdict() which would try to deepcopy the Attachment.
|
|
560
|
+
self.assertIsInstance(result["data"], dict)
|
|
561
|
+
self.assertEqual(result["data"]["name"], "test")
|
|
562
|
+
self.assertEqual(result["data"]["value"], 42)
|
|
563
|
+
self.assertIs(result["data"]["file"], attachment)
|
|
564
|
+
|
|
565
|
+
# Attachments in regular dicts also work fine
|
|
566
|
+
dict_with_attachment = {"name": "test", "value": 42, "file": attachment}
|
|
567
|
+
result2 = bt_safe_deep_copy({"data": dict_with_attachment})
|
|
568
|
+
self.assertIsInstance(result2["data"], dict)
|
|
569
|
+
self.assertIs(result2["data"]["file"], attachment)
|
|
570
|
+
|
|
571
|
+
def test_bt_safe_deep_copy_circular_in_pydantic_deferred_to_bt_dumps(self):
|
|
572
|
+
"""Test that circular references inside Pydantic model results bypass bt_safe_deep_copy detection.
|
|
573
|
+
|
|
574
|
+
Current behavior: model_dump() preserves the circular structure (with different object
|
|
575
|
+
identity). bt_safe_deep_copy passes it through, and bt_dumps catches it at serialization.
|
|
576
|
+
This differs from plain dicts where circular refs are caught and replaced with
|
|
577
|
+
'<circular reference>'.
|
|
578
|
+
"""
|
|
579
|
+
try:
|
|
580
|
+
from pydantic import BaseModel
|
|
581
|
+
except ImportError:
|
|
582
|
+
self.skipTest("Pydantic not available")
|
|
583
|
+
|
|
584
|
+
class ModelWithObject(BaseModel):
|
|
585
|
+
data: object
|
|
586
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
587
|
+
|
|
588
|
+
circular: dict[str, Any] = {"value": 1}
|
|
589
|
+
circular["self"] = circular
|
|
590
|
+
|
|
591
|
+
model = ModelWithObject(data=circular)
|
|
592
|
+
|
|
593
|
+
# bt_safe_deep_copy passes through the circular structure from model_dump()
|
|
594
|
+
result = bt_safe_deep_copy({"model": model})
|
|
595
|
+
self.assertIsInstance(result["model"], dict)
|
|
596
|
+
# model_dump() preserves circular structure but NOT object identity
|
|
597
|
+
self.assertIsInstance(result["model"]["data"]["self"], dict)
|
|
598
|
+
self.assertEqual(result["model"]["data"]["self"]["value"], 1)
|
|
599
|
+
|
|
600
|
+
# bt_dumps catches the circular reference at serialization time
|
|
601
|
+
with self.assertRaises(ValueError) as ctx:
|
|
602
|
+
bt_dumps(result)
|
|
603
|
+
self.assertIn("Circular reference", str(ctx.exception))
|
|
604
|
+
|
|
605
|
+
def test_bt_safe_deep_copy_pydantic_with_attachment_field(self):
|
|
606
|
+
"""Test Pydantic model with a Braintrust Attachment field.
|
|
607
|
+
|
|
608
|
+
Pydantic models with Attachment fields should work correctly through
|
|
609
|
+
bt_safe_deep_copy, with the Attachment object preserved.
|
|
610
|
+
"""
|
|
611
|
+
try:
|
|
612
|
+
from pydantic import BaseModel
|
|
613
|
+
except ImportError:
|
|
614
|
+
self.skipTest("Pydantic not available")
|
|
615
|
+
|
|
616
|
+
attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
|
|
617
|
+
|
|
618
|
+
class ModelWithAttachment(BaseModel):
|
|
619
|
+
name: str
|
|
620
|
+
file: object
|
|
621
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
622
|
+
|
|
623
|
+
model = ModelWithAttachment(name="test", file=attachment)
|
|
624
|
+
|
|
625
|
+
result = bt_safe_deep_copy({"model": model})
|
|
626
|
+
|
|
627
|
+
self.assertIsInstance(result["model"], dict)
|
|
628
|
+
self.assertEqual(result["model"]["name"], "test")
|
|
629
|
+
# Attachment should be preserved through model_dump()
|
|
630
|
+
self.assertIs(result["model"]["file"], attachment)
|
|
631
|
+
|
|
632
|
+
def test_bt_safe_deep_copy_circular_in_plain_dict_is_caught(self):
|
|
633
|
+
"""Contrast test: circular references in plain dicts ARE caught by bt_safe_deep_copy."""
|
|
634
|
+
circular: dict[str, Any] = {"value": 1}
|
|
635
|
+
circular["self"] = circular
|
|
636
|
+
|
|
637
|
+
result = bt_safe_deep_copy({"data": circular})
|
|
638
|
+
|
|
639
|
+
# Circular reference IS detected and replaced
|
|
640
|
+
self.assertEqual(result["data"]["self"], "<circular reference>")
|
|
641
|
+
|
|
642
|
+
# bt_dumps succeeds because the circular ref was sanitized
|
|
643
|
+
json_str = bt_dumps(result)
|
|
644
|
+
self.assertIn("<circular reference>", json_str)
|