Skip to content

Commit 9d370cc

Browse files
fix: wrap async tasks for sync Phoenix client (#5)
1 parent 5cb8845 commit 9d370cc

5 files changed

Lines changed: 79 additions & 3 deletions

File tree

src/evalwire/evaluators.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ def top_k(output: list[str], expected: dict) -> float:
3434

3535
raw = expected.get("expected_output", [])
3636
if isinstance(raw, str):
37-
raw = ast.literal_eval(raw)
37+
try:
38+
raw = ast.literal_eval(raw)
39+
except (ValueError, SyntaxError):
40+
raw = [raw]
3841
expected_items: list[str] = list(raw)
3942

4043
if not expected_items:
@@ -74,7 +77,10 @@ def make_membership_evaluator() -> Callable[[str, dict], bool]:
7477
def is_in(output: str, expected: dict) -> bool:
7578
raw = expected.get("expected_output", [])
7679
if isinstance(raw, str):
77-
raw = ast.literal_eval(raw)
80+
try:
81+
raw = ast.literal_eval(raw)
82+
except (ValueError, SyntaxError):
83+
raw = [raw]
7884
expected_items: list[str] = list(raw)
7985
return output in expected_items
8086

src/evalwire/runner.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
import importlib
67
import importlib.util
8+
import inspect
79
import logging
810
import sys
911
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -87,6 +89,14 @@ def _run_one(exp_name: str, task: Any, evaluators: list[Any]) -> Any:
8789
if dataset is None:
8890
return None
8991

92+
# Phoenix's sync client cannot await coroutine functions directly.
93+
# Wrap async tasks in a sync bridge so they work transparently.
94+
if inspect.iscoroutinefunction(task):
95+
_async_task = task
96+
97+
def task(example: Any, _fn: Any = _async_task) -> Any: # noqa: E731
98+
return asyncio.run(_fn(example))
99+
90100
timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
91101
experiment_name = f"{experiment_name_prefix}_{exp_name}_{timestamp}"
92102
exp_metadata = {"dataset": exp_name, "run_by": "evalwire"}

tests/test_evaluators.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ def test_missing_expected_output_key_returns_0(self):
8282
score = top_k(["a"], {})
8383
assert score == pytest.approx(0.0)
8484

85+
def test_bare_string_expected_output_is_treated_as_single_item(self):
86+
"""A plain identifier string (e.g. from a CSV column) must not crash
87+
ast.literal_eval and should be treated as a single-item expected list."""
88+
top_k = make_top_k_evaluator(K=10)
89+
# "some_url" is a bare identifier — not a Python literal
90+
score = top_k(["some_url"], {"expected_output": "some_url"})
91+
assert score == pytest.approx(1.0)
92+
93+
def test_bare_string_expected_output_no_match(self):
94+
top_k = make_top_k_evaluator(K=10)
95+
score = top_k(["other_url"], {"expected_output": "some_url"})
96+
assert score == pytest.approx(0.0)
97+
8598

8699
class TestMakeMembershipEvaluator:
87100
def test_returns_callable_named_is_in(self):
@@ -120,3 +133,14 @@ def test_single_item_list_match(self):
120133
def test_case_sensitive(self):
121134
is_in = make_membership_evaluator()
122135
assert is_in("ES_SEARCH", {"expected_output": ["es_search"]}) is False
136+
137+
def test_bare_string_expected_output_is_treated_as_single_item(self):
138+
"""A plain identifier string (e.g. from a CSV column) must not crash
139+
ast.literal_eval and should be treated as a single-item expected list."""
140+
is_in = make_membership_evaluator()
141+
# "elasticsearch" is a bare identifier — not a Python literal
142+
assert is_in("elasticsearch", {"expected_output": "elasticsearch"}) is True
143+
144+
def test_bare_string_expected_output_no_match(self):
145+
is_in = make_membership_evaluator()
146+
assert is_in("cms", {"expected_output": "elasticsearch"}) is False

tests/test_runner.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,39 @@ def test_experiment_name_includes_dataset_name(
227227
runner.run(names=["es_search"])
228228
call_kwargs = mock_phoenix_client.experiments.run_experiment.call_args.kwargs
229229
assert "es_search" in call_kwargs["experiment_name"]
230+
231+
def test_async_task_is_wrapped_and_callable_by_sync_phoenix(
232+
self, tmp_path: Path, mock_phoenix_client: MagicMock
233+
):
234+
"""An async task must be transparently wrapped so Phoenix's sync runner
235+
can call it without getting back an unawaited coroutine."""
236+
base = tmp_path / "experiments"
237+
base.mkdir()
238+
exp = base / "async_exp"
239+
exp.mkdir()
240+
(exp / "task.py").write_text("async def task(example): return 'async_result'\n")
241+
242+
# Capture the task callable that is passed to run_experiment.
243+
captured: dict = {}
244+
245+
def _capture_task(**kwargs: object) -> MagicMock:
246+
captured["task"] = kwargs["task"]
247+
return MagicMock()
248+
249+
mock_phoenix_client.experiments.run_experiment.side_effect = _capture_task
250+
251+
runner = _make_runner(base, mock_phoenix_client)
252+
runner.run()
253+
254+
assert "task" in captured, "run_experiment was not called"
255+
wrapped = captured["task"]
256+
# The wrapped task must be a plain callable (not a coroutine function)
257+
# so Phoenix's sync runner can call it directly.
258+
import inspect
259+
260+
assert not inspect.iscoroutinefunction(wrapped), (
261+
"Async task should have been wrapped into a sync callable"
262+
)
263+
# Calling it must return the real result, not a coroutine.
264+
result = wrapped(example=object())
265+
assert result == "async_result"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)