braintrust 0.3.7__py3-none-any.whl → 0.3.9__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.
@@ -212,6 +212,17 @@ CallEvent = Union[
212
212
  ]
213
213
 
214
214
 
215
+ class ChatCompletionContentPartFileFile(TypedDict):
216
+ file_data: NotRequired[Optional[str]]
217
+ filename: NotRequired[Optional[str]]
218
+ file_id: NotRequired[Optional[str]]
219
+
220
+
221
+ class ChatCompletionContentPartFileWithTitle(TypedDict):
222
+ file: ChatCompletionContentPartFileFile
223
+ type: Literal['file']
224
+
225
+
215
226
  class ChatCompletionContentPartImageWithTitleImageUrl(TypedDict):
216
227
  url: str
217
228
  detail: NotRequired[Optional[Union[Literal['auto'], Literal['low'], Literal['high']]]]
@@ -342,7 +353,7 @@ class ChatCompletionTool(TypedDict):
342
353
 
343
354
 
344
355
  class CodeBundleRuntimeContext(TypedDict):
345
- runtime: Literal['node', 'python']
356
+ runtime: Literal['node', 'python', 'browser']
346
357
  version: str
347
358
 
348
359
 
@@ -570,7 +581,7 @@ class Data(CodeBundle):
570
581
 
571
582
 
572
583
  class FunctionDataFunctionData1DataRuntimeContext(TypedDict):
573
- runtime: Literal['node', 'python']
584
+ runtime: Literal['node', 'python', 'browser']
574
585
  version: str
575
586
 
576
587
 
@@ -649,7 +660,7 @@ class FunctionIdFunctionId3(TypedDict):
649
660
 
650
661
 
651
662
  class FunctionIdFunctionId4InlineContext(TypedDict):
652
- runtime: Literal['node', 'python']
663
+ runtime: Literal['node', 'python', 'browser']
653
664
  version: str
654
665
 
655
666
 
@@ -668,16 +679,16 @@ class FunctionIdFunctionId4(TypedDict):
668
679
  FunctionIdRef = Mapping[str, Any]
669
680
 
670
681
 
671
- FunctionObjectType = Literal['prompt', 'tool', 'scorer', 'task', 'agent']
682
+ FunctionObjectType = Literal['prompt', 'tool', 'scorer', 'task', 'agent', 'custom_view']
672
683
 
673
684
 
674
685
  FunctionOutputType = Literal['completion', 'score', 'any']
675
686
 
676
687
 
677
- FunctionTypeEnum = Literal['llm', 'scorer', 'task', 'tool']
688
+ FunctionTypeEnum = Literal['llm', 'scorer', 'task', 'tool', 'custom_view']
678
689
 
679
690
 
680
- FunctionTypeEnumNullish = Literal['llm', 'scorer', 'task', 'tool']
691
+ FunctionTypeEnumNullish = Literal['llm', 'scorer', 'task', 'tool', 'custom_view']
681
692
 
682
693
 
683
694
  class GitMetadataSettings(TypedDict):
@@ -1854,7 +1865,7 @@ class AnyModelParams(TypedDict):
1854
1865
  function_call: NotRequired[Optional[Union[Literal['auto'], Literal['none'], AnyModelParamsFunctionCall]]]
1855
1866
  n: NotRequired[Optional[float]]
1856
1867
  stop: NotRequired[Optional[Sequence[str]]]
1857
- reasoning_effort: NotRequired[Optional[Literal['minimal', 'low', 'medium', 'high']]]
1868
+ reasoning_effort: NotRequired[Optional[Literal['none', 'minimal', 'low', 'medium', 'high']]]
1858
1869
  verbosity: NotRequired[Optional[Literal['low', 'medium', 'high']]]
1859
1870
  top_k: NotRequired[Optional[float]]
1860
1871
  stop_sequences: NotRequired[Optional[Sequence[str]]]
@@ -1894,7 +1905,11 @@ class AttachmentStatus(TypedDict):
1894
1905
  """
1895
1906
 
1896
1907
 
1897
- ChatCompletionContentPart = Union[ChatCompletionContentPartTextWithTitle, ChatCompletionContentPartImageWithTitle]
1908
+ ChatCompletionContentPart = Union[
1909
+ ChatCompletionContentPartTextWithTitle,
1910
+ ChatCompletionContentPartImageWithTitle,
1911
+ ChatCompletionContentPartFileWithTitle,
1912
+ ]
1898
1913
 
1899
1914
 
1900
1915
  class ChatCompletionMessageParamChatCompletionMessageParam1(TypedDict):
@@ -1993,6 +2008,14 @@ class DatasetEvent(TypedDict):
1993
2008
  Whether this span is a root span
1994
2009
  """
1995
2010
  origin: NotRequired[Optional[ObjectReferenceNullish]]
2011
+ comments: NotRequired[Optional[Sequence[Any]]]
2012
+ """
2013
+ Optional list of comments attached to this event
2014
+ """
2015
+ audit_data: NotRequired[Optional[Sequence[Any]]]
2016
+ """
2017
+ Optional list of audit entries attached to this event
2018
+ """
1996
2019
 
1997
2020
 
1998
2021
  class Experiment(TypedDict):
@@ -2075,7 +2098,7 @@ class ModelParamsModelParams(TypedDict):
2075
2098
  function_call: NotRequired[Optional[Union[Literal['auto'], Literal['none'], ModelParamsModelParamsFunctionCall]]]
2076
2099
  n: NotRequired[Optional[float]]
2077
2100
  stop: NotRequired[Optional[Sequence[str]]]
2078
- reasoning_effort: NotRequired[Optional[Literal['minimal', 'low', 'medium', 'high']]]
2101
+ reasoning_effort: NotRequired[Optional[Literal['none', 'minimal', 'low', 'medium', 'high']]]
2079
2102
  verbosity: NotRequired[Optional[Literal['low', 'medium', 'high']]]
2080
2103
 
2081
2104
 
@@ -2327,6 +2350,14 @@ class ExperimentEvent(TypedDict):
2327
2350
  Whether this span is a root span
2328
2351
  """
2329
2352
  origin: NotRequired[Optional[ObjectReferenceNullish]]
2353
+ comments: NotRequired[Optional[Sequence[Any]]]
2354
+ """
2355
+ Optional list of comments attached to this event
2356
+ """
2357
+ audit_data: NotRequired[Optional[Sequence[Any]]]
2358
+ """
2359
+ Optional list of audit entries attached to this event
2360
+ """
2330
2361
 
2331
2362
 
2332
2363
  class GraphNodeGraphNode7(TypedDict):
@@ -2437,6 +2468,18 @@ class ProjectLogsEvent(TypedDict):
2437
2468
  """
2438
2469
  span_attributes: NotRequired[Optional[SpanAttributes]]
2439
2470
  origin: NotRequired[Optional[ObjectReferenceNullish]]
2471
+ comments: NotRequired[Optional[Sequence[Any]]]
2472
+ """
2473
+ Optional list of comments attached to this event
2474
+ """
2475
+ audit_data: NotRequired[Optional[Sequence[Any]]]
2476
+ """
2477
+ Optional list of audit entries attached to this event
2478
+ """
2479
+ _async_scoring_state: NotRequired[Optional[Any]]
2480
+ """
2481
+ The async scoring state for this event
2482
+ """
2440
2483
 
2441
2484
 
2442
2485
  class ProjectScore(TypedDict):
braintrust/framework2.py CHANGED
@@ -3,7 +3,6 @@ import json
3
3
  from typing import Any, Callable, Dict, List, Optional, Union, overload
4
4
 
5
5
  import slugify
6
-
7
6
  from braintrust.logger import api_conn, app_conn, login
8
7
 
9
8
  from .framework import _is_lazy_load, bcolors # type: ignore
@@ -1,4 +1,4 @@
1
- """Auto-generated file (internal git SHA 93e76a7bcdf0f874a1827af017d26ac37995a47b) -- do not modify"""
1
+ """Auto-generated file (internal git SHA 8e9c0a96b3cf291360978c17580f72f6817bd6c8) -- do not modify"""
2
2
 
3
3
  from ._generated_types import (
4
4
  Acl,
@@ -14,6 +14,8 @@ from ._generated_types import (
14
14
  BraintrustModelParams,
15
15
  CallEvent,
16
16
  ChatCompletionContentPart,
17
+ ChatCompletionContentPartFileFile,
18
+ ChatCompletionContentPartFileWithTitle,
17
19
  ChatCompletionContentPartImageWithTitle,
18
20
  ChatCompletionContentPartText,
19
21
  ChatCompletionContentPartTextWithTitle,
@@ -111,6 +113,8 @@ __all__ = [
111
113
  "BraintrustModelParams",
112
114
  "CallEvent",
113
115
  "ChatCompletionContentPart",
116
+ "ChatCompletionContentPartFileFile",
117
+ "ChatCompletionContentPartFileWithTitle",
114
118
  "ChatCompletionContentPartImageWithTitle",
115
119
  "ChatCompletionContentPartText",
116
120
  "ChatCompletionContentPartTextWithTitle",
braintrust/logger.py CHANGED
@@ -459,22 +459,24 @@ class BraintrustState:
459
459
 
460
460
  def copy_state(self, other: "BraintrustState"):
461
461
  """Copy login information from another BraintrustState instance."""
462
- self.__dict__.update({
463
- k: v
464
- for (k, v) in other.__dict__.items()
465
- if k
466
- not in (
467
- "current_experiment",
468
- "current_logger",
469
- "current_parent",
470
- "current_span",
471
- "_global_bg_logger",
472
- "_override_bg_logger",
473
- "_context_manager",
474
- "_last_otel_setting",
475
- "_context_manager_lock",
476
- )
477
- })
462
+ self.__dict__.update(
463
+ {
464
+ k: v
465
+ for (k, v) in other.__dict__.items()
466
+ if k
467
+ not in (
468
+ "current_experiment",
469
+ "current_logger",
470
+ "current_parent",
471
+ "current_span",
472
+ "_global_bg_logger",
473
+ "_override_bg_logger",
474
+ "_context_manager",
475
+ "_last_otel_setting",
476
+ "_context_manager_lock",
477
+ )
478
+ }
479
+ )
478
480
 
479
481
  def login(
480
482
  self,
@@ -750,7 +752,7 @@ def construct_logs3_data(items: Sequence[str]):
750
752
  def _check_json_serializable(event):
751
753
  try:
752
754
  return bt_dumps(event)
753
- except TypeError as e:
755
+ except (TypeError, ValueError) as e:
754
756
  raise Exception(f"All logged values must be JSON-serializable: {event}") from e
755
757
 
756
758
 
@@ -2439,19 +2441,46 @@ def _deep_copy_event(event: Mapping[str, Any]) -> Dict[str, Any]:
2439
2441
  Creates a deep copy of the given event. Replaces references to user objects
2440
2442
  with placeholder strings to ensure serializability, except for `Attachment`
2441
2443
  and `ExternalAttachment` objects, which are preserved and not deep-copied.
2442
- """
2443
2444
 
2444
- def _deep_copy_object(v: Any) -> Any:
2445
- if isinstance(v, Mapping):
2446
- # Prevent dict keys from holding references to user data. Note that
2447
- # `bt_json` already coerces keys to string, a behavior that comes from
2448
- # `json.dumps`. However, that runs at log upload time, while we want to
2449
- # cut out all the references to user objects synchronously in this
2450
- # function.
2451
- return {str(k): _deep_copy_object(v[k]) for k in v}
2452
- elif isinstance(v, (List, Tuple, Set)):
2453
- return [_deep_copy_object(x) for x in v]
2454
- elif isinstance(v, Span):
2445
+ Handles circular references and excessive nesting depth to prevent
2446
+ RecursionError during serialization.
2447
+ """
2448
+ # Maximum depth to prevent hitting Python's recursion limit
2449
+ # Python's default limit is ~1000, we use a conservative limit
2450
+ # to account for existing call stack usage from pytest, application code, etc.
2451
+ MAX_DEPTH = 200
2452
+
2453
+ # Track visited objects to detect circular references
2454
+ visited: set[int] = set()
2455
+
2456
+ def _deep_copy_object(v: Any, depth: int = 0) -> Any:
2457
+ # Check depth limit - use >= to stop before exceeding
2458
+ if depth >= MAX_DEPTH:
2459
+ return "<max depth exceeded>"
2460
+
2461
+ # Check for circular references in mutable containers
2462
+ # Use id() to track object identity
2463
+ if isinstance(v, (Mapping, List, Tuple, Set)):
2464
+ obj_id = id(v)
2465
+ if obj_id in visited:
2466
+ return "<circular reference>"
2467
+ visited.add(obj_id)
2468
+ try:
2469
+ if isinstance(v, Mapping):
2470
+ # Prevent dict keys from holding references to user data. Note that
2471
+ # `bt_json` already coerces keys to string, a behavior that comes from
2472
+ # `json.dumps`. However, that runs at log upload time, while we want to
2473
+ # cut out all the references to user objects synchronously in this
2474
+ # function.
2475
+ return {str(k): _deep_copy_object(v[k], depth + 1) for k in v}
2476
+ elif isinstance(v, (List, Tuple, Set)):
2477
+ return [_deep_copy_object(x, depth + 1) for x in v]
2478
+ finally:
2479
+ # Remove from visited set after processing to allow the same object
2480
+ # to appear in different branches of the tree
2481
+ visited.discard(obj_id)
2482
+
2483
+ if isinstance(v, Span):
2455
2484
  return "<span>"
2456
2485
  elif isinstance(v, Experiment):
2457
2486
  return "<experiment>"
@@ -3278,17 +3307,17 @@ def _start_span_parent_args(
3278
3307
  if parent:
3279
3308
  assert parent_span_ids is None, "Cannot specify both parent and parent_span_ids"
3280
3309
  parent_components = SpanComponentsV4.from_str(parent)
3281
- assert (
3282
- parent_object_type == parent_components.object_type
3283
- ), f"Mismatch between expected span parent object type {parent_object_type} and provided type {parent_components.object_type}"
3310
+ assert parent_object_type == parent_components.object_type, (
3311
+ f"Mismatch between expected span parent object type {parent_object_type} and provided type {parent_components.object_type}"
3312
+ )
3284
3313
 
3285
3314
  parent_components_object_id_lambda = _span_components_to_object_id_lambda(parent_components)
3286
3315
 
3287
3316
  def compute_parent_object_id():
3288
3317
  parent_components_object_id = parent_components_object_id_lambda()
3289
- assert (
3290
- parent_object_id.get() == parent_components_object_id
3291
- ), f"Mismatch between expected span parent object id {parent_object_id.get()} and provided id {parent_components_object_id}"
3318
+ assert parent_object_id.get() == parent_components_object_id, (
3319
+ f"Mismatch between expected span parent object id {parent_object_id.get()} and provided id {parent_components_object_id}"
3320
+ )
3292
3321
  return parent_object_id.get()
3293
3322
 
3294
3323
  arg_parent_object_id = LazyValue(compute_parent_object_id, use_mutex=False)
@@ -3896,8 +3925,8 @@ class SpanImpl(Span):
3896
3925
  **{IS_MERGE_FIELD: self._is_merge},
3897
3926
  )
3898
3927
 
3899
- _check_json_serializable(partial_record)
3900
3928
  serializable_partial_record = _deep_copy_event(partial_record)
3929
+ _check_json_serializable(serializable_partial_record)
3901
3930
  if serializable_partial_record.get("metrics", {}).get("end") is not None:
3902
3931
  self._logged_end_time = serializable_partial_record["metrics"]["end"]
3903
3932
 
@@ -4453,10 +4482,24 @@ def render_message(render: Callable[[str], str], message: PromptMessage):
4453
4482
  if c["type"] == "text":
4454
4483
  rendered_content.append({**c, "text": render(c["text"])})
4455
4484
  elif c["type"] == "image_url":
4456
- rendered_content.append({
4457
- **c,
4458
- "image_url": {**c["image_url"], "url": render(c["image_url"]["url"])},
4459
- })
4485
+ rendered_content.append(
4486
+ {
4487
+ **c,
4488
+ "image_url": {**c["image_url"], "url": render(c["image_url"]["url"])},
4489
+ }
4490
+ )
4491
+ elif c["type"] == "file":
4492
+ rendered_content.append(
4493
+ {
4494
+ **c,
4495
+ "file": {
4496
+ **c["file"],
4497
+ "file_data": render(c["file"]["file_data"]),
4498
+ **({} if "file_id" not in c["file"] else {"file_id": render(c["file"]["file_id"])}),
4499
+ **({} if "filename" not in c["file"] else {"filename": render(c["file"]["filename"])}),
4500
+ },
4501
+ }
4502
+ )
4460
4503
  else:
4461
4504
  raise ValueError(f"Unknown content type: {c['type']}")
4462
4505
 
braintrust/test_logger.py CHANGED
@@ -20,7 +20,7 @@ from braintrust import (
20
20
  logger,
21
21
  )
22
22
  from braintrust.id_gen import OTELIDGenerator, get_id_generator
23
- from braintrust.logger import _deep_copy_event, _extract_attachments, parent_context, render_mustache
23
+ from braintrust.logger import _deep_copy_event, _extract_attachments, parent_context, render_message, render_mustache
24
24
  from braintrust.prompt import PromptChatBlock, PromptData, PromptMessage, PromptSchema
25
25
  from braintrust.test_helpers import (
26
26
  assert_dict_matches,
@@ -252,6 +252,32 @@ class TestLogger(TestCase):
252
252
  self.assertIs(copy["output"]["attachmentList"][1], attachment2)
253
253
  self.assertIs(copy["output"]["attachmentList"][3], attachment3)
254
254
 
255
+ def test_check_json_serializable_catches_circular_references(self):
256
+ """Test that _check_json_serializable properly handles circular references.
257
+
258
+ After fix, _check_json_serializable should catch ValueError from circular
259
+ references and convert them to a more appropriate exception or handle them.
260
+ """
261
+ from braintrust.logger import _check_json_serializable
262
+
263
+ # Create data with circular reference
264
+ data = {"a": "b"}
265
+ data["self"] = data
266
+
267
+ # Should either succeed (by handling circular refs) or raise a clear exception
268
+ # The error message should indicate the data is not serializable
269
+ try:
270
+ result = _check_json_serializable(data)
271
+ # If it succeeds, it should return a serialized string
272
+ self.assertIsInstance(result, str)
273
+ except Exception as e:
274
+ # If it raises an exception, it should mention serialization issue
275
+ error_msg = str(e).lower()
276
+ self.assertTrue(
277
+ "json-serializable" in error_msg or "circular" in error_msg,
278
+ f"Expected error message to mention serialization issue, got: {e}",
279
+ )
280
+
255
281
  def test_prompt_build_with_structured_output_templating(self):
256
282
  self.maxDiff = None
257
283
  prompt = Prompt(
@@ -459,6 +485,46 @@ class TestLogger(TestCase):
459
485
  with self.assertRaises(ValueError):
460
486
  prompt.build(items=["only_one"], strict=True)
461
487
 
488
+ def test_render_message_with_file_content_parts(self):
489
+ """Test render_message with mixed text, image, and file content parts including all file fields."""
490
+ message = PromptMessage(
491
+ role="user",
492
+ content=[
493
+ {"type": "text", "text": "Here is a {{item}}:"},
494
+ {"type": "image_url", "image_url": {"url": "{{image_url}}"}},
495
+ {
496
+ "type": "file",
497
+ "file": {
498
+ "file_data": "{{file_data}}",
499
+ "file_id": "{{file_id}}",
500
+ "filename": "{{filename}}",
501
+ },
502
+ },
503
+ ],
504
+ )
505
+
506
+ rendered = render_message(
507
+ lambda template: template.replace("{{item}}", "document")
508
+ .replace("{{image_url}}", "https://example.com/image.png")
509
+ .replace("{{file_data}}", "base64data")
510
+ .replace("{{file_id}}", "file-456")
511
+ .replace("{{filename}}", "report.pdf"),
512
+ message,
513
+ )
514
+
515
+ assert rendered["content"] == [
516
+ {"type": "text", "text": "Here is a document:"},
517
+ {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
518
+ {
519
+ "type": "file",
520
+ "file": {
521
+ "file_data": "base64data",
522
+ "file_id": "file-456",
523
+ "filename": "report.pdf",
524
+ },
525
+ },
526
+ ]
527
+
462
528
 
463
529
  def test_noop_permalink_issue_1837():
464
530
  # fixes issue #BRA-1837
@@ -471,6 +537,185 @@ def test_noop_permalink_issue_1837():
471
537
  assert span.link() == "https://www.braintrust.dev/noop-span"
472
538
 
473
539
 
540
+ def test_span_log_with_simple_circular_reference(with_memory_logger):
541
+ """Test that span.log() with simple circular reference works gracefully."""
542
+ logger = init_test_logger(__name__)
543
+
544
+ with logger.start_span(name="test_span") as span:
545
+ # Create simple circular reference
546
+ data = {"key": "value"}
547
+ data["self"] = data
548
+
549
+ # Should handle circular reference gracefully
550
+ span.log(
551
+ input={"test": "simple circular ref"},
552
+ output=data,
553
+ )
554
+
555
+ # Verify the log was recorded with circular reference replaced by placeholder
556
+ logs = with_memory_logger.pop()
557
+ assert len(logs) == 1
558
+
559
+ logged_output = logs[0]["output"]
560
+ assert logged_output["key"] == "value"
561
+ # Circular reference should be replaced with a placeholder string
562
+ assert isinstance(logged_output["self"], str)
563
+ assert "circular" in logged_output["self"].lower()
564
+
565
+
566
+ def test_span_log_with_nested_circular_reference(with_memory_logger):
567
+ """Test that span.log() with nested circular reference works gracefully."""
568
+ logger = init_test_logger(__name__)
569
+
570
+ with logger.start_span(name="test_span") as span:
571
+ # Create nested structure with circular reference
572
+ page = {"page_number": 1, "content": "text"}
573
+ document = {"pages": [page]}
574
+ page["document"] = document
575
+
576
+ # Should handle circular reference gracefully
577
+ span.log(
578
+ input={"file": "test.pdf"},
579
+ output=document,
580
+ )
581
+
582
+ # Verify the log was recorded with nested circular reference handled
583
+ logs = with_memory_logger.pop()
584
+ assert len(logs) == 1
585
+
586
+ logged_output = logs[0]["output"]
587
+ assert len(logged_output["pages"]) == 1
588
+ assert logged_output["pages"][0]["page_number"] == 1
589
+ assert logged_output["pages"][0]["content"] == "text"
590
+ # Circular reference should be replaced with a placeholder
591
+ assert isinstance(logged_output["pages"][0]["document"], str)
592
+ assert "circular" in logged_output["pages"][0]["document"].lower()
593
+
594
+
595
+ def test_span_log_with_deep_document_structure(with_memory_logger):
596
+ """Test that span.log() with deeply nested document structure works gracefully."""
597
+ logger = init_test_logger(__name__)
598
+
599
+ with logger.start_span(name="test_span") as span:
600
+ # Create deeply nested document structure with circular reference
601
+ doc_data = {
602
+ "model_id": "document-model",
603
+ "content": "Document content",
604
+ "pages": [],
605
+ }
606
+
607
+ page = {
608
+ "page_number": 1,
609
+ "lines": [{"content": "Line 1"}],
610
+ }
611
+
612
+ # Create circular reference
613
+ page["document"] = doc_data
614
+ doc_data["pages"].append(page)
615
+
616
+ # Should handle circular reference gracefully
617
+ span.log(
618
+ input={"file": "test.pdf"},
619
+ output=doc_data,
620
+ metadata={"source": "document_processor"},
621
+ )
622
+
623
+ # Verify the log was recorded with proper structure
624
+ logs = with_memory_logger.pop()
625
+ assert len(logs) == 1
626
+
627
+ logged_output = logs[0]["output"]
628
+ assert logged_output["model_id"] == "document-model"
629
+ assert logged_output["content"] == "Document content"
630
+ assert len(logged_output["pages"]) == 1
631
+ assert logged_output["pages"][0]["page_number"] == 1
632
+ assert len(logged_output["pages"][0]["lines"]) == 1
633
+ assert logged_output["pages"][0]["lines"][0]["content"] == "Line 1"
634
+ # Circular reference should be replaced with placeholder
635
+ assert isinstance(logged_output["pages"][0]["document"], str)
636
+ assert "circular" in logged_output["pages"][0]["document"].lower()
637
+
638
+
639
+ def test_span_log_with_extremely_deep_nesting(with_memory_logger):
640
+ """Test that span.log() with extremely deep nesting works gracefully."""
641
+ import sys
642
+
643
+ logger = init_test_logger(__name__)
644
+
645
+ with logger.start_span(name="test_span") as span:
646
+ recursion_limit = sys.getrecursionlimit()
647
+
648
+ # Create structure deeper than recursion limit
649
+ deeply_nested = {"level": 0}
650
+ current = deeply_nested
651
+ for i in range(1, recursion_limit + 100):
652
+ current["nested"] = {"level": i}
653
+ current = current["nested"]
654
+
655
+ # Should handle extremely deep nesting without RecursionError
656
+ span.log(
657
+ input={"test": "deep nesting"},
658
+ output=deeply_nested,
659
+ )
660
+
661
+ # Verify the log was recorded (may be truncated or have placeholder for deep nesting)
662
+ logs = with_memory_logger.pop()
663
+ assert len(logs) == 1
664
+
665
+ logged_output = logs[0]["output"]
666
+ assert logged_output["level"] == 0
667
+ # Either the structure is preserved up to a safe depth, or replaced with placeholder
668
+ assert "nested" in logged_output
669
+
670
+
671
+ def test_span_log_with_large_document_many_pages(with_memory_logger):
672
+ """Test that span.log() with large multi-page document works gracefully."""
673
+ logger = init_test_logger(__name__)
674
+
675
+ with logger.start_span(name="test_span") as span:
676
+ # Create realistic large document: 20 pages × 30 lines × 10 words
677
+ pages = []
678
+ for page_num in range(20):
679
+ lines = []
680
+ for line_num in range(30):
681
+ words = []
682
+ for word_num in range(10):
683
+ words.append(
684
+ {
685
+ "content": f"word_{word_num}",
686
+ "confidence": 0.98,
687
+ }
688
+ )
689
+ lines.append(
690
+ {
691
+ "content": f"line_{line_num}",
692
+ "words": words,
693
+ }
694
+ )
695
+ pages.append(
696
+ {
697
+ "page_number": page_num + 1,
698
+ "lines": lines,
699
+ }
700
+ )
701
+
702
+ # Should handle large document structure
703
+ span.log(
704
+ input={"file": "large_document.pdf"},
705
+ output={"pages": pages},
706
+ )
707
+
708
+ # Verify the log was recorded with full structure intact (no circular refs)
709
+ logs = with_memory_logger.pop()
710
+ assert len(logs) == 1
711
+
712
+ logged_output = logs[0]["output"]
713
+ assert len(logged_output["pages"]) == 20
714
+ assert len(logged_output["pages"][0]["lines"]) == 30
715
+ assert len(logged_output["pages"][0]["lines"][0]["words"]) == 10
716
+ assert logged_output["pages"][0]["lines"][0]["words"][0]["content"] == "word_0"
717
+
718
+
474
719
  def test_span_link_logged_out(with_memory_logger):
475
720
  simulate_logout()
476
721
  assert_logged_out()
@@ -1762,7 +2007,6 @@ def test_parent_precedence_traced_baseline(with_memory_logger, with_simulate_log
1762
2007
  assert top_log["span_id"] in (child_log.get("span_parents") or [])
1763
2008
 
1764
2009
 
1765
-
1766
2010
  def test_parent_precedence_explicit_parent_overrides(with_memory_logger, with_simulate_login):
1767
2011
  """Test that explicit parent overrides current span."""
1768
2012
  init_test_logger(__name__)
@@ -1800,6 +2044,7 @@ def reset_id_generator_state():
1800
2044
  if original_env:
1801
2045
  os.environ["BRAINTRUST_OTEL_COMPAT"] = original_env
1802
2046
 
2047
+
1803
2048
  def test_otel_compatible_span_export_import():
1804
2049
  """Test that spans with OTEL-compatible IDs can be exported and imported correctly."""
1805
2050
  from braintrust.span_identifier_v4 import SpanComponentsV4, SpanObjectTypeV3
@@ -1807,21 +2052,21 @@ def test_otel_compatible_span_export_import():
1807
2052
  # Generate OTEL-compatible IDs
1808
2053
  otel_gen = OTELIDGenerator()
1809
2054
  trace_id = otel_gen.get_trace_id() # 32-char hex (16 bytes)
1810
- span_id = otel_gen.get_span_id() # 16-char hex (8 bytes)
2055
+ span_id = otel_gen.get_span_id() # 16-char hex (8 bytes)
1811
2056
 
1812
2057
  # Test that trace_id is 32 chars and span_id is 16 chars
1813
2058
  assert len(trace_id) == 32
1814
2059
  assert len(span_id) == 16
1815
- assert all(c in '0123456789abcdef' for c in trace_id)
1816
- assert all(c in '0123456789abcdef' for c in span_id)
2060
+ assert all(c in "0123456789abcdef" for c in trace_id)
2061
+ assert all(c in "0123456789abcdef" for c in span_id)
1817
2062
 
1818
2063
  # Create span components
1819
2064
  components = SpanComponentsV4(
1820
2065
  object_type=SpanObjectTypeV3.PROJECT_LOGS,
1821
- object_id='test-project-id',
1822
- row_id='test-row-id',
2066
+ object_id="test-project-id",
2067
+ row_id="test-row-id",
1823
2068
  span_id=span_id,
1824
- root_span_id=trace_id
2069
+ root_span_id=trace_id,
1825
2070
  )
1826
2071
 
1827
2072
  # Test export/import cycle
@@ -1856,14 +2101,15 @@ def test_span_with_otel_ids_export_import(reset_id_generator_state):
1856
2101
  # Verify the span has OTEL-compatible IDs
1857
2102
  assert len(span.span_id) == 16 # 8-byte hex
1858
2103
  assert len(span.root_span_id) == 32 # 16-byte hex
1859
- assert all(c in '0123456789abcdef' for c in span.span_id)
1860
- assert all(c in '0123456789abcdef' for c in span.root_span_id)
2104
+ assert all(c in "0123456789abcdef" for c in span.span_id)
2105
+ assert all(c in "0123456789abcdef" for c in span.root_span_id)
1861
2106
 
1862
2107
  # Export the span
1863
2108
  exported = span.export()
1864
2109
 
1865
2110
  # Parse it back
1866
2111
  from braintrust.span_identifier_v4 import SpanComponentsV4
2112
+
1867
2113
  imported = SpanComponentsV4.from_str(exported)
1868
2114
 
1869
2115
  # Verify IDs are preserved exactly
@@ -1874,9 +2120,10 @@ def test_span_with_otel_ids_export_import(reset_id_generator_state):
1874
2120
  def test_span_with_uuid_ids_share_root_span_id(reset_id_generator_state):
1875
2121
  """Test that UUID generators share span_id as root_span_id for backwards compatibility."""
1876
2122
  import os
2123
+
1877
2124
  # Ensure UUID generator is used (default behavior)
1878
- if 'BRAINTRUST_OTEL_COMPAT' in os.environ:
1879
- del os.environ['BRAINTRUST_OTEL_COMPAT']
2125
+ if "BRAINTRUST_OTEL_COMPAT" in os.environ:
2126
+ del os.environ["BRAINTRUST_OTEL_COMPAT"]
1880
2127
 
1881
2128
  init_test_logger(__name__)
1882
2129
 
@@ -1901,7 +2148,7 @@ def test_parent_context_with_otel_ids(with_memory_logger, reset_id_generator_sta
1901
2148
  original_root_span_id = parent_span.root_span_id
1902
2149
 
1903
2150
  def is_hex(s):
1904
- return all(c in '0123456789abcdef' for c in s.lower())
2151
+ return all(c in "0123456789abcdef" for c in s.lower())
1905
2152
 
1906
2153
  assert is_hex(original_span_id)
1907
2154
  assert is_hex(original_root_span_id)
@@ -1978,14 +2225,15 @@ def test_span_start_span_with_explicit_parent(with_memory_logger):
1978
2225
  span3_log = next(l for l in logs if l.get("span_attributes", {}).get("name") == "span3")
1979
2226
 
1980
2227
  # span3 should NOT have span2 as parent (would happen if it inherited from context)
1981
- assert span2_span_id not in span3_log.get("span_parents", []), \
2228
+ assert span2_span_id not in span3_log.get("span_parents", []), (
1982
2229
  "span3 should not inherit from span2 context when explicit parent is provided"
2230
+ )
1983
2231
 
1984
2232
  # span3 should inherit from root (the explicit parent)
1985
- assert root_span_id in span3_log.get("span_parents", []), \
2233
+ assert root_span_id in span3_log.get("span_parents", []), (
1986
2234
  "span3 should have root_span_id in span_parents from explicit parent"
1987
- assert span3_log["root_span_id"] == root_root_span_id, \
1988
- "span3 should have root's root_span_id"
2235
+ )
2236
+ assert span3_log["root_span_id"] == root_root_span_id, "span3 should have root's root_span_id"
1989
2237
 
1990
2238
 
1991
2239
  def test_span_start_span_inherits_from_self(with_memory_logger):
@@ -2011,8 +2259,9 @@ def test_span_start_span_inherits_from_self(with_memory_logger):
2011
2259
 
2012
2260
  # Child should inherit parent's root_span_id and have parent_span_id in span_parents
2013
2261
  assert child_log["root_span_id"] == parent_root_span_id
2014
- assert parent_span_id in child_log.get("span_parents", []), \
2262
+ assert parent_span_id in child_log.get("span_parents", []), (
2015
2263
  "child should have parent_span_id in span_parents when no explicit parent is provided"
2264
+ )
2016
2265
 
2017
2266
 
2018
2267
  def test_span_start_span_with_exported_span_parent(with_memory_logger):
@@ -2046,10 +2295,12 @@ def test_span_start_span_with_exported_span_parent(with_memory_logger):
2046
2295
 
2047
2296
  # Child should inherit from exported_parent, not active_context
2048
2297
  assert child_log["root_span_id"] == exported_parent_root_span_id
2049
- assert exported_parent_span_id in child_log.get("span_parents", []), \
2298
+ assert exported_parent_span_id in child_log.get("span_parents", []), (
2050
2299
  "child should have exported_parent_span_id in span_parents"
2051
- assert active_context_span_id not in child_log.get("span_parents", []), \
2300
+ )
2301
+ assert active_context_span_id not in child_log.get("span_parents", []), (
2052
2302
  "child should NOT have active_context_span_id in span_parents"
2303
+ )
2053
2304
 
2054
2305
 
2055
2306
  def test_get_exporter_returns_v3_by_default():
@@ -2082,6 +2333,7 @@ def test_experiment_export_respects_otel_compat_default():
2082
2333
  exported = experiment.export()
2083
2334
 
2084
2335
  from braintrust.span_identifier_v4 import SpanComponentsV4
2336
+
2085
2337
  version = SpanComponentsV4.get_version(exported)
2086
2338
  assert version == 3, f"Expected V3 encoding (version=3), got version={version}"
2087
2339
 
@@ -2094,6 +2346,7 @@ def test_experiment_export_respects_otel_compat_enabled():
2094
2346
  exported = experiment.export()
2095
2347
 
2096
2348
  from braintrust.span_identifier_v4 import SpanComponentsV4
2349
+
2097
2350
  version = SpanComponentsV4.get_version(exported)
2098
2351
  assert version == 4, f"Expected V4 encoding (version=4), got version={version}"
2099
2352
 
@@ -2106,6 +2359,7 @@ def test_logger_export_respects_otel_compat_default():
2106
2359
  exported = test_logger.export()
2107
2360
 
2108
2361
  from braintrust.span_identifier_v4 import SpanComponentsV4
2362
+
2109
2363
  version = SpanComponentsV4.get_version(exported)
2110
2364
  assert version == 3, f"Expected V3 encoding (version=3), got version={version}"
2111
2365
 
@@ -2118,6 +2372,7 @@ def test_logger_export_respects_otel_compat_enabled():
2118
2372
  exported = test_logger.export()
2119
2373
 
2120
2374
  from braintrust.span_identifier_v4 import SpanComponentsV4
2375
+
2121
2376
  version = SpanComponentsV4.get_version(exported)
2122
2377
  assert version == 4, f"Expected V4 encoding (version=4), got version={version}"
2123
2378
 
braintrust/version.py CHANGED
@@ -1,4 +1,4 @@
1
- VERSION = "0.3.7"
1
+ VERSION = "0.3.9"
2
2
 
3
3
  # this will be templated during the build
4
- GIT_COMMIT = "1cbc14e38442cfbae772c080de6e17b3df09868f"
4
+ GIT_COMMIT = "238ae256a3a1ab93e682d603c4b53652fa0059f1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: braintrust
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: SDK for integrating Braintrust
5
5
  Home-page: https://www.braintrust.dev
6
6
  Author: Braintrust
@@ -1,5 +1,5 @@
1
1
  braintrust/__init__.py,sha256=-NLWOaTdzVtQFu2TA0qYULbxP4pAdVdgqzZWosqL2eI,2092
2
- braintrust/_generated_types.py,sha256=B3m9AxXfe2Fza63Kfq7doyecINU26Bqio1HxZbObqQM,79603
2
+ braintrust/_generated_types.py,sha256=X5UGdtWp9vnoyUd4PyY74xjWIoNRVqiqWHNpYd-v0to,80911
3
3
  braintrust/audit.py,sha256=Na3LJhpHj8Nd1az41HMQLLbHeWQkDZIOYHLLmVZdAdQ,467
4
4
  braintrust/aws.py,sha256=OBz_SRyopgpCDSNvETLypzGwTXk-bNLn-Eisevnjfwo,377
5
5
  braintrust/bt_json.py,sha256=NrpEpl7FkwgL3sSg_XoKCR8HYy9uEiBOfaJ4r7HkW2U,816
@@ -7,14 +7,14 @@ braintrust/conftest.py,sha256=_leQUhneeVovUhXeHdXiyC04bykrjWs75XJNvmXsR_g,1385
7
7
  braintrust/context.py,sha256=ZXIOc3zXIXOzAopkuPIM9tq2prfsTjH0-e_FEEFTTJI,4235
8
8
  braintrust/db_fields.py,sha256=DBGFhfu9B3aLQI6cU6P2WGrdfCIs8AzDFRQEUBt9NCw,439
9
9
  braintrust/framework.py,sha256=Uf2DogzQG4suCcmABeQzc-5SWCO5tB2ic1OeQFZo_o4,57618
10
- braintrust/framework2.py,sha256=tIYwx7ufiUy0Nl9aoz4D0a7CE9k0ng5nZ72K1o--aGA,15886
11
- braintrust/generated_types.py,sha256=G5p5dXST8QeQzPKaGoSP2Gxrw_PT3dU_Trrk50QBS5Y,4351
10
+ braintrust/framework2.py,sha256=ck1-ybuEHf_ExEfveMGmQ02FDiSrIRsxJxTd64qlotM,15885
11
+ braintrust/generated_types.py,sha256=StMC58Mg_Imc4Z-xpLzqZql2E5K14b92FAnxj0aOrtw,4521
12
12
  braintrust/git_fields.py,sha256=oT1h1nVkidGrIIamtIQfOpbuEzd1n-nup9bx54J_u0A,1480
13
13
  braintrust/gitutil.py,sha256=bEk38AlNtT-umtdCJ9lnSXlbKXsvjBOyTTsmzUKiVtM,5586
14
14
  braintrust/graph_util.py,sha256=lABIOMzxHf6E5LfDYfqa4OUR4uaW7xgUYNq5WGewD4w,5594
15
15
  braintrust/http_headers.py,sha256=9ZsDcsAKG04SGowsgchZktD6rG_oSTKWa8QyGUPA4xE,154
16
16
  braintrust/id_gen.py,sha256=PVkz-pS-9AzgmnAgpV-jgOFFo4hfl6e3IP9dVt6FouQ,1595
17
- braintrust/logger.py,sha256=z4uovYtyD-ZIqbbJFseRQ8ZDNd2DLdI05HqTKyYv9OU,207448
17
+ braintrust/logger.py,sha256=gODmFkQpS7wW3NCPlFvD7XHanW4ui2-5ld1n1MChrdc,209442
18
18
  braintrust/merge_row_batch.py,sha256=NX4jRE9uuFB3Z7btrarQp_di84_NGTjvzpJhksn82W8,9882
19
19
  braintrust/oai.py,sha256=8gUtISG8oIwgMRcFtlfDndzaF_md1Po_4_DFZcH_PkQ,33159
20
20
  braintrust/object.py,sha256=vYLyYWncsqLD00zffZUJwGTSkcJF9IIXmgIzrx3Np5c,632
@@ -33,7 +33,7 @@ braintrust/span_types.py,sha256=UvuyjfL_Delao14TJs9IjnpYLcgDSodKJfAYju01aXQ,292
33
33
  braintrust/test_framework.py,sha256=fALvUefSmNOdQcEVgKHvdCOnPlUreZjhF5AiqfNLBPg,14657
34
34
  braintrust/test_helpers.py,sha256=-RrxDtyCfOOukhdN6tFSwr-Nmq7DrNQ7KO45hySqNZ0,13549
35
35
  braintrust/test_id_gen.py,sha256=mgArTyEBV-Xv21ARHPSHEPBsJshZrvIiPjBLNOKsAko,2490
36
- braintrust/test_logger.py,sha256=ffVKQWrBcovc90-7ojro_aHca1M2DwRNSUbFsdbadqM,83627
36
+ braintrust/test_logger.py,sha256=UGRwDtGnoni5HrwTSHUsJQjM0njH77H1cJTVKNb40dw,92756
37
37
  braintrust/test_otel.py,sha256=janmEtu6dyFQL8N68WjRTS-65Kr5vSNSSwIaBWqXlyw,27243
38
38
  braintrust/test_queue.py,sha256=MdH6R9uSk_4akY4Db514Cpukwiy2RJ76Lqts1nQwZJY,8432
39
39
  braintrust/test_serializable_data_class.py,sha256=b04Ym64YtC6GJRGbKIN4J20RG1QN1FlnODwtEQh4sv0,1897
@@ -41,7 +41,7 @@ braintrust/test_span_components.py,sha256=UnF6ZL4k41XZ-CnfbjuqLeK4MZLtHTMdID3CMh
41
41
  braintrust/test_util.py,sha256=gyqe2JspRP7oXlp6ENztZe2fdRTOEMZMKpQi00y1DSc,4538
42
42
  braintrust/test_version.py,sha256=hk5JKjEFbNJ_ONc1VEkqHquflzre34RpFhCEYLTK8iA,1051
43
43
  braintrust/util.py,sha256=Ec6sRkQw5BckGrFjdA4YTyu_2BaKmHh4tWDwAi_ysOw,7227
44
- braintrust/version.py,sha256=nF2uDtOugToR0b5XKMnral46yFA39LnqdbT1Wb241OI,117
44
+ braintrust/version.py,sha256=3QoSvR9upAkmscSlqZqj5iUZV0cddFtBgRVYdbEJU_0,117
45
45
  braintrust/xact_ids.py,sha256=bdyp88HjlyIkglgLSqYlCYscdSH6EWVyE14sR90Xl1s,658
46
46
  braintrust/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
47
  braintrust/cli/__main__.py,sha256=wCBKHGVmn3IT_yMXk5qfDwyI2SV2gf1tLr0NTxm9T8k,1519
@@ -104,8 +104,8 @@ braintrust/wrappers/claude_agent_sdk/__init__.py,sha256=CSXJWy-z2fHF7h4VJjLSnXJv
104
104
  braintrust/wrappers/claude_agent_sdk/_wrapper.py,sha256=uzElIOwwPmF_Y5fbWcKWEPC8HnSzW7byzpiuVKK0TXE,15613
105
105
  braintrust/wrappers/claude_agent_sdk/test_wrapper.py,sha256=0NmohdECudFvWtc-5PbANtTXzexkkwIJhGbujydDrT8,6826
106
106
  braintrust/wrappers/google_genai/__init__.py,sha256=PGFMuR3c4Gc3SUt24eP7z5AzdS2Dc1uF1d3QPCnLnuo,16018
107
- braintrust-0.3.7.dist-info/METADATA,sha256=qUVhqTkwvsfCYY_cDnU64MRs6f9LWYzWpYC114F83A8,2942
108
- braintrust-0.3.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
109
- braintrust-0.3.7.dist-info/entry_points.txt,sha256=Zpc0_09g5xm8as5jHqqFq7fhwO0xHSNct_TrEMONS7Q,60
110
- braintrust-0.3.7.dist-info/top_level.txt,sha256=hw1-y-UFMf60RzAr8x_eM7SThbIuWfQsQIbVvqSF83A,11
111
- braintrust-0.3.7.dist-info/RECORD,,
107
+ braintrust-0.3.9.dist-info/METADATA,sha256=xEK83D4NuSxMV9ETcoBVzO0AqltIerG5NQP6z1galOk,2942
108
+ braintrust-0.3.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
109
+ braintrust-0.3.9.dist-info/entry_points.txt,sha256=Zpc0_09g5xm8as5jHqqFq7fhwO0xHSNct_TrEMONS7Q,60
110
+ braintrust-0.3.9.dist-info/top_level.txt,sha256=hw1-y-UFMf60RzAr8x_eM7SThbIuWfQsQIbVvqSF83A,11
111
+ braintrust-0.3.9.dist-info/RECORD,,