braintrust 0.4.0__py3-none-any.whl → 0.4.2__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.
@@ -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)