Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions py/src/braintrust/bt_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,7 @@ def _to_bt_safe(v: Any) -> Any:
return v.reference

if dataclasses.is_dataclass(v) and not isinstance(v, type):
# Use manual field iteration instead of dataclasses.asdict() because
# asdict() deep-copies values, which breaks objects like Attachment
# that contain non-copyable items (thread locks, file handles, etc.)
return {f.name: _to_bt_safe(getattr(v, f.name)) for f in dataclasses.fields(v)}
return bt_safe_deep_copy(v)

# Pydantic model classes (not instances) with model_json_schema
if isinstance(v, type) and hasattr(v, "model_json_schema") and callable(cast(Any, v).model_json_schema):
Expand Down Expand Up @@ -216,6 +213,24 @@ def _deep_copy_object(v: Any, depth: int = 0) -> Any:
visited_discard(obj_id)

try:
if dataclasses.is_dataclass(v) and not isinstance(v, type):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve dataclass-based BaseAttachment instances

When a user implements the public BaseAttachment ABC as a dataclass, this branch matches before _to_bt_safe() can apply its BaseAttachment check, so bt_safe_deep_copy() converts the attachment into a plain field dictionary. _extract_attachments() only uploads values that remain BaseAttachment instances, so logging this otherwise-valid custom attachment silently skips its upload and reference replacement; the parent implementation preserved it by passing through _to_bt_safe(). Check the special attachment types before flattening dataclasses.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 30c6cbe. Dataclass BaseAttachment and ReadonlyAttachment subclasses now retain their existing special handling before generic dataclass traversal. Added 14 regression cases covering direct and nested serialization plus local logger extraction; all failed on the reviewed head and pass with the fix. The original dataclass regressions also remain green (28 focused tests total), and test_core passes 1004 tests. Logger coverage verifies extraction/scheduling and reference replacement without real uploads. AI-assisted follow-up.

# Attachment subclasses may also be dataclasses. Keep their
# specialized handling instead of copying their fields.
from braintrust.logger import BaseAttachment, ReadonlyAttachment

if isinstance(v, (BaseAttachment, ReadonlyAttachment)):
return _to_bt_safe(v)
obj_id = id(v)
if obj_id in visited:
return "<circular reference>"
visited_add(obj_id)
try:
# Share the container traversal so nested attachments retain
# their identity and dataclass fields honor cycle/depth limits.
# dataclasses.asdict() would try to deepcopy attachment locks.
return {f.name: _deep_copy_object(getattr(v, f.name), depth + 1) for f in dataclasses.fields(v)}
finally:
visited_discard(obj_id)
return _to_bt_safe(v)
except Exception:
return f"<non-sanitizable: {type(v).__name__}>"
Expand Down
135 changes: 135 additions & 0 deletions py/src/braintrust/test_bt_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,146 @@
from typing import Any
from unittest import TestCase

import pytest
from braintrust.bt_json import bt_dumps, bt_safe_deep_copy
from braintrust.logger import Attachment, ExternalAttachment
from braintrust.test_helpers import with_memory_logger # noqa: F401


@pytest.mark.parametrize("shape", ["direct", "list", "dict", "tuple", "dataclass", "dataclass-in-list"])
def test_dataclass_nested_attachments(shape):
from dataclasses import dataclass

@dataclass
class Payload:
value: Any

attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
original, expected = {
"direct": (attachment, attachment),
"list": ([attachment], [attachment]),
"dict": ({"file": attachment}, {"file": attachment}),
"tuple": ((attachment,), [attachment]),
"dataclass": (Payload(attachment), {"value": attachment}),
"dataclass-in-list": ([Payload(attachment)], [{"value": attachment}]),
}[shape]

assert bt_safe_deep_copy(Payload(original)) == {"value": expected}


@pytest.mark.parametrize("kind", ["base", "readonly"])
@pytest.mark.parametrize("shape", ["direct", "list", "dict", "dataclass", "dataclass-in-list"])
def test_dataclass_attachment_types_keep_special_handling(kind, shape):
from dataclasses import dataclass

from braintrust.logger import BaseAttachment, ReadonlyAttachment

@dataclass
class CustomAttachment(BaseAttachment):
key: str

@property
def reference(self):
return {
"type": "braintrust_attachment",
"key": self.key,
"filename": "file.txt",
"content_type": "text/plain",
}

@property
def data(self):
return b"data"

def upload(self):
raise AssertionError("Serialization must not upload attachments")

def debug_info(self):
return {"key": self.key}

@dataclass
class CustomReadonlyAttachment(ReadonlyAttachment):
reference: dict[str, Any]

def __post_init__(self):
super().__init__(self.reference)

@dataclass
class Payload:
value: Any

attachment = CustomAttachment("test-key")
if kind == "readonly":
attachment = CustomReadonlyAttachment(attachment.reference)
expected = attachment if kind == "base" else attachment.reference
original, expected = {
"direct": (attachment, expected),
"list": ([attachment], [expected]),
"dict": ({"file": attachment}, {"file": expected}),
"dataclass": (Payload(attachment), {"value": expected}),
"dataclass-in-list": (Payload([attachment]), {"value": [expected]}),
}[shape]

copied = bt_safe_deep_copy(original)
assert copied == expected
if shape == "direct" and kind == "base":
assert copied is attachment
assert json.loads(bt_dumps(copied)) == json.loads(bt_dumps(expected))


def test_dataclass_container_cycles_and_shared_values():
from dataclasses import dataclass

@dataclass
class Payload:
value: Any

attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
payload = Payload([attachment])
payload.value.append(payload)

assert bt_safe_deep_copy([payload, payload]) == [
{"value": [attachment, "<circular reference>"]},
{"value": [attachment, "<circular reference>"]},
]


def test_dataclass_container_depth_limit():
from dataclasses import dataclass

@dataclass
class Payload:
value: Any

assert bt_safe_deep_copy(Payload([Payload("leaf")]), max_depth=2) == {"value": ["<max depth exceeded>"]}


def test_dataclass_json_container_fields():
from dataclasses import dataclass

@dataclass
class Payload:
value: Any

payload = Payload({"items": (1, None, "text"), "nested": {"enabled": True}})
assert json.loads(bt_dumps(payload)) == {"value": {"items": [1, None, "text"], "nested": {"enabled": True}}}


def test_dataclass_unreadable_field_is_non_sanitizable():
from dataclasses import dataclass

@dataclass
class Payload:
value: int

def __getattribute__(self, name):
if name == "value":
raise RuntimeError("Cannot read field")
return super().__getattribute__(name)

assert bt_safe_deep_copy(Payload(1)) == "<non-sanitizable: Payload>"


class TestBTJson(TestCase):
def testdeep_copy_event_basic(self):
original = {
Expand Down
68 changes: 68 additions & 0 deletions py/src/braintrust/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -3426,6 +3426,74 @@ def test_attachment_upload_tracked_on_flush(with_memory_logger, with_simulate_lo
assert with_memory_logger.upload_attempts[0] is attachment


@pytest.mark.parametrize("field", ["input", "output"])
def test_dataclass_nested_attachments_reach_flush(field, with_memory_logger, with_simulate_login):
from dataclasses import dataclass

@dataclass
class Payload:
files: list[BaseAttachment]

attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
external = ExternalAttachment(url="s3://bucket/key", filename="file.pdf", content_type="application/pdf")
payload = Payload([attachment, external])
test_logger = init_test_logger(__name__)
with test_logger.start_span(name="dataclass-attachments") as span:
span.log(**{field: payload})
test_logger.flush()

# The memory logger exercises extraction without making upload requests.
assert with_memory_logger.upload_attempts == [attachment, external]
assert with_memory_logger.pop()[0][field] == {"files": [attachment.reference, external.reference]}
assert payload.files == [attachment, external]


@pytest.mark.parametrize("field", ["input", "output"])
@pytest.mark.parametrize("nested", [False, True])
def test_dataclass_base_attachment_reaches_flush(field, nested, with_memory_logger, with_simulate_login):
from dataclasses import dataclass

@dataclass
class CustomAttachment(BaseAttachment):
key: str

@property
def reference(self):
return {
"type": "braintrust_attachment",
"key": self.key,
"filename": "file.txt",
"content_type": "text/plain",
}

@property
def data(self):
return b"data"

def upload(self):
raise AssertionError("The memory logger must not perform real uploads")

def debug_info(self):
return {"key": self.key}

@dataclass
class Payload:
files: list[BaseAttachment]

attachment = CustomAttachment("test-key")
value = Payload([attachment]) if nested else attachment
test_logger = init_test_logger(__name__)
with test_logger.start_span(name="custom-dataclass-attachment") as span:
span.log(**{field: value})
test_logger.flush()

# The memory logger verifies extraction and scheduling, without real uploads.
assert len(with_memory_logger.upload_attempts) == 1
assert with_memory_logger.upload_attempts[0] is attachment
expected = {"files": [attachment.reference]} if nested else attachment.reference
assert with_memory_logger.pop()[0][field] == expected


def test_same_attachment_logged_twice_tracked_twice(with_memory_logger, with_simulate_login):
"""Test that same attachment logged twice appears twice in upload attempts."""
attachment = Attachment(data=b"data", filename="file.txt", content_type="text/plain")
Expand Down