Skip to content

Commit 0b717a9

Browse files
authored
ref(dspy): migrate dspy wrapper to integrations API (#156)
Move the DSPy provider from braintrust.wrappers.dspy into braintrust.integrations.dspy following the standard integration package layout (integration.py, patchers.py, tracing.py). - DSPyIntegration uses FunctionWrapperPatcher to patch dspy.configure - BraintrustDSpyCallback and _configure_wrapper live in tracing.py - Wrapper module reduced to compatibility re-exports - auto.py uses DSPyIntegration.setup() instead of the old wrapper - Tests, cassettes, and auto-test script moved to integration dir - noxfile updated to point at new test location FunctionWrapperPatcher.mark_patched/is_patched/patch now handle targets that don't support setattr (e.g. bound methods) by falling back to storing the marker on the root module. This avoids the need for per-patcher overrides when the patch target is a bound method, as is the case with dspy.configure.
1 parent 7a4328e commit 0b717a9

12 files changed

Lines changed: 488 additions & 493 deletions

File tree

py/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def test_dspy(session, version):
255255
session.skip("dspy latest requires Python >= 3.10 (litellm dependency)")
256256
_install_test_deps(session)
257257
_install(session, "dspy", version)
258-
_run_tests(session, f"{WRAPPER_DIR}/test_dspy.py")
258+
_run_tests(session, f"{INTEGRATION_DIR}/dspy/test_dspy.py")
259259

260260

261261
@nox.session()

py/src/braintrust/auto.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AgnoIntegration,
1313
AnthropicIntegration,
1414
ClaudeAgentSDKIntegration,
15+
DSPyIntegration,
1516
GoogleGenAIIntegration,
1617
)
1718

@@ -125,7 +126,7 @@ def auto_instrument(
125126
if claude_agent_sdk:
126127
results["claude_agent_sdk"] = _instrument_integration(ClaudeAgentSDKIntegration)
127128
if dspy:
128-
results["dspy"] = _instrument_dspy()
129+
results["dspy"] = _instrument_integration(DSPyIntegration)
129130
if adk:
130131
results["adk"] = _instrument_integration(ADKIntegration)
131132

@@ -160,11 +161,3 @@ def _instrument_pydantic_ai() -> bool:
160161

161162
return setup_pydantic_ai()
162163
return False
163-
164-
165-
def _instrument_dspy() -> bool:
166-
with _try_patch():
167-
from braintrust.wrappers.dspy import patch_dspy
168-
169-
return patch_dspy()
170-
return False

py/src/braintrust/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .agno import AgnoIntegration
33
from .anthropic import AnthropicIntegration
44
from .claude_agent_sdk import ClaudeAgentSDKIntegration
5+
from .dspy import DSPyIntegration
56
from .google_genai import GoogleGenAIIntegration
67

78

@@ -10,5 +11,6 @@
1011
"AgnoIntegration",
1112
"AnthropicIntegration",
1213
"ClaudeAgentSDKIntegration",
14+
"DSPyIntegration",
1315
"GoogleGenAIIntegration",
1416
]

py/src/braintrust/integrations/auto_test_scripts/test_auto_dspy.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,15 @@
77

88
import dspy
99
from braintrust.auto import auto_instrument
10-
from braintrust.wrappers.dspy import BraintrustDSpyCallback
10+
from braintrust.integrations.dspy import BraintrustDSpyCallback
1111

1212

1313
# 1. Verify not patched initially
14-
assert not getattr(dspy, "__braintrust_wrapped__", False)
14+
assert not getattr(dspy.configure, "__braintrust_patched_dspy_configure__", False)
1515

1616
# 2. Instrument
1717
results = auto_instrument()
1818
assert results.get("dspy") == True
19-
assert getattr(dspy, "__braintrust_wrapped__", False)
2019

2120
# 3. Idempotent
2221
results2 = auto_instrument()

py/src/braintrust/integrations/base.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,27 @@ def patch_marker_attr(cls) -> str:
114114
@classmethod
115115
def mark_patched(cls, obj: Any) -> None:
116116
"""Mark a wrapped target so future patch attempts are idempotent."""
117-
setattr(obj, cls.patch_marker_attr(), True)
117+
try:
118+
setattr(obj, cls.patch_marker_attr(), True)
119+
except AttributeError:
120+
# Some objects (e.g. bound methods) don't support setattr.
121+
# Callers that need a fallback location (like ``patch()``) handle
122+
# this by catching the failure and storing the marker elsewhere.
123+
pass
118124

119125
@classmethod
120126
def is_patched(cls, module: Any | None, version: str | None, *, target: Any | None = None) -> bool:
121127
"""Return whether this patcher's target has already been instrumented."""
128+
marker = cls.patch_marker_attr()
122129
resolved_target = cls.resolve_target(module, version, target=target)
123-
return bool(resolved_target is not None and getattr(resolved_target, cls.patch_marker_attr(), False))
130+
if resolved_target is not None and getattr(resolved_target, marker, False):
131+
return True
132+
# Fall back to checking the root — the marker may live there when the
133+
# resolved target does not support setattr (e.g. bound methods).
134+
root = cls.resolve_root(module, version, target=target)
135+
if root is not None and root is not resolved_target and getattr(root, marker, False):
136+
return True
137+
return False
124138

125139
@classmethod
126140
def patch(cls, module: Any | None, version: str | None, *, target: Any | None = None) -> bool:
@@ -130,11 +144,16 @@ def patch(cls, module: Any | None, version: str | None, *, target: Any | None =
130144
return False
131145

132146
wrap_function_wrapper(root, cls.target_path, cls.wrapper)
133-
resolved_target = cls.resolve_target(module, version, target=target)
147+
resolved_target = _resolve_attr_path(root, cls.target_path)
134148
if resolved_target is None:
135149
return False
136150

151+
marker = cls.patch_marker_attr()
137152
cls.mark_patched(resolved_target)
153+
# If mark_patched could not store the marker on the target (e.g. bound
154+
# methods), store it on the root so is_patched() can still find it.
155+
if not getattr(resolved_target, marker, False):
156+
setattr(root, marker, True)
138157
return True
139158

140159
@classmethod
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Braintrust integration for DSPy."""
2+
3+
from .integration import DSPyIntegration
4+
from .patchers import patch_dspy
5+
from .tracing import BraintrustDSpyCallback
6+
7+
8+
__all__ = [
9+
"BraintrustDSpyCallback",
10+
"DSPyIntegration",
11+
"patch_dspy",
12+
]

py/src/braintrust/wrappers/cassettes/test_dspy_callback.yaml renamed to py/src/braintrust/integrations/dspy/cassettes/test_dspy_callback.yaml

File renamed without changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""DSPy integration — orchestration class and setup entry-point."""
2+
3+
from braintrust.integrations.base import BaseIntegration
4+
5+
from .patchers import DSPyConfigurePatcher
6+
7+
8+
class DSPyIntegration(BaseIntegration):
9+
"""Braintrust instrumentation for DSPy."""
10+
11+
name = "dspy"
12+
import_names = ("dspy",)
13+
patchers = (DSPyConfigurePatcher,)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""DSPy patchers — one patcher per coherent patch target."""
2+
3+
from braintrust.integrations.base import FunctionWrapperPatcher
4+
5+
from .tracing import _configure_wrapper
6+
7+
8+
class DSPyConfigurePatcher(FunctionWrapperPatcher):
9+
"""Patch ``dspy.configure`` to auto-add ``BraintrustDSpyCallback``."""
10+
11+
name = "dspy.configure"
12+
target_path = "configure"
13+
wrapper = _configure_wrapper
14+
15+
16+
# ---------------------------------------------------------------------------
17+
# Public helper
18+
# ---------------------------------------------------------------------------
19+
20+
21+
def patch_dspy() -> bool:
22+
"""
23+
Patch DSPy to automatically add Braintrust tracing callback.
24+
25+
After calling this, all calls to dspy.configure() will automatically
26+
include the BraintrustDSpyCallback.
27+
28+
Returns:
29+
True if DSPy was patched (or already patched), False if DSPy is not installed.
30+
31+
Example:
32+
```python
33+
import braintrust
34+
braintrust.patch_dspy()
35+
36+
import dspy
37+
lm = dspy.LM("openai/gpt-4o-mini")
38+
dspy.configure(lm=lm) # BraintrustDSpyCallback auto-added!
39+
```
40+
"""
41+
from .integration import DSPyIntegration
42+
43+
return DSPyIntegration.setup()

py/src/braintrust/wrappers/test_dspy.py renamed to py/src/braintrust/integrations/dspy/test_dspy.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import dspy
66
import pytest
77
from braintrust import logger
8+
from braintrust.integrations.dspy import BraintrustDSpyCallback
89
from braintrust.test_helpers import init_test_logger
9-
from braintrust.wrappers.dspy import BraintrustDSpyCallback
1010
from braintrust.wrappers.test_utils import run_in_subprocess, verify_autoinstrument_script
1111

1212

@@ -63,17 +63,14 @@ def test_dspy_callback(memory_logger):
6363

6464

6565
class TestPatchDSPy:
66-
"""Tests for patch_dspy() / unpatch_dspy()."""
66+
"""Tests for patch_dspy()."""
6767

68-
def test_patch_dspy_sets_wrapped_flag(self):
69-
"""patch_dspy() should set __braintrust_wrapped__ on dspy module."""
68+
def test_patch_dspy_patches_configure(self):
69+
"""patch_dspy() should patch dspy.configure via the integration patcher."""
7070
result = run_in_subprocess("""
71-
dspy = __import__("dspy")
72-
from braintrust.wrappers.dspy import patch_dspy
73-
74-
assert not hasattr(dspy, "__braintrust_wrapped__")
75-
patch_dspy()
76-
assert hasattr(dspy, "__braintrust_wrapped__")
71+
from braintrust.integrations.dspy import patch_dspy
72+
result = patch_dspy()
73+
assert result, "patch_dspy() should return True"
7774
print("SUCCESS")
7875
""")
7976
assert result.returncode == 0, f"Failed: {result.stderr}"
@@ -82,7 +79,7 @@ def test_patch_dspy_sets_wrapped_flag(self):
8279
def test_patch_dspy_wraps_configure(self):
8380
"""After patch_dspy(), dspy.configure() should auto-add BraintrustDSpyCallback."""
8481
result = run_in_subprocess("""
85-
from braintrust.wrappers.dspy import patch_dspy, BraintrustDSpyCallback
82+
from braintrust.integrations.dspy import patch_dspy, BraintrustDSpyCallback
8683
patch_dspy()
8784
8885
import dspy
@@ -103,7 +100,7 @@ def test_patch_dspy_wraps_configure(self):
103100
def test_patch_dspy_preserves_existing_callbacks(self):
104101
"""patch_dspy() should preserve user-provided callbacks."""
105102
result = run_in_subprocess("""
106-
from braintrust.wrappers.dspy import patch_dspy, BraintrustDSpyCallback
103+
from braintrust.integrations.dspy import patch_dspy, BraintrustDSpyCallback
107104
patch_dspy()
108105
109106
import dspy
@@ -132,7 +129,7 @@ class MyCallback(BaseCallback):
132129
def test_patch_dspy_does_not_duplicate_callback(self):
133130
"""patch_dspy() should not add duplicate BraintrustDSpyCallback."""
134131
result = run_in_subprocess("""
135-
from braintrust.wrappers.dspy import patch_dspy, BraintrustDSpyCallback
132+
from braintrust.integrations.dspy import patch_dspy, BraintrustDSpyCallback
136133
patch_dspy()
137134
138135
import dspy
@@ -155,7 +152,7 @@ def test_patch_dspy_does_not_duplicate_callback(self):
155152
def test_patch_dspy_idempotent(self):
156153
"""Multiple patch_dspy() calls should be safe."""
157154
result = run_in_subprocess("""
158-
from braintrust.wrappers.dspy import patch_dspy
155+
from braintrust.integrations.dspy import patch_dspy
159156
import dspy
160157
161158
patch_dspy()
@@ -169,6 +166,17 @@ def test_patch_dspy_idempotent(self):
169166
assert result.returncode == 0, f"Failed: {result.stderr}"
170167
assert "SUCCESS" in result.stdout
171168

169+
def test_legacy_wrapper_import_still_works(self):
170+
"""The old braintrust.wrappers.dspy import path should still work."""
171+
result = run_in_subprocess("""
172+
from braintrust.wrappers.dspy import BraintrustDSpyCallback, patch_dspy
173+
assert BraintrustDSpyCallback is not None
174+
assert callable(patch_dspy)
175+
print("SUCCESS")
176+
""")
177+
assert result.returncode == 0, f"Failed: {result.stderr}"
178+
assert "SUCCESS" in result.stdout
179+
172180

173181
class TestAutoInstrumentDSPy:
174182
"""Tests for auto_instrument() with DSPy."""

0 commit comments

Comments
 (0)