feat(checks): add progress bar to suite runs#2514
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces an interactive progress bar using the rich library to display the status of running scenarios in a suite, supporting both serial and parallel execution modes. The feedback highlights three key areas for improvement: propagating the internal _progress reference to wrapped columns in the custom _OverallOnly class to avoid potential AttributeErrors, resetting the progress bar description to a clean state upon completion, and guarding task creation calls when progress tracking is disabled to avoid unnecessary overhead.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| class _OverallOnly(ProgressColumn): | ||
| """Render the wrapped column only for the overall task, not per-scenario rows.""" | ||
|
|
||
| def __init__(self, column: ProgressColumn) -> None: | ||
| super().__init__() | ||
| self._column: ProgressColumn = column | ||
|
|
||
| def render(self, task: "Task") -> RenderableType: | ||
| if not task.fields.get("overall"): | ||
| return Text("") | ||
| return self._column.render(task) |
There was a problem hiding this comment.
When wrapping a ProgressColumn inside _OverallOnly, the inner column (self._column) does not automatically receive the _progress reference from the parent Progress instance. If the wrapped column (or any custom column) attempts to access self.progress, it will raise an AttributeError because _progress is unset.
To prevent this, we can override __setattr__ to propagate _progress to the wrapped column. Additionally, we should invoke the wrapped column using self._column(task) (the standard __call__ interface) instead of calling render directly to respect any potential custom wrapper behaviors in rich.
class _OverallOnly(ProgressColumn):
"""Render the wrapped column only for the overall task, not per-scenario rows."""
def __init__(self, column: ProgressColumn) -> None:
super().__init__()
self._column: ProgressColumn = column
def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
if name == "_progress":
self._column._progress = value
def render(self, task: "Task") -> RenderableType:
if not task.fields.get("overall"):
return Text("")
return self._column(task)| @contextmanager | ||
| def _progress_bar(self, enabled: bool) -> Iterator[tuple[Progress, TaskID]]: | ||
| """Yield ``(progress, task_id)``; disabled when ``enabled`` is False so calls are no-ops.""" | ||
| with Progress( | ||
| SpinnerColumn(), | ||
| TextColumn("[progress.description]{task.description}"), | ||
| _OverallOnly(BarColumn()), | ||
| _OverallOnly(MofNCompleteColumn()), | ||
| TimeElapsedColumn(), | ||
| disable=not enabled, | ||
| ) as progress: | ||
| task_id = progress.add_task( | ||
| "Running scenarios", total=len(self.scenarios), overall=True | ||
| ) | ||
| yield progress, task_id | ||
| progress.update(task_id, completed=len(self.scenarios)) | ||
| progress.refresh() |
There was a problem hiding this comment.
During serial runs, the overall task description is updated to "Running: {scenario.name}". When the suite run finishes, the progress bar remains on the screen (since transient is False by default) showing "Running: <last_scenario_name>" even though execution has completed.
Resetting the description back to "Running scenarios" (or a completed message) when exiting the context manager ensures the final terminal output is clean and accurate.
@contextmanager
def _progress_bar(self, enabled: bool) -> Iterator[tuple[Progress, TaskID]]:
"""Yield ``(progress, task_id)``; disabled when ``enabled`` is False so calls are no-ops."""
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
_OverallOnly(BarColumn()),
_OverallOnly(MofNCompleteColumn()),
TimeElapsedColumn(),
disable=not enabled,
) as progress:
task_id = progress.add_task(
"Running scenarios", total=len(self.scenarios), overall=True
)
yield progress, task_id
progress.update(
task_id,
description="Running scenarios",
completed=len(self.scenarios),
)
progress.refresh()| async def run_scenario( | ||
| scenario: Scenario[InputType, OutputType, Trace[Any, Any]], | ||
| ) -> ScenarioResult[Trace[Any, Any]]: | ||
| async with semaphore: | ||
| return await scenario.run( | ||
| target=target, return_exception=return_exception | ||
| ) | ||
| # Show a row per scenario while it runs, so all in-flight ones appear. | ||
| sub_id = progress.add_task(f" ↳ {scenario.name}", total=None) | ||
| try: | ||
| result = await scenario.run( | ||
| target=target, return_exception=return_exception | ||
| ) | ||
| finally: | ||
| progress.remove_task(sub_id) | ||
| progress.advance(overall_id) | ||
| return result |
There was a problem hiding this comment.
When progress is disabled (e.g., in non-interactive environments or CI/CD), calling progress.add_task and progress.remove_task still executes and allocates/deallocates Task objects internally in rich.
Guarding these calls with if not progress.disable avoids unnecessary overhead and memory allocations during parallel execution of large suites.
async def run_scenario(
scenario: Scenario[InputType, OutputType, Trace[Any, Any]],
) -> ScenarioResult[Trace[Any, Any]]:
async with semaphore:
# Show a row per scenario while it runs, so all in-flight ones appear.
sub_id = (
progress.add_task(f" ↳ {scenario.name}", total=None)
if not progress.disable
else None
)
try:
result = await scenario.run(
target=target, return_exception=return_exception
)
finally:
if sub_id is not None:
progress.remove_task(sub_id)
progress.advance(overall_id)
return result
Description
This PR adds a progress bar to
Suiterun, making it easier to track whichScenariois being executed (or more than one ifparallel=True).Related Issue
#2473
Type of Change