Skip to content

Commit c27eff3

Browse files
Relax configure allowance in async tasks (#8444)
* init * add tests
1 parent d297f1f commit c27eff3

File tree

2 files changed

+52
-21
lines changed

2 files changed

+52
-21
lines changed

dspy/dsp/utils/settings.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
# Global base configuration and owner tracking
3232
main_thread_config = copy.deepcopy(DEFAULT_CONFIG)
3333
config_owner_thread_id = None
34+
config_owner_async_task = None
3435

3536
# Global lock for settings configuration
3637
global_lock = threading.Lock()
@@ -105,13 +106,12 @@ def config(self):
105106
return self.copy()
106107

107108
def _ensure_configure_allowed(self):
108-
global main_thread_config, config_owner_thread_id
109+
global main_thread_config, config_owner_thread_id, config_owner_async_task
109110
current_thread_id = threading.get_ident()
110111

111112
if config_owner_thread_id is None:
112-
# First `configure` call is always allowed.
113+
# First `configure` call assigns the owner thread id.
113114
config_owner_thread_id = current_thread_id
114-
return
115115

116116
if config_owner_thread_id != current_thread_id:
117117
# Disallow a second `configure` calls from other threads.
@@ -130,6 +130,11 @@ def _ensure_configure_allowed(self):
130130
if not is_async_task:
131131
return
132132

133+
if config_owner_async_task is None:
134+
# First `configure` call assigns the owner async task.
135+
config_owner_async_task = asyncio.current_task()
136+
return
137+
133138
# We are in an async task. Now check for IPython and allow calling `configure` from IPython.
134139
in_ipython = False
135140
try:
@@ -142,10 +147,10 @@ def _ensure_configure_allowed(self):
142147
# If `IPython` is not installed or `get_ipython` failed, we are not in an IPython environment.
143148
in_ipython = False
144149

145-
if not in_ipython:
150+
if not in_ipython and config_owner_async_task != asyncio.current_task():
146151
raise RuntimeError(
147-
"dspy.settings.configure(...) cannot be called a second time from an async task. Use "
148-
"`dspy.context(...)` instead."
152+
"dspy.settings.configure(...) can only be called from the same async task that called it first. Please "
153+
"use `dspy.context(...)` in other async tasks instead."
149154
)
150155

151156
def configure(self, **kwargs):

tests/utils/test_settings.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,13 @@ def test_forbid_configure_call_in_child_thread():
2020
dspy.configure(lm=dspy.LM("openai/gpt-4o"), adapter=dspy.JSONAdapter(), callbacks=[lambda x: x])
2121

2222
def worker():
23-
with pytest.raises(RuntimeError, match="Cannot call dspy.configure in a child thread"):
23+
with pytest.raises(RuntimeError, match="Cannot call dspy.configure"):
2424
dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"), callbacks=[])
2525

2626
with ThreadPoolExecutor(max_workers=1) as executor:
2727
executor.submit(worker)
2828

2929

30-
@pytest.mark.asyncio
31-
async def test_forbid_configure_call_in_async_function():
32-
with pytest.raises(
33-
RuntimeError,
34-
match=r"dspy.settings.configure\(\.\.\.\) cannot be called a second time from*",
35-
):
36-
dspy.configure(lm=dspy.LM("openai/gpt-4o"), adapter=dspy.JSONAdapter(), callbacks=[lambda x: x])
37-
dspy.configure(lm=dspy.LM("openai/gpt-4o"), adapter=dspy.JSONAdapter(), callbacks=[lambda x: x])
38-
39-
with dspy.context(lm=dspy.LM("openai/gpt-4o-mini"), callbacks=[]):
40-
# context is allowed
41-
pass
42-
43-
4430
def test_dspy_context():
4531
dspy.configure(lm=dspy.LM("openai/gpt-4o"), adapter=dspy.JSONAdapter(), callbacks=[lambda x: x])
4632
with dspy.context(lm=dspy.LM("openai/gpt-4o-mini"), callbacks=[]):
@@ -165,3 +151,43 @@ async def aforward(self, question: str) -> str:
165151
# The main thread is not affected by the context
166152
assert dspy.settings.lm.model == "openai/gpt-4.1"
167153
assert dspy.settings.trace == []
154+
155+
156+
@pytest.mark.asyncio
157+
async def test_dspy_configure_allowance_async():
158+
def bar1():
159+
# `dspy.configure` is disallowed in different async tasks from the initial one.
160+
# In this case, foo1 (async) calls bar1 (sync), and bar1 uses the async task from foo1.
161+
with pytest.raises(RuntimeError) as e:
162+
dspy.configure(lm=dspy.LM("openai/gpt-4o"))
163+
assert "dspy.settings.configure(...) can only be called from the same async" in str(e.value)
164+
165+
async def foo1():
166+
bar1()
167+
await asyncio.sleep(0.1)
168+
169+
async def foo2():
170+
# `dspy.configure` is disallowed in different async tasks from the initial one.
171+
with pytest.raises(RuntimeError) as e:
172+
dspy.configure(lm=dspy.LM("openai/gpt-4o"))
173+
assert "dspy.settings.configure(...) can only be called from the same async" in str(e.value)
174+
await asyncio.sleep(0.1)
175+
176+
async def foo3():
177+
# `dspy.context` is allowed in different async tasks from the initial one.
178+
with dspy.context(lm=dspy.LM("openai/gpt-4o")):
179+
await asyncio.sleep(0.1)
180+
181+
async def foo4():
182+
# foo4 is directly invoked by the entry task, so it has the same async task as the entry task.
183+
dspy.configure(lm=dspy.LM("openai/gpt-4o"))
184+
await asyncio.sleep(0.1)
185+
186+
# `dspy.configure` is allowed to be called multiple times in the same async task.
187+
dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))
188+
dspy.configure(lm=dspy.LM("openai/gpt-4o"))
189+
dspy.configure(adapter=dspy.JSONAdapter())
190+
191+
await asyncio.gather(foo1(), foo2(), foo3())
192+
193+
foo4()

0 commit comments

Comments
 (0)