Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 45 additions & 28 deletions src/galileo/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@
import builtins
import datetime
import re
import warnings
from collections.abc import Iterator
from time import sleep
from typing import TYPE_CHECKING, Any

from tqdm.auto import tqdm

from galileo.config import GalileoPythonConfig
from galileo.datasets import Dataset as LegacyDataset
from galileo.exceptions import NotFoundError
from galileo.experiment_tags import upsert_experiment_tag
from galileo.experiments import Experiments as ExperimentsService
from galileo.experiments import _default_prompt_settings
from galileo.export import ExportClient
from galileo.job_progress import get_run_scorer_jobs, job_progress
from galileo.prompts import PromptTemplate, get_prompt
from galileo.resources.api.experiment import (
delete_experiment_projects_project_id_experiments_experiment_id_delete,
Expand Down Expand Up @@ -1122,54 +1125,68 @@ def get_status(self) -> ExperimentStatusInfo:

return ExperimentStatusInfo(self._experiment_response)

def monitor_progress(self, job_id: str | None = None) -> str:
def monitor_progress(self, poll_interval_seconds: float = 2.0, *, job_id: str | None = None) -> None:
"""
Monitor the progress of the experiment job with a progress bar.
Monitor the progress of the experiment with a progress bar.

Args:
job_id: Optional job ID to monitor. If not provided, will attempt to find
the primary job for this experiment.
Polls the experiment status via the API until the experiment completes,
displaying a tqdm progress bar reflecting `log_generation` progress.

Parameters
----------
poll_interval_seconds : float, optional
Seconds to wait between status polls. Defaults to 2.0.
job_id : str or None, optional
Deprecated. This parameter is ignored; it existed in a prior version
that polled the jobs table, which has been retired.

Returns
-------
str: The unique identifier of the completed job.
None

Raises
------
ValueError: If the experiment lacks required id or project_id attributes,
or if no job_id is provided and no job can be found.
ValueError
If the experiment lacks required id or project_id attributes.

Examples
--------
experiment = Experiment.get(name="ml-evaluation", project_name="My AI Project")
result = experiment.run()
experiment = Experiment(
name="ml-evaluation",
dataset_name="ml-dataset",
project_name="My AI Project"
).create()

# Monitor the job progress
completed_job_id = experiment.monitor_progress()
experiment.monitor_progress()
"""
if job_id is not None:
warnings.warn(
"The 'job_id' parameter of monitor_progress() is deprecated and will be removed in a future release. "
"Progress is now tracked directly via experiment status; the job_id value is ignored.",
DeprecationWarning,
stacklevel=2,
)

if self.id is None:
raise ValueError("Experiment ID is not set. Cannot monitor progress for a local-only experiment.")
if self.project_id is None:
raise ValueError("Project ID is not set. Cannot monitor progress without project_id.")

if job_id is None:
# Try to get job from stored state or query for it
if self._job_id:
job_id = self._job_id
else:
# Get the first scorer job
scorer_jobs = get_run_scorer_jobs(project_id=self.project_id, run_id=self.id)
if not scorer_jobs:
raise ValueError("No job found for this experiment. Run the experiment first.")
job_id = str(scorer_jobs[0].id)

_logger.info(f"Experiment.monitor_progress: experiment_id='{self.id}' job_id='{job_id}' - started")
_logger.info(f"Experiment.monitor_progress: experiment_id='{self.id}' - started")

# Monitor job progress with progress bar
completed_job_id = job_progress(job_id=job_id, project_id=self.project_id, run_id=self.id)
status = self.get_status()
progress_bar = tqdm(total=100, unit="%", desc="Experiment progress")
try:
while not status.is_complete:
new_progress = status.overall_progress
progress_bar.update(new_progress - progress_bar.n)
sleep(poll_interval_seconds)
status = self.get_status()
Comment on lines +1180 to +1184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

monitor_progress() polls forever in two cases: when status.log_generation is absent (defaulting overall_progress to 0) and when a run stalls below 100% (since is_failed always returns False) — should we fail fast on missing log_generation and break on explicit failure/cancelled states, plus add a timeout/max_polls guard?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
In `src/galileo/experiment.py` around lines 1089-1168, fix `monitor_progress` to exit
deterministically: 1. In `get_status` (or `ExperimentStatusInfo` construction), detect
when `ExperimentResponse.status` or `status.log_generation` is absent/Unset and raise a
clear `ValueError` (e.g., "Experiment status.log_generation missing; cannot monitor
progress reliably") instead of defaulting to a synthetic 0% phase. 2. In the polling
loop, break on additional terminal signals from the status payload
(failed/cancelled/stopped) and raise a clear exception when detected; update
`ExperimentStatusInfo.is_failed` so completion/failure is derived from explicit backend
run states rather than only `log_generation.progress_percent >= 100`. 3. Add a `timeout`
or `max_polls` guard so the loop exits deterministically even if the backend never
reaches 100%.

progress_bar.update(100 - progress_bar.n)
finally:
progress_bar.close()

_logger.info(f"Experiment.monitor_progress: experiment_id='{self.id}' - completed")
return str(completed_job_id)

# Query and export methods - similar to LogStream

Expand Down
119 changes: 0 additions & 119 deletions src/galileo/job_progress.py

This file was deleted.

55 changes: 0 additions & 55 deletions src/galileo/jobs.py

This file was deleted.

102 changes: 102 additions & 0 deletions tests/test_experiment_progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from unittest.mock import MagicMock, patch
from uuid import uuid4

import pytest

from galileo.experiment import Experiment
from galileo.shared.base import SyncState
from galileo.shared.experiment_result import ExperimentStatusInfo

FIXED_PROJECT_ID = str(uuid4())
FIXED_EXPERIMENT_ID = str(uuid4())


def _make_status(progress_percent: float) -> ExperimentStatusInfo:
"""Build an ExperimentStatusInfo with a given log_generation progress (0-100)."""
phase = MagicMock()
phase.progress_percent = progress_percent / 100.0 # API uses 0.0-1.0
response = MagicMock()
response.status.log_generation = phase
return ExperimentStatusInfo(response)


def _make_experiment() -> Experiment:
exp = Experiment._create_empty()
exp.id = FIXED_EXPERIMENT_ID
exp.project_id = FIXED_PROJECT_ID
exp.name = "test-experiment"
exp._set_state(SyncState.SYNCED)
return exp


class TestMonitorProgress:
@patch("galileo.experiment.Experiment.get_status")
@patch("galileo.experiment.sleep", return_value=None)
def test_completes_when_status_reaches_100(self, mock_sleep, mock_get_status):
# Given: an experiment that progresses through 0%, 50%, then 100%
mock_get_status.side_effect = [_make_status(0.0), _make_status(50.0), _make_status(100.0)]
exp = _make_experiment()

# When: monitoring progress until completion
exp.monitor_progress(poll_interval_seconds=0.0)

# Then: get_status is polled until 100% is reached
assert mock_get_status.call_count == 3

@patch("galileo.experiment.Experiment.get_status")
@patch("galileo.experiment.sleep", return_value=None)
def test_already_complete_on_first_poll(self, mock_sleep, mock_get_status):
# Given: an experiment that is already at 100% on the first poll
mock_get_status.return_value = _make_status(100.0)
exp = _make_experiment()

# When: monitoring progress
exp.monitor_progress(poll_interval_seconds=0.0)

# Then: get_status is called once and sleep is never called
assert mock_get_status.call_count == 1
mock_sleep.assert_not_called()

@patch("galileo.experiment.Experiment.get_status")
@patch("galileo.experiment.sleep", return_value=None)
def test_uses_poll_interval_seconds(self, mock_sleep, mock_get_status):
# Given: an experiment that completes on the second poll
mock_get_status.side_effect = [_make_status(0.0), _make_status(100.0)]
exp = _make_experiment()

# When: monitoring with a custom poll interval
exp.monitor_progress(poll_interval_seconds=5.0)

# Then: sleep is called once with the specified interval
mock_sleep.assert_called_once_with(5.0)

def test_raises_without_experiment_id(self):
# Given: an experiment without an id
exp = Experiment._create_empty()
exp.id = None
exp.project_id = FIXED_PROJECT_ID

# When/Then: monitoring raises ValueError about the missing experiment id
with pytest.raises(ValueError, match="Experiment ID is not set"):
exp.monitor_progress()

def test_raises_without_project_id(self):
# Given: an experiment without a project_id
exp = Experiment._create_empty()
exp.id = FIXED_EXPERIMENT_ID
exp.project_id = None

# When/Then: monitoring raises ValueError about the missing project id
with pytest.raises(ValueError, match="Project ID is not set"):
exp.monitor_progress()

@patch("galileo.experiment.Experiment.get_status")
@patch("galileo.experiment.sleep", return_value=None)
def test_deprecated_job_id_warns(self, mock_sleep, mock_get_status):
# Given: an experiment that is already complete, and a caller passing the deprecated job_id
mock_get_status.return_value = _make_status(100.0)
exp = _make_experiment()

# When/Then: monitor_progress emits a DeprecationWarning when job_id is supplied
with pytest.warns(DeprecationWarning, match="job_id"):
exp.monitor_progress(job_id="some-old-job-id")
Loading
Loading