Skip to content

Commit 69da4f4

Browse files
NicolayNicolay
authored andcommitted
feat: Add RichConsoleReporter
1 parent 3e4af42 commit 69da4f4

5 files changed

Lines changed: 305 additions & 4 deletions

File tree

agentune/api/base.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from agentune.core.llmcache.sqlite_lru import ConnectionProviderFactory, SqliteLru
2727
from agentune.core.progress.reporters.base import ProgressReporter, progress_setup
2828
from agentune.core.progress.reporters.log import LogReporter
29+
from agentune.core.progress.reporters.rich_console import RichConsoleReporter
2930
from agentune.core.sercontext import SerializationContext
3031
from agentune.core.util.lrucache import LRUCache
3132

@@ -73,6 +74,13 @@ class WriteProgressToLog(ProgressReporterParams):
7374
logger_name: str = 'agentune.progress'
7475
log_level: int = logging.INFO
7576

77+
@frozen
78+
class WriteProgressToConsole(ProgressReporterParams):
79+
"""Writes progress updates in the console/terminal, shows interactive progress tree"""
80+
poll_interval: timedelta = timedelta(milliseconds=500)
81+
show_percentages: bool = True
82+
show_colors: bool = True
83+
7684

7785
@frozen
7886
class RunContext:
@@ -127,7 +135,7 @@ async def create(duckdb: DuckdbDatabase | DuckdbManager = DuckdbInMemory(),
127135
httpx_async_client: httpx.AsyncClient | None = None,
128136
llm_providers: LLMProvider | Sequence[LLMProvider] | None = None,
129137
llm_cache: LlmCacheInMemory | LlmCacheOnDisk | LLMCacheBackend | None = LlmCacheInMemory(1000),
130-
progress_reporter: WriteProgressToLog | ProgressReporter | None = WriteProgressToLog()
138+
progress_reporter: WriteProgressToLog | WriteProgressToConsole | ProgressReporter | None = WriteProgressToLog()
131139
) -> RunContext:
132140
"""Create a new context instance (see the class doc). Remember to close it when you are done, by using it as
133141
a context manager or by calling the aclose() method explicitly.
@@ -194,6 +202,14 @@ async def create(duckdb: DuckdbDatabase | DuckdbManager = DuckdbInMemory(),
194202
match progress_reporter:
195203
case WriteProgressToLog(poll_interval, logger_name, log_level):
196204
reporter_instance = LogReporter(poll_interval, logger_name, log_level)
205+
case WriteProgressToConsole(poll_interval, show_percentages, show_colors):
206+
from rich.console import Console
207+
# Check whether the running environment supports Rich console
208+
if Console().is_terminal:
209+
reporter_instance = RichConsoleReporter(poll_interval, show_percentages, show_colors)
210+
# Fall back to logging if Rich console is not supported
211+
else:
212+
reporter_instance = LogReporter(poll_interval, 'agentune.progress', logging.INFO)
197213
case ProgressReporter() as reporter:
198214
reporter_instance = reporter
199215
owns_reporter = False
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Rich console-based progress reporter for interactive display."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
from typing import override
7+
8+
from rich.console import Console
9+
from rich.live import Live
10+
from rich.tree import Tree
11+
12+
from agentune.core.progress.base import ProgressStage, root_stage
13+
from agentune.core.progress.reporters.base import ProgressReporter
14+
15+
16+
class RichConsoleReporter(ProgressReporter):
17+
"""Progress reporter that displays interactive progress in the console using Rich.
18+
19+
Progress updates are displayed in a hierarchical tree visualization. Can be used in interactive terminals and Jupyter notebooks.
20+
21+
Args:
22+
poll_interval: How often to poll for progress updates.
23+
show_percentages: Whether to show percentage completion for counted stages.
24+
show_colors: Whether to use colors in the display.
25+
"""
26+
27+
def __init__(
28+
self,
29+
poll_interval: timedelta,
30+
show_percentages: bool,
31+
show_colors: bool,
32+
) -> None:
33+
self.poll_interval = poll_interval
34+
self._show_percentages = show_percentages
35+
self._show_colors = show_colors
36+
self._live: Live | None = None
37+
self._progress_tree: Tree | None = None
38+
39+
@override
40+
async def start(self, root_stage: ProgressStage) -> None:
41+
"""Start displaying progress for the given root stage."""
42+
snapshot = root_stage.deepcopy()
43+
self._progress_tree = self._build_tree(snapshot)
44+
self._live = Live(
45+
self._progress_tree,
46+
console=Console(),
47+
refresh_per_second=1 / self.poll_interval.total_seconds(),
48+
)
49+
self._live.start()
50+
51+
@override
52+
async def update(self, snapshot: ProgressStage) -> None:
53+
"""Update the display with the latest progress snapshot."""
54+
if self._live is None:
55+
return
56+
self._progress_tree = self._build_tree(snapshot)
57+
self._live.update(self._progress_tree)
58+
59+
@override
60+
async def stop(self) -> None:
61+
"""Stop displaying and perform cleanup."""
62+
if self._live is not None:
63+
current_root = root_stage()
64+
if current_root is not None:
65+
await self.update(current_root.deepcopy())
66+
self._live.stop()
67+
self._live = None
68+
self._progress_tree = None
69+
70+
def _build_tree(self, stage: ProgressStage) -> Tree:
71+
"""Build a Rich Tree structure from a progress stage.
72+
73+
Args:
74+
stage: The progress stage to convert.
75+
76+
Returns:
77+
A Rich Tree object representing the progress hierarchy.
78+
"""
79+
label = self._format_stage_label(stage)
80+
tree = Tree(label)
81+
82+
for child in stage.children:
83+
tree.add(self._build_tree(child))
84+
85+
return tree
86+
87+
def _format_stage_label(self, stage: ProgressStage) -> str:
88+
"""Format a stage label with optional Rich markup for colors."""
89+
count = stage.count
90+
total = stage.total
91+
92+
progress = ''
93+
if count is not None and total is not None:
94+
if self._show_percentages and total > 0:
95+
percentage = (count / total) * 100
96+
progress = f' [{count}/{total} ({percentage:.1f}%)]'
97+
else:
98+
progress = f' [{count}/{total}]'
99+
elif count is not None:
100+
progress = f' [{count}]'
101+
elif total is not None:
102+
progress = f' [0/{total}]'
103+
104+
if not self._show_colors:
105+
name = stage.name + progress
106+
return name + ' ✓' if stage.is_completed else name
107+
108+
if stage.is_completed:
109+
return f'[green]{stage.name}[/green][dim]{progress}[/dim] [green]✓[/green]'
110+
elif count is not None:
111+
return f'[cyan]{stage.name}[/cyan][dim]{progress}[/dim]'
112+
else:
113+
return f'[white]{stage.name}[/white][dim]{progress}[/dim]'
114+

poetry.lock

Lines changed: 58 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ more-itertools = "^10.7.0"
6161
llama-index-core = "^0.14.7"
6262
llama-index-llms-openai = "^0.6.7"
6363
lightgbm = "^4.6.0"
64+
rich = "^14.0.0"
6465

6566
[tool.poetry.group.dev.dependencies]
6667
mypy = "^1.18.2"
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Tests for RichConsoleReporter formatting and tree building."""
2+
from datetime import timedelta
3+
4+
import pytest
5+
6+
from agentune.core.progress.base import ProgressStage
7+
from agentune.core.progress.reporters.rich_console import RichConsoleReporter
8+
9+
10+
@pytest.fixture
11+
def reporter_with_colors() -> RichConsoleReporter:
12+
return RichConsoleReporter(timedelta(seconds=0.1), show_percentages=True, show_colors=True)
13+
14+
15+
@pytest.fixture
16+
def reporter_no_colors() -> RichConsoleReporter:
17+
return RichConsoleReporter(timedelta(seconds=0.1), show_percentages=True, show_colors=False)
18+
19+
20+
@pytest.fixture
21+
def reporter_no_percentages() -> RichConsoleReporter:
22+
return RichConsoleReporter(timedelta(seconds=0.1), show_percentages=False, show_colors=True)
23+
24+
25+
def test_label_in_progress_with_colors(reporter_with_colors: RichConsoleReporter) -> None:
26+
"""In-progress stage with count shows cyan name."""
27+
stage = ProgressStage(name='task', count=5, total=10)
28+
label = reporter_with_colors._format_stage_label(stage)
29+
30+
assert '[cyan]task[/cyan]' in label
31+
assert '5/10' in label
32+
assert '50.0%' in label
33+
34+
35+
def test_label_completed_with_colors(reporter_with_colors: RichConsoleReporter) -> None:
36+
"""Completed stage shows green name and checkmark."""
37+
stage = ProgressStage(name='done', count=10, total=10)
38+
stage.complete()
39+
label = reporter_with_colors._format_stage_label(stage)
40+
41+
assert '[green]done[/green]' in label
42+
assert '[green]✓[/green]' in label
43+
44+
45+
def test_label_started_no_count_with_colors(reporter_with_colors: RichConsoleReporter) -> None:
46+
"""Stage without count shows white name."""
47+
stage = ProgressStage(name='waiting')
48+
label = reporter_with_colors._format_stage_label(stage)
49+
50+
assert '[white]waiting[/white]' in label
51+
52+
53+
def test_label_without_colors(reporter_no_colors: RichConsoleReporter) -> None:
54+
"""Without colors, labels have no Rich markup."""
55+
stage = ProgressStage(name='task', count=5, total=10)
56+
label = reporter_no_colors._format_stage_label(stage)
57+
58+
assert '[' not in label or label.startswith('task [') # Only progress brackets, no color markup
59+
assert 'task' in label
60+
assert '5/10' in label
61+
62+
63+
def test_label_completed_without_colors(reporter_no_colors: RichConsoleReporter) -> None:
64+
"""Completed stage without colors shows plain checkmark."""
65+
stage = ProgressStage(name='done')
66+
stage.complete()
67+
label = reporter_no_colors._format_stage_label(stage)
68+
69+
assert label == 'done ✓'
70+
71+
72+
def test_percentage_display(reporter_with_colors: RichConsoleReporter) -> None:
73+
"""Percentage shown when show_percentages=True."""
74+
stage = ProgressStage(name='work', count=3, total=12)
75+
label = reporter_with_colors._format_stage_label(stage)
76+
77+
assert '25.0%' in label
78+
79+
80+
def test_no_percentage_display(reporter_no_percentages: RichConsoleReporter) -> None:
81+
"""Percentage hidden when show_percentages=False."""
82+
stage = ProgressStage(name='work', count=3, total=12)
83+
label = reporter_no_percentages._format_stage_label(stage)
84+
85+
assert '%' not in label
86+
assert '3/12' in label
87+
88+
89+
def test_count_only_no_total(reporter_with_colors: RichConsoleReporter) -> None:
90+
"""Count without total shows just the count."""
91+
stage = ProgressStage(name='items', count=42)
92+
label = reporter_with_colors._format_stage_label(stage)
93+
94+
assert '[42]' in label
95+
assert '42/' not in label
96+
97+
98+
def test_total_only_no_count(reporter_with_colors: RichConsoleReporter) -> None:
99+
"""Total without count shows 0/total."""
100+
stage = ProgressStage(name='pending', total=100)
101+
label = reporter_with_colors._format_stage_label(stage)
102+
103+
assert '[0/100]' in label
104+
105+
106+
def test_tree_builds_hierarchy(reporter_with_colors: RichConsoleReporter) -> None:
107+
"""Tree structure reflects stage hierarchy."""
108+
root = ProgressStage(name='root')
109+
root.add_child('child1')
110+
root.add_child('child2')
111+
112+
tree = reporter_with_colors._build_tree(root)
113+
114+
assert len(tree.children) == 2
115+

0 commit comments

Comments
 (0)