Skip to content

Commit 76bb2d6

Browse files
authored
Simplify trajectory capture scopes (#779)
* simplify trajectory group capture scopes * isolate trajectory group input iteration
1 parent 3bdbf22 commit 76bb2d6

4 files changed

Lines changed: 150 additions & 92 deletions

File tree

src/art/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
auto_trajectory, # ty: ignore[deprecated]
109109
capture_auto_trajectory, # ty: ignore[deprecated]
110110
current_trajectory,
111-
current_trajectory_group,
111+
no_capture,
112112
trajectory,
113113
trajectory_group,
114114
)
@@ -132,7 +132,7 @@
132132
"auto_trajectory",
133133
"capture_auto_trajectory",
134134
"current_trajectory",
135-
"current_trajectory_group",
135+
"no_capture",
136136
"gather_trajectories",
137137
"gather_trajectory_groups",
138138
"trajectory_group_batches",

src/art/trajectories/__init__.py

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
Iterator,
99
Mapping,
1010
)
11-
from contextlib import asynccontextmanager
11+
from contextlib import AbstractContextManager, asynccontextmanager
1212
from dataclasses import dataclass
1313
from datetime import datetime
1414
from enum import IntFlag
@@ -721,21 +721,6 @@ def __init__(
721721
logs=logs,
722722
)
723723

724-
def __enter__(self) -> TrajectoryGroup:
725-
from ._scope import enter_trajectory_group
726-
727-
return enter_trajectory_group(self)
728-
729-
def __exit__(
730-
self,
731-
exc_type: type[BaseException] | None,
732-
exc_value: BaseException | None,
733-
traceback: TracebackType | None,
734-
) -> None:
735-
from ._scope import exit_trajectory_group
736-
737-
exit_trajectory_group(self, exc_type, exc_value, traceback)
738-
739724
def __copy__(self) -> TrajectoryGroup:
740725
from ._compat import copy_trajectory_group
741726

@@ -859,20 +844,12 @@ def current_trajectory(*, require: bool = False) -> Trajectory | None:
859844
return get_current_trajectory(required=require)
860845

861846

862-
@overload
863-
def current_trajectory_group(*, require: Literal[True]) -> TrajectoryGroup: ...
864-
865-
866-
@overload
867-
def current_trajectory_group(
868-
*, require: Literal[False] = False
869-
) -> TrajectoryGroup | None: ...
870-
847+
def no_capture() -> AbstractContextManager[None]:
848+
"""Hide enclosing trajectory capture while allowing new nested scopes."""
871849

872-
def current_trajectory_group(*, require: bool = False) -> TrajectoryGroup | None:
873-
from ._scope import get_current_trajectory_group
850+
from ._scope import no_capture as capture_barrier
874851

875-
return get_current_trajectory_group(required=require)
852+
return capture_barrier()
876853

877854

878855
async def trajectory(coroutine: Coroutine[Any, Any, object]) -> Trajectory:
@@ -957,7 +934,7 @@ def get_messages(messages_and_choices: MessagesAndChoices) -> Messages:
957934
"TokenFlag",
958935
"MetadataValue",
959936
"current_trajectory",
960-
"current_trajectory_group",
937+
"no_capture",
961938
"trajectory",
962939
"trajectory_group",
963940
"auto_trajectory",

src/art/trajectories/_scope.py

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from collections.abc import Coroutine, Iterable
4+
from collections.abc import Coroutine, Iterable, Iterator
5+
from contextlib import contextmanager
56
import contextvars
67
from types import TracebackType
78
from typing import Any
@@ -12,9 +13,6 @@
1213
_trajectories: contextvars.ContextVar[tuple[Trajectory, ...]] = contextvars.ContextVar(
1314
"art_trajectories", default=()
1415
)
15-
_groups: contextvars.ContextVar[tuple[TrajectoryGroup, ...]] = contextvars.ContextVar(
16-
"art_trajectory_groups", default=()
17-
)
1816

1917

2018
def get_current_trajectory(*, required: bool) -> Trajectory | None:
@@ -26,15 +24,6 @@ def get_current_trajectory(*, required: bool) -> Trajectory | None:
2624
return None
2725

2826

29-
def get_current_trajectory_group(*, required: bool) -> TrajectoryGroup | None:
30-
current = _groups.get()
31-
if current:
32-
return current[-1]
33-
if required:
34-
raise RuntimeError("No trajectory group is active in this context")
35-
return None
36-
37-
3827
def enter_trajectory(trajectory: Trajectory) -> Trajectory:
3928
from ._capture import install
4029

@@ -46,37 +35,25 @@ def enter_trajectory(trajectory: Trajectory) -> Trajectory:
4635
def exit_trajectory(
4736
trajectory: Trajectory,
4837
_exc_type: type[BaseException] | None,
49-
exc_value: BaseException | None,
38+
_exc_value: BaseException | None,
5039
_traceback: TracebackType | None,
5140
) -> None:
5241
current = _trajectories.get()
5342
if not current or current[-1] is not trajectory:
5443
raise RuntimeError("Trajectory contexts must exit in stack order")
5544
_trajectories.set(current[:-1])
5645
trajectory.finish()
57-
group = get_current_trajectory_group(required=False)
58-
if group is not None:
59-
if exc_value is not None:
60-
group.exceptions.append(exception_model(exc_value))
61-
elif all(item is not trajectory for item in group.trajectories):
62-
group.trajectories.append(trajectory)
63-
6446

65-
def enter_trajectory_group(group: TrajectoryGroup) -> TrajectoryGroup:
66-
_groups.set((*_groups.get(), group))
67-
return group
6847

48+
@contextmanager
49+
def no_capture() -> Iterator[None]:
50+
"""Hide enclosing trajectory capture while allowing new nested scopes."""
6951

70-
def exit_trajectory_group(
71-
group: TrajectoryGroup,
72-
_exc_type: type[BaseException] | None,
73-
_exc_value: BaseException | None,
74-
_traceback: TracebackType | None,
75-
) -> None:
76-
current = _groups.get()
77-
if not current or current[-1] is not group:
78-
raise RuntimeError("TrajectoryGroup contexts must exit in stack order")
79-
_groups.set(current[:-1])
52+
token = _trajectories.set(())
53+
try:
54+
yield
55+
finally:
56+
_trajectories.reset(token)
8057

8158

8259
def _require_raw_coroutine(value: object) -> None:
@@ -98,12 +75,16 @@ async def capture_trajectory_group(
9875
*,
9976
return_exceptions: bool,
10077
) -> TrajectoryGroup:
101-
coroutines = list(trajectories)
102-
for coroutine in coroutines:
103-
_require_raw_coroutine(coroutine)
78+
with no_capture():
79+
coroutines = list(trajectories)
80+
for coroutine in coroutines:
81+
_require_raw_coroutine(coroutine)
82+
results = await asyncio.gather(
83+
*coroutines,
84+
return_exceptions=return_exceptions,
85+
)
10486
if not return_exceptions:
105-
return TrajectoryGroup(await asyncio.gather(*coroutines))
106-
results = await asyncio.gather(*coroutines, return_exceptions=True)
87+
return TrajectoryGroup(results)
10788
completed: list[Trajectory] = []
10889
exceptions: list[PydanticException] = []
10990
for result in results:

tests/unit/trajectories/test_capture.py

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from collections.abc import AsyncGenerator, AsyncIterator, Generator
4+
from collections.abc import AsyncGenerator, AsyncIterator, Coroutine, Generator
55
import copy
66
from datetime import datetime, timedelta
77
import gzip
@@ -226,44 +226,144 @@ async def child() -> art.Trajectory:
226226
art.current_trajectory(require=True)
227227

228228

229-
async def test_group_context_and_async_helpers() -> None:
230-
with art.TrajectoryGroup() as group:
231-
with art.Trajectory() as first:
232-
pass
233-
with art.Trajectory() as second:
234-
pass
235-
assert group.trajectories == [first, second]
229+
def test_no_capture_hides_enclosing_trajectory_but_allows_nested_capture() -> None:
230+
def capture_exchange() -> None:
231+
state, token = begin(
232+
"POST",
233+
"https://example.test/v1/chat/completions",
234+
{"model": "test/model", "messages": []},
235+
)
236+
reset(token)
237+
if state is not None:
238+
state.status_code = 200
239+
state.add(json.dumps(CHAT).encode())
240+
state.finish()
241+
242+
with art.Trajectory() as outer:
243+
capture_exchange()
244+
with art.no_capture():
245+
assert art.current_trajectory() is None
246+
with pytest.raises(RuntimeError, match="No trajectory"):
247+
art.current_trajectory(require=True)
248+
capture_exchange()
249+
with art.Trajectory() as inner:
250+
capture_exchange()
251+
with art.no_capture():
252+
assert art.current_trajectory() is None
253+
capture_exchange()
254+
with art.Trajectory() as nested:
255+
capture_exchange()
256+
assert art.current_trajectory() is None
257+
assert art.current_trajectory() is inner
258+
capture_exchange()
259+
assert art.current_trajectory() is None
260+
assert art.current_trajectory() is outer
261+
capture_exchange()
262+
263+
with pytest.raises(ValueError, match="restore"):
264+
with art.no_capture():
265+
raise ValueError("restore")
266+
assert art.current_trajectory() is outer
267+
268+
assert len(outer.exchanges.chat_completions) == 2
269+
assert len(inner.exchanges.chat_completions) == 2
270+
assert len(nested.exchanges.chat_completions) == 1
271+
272+
273+
def test_no_capture_uses_the_scope_active_when_a_request_begins() -> None:
274+
with art.Trajectory() as trajectory:
275+
state, token = begin(
276+
"POST",
277+
"https://example.test/v1/chat/completions",
278+
{"model": "test/model", "messages": []},
279+
)
280+
reset(token)
281+
assert state is not None
282+
283+
with art.no_capture():
284+
state.status_code = 200
285+
state.add(json.dumps(CHAT).encode())
286+
state.finish()
287+
ignored, ignored_token = begin(
288+
"POST",
289+
"https://example.test/v1/chat/completions",
290+
{"model": "test/model", "messages": []},
291+
)
292+
reset(ignored_token)
293+
assert ignored is None
294+
295+
assert len(trajectory.exchanges.chat_completions) == 1
296+
297+
298+
async def test_no_capture_context_is_copied_when_tasks_are_created() -> None:
299+
async def current_after_scheduling() -> art.Trajectory | None:
300+
await asyncio.sleep(0)
301+
return art.current_trajectory()
302+
303+
with art.Trajectory() as outer:
304+
inherited = asyncio.create_task(current_after_scheduling())
305+
with art.no_capture():
306+
detached = asyncio.create_task(current_after_scheduling())
307+
assert await current_after_scheduling() is None
308+
assert await inherited is outer
309+
assert await detached is None
310+
311+
312+
async def test_async_helpers_and_group_aggregation_are_isolated() -> None:
313+
def capture_exchange() -> None:
314+
state, token = begin(
315+
"POST",
316+
"https://example.test/v1/chat/completions",
317+
{"model": "test/model", "messages": []},
318+
)
319+
reset(token)
320+
if state is not None:
321+
state.status_code = 200
322+
state.add(json.dumps(CHAT).encode())
323+
state.finish()
236324

237325
async def rollout() -> None:
238326
await asyncio.sleep(0)
327+
capture_exchange()
239328

240329
captured = await art.trajectory(rollout())
241330
assert isinstance(captured, art.Trajectory)
331+
assert len(captured.exchanges.chat_completions) == 1
242332
task = asyncio.create_task(rollout())
243333
with pytest.raises(TypeError, match="raw coroutine"):
244334
# Passing a Task is deliberately a static type error and a runtime error.
245335
await art.trajectory(task) # ty: ignore[invalid-argument-type]
246336
await task
247337

338+
async def unscoped() -> art.Trajectory:
339+
assert art.current_trajectory() is None
340+
capture_exchange()
341+
return art.Trajectory()
342+
248343
async def failed() -> art.Trajectory:
249344
raise ValueError("boom")
250345

251-
successful = art.trajectory(rollout())
252-
result = await art.trajectory_group([successful, failed()], return_exceptions=True)
253-
assert len(result.trajectories) == 1
254-
assert result.exceptions[0].message == "boom"
255-
256-
257-
def test_failed_trajectory_context_records_exception_without_trajectory() -> None:
258-
group = art.TrajectoryGroup()
346+
def generated() -> Generator[Coroutine[Any, Any, art.Trajectory], None, None]:
347+
capture_exchange()
348+
yield art.trajectory(rollout())
259349

260-
with pytest.raises(ValueError, match="boom"):
261-
with group:
262-
with art.Trajectory() as failed:
263-
raise ValueError("boom")
350+
with art.Trajectory() as outer:
351+
successful = art.trajectory(rollout())
352+
result = await art.trajectory_group(
353+
[successful, unscoped(), failed()],
354+
return_exceptions=True,
355+
)
356+
generated_result = await art.trajectory_group(generated())
357+
with pytest.raises(ValueError, match="boom"):
358+
await art.trajectory_group([failed()])
359+
assert art.current_trajectory() is outer
264360

265-
assert failed not in group.trajectories
266-
assert [exception.message for exception in group.exceptions] == ["boom"]
361+
assert len(result.trajectories) == 2
362+
assert len(result.trajectories[0].exchanges.chat_completions) == 1
363+
assert not result.trajectories[1].exchanges
364+
assert result.exceptions[0].message == "boom"
365+
assert len(generated_result.trajectories[0].exchanges.chat_completions) == 1
366+
assert not outer.exchanges
267367

268368

269369
def test_sync_group_generator_initializes_once(

0 commit comments

Comments
 (0)