Skip to content

Commit b28532f

Browse files
stainless-app[bot]Vivek Nair
andauthored
release: 1.5.0 (#370)
* feat(progress): show experiment URL in reporters (#369) * release: 1.5.0 --------- Co-authored-by: Vivek Nair <vivek@gentrace.ai> Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com>
1 parent 8ce4eb4 commit b28532f

File tree

9 files changed

+97
-30
lines changed

9 files changed

+97
-30
lines changed

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "1.4.1"
2+
".": "1.5.0"
33
}

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 1.5.0 (2025-08-12)
4+
5+
Full Changelog: [v1.4.1...v1.5.0](https://github.com/gentrace/gentrace-python/compare/v1.4.1...v1.5.0)
6+
7+
### Features
8+
9+
* **progress:** show experiment URL in reporters ([#369](https://github.com/gentrace/gentrace-python/issues/369)) ([5a48f44](https://github.com/gentrace/gentrace-python/commit/5a48f445af2131ceb2d758efff95826b24c8ac57))
10+
311
## 1.4.1 (2025-08-11)
412

513
Full Changelog: [v1.4.0...v1.4.1](https://github.com/gentrace/gentrace-python/compare/v1.4.0...v1.4.1)

examples/eval_dataset_local_cases.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,15 @@ async def dataset_evaluation() -> None:
4444

4545
# Using TestInput with TypedDict for type safety
4646
test_cases = [
47-
TestInput[PromptInputs](name="greeting", inputs={"prompt": "Hello! How are you doing today?"}),
48-
TestInput[PromptInputs](name="factual_question", inputs={"prompt": "What is the capital of France?"}),
49-
TestInput[PromptInputs](name="math_problem", inputs={"prompt": "What is 25 * 4?"}),
47+
TestInput[PromptInputs](
48+
name="greeting", inputs={"prompt": "Hello! How are you doing today?"}
49+
),
50+
TestInput[PromptInputs](
51+
name="factual_question", inputs={"prompt": "What is the capital of France?"}
52+
),
53+
TestInput[PromptInputs](
54+
name="math_problem", inputs={"prompt": "What is 25 * 4?"}
55+
),
5056
TestInput[PromptInputs](
5157
name="creative_writing",
5258
inputs={"prompt": "Write a haiku about artificial intelligence"},
@@ -64,7 +70,4 @@ async def dataset_evaluation() -> None:
6470

6571

6672
if __name__ == "__main__":
67-
result = asyncio.run(dataset_evaluation())
68-
69-
if result:
70-
print(f"Experiment URL: {result.url}")
73+
asyncio.run(dataset_evaluation())

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "gentrace-py"
3-
version = "1.4.1"
3+
version = "1.5.0"
44
description = "The official Python library for the gentrace API"
55
dynamic = ["readme"]
66
license = "MIT"

src/gentrace/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

33
__title__ = "gentrace"
4-
__version__ = "1.4.1" # x-release-please-version
4+
__version__ = "1.5.0" # x-release-please-version

src/gentrace/lib/eval_dataset.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,9 @@ async def eval_dataset(
458458
logger.setLevel(logging.INFO)
459459
progress_reporter = SimpleProgressReporter(logger)
460460

461-
# Start progress reporting
462-
progress_reporter.start(experiment_context["pipeline_id"], len(converted_test_cases))
461+
# Start progress reporting with experiment URL if available
462+
experiment_url = experiment_context.get("experiment_url")
463+
progress_reporter.start(experiment_context["pipeline_id"], len(converted_test_cases), experiment_url)
463464

464465
evaluation_tasks: List[Tuple[str, Awaitable[Optional[TResult]]]] = []
465466
for i, test_case in enumerate(converted_test_cases):

src/gentrace/lib/experiment.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@
2020
class ExperimentContext(TypedDict):
2121
"""
2222
Represents the context for an experiment run. This context is stored in
23-
a ContextVar to make the experiment ID and pipeline ID available throughout
23+
a ContextVar to make the experiment ID, pipeline ID, and URL available throughout
2424
the asynchronous execution flow.
2525
"""
2626

2727
experiment_id: str
2828
pipeline_id: str
29+
experiment_url: Optional[str] # URL to view the experiment in the Gentrace UI
2930

3031

3132
experiment_context_var: contextvars.ContextVar[Optional[ExperimentContext]] = contextvars.ContextVar(
@@ -182,9 +183,24 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> ExperimentResult:
182183
if not experiment_obj:
183184
raise RuntimeError("Failed to obtain experiment from API.")
184185

186+
# Construct the experiment URL early so it can be displayed immediately
187+
# Get the client to access base_url
188+
client = _get_async_client_instance()
189+
base_url = str(client.base_url).rstrip('/')
190+
191+
# Extract hostname from base URL (remove /api suffix if present)
192+
if base_url.endswith('/api'):
193+
hostname = base_url[:-4]
194+
else:
195+
hostname = base_url
196+
197+
# Construct the URL using resource_path
198+
experiment_url = f"{hostname}{experiment_obj.resource_path}"
199+
185200
context_data: ExperimentContext = {
186201
"experiment_id": experiment_obj.id,
187202
"pipeline_id": effective_pipeline_id,
203+
"experiment_url": experiment_url,
188204
}
189205

190206
token = experiment_context_var.set(context_data)
@@ -200,26 +216,13 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> ExperimentResult:
200216
if experiment_obj:
201217
await finish_experiment_api(id=experiment_obj.id)
202218

203-
# Get the client to access base_url
204-
client = _get_async_client_instance()
205-
base_url = str(client.base_url).rstrip('/')
206-
207-
# Extract hostname from base URL (remove /api suffix if present)
208-
if base_url.endswith('/api'):
209-
hostname = base_url[:-4]
210-
else:
211-
hostname = base_url
212-
213-
# Construct the URL using resource_path
214-
url = f"{hostname}{experiment_obj.resource_path}"
215-
216219
# Create ExperimentResult instance with all fields from experiment plus URL
217220
# Use model_dump with by_alias=True to get camelCase field names
218221
experiment_data = experiment_obj.model_dump(by_alias=True)
219222

220223
result = ExperimentResult(
221224
**experiment_data,
222-
url=url
225+
url=experiment_url # Use the URL we constructed earlier
223226
)
224227

225228
return result

src/gentrace/lib/progress.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ class ProgressReporter(ABC):
3434
"""
3535

3636
@abstractmethod
37-
def start(self, pipeline_id: str, total: int) -> None:
37+
def start(self, pipeline_id: str, total: int, experiment_url: Optional[str] = None) -> None:
3838
"""
3939
Initialize the progress reporter for a new evaluation run.
4040
4141
Args:
4242
pipeline_id: The ID of the pipeline being evaluated.
4343
total: The total number of test cases to be executed.
44+
experiment_url: Optional URL to view the experiment in the Gentrace UI.
4445
"""
4546
pass
4647

@@ -93,14 +94,18 @@ def __init__(self, logger: Optional[logging.Logger] = None) -> None:
9394
self.logger = logger if logger is not None else logging.getLogger("gentrace")
9495

9596
@override
96-
def start(self, pipeline_id: str, total: int) -> None:
97+
def start(self, pipeline_id: str, total: int, experiment_url: Optional[str] = None) -> None:
9798
"""Initialize a new evaluation run with line-by-line output."""
9899
self.pipeline_id = pipeline_id
99100
self.total = total
100101
self.current = 0
101102

102103
message = f"\nRunning experiment with {total} test {'case' if total == 1 else 'cases'}..."
103104
self.logger.info(message)
105+
106+
# Display the experiment URL if available
107+
if experiment_url:
108+
self.logger.info(f"Experiment URL: {experiment_url}")
104109

105110
def update_current_test(self, test_name: str) -> None:
106111
"""
@@ -154,6 +159,7 @@ def __init__(self) -> None:
154159
self.completed_count = 0
155160
self.total_count = 0
156161
self.last_completed_test = ""
162+
self.experiment_url: Optional[str] = None
157163

158164
def _create_display(self) -> Table:
159165
"""Create the display table with current test info and progress bar."""
@@ -178,12 +184,20 @@ def _create_display(self) -> Table:
178184
return table
179185

180186
@override
181-
def start(self, pipeline_id: str, total: int) -> None:
187+
def start(self, pipeline_id: str, total: int, experiment_url: Optional[str] = None) -> None:
182188
"""Initialize a new progress bar for the evaluation run."""
183189
self.total_count = total
184190
self.completed_count = 0
185191
self.current_test_name = ""
186192
self.last_completed_test = ""
193+
self.experiment_url = experiment_url
194+
195+
# Print the experiment URL separately before starting the Live display
196+
# Using Rich's hyperlink markup for clickable links in supported terminals
197+
if experiment_url:
198+
# Use Rich's link markup to make the URL clickable in supported terminals
199+
self.console.print(f"[bold cyan]Experiment:[/bold cyan] [link={experiment_url}]{experiment_url}[/link]", crop=False, overflow="ignore")
200+
self.console.print() # Add spacing
187201

188202
# Create progress bar without description in the bar itself
189203
self.progress = Progress(

tests/lib/test_progress.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,20 @@ def test_stop(self) -> None:
226226

227227
mock_logger.info.assert_called_once_with("Evaluation complete.")
228228

229+
def test_start_with_url(self) -> None:
230+
"""Test starting with an experiment URL."""
231+
mock_logger = Mock(spec=logging.Logger)
232+
reporter = SimpleProgressReporter(logger=mock_logger)
233+
234+
test_url = "https://gentrace.ai/t/org/pipeline/123/experiments/abc"
235+
reporter.start("pipeline-123", 5, test_url)
236+
237+
# Verify both the start message and URL were logged
238+
assert mock_logger.info.call_count == 2
239+
calls = mock_logger.info.call_args_list
240+
assert "Running experiment with 5 test cases..." in calls[0][0][0]
241+
assert f"Experiment URL: {test_url}" in calls[1][0][0]
242+
229243
def test_full_lifecycle(self) -> None:
230244
"""Test complete lifecycle of progress reporting."""
231245
mock_logger = Mock(spec=logging.Logger)
@@ -387,6 +401,30 @@ def test_stop(self, mock_progress_class: Any, mock_console_class: Any, mock_live
387401
break
388402
assert found_complete_msg
389403

404+
@patch("gentrace.lib.progress.Live")
405+
@patch("gentrace.lib.progress.Console")
406+
@patch("gentrace.lib.progress.Progress")
407+
def test_start_with_url(self, mock_progress_class: Any, _mock_console_class: Any, mock_live_class: Any) -> None:
408+
"""Test starting with an experiment URL."""
409+
mock_progress = Mock()
410+
mock_task_id = 999
411+
mock_progress.add_task.return_value = mock_task_id
412+
mock_progress_class.return_value = mock_progress
413+
414+
mock_live = Mock()
415+
mock_live_class.return_value = mock_live
416+
417+
reporter = RichProgressReporter()
418+
test_url = "https://gentrace.ai/t/org/pipeline/456/experiments/def"
419+
420+
# Mock _create_display to avoid rendering issues with mocked objects
421+
with patch.object(reporter, '_create_display', return_value=Mock()):
422+
reporter.start("pipeline-456", 10, test_url)
423+
424+
assert reporter.experiment_url == test_url
425+
assert reporter.total_count == 10
426+
mock_live.start.assert_called_once()
427+
390428
@patch("gentrace.lib.progress.Live")
391429
@patch("gentrace.lib.progress.Console")
392430
@patch("gentrace.lib.progress.Progress")

0 commit comments

Comments
 (0)