Skip to content

Commit c104e90

Browse files
AbhiPrasadviadezo1er
authored andcommitted
fix: Make sure cross context cleanup doesn't raise an error (#58)
1 parent 1715fbe commit c104e90

6 files changed

Lines changed: 153 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ make test-core
5858
nox -l
5959
```
6060

61+
For larger or cross-cutting changes, also run `make pylint` from `py/` before handing work off.
62+
6163
Targeted wrapper/session runs:
6264

6365
```bash
@@ -77,6 +79,7 @@ Key facts:
7779
- `test_core` runs without optional vendor packages.
7880
- wrapper coverage is split across dedicated nox sessions by provider/version.
7981
- `pylint` installs the broad dependency surface before checking files.
82+
- `cd py && make pylint` runs only `pylint`; `cd py && make lint` runs pre-commit hooks first and then `pylint`.
8083
- `test-wheel` is a wheel sanity check and requires a built wheel first.
8184

8285
When changing behavior, run the narrowest affected session first, then expand only if needed.

Makefile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ test-core:
2424
test-wheel:
2525
mise exec -- $(MAKE) -C py test-wheel
2626

27-
lint pylint:
27+
lint:
2828
mise exec -- $(MAKE) -C py lint
2929

30+
pylint:
31+
mise exec -- $(MAKE) -C py pylint
32+
3033
nox: test
3134

3235
help:
@@ -35,8 +38,8 @@ help:
3538
@echo " fixup - Run pre-commit hooks across the repo"
3639
@echo " install-deps - Install Python SDK dependencies via py/Makefile"
3740
@echo " install-dev - Install pinned tools and create/update the repo env via mise"
38-
@echo " lint - Run Python SDK lint checks via py/Makefile"
39-
@echo " pylint - Alias for lint"
41+
@echo " lint - Run pre-commit hooks plus Python SDK pylint via py/Makefile"
42+
@echo " pylint - Run Python SDK pylint only via py/Makefile"
4043
@echo " nox - Alias for test"
4144
@echo " test - Run the Python SDK nox matrix via py/Makefile"
4245
@echo " test-core - Run Python SDK core tests via py/Makefile"

py/Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ PYTHON ?= python
22
UV := $(PYTHON) -m uv
33
UV_VERSION := $(shell awk '$$1=="uv" { print $$2 }' ../.tool-versions)
44

5-
.PHONY: lint test test-wheel _template-version clean fixup build verify-build verify help install-build-deps install-dev install-optional test-core _check-git-clean
5+
.PHONY: lint pylint test test-wheel _template-version clean fixup build verify-build verify help install-build-deps install-dev install-optional test-core _check-git-clean
66

77
clean:
88
rm -rf build dist
@@ -14,6 +14,9 @@ fixup:
1414
lint: fixup
1515
nox -s pylint
1616

17+
pylint:
18+
nox -s pylint
19+
1720
test:
1821
nox -x
1922

@@ -69,6 +72,7 @@ help:
6972
@echo " install-build-deps - Install build dependencies for CI"
7073
@echo " install-dev - Install package in development mode with all dependencies"
7174
@echo " lint - Run pylint checks"
75+
@echo " pylint - Run pylint without pre-commit hooks"
7276
@echo " test - Run all tests"
7377
@echo " test-core - Run core tests only"
7478
@echo " test-wheel - Run tests against built wheel"

py/src/braintrust/context.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ def set_current_span(self, span_object: Any) -> Any:
103103
def unset_current_span(self, context_token: Any = None) -> None:
104104
"""Unset the current active span."""
105105
if context_token:
106-
self._current_span.reset(context_token)
106+
try:
107+
self._current_span.reset(context_token)
108+
except ValueError:
109+
self._current_span.set(None)
107110
else:
108111
self._current_span.set(None)
109112

py/src/braintrust/test_context.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,27 @@ async def task_work():
753753
)
754754

755755

756+
@pytest.mark.asyncio
757+
async def test_unset_current_span_with_cross_context_token_falls_back_to_clear():
758+
"""Cross-context cleanup should not raise if the token can't be reset."""
759+
from braintrust.context import BraintrustContextManager
760+
761+
context_manager = BraintrustContextManager()
762+
token = context_manager.set_current_span("parent")
763+
result = {}
764+
765+
async def other_task():
766+
try:
767+
context_manager.unset_current_span(token)
768+
result["outcome"] = "ok"
769+
except Exception as e:
770+
result["outcome"] = f"{type(e).__name__}: {e}"
771+
772+
await asyncio.create_task(other_task())
773+
774+
assert result["outcome"] == "ok"
775+
776+
756777
@pytest.mark.asyncio
757778
async def test_async_generator_early_break_context_token(test_logger, with_memory_logger):
758779
"""

py/src/braintrust/wrappers/claude_agent_sdk/test_wrapper.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
the actual Claude Agent SDK.
66
"""
77

8+
import asyncio
9+
import gc
10+
import sys
11+
import types
12+
from typing import Type
13+
814
import pytest
915

1016
# Try to import the Claude Agent SDK - skip tests if not available
@@ -19,6 +25,7 @@
1925
from braintrust import logger
2026
from braintrust.span_types import SpanTypeAttribute
2127
from braintrust.test_helpers import init_test_logger
28+
from braintrust.wrappers.claude_agent_sdk import setup_claude_agent_sdk
2229
from braintrust.wrappers.claude_agent_sdk._wrapper import (
2330
_create_client_wrapper_class,
2431
_create_tool_wrapper_class,
@@ -292,3 +299,110 @@ class TestAutoInstrumentClaudeAgentSDK:
292299
def test_auto_instrument_claude_agent_sdk(self):
293300
"""Test auto_instrument patches Claude Agent SDK and creates spans."""
294301
verify_autoinstrument_script("test_auto_claude_agent_sdk.py")
302+
303+
304+
class _FakeClaudeAgentOptions:
305+
def __init__(self, model, permission_mode=None):
306+
self.model = model
307+
self.permission_mode = permission_mode
308+
309+
310+
class _FakeMessage:
311+
def __init__(self, content):
312+
self.content = content
313+
314+
315+
class _FakeResultMessage:
316+
def __init__(self):
317+
self.usage = types.SimpleNamespace(input_tokens=1, output_tokens=1, cache_creation_input_tokens=0)
318+
self.num_turns = 1
319+
self.session_id = "session-123"
320+
321+
322+
class _FakeClaudeSDKClient:
323+
def __init__(self, options):
324+
self.options = options
325+
self._prompt = None
326+
327+
async def __aenter__(self):
328+
return self
329+
330+
async def __aexit__(self, *args):
331+
return None
332+
333+
async def query(self, prompt):
334+
self._prompt = prompt
335+
336+
async def receive_response(self):
337+
yield _FakeMessage("Hello")
338+
await asyncio.sleep(0)
339+
yield _FakeResultMessage()
340+
341+
342+
class _FakeClaudeSdkModule(types.ModuleType):
343+
ClaudeSDKClient: Type[_FakeClaudeSDKClient]
344+
ClaudeAgentOptions: Type[_FakeClaudeAgentOptions]
345+
SdkMcpTool = None
346+
tool = None
347+
348+
349+
class _FakeConsumerModule(types.ModuleType):
350+
ClaudeSDKClient: Type[_FakeClaudeSDKClient]
351+
ClaudeAgentOptions: Type[_FakeClaudeAgentOptions]
352+
353+
354+
def _install_fake_claude_sdk(monkeypatch):
355+
fake_module = _FakeClaudeSdkModule("claude_agent_sdk")
356+
fake_module.ClaudeSDKClient = _FakeClaudeSDKClient
357+
fake_module.ClaudeAgentOptions = _FakeClaudeAgentOptions
358+
monkeypatch.setitem(sys.modules, "claude_agent_sdk", fake_module)
359+
return fake_module
360+
361+
362+
@pytest.mark.asyncio
363+
async def test_setup_claude_agent_sdk_repro_import_before_setup(memory_logger, monkeypatch):
364+
"""Regression test for https://github.com/braintrustdata/braintrust-sdk-python/issues/7."""
365+
assert not memory_logger.pop()
366+
367+
fake_sdk = _install_fake_claude_sdk(monkeypatch)
368+
consumer_module_name = "test_issue7_repro_module"
369+
consumer_module = _FakeConsumerModule(consumer_module_name)
370+
consumer_module.ClaudeSDKClient = fake_sdk.ClaudeSDKClient
371+
consumer_module.ClaudeAgentOptions = fake_sdk.ClaudeAgentOptions
372+
monkeypatch.setitem(sys.modules, consumer_module_name, consumer_module)
373+
374+
# Mirror the reported import pattern:
375+
# from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
376+
assert setup_claude_agent_sdk(project=PROJECT_NAME, api_key=logger.TEST_API_KEY)
377+
assert consumer_module.ClaudeSDKClient is not _FakeClaudeSDKClient
378+
379+
loop_errors = []
380+
received_types = []
381+
382+
async def main():
383+
loop = asyncio.get_running_loop()
384+
loop.set_exception_handler(lambda loop, ctx: loop_errors.append(ctx.get("exception") or ctx.get("message")))
385+
386+
options = consumer_module.ClaudeAgentOptions(
387+
model="claude-sonnet-4-20250514",
388+
permission_mode="bypassPermissions",
389+
)
390+
async with consumer_module.ClaudeSDKClient(options=options) as client:
391+
await client.query("Hello")
392+
async for message in client.receive_response():
393+
received_types.append(type(message).__name__)
394+
395+
await asyncio.sleep(0)
396+
gc.collect()
397+
await asyncio.sleep(0.01)
398+
399+
await main()
400+
401+
assert loop_errors == []
402+
assert received_types == ["_FakeMessage", "_FakeResultMessage"]
403+
404+
spans = memory_logger.pop()
405+
task_spans = [s for s in spans if s["span_attributes"]["type"] == SpanTypeAttribute.TASK]
406+
assert len(task_spans) == 1
407+
assert task_spans[0]["span_attributes"]["name"] == "Claude Agent"
408+
assert task_spans[0]["input"] == "Hello"

0 commit comments

Comments
 (0)