python-async-aware-progress-bar 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- asyncprogress/__init__.py +25 -0
- asyncprogress/_bar.py +212 -0
- asyncprogress/_eta.py +77 -0
- asyncprogress/_gather.py +69 -0
- asyncprogress/_iterator.py +90 -0
- asyncprogress/_multi.py +144 -0
- asyncprogress/_renderer.py +102 -0
- asyncprogress/_terminal.py +71 -0
- asyncprogress/py.typed +0 -0
- python_async_aware_progress_bar-0.1.0.dist-info/METADATA +50 -0
- python_async_aware_progress_bar-0.1.0.dist-info/RECORD +13 -0
- python_async_aware_progress_bar-0.1.0.dist-info/WHEEL +4 -0
- python_async_aware_progress_bar-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
asyncprogress — Async-aware progress bar for Python asyncio applications.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- aprogress: async generator wrapper for iterables
|
|
6
|
+
- ProgressBar: manual async context manager
|
|
7
|
+
- MultiProgressBar: concurrent progress bars manager
|
|
8
|
+
- gather: drop-in asyncio.gather replacement with progress tracking
|
|
9
|
+
- EWMACalculator: exponential weighted moving average ETA engine
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from asyncprogress._bar import ProgressBar
|
|
13
|
+
from asyncprogress._eta import EWMACalculator
|
|
14
|
+
from asyncprogress._gather import gather
|
|
15
|
+
from asyncprogress._iterator import aprogress
|
|
16
|
+
from asyncprogress._multi import MultiProgressBar
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"aprogress",
|
|
21
|
+
"ProgressBar",
|
|
22
|
+
"MultiProgressBar",
|
|
23
|
+
"gather",
|
|
24
|
+
"EWMACalculator",
|
|
25
|
+
]
|
asyncprogress/_bar.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Core ProgressBar class: async context manager with update scheduling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
from asyncprogress._eta import EWMACalculator
|
|
11
|
+
from asyncprogress._renderer import BarRenderer
|
|
12
|
+
from asyncprogress._terminal import ERASE_LINE, is_tty
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProgressBar:
|
|
16
|
+
"""
|
|
17
|
+
Async context manager progress bar with EWMA-based ETA estimation.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
total: Total number of items, or None for indeterminate.
|
|
21
|
+
description: Label shown before the bar.
|
|
22
|
+
bar_width: Width of the bar graphic in characters.
|
|
23
|
+
fill_char: Character for completed portion.
|
|
24
|
+
empty_char: Character for remaining portion.
|
|
25
|
+
color: ANSI color name (e.g., "green", "cyan") or None.
|
|
26
|
+
show_eta: Whether to show ETA.
|
|
27
|
+
show_elapsed: Whether to show elapsed time.
|
|
28
|
+
show_count: Whether to show item count.
|
|
29
|
+
show_percentage: Whether to show percentage.
|
|
30
|
+
update_interval: Seconds between terminal redraws.
|
|
31
|
+
ewma_alpha: EWMA smoothing factor (0 < alpha ≤ 1).
|
|
32
|
+
file: Output file (default: sys.stderr).
|
|
33
|
+
disable: If True, suppress all output.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
total: int | None = None,
|
|
39
|
+
description: str = "",
|
|
40
|
+
bar_width: int = 40,
|
|
41
|
+
fill_char: str = "█",
|
|
42
|
+
empty_char: str = "░",
|
|
43
|
+
color: str | None = None,
|
|
44
|
+
show_eta: bool = True,
|
|
45
|
+
show_elapsed: bool = True,
|
|
46
|
+
show_count: bool = True,
|
|
47
|
+
show_percentage: bool = True,
|
|
48
|
+
update_interval: float = 0.1,
|
|
49
|
+
ewma_alpha: float = 0.3,
|
|
50
|
+
file: TextIO = sys.stderr,
|
|
51
|
+
disable: bool = False,
|
|
52
|
+
_manager: object = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.total = total
|
|
55
|
+
self.description = description
|
|
56
|
+
self.bar_width = bar_width
|
|
57
|
+
self.fill_char = fill_char
|
|
58
|
+
self.empty_char = empty_char
|
|
59
|
+
self.color = color
|
|
60
|
+
self.show_eta = show_eta
|
|
61
|
+
self.show_elapsed = show_elapsed
|
|
62
|
+
self.show_count = show_count
|
|
63
|
+
self.show_percentage = show_percentage
|
|
64
|
+
self.update_interval = update_interval
|
|
65
|
+
self.file = file
|
|
66
|
+
self.disable = disable
|
|
67
|
+
self._manager = _manager
|
|
68
|
+
|
|
69
|
+
self._completed: int = 0
|
|
70
|
+
self._start_time: float = 0.0
|
|
71
|
+
self._finished: bool = False
|
|
72
|
+
self._ewma = EWMACalculator(alpha=ewma_alpha)
|
|
73
|
+
self._renderer = BarRenderer()
|
|
74
|
+
self._render_task: asyncio.Task | None = None # type: ignore[type-arg]
|
|
75
|
+
self._last_render_time: float = 0.0
|
|
76
|
+
self._is_tty = is_tty(file)
|
|
77
|
+
|
|
78
|
+
async def __aenter__(self) -> "ProgressBar":
|
|
79
|
+
self._start_time = time.monotonic()
|
|
80
|
+
self._completed = 0
|
|
81
|
+
self._finished = False
|
|
82
|
+
self._ewma.reset()
|
|
83
|
+
if not self.disable and self._manager is None:
|
|
84
|
+
self._render_task = asyncio.get_event_loop().create_task(
|
|
85
|
+
self._render_loop()
|
|
86
|
+
)
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
async def __aexit__(self, *args: object) -> None:
|
|
90
|
+
await self.finish()
|
|
91
|
+
|
|
92
|
+
async def _render_loop(self) -> None:
|
|
93
|
+
"""Background task that periodically redraws the progress bar."""
|
|
94
|
+
try:
|
|
95
|
+
while not self._finished:
|
|
96
|
+
self._render()
|
|
97
|
+
await asyncio.sleep(self.update_interval)
|
|
98
|
+
# Final render after finish
|
|
99
|
+
self._render(final=True)
|
|
100
|
+
except asyncio.CancelledError:
|
|
101
|
+
self._render(final=True)
|
|
102
|
+
|
|
103
|
+
def _render(self, final: bool = False) -> None:
|
|
104
|
+
"""Render the progress bar to the output file."""
|
|
105
|
+
if self.disable:
|
|
106
|
+
return
|
|
107
|
+
line = self._renderer.render(
|
|
108
|
+
description=self.description,
|
|
109
|
+
completed=self._completed,
|
|
110
|
+
total=self.total,
|
|
111
|
+
elapsed=self.elapsed,
|
|
112
|
+
eta=self.eta,
|
|
113
|
+
rate=self.rate,
|
|
114
|
+
bar_width=self.bar_width,
|
|
115
|
+
fill_char=self.fill_char,
|
|
116
|
+
empty_char=self.empty_char,
|
|
117
|
+
color=self.color,
|
|
118
|
+
show_eta=self.show_eta,
|
|
119
|
+
show_elapsed=self.show_elapsed,
|
|
120
|
+
show_count=self.show_count,
|
|
121
|
+
show_percentage=self.show_percentage,
|
|
122
|
+
)
|
|
123
|
+
if self._is_tty:
|
|
124
|
+
self.file.write(f"{ERASE_LINE}{line}")
|
|
125
|
+
if final:
|
|
126
|
+
self.file.write("\n")
|
|
127
|
+
else:
|
|
128
|
+
# Non-TTY: print milestone percentages
|
|
129
|
+
if self.total and self.total > 0:
|
|
130
|
+
pct = int(self._completed / self.total * 100)
|
|
131
|
+
milestones = [10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 100]
|
|
132
|
+
if pct in milestones or final:
|
|
133
|
+
self.file.write(line + "\n")
|
|
134
|
+
elif final:
|
|
135
|
+
self.file.write(line + "\n")
|
|
136
|
+
self.file.flush()
|
|
137
|
+
|
|
138
|
+
async def update(self, n: int = 1) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Increment progress by n steps.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
n: Number of steps to increment.
|
|
144
|
+
"""
|
|
145
|
+
self._completed += n
|
|
146
|
+
now = time.monotonic()
|
|
147
|
+
self._ewma.record(now, self._completed)
|
|
148
|
+
if self._manager is not None:
|
|
149
|
+
# Managed bars delegate rendering to the manager
|
|
150
|
+
return
|
|
151
|
+
# Throttled direct render for non-managed bars without render loop
|
|
152
|
+
# (render loop handles periodic rendering; this is a no-op here)
|
|
153
|
+
|
|
154
|
+
async def set(self, value: int) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Set absolute progress to value.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
value: Absolute progress value.
|
|
160
|
+
"""
|
|
161
|
+
self._completed = value
|
|
162
|
+
now = time.monotonic()
|
|
163
|
+
self._ewma.record(now, self._completed)
|
|
164
|
+
|
|
165
|
+
async def set_description(self, description: str) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Update the description text dynamically.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
description: New description string.
|
|
171
|
+
"""
|
|
172
|
+
self.description = description
|
|
173
|
+
|
|
174
|
+
async def finish(self) -> None:
|
|
175
|
+
"""Mark as complete regardless of current count."""
|
|
176
|
+
if self._finished:
|
|
177
|
+
return
|
|
178
|
+
self._finished = True
|
|
179
|
+
if self._render_task is not None:
|
|
180
|
+
self._render_task.cancel()
|
|
181
|
+
try:
|
|
182
|
+
await self._render_task
|
|
183
|
+
except asyncio.CancelledError:
|
|
184
|
+
pass
|
|
185
|
+
self._render_task = None
|
|
186
|
+
if not self.disable and self._manager is None:
|
|
187
|
+
self._render(final=True)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def elapsed(self) -> float:
|
|
191
|
+
"""Seconds since bar was started."""
|
|
192
|
+
if self._start_time == 0.0:
|
|
193
|
+
return 0.0
|
|
194
|
+
return time.monotonic() - self._start_time
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def eta(self) -> float | None:
|
|
198
|
+
"""Estimated seconds remaining, or None if unknown."""
|
|
199
|
+
if self.total is None:
|
|
200
|
+
return None
|
|
201
|
+
remaining = max(0, self.total - self._completed)
|
|
202
|
+
return self._ewma.eta(remaining)
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def rate(self) -> float | None:
|
|
206
|
+
"""Current EWMA-smoothed items/second rate."""
|
|
207
|
+
return self._ewma.rate()
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def completed(self) -> int:
|
|
211
|
+
"""Number of completed items."""
|
|
212
|
+
return self._completed
|
asyncprogress/_eta.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""EWMA-based ETA calculator for asyncprogress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EWMACalculator:
|
|
7
|
+
"""
|
|
8
|
+
Exponential Weighted Moving Average calculator for ETA estimation.
|
|
9
|
+
|
|
10
|
+
Uses EWMA over completion timestamps to produce smooth rate estimates
|
|
11
|
+
that handle bursty async I/O patterns better than simple linear regression.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
alpha: Smoothing factor. Higher values weight recent samples more.
|
|
15
|
+
Recommended range: 0.1 (smooth) to 0.5 (reactive).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, alpha: float = 0.3) -> None:
|
|
19
|
+
if not (0 < alpha <= 1):
|
|
20
|
+
raise ValueError(f"alpha must be in (0, 1], got {alpha}")
|
|
21
|
+
self.alpha = alpha
|
|
22
|
+
self._ewma_rate: float | None = None
|
|
23
|
+
self._last_timestamp: float | None = None
|
|
24
|
+
self._last_completed: int | None = None
|
|
25
|
+
|
|
26
|
+
def record(self, timestamp: float, completed: int) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Record a completion event.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
timestamp: Current time in seconds (e.g., from time.monotonic()).
|
|
32
|
+
completed: Total number of items completed so far.
|
|
33
|
+
"""
|
|
34
|
+
if self._last_timestamp is not None and self._last_completed is not None:
|
|
35
|
+
dt = timestamp - self._last_timestamp
|
|
36
|
+
delta = completed - self._last_completed
|
|
37
|
+
if dt > 0 and delta >= 0:
|
|
38
|
+
instant_rate = delta / dt
|
|
39
|
+
if self._ewma_rate is None:
|
|
40
|
+
self._ewma_rate = instant_rate
|
|
41
|
+
else:
|
|
42
|
+
self._ewma_rate = (
|
|
43
|
+
self.alpha * instant_rate
|
|
44
|
+
+ (1 - self.alpha) * self._ewma_rate
|
|
45
|
+
)
|
|
46
|
+
self._last_timestamp = timestamp
|
|
47
|
+
self._last_completed = completed
|
|
48
|
+
|
|
49
|
+
def rate(self) -> float | None:
|
|
50
|
+
"""
|
|
51
|
+
Return smoothed items/second rate, or None if insufficient data.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Smoothed rate in items/second, or None.
|
|
55
|
+
"""
|
|
56
|
+
return self._ewma_rate
|
|
57
|
+
|
|
58
|
+
def eta(self, remaining: int) -> float | None:
|
|
59
|
+
"""
|
|
60
|
+
Return estimated seconds to completion.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
remaining: Number of items remaining.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Estimated seconds remaining, or None if rate is unknown.
|
|
67
|
+
"""
|
|
68
|
+
r = self._ewma_rate
|
|
69
|
+
if r is None or r <= 0:
|
|
70
|
+
return None
|
|
71
|
+
return remaining / r
|
|
72
|
+
|
|
73
|
+
def reset(self) -> None:
|
|
74
|
+
"""Reset all state."""
|
|
75
|
+
self._ewma_rate = None
|
|
76
|
+
self._last_timestamp = None
|
|
77
|
+
self._last_completed = None
|
asyncprogress/_gather.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""gather(): drop-in asyncio.gather replacement with progress tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Coroutine
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from asyncprogress._bar import ProgressBar
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def gather(
|
|
13
|
+
*coros_or_tasks: Coroutine[Any, Any, Any] | asyncio.Task[Any],
|
|
14
|
+
description: str = "",
|
|
15
|
+
return_exceptions: bool = False,
|
|
16
|
+
**progress_kwargs: Any,
|
|
17
|
+
) -> list[Any]:
|
|
18
|
+
"""
|
|
19
|
+
Drop-in replacement for asyncio.gather() with progress tracking.
|
|
20
|
+
|
|
21
|
+
Returns results in the same order as inputs.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
*coros_or_tasks: Coroutines or Tasks to execute concurrently.
|
|
25
|
+
description: Label shown on the progress bar.
|
|
26
|
+
return_exceptions: If True, exceptions are returned as results
|
|
27
|
+
rather than raised.
|
|
28
|
+
**progress_kwargs: Additional kwargs forwarded to ProgressBar.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
List of results in the same order as the input coroutines/tasks.
|
|
32
|
+
"""
|
|
33
|
+
n = len(coros_or_tasks)
|
|
34
|
+
bar = ProgressBar(
|
|
35
|
+
total=n,
|
|
36
|
+
description=description,
|
|
37
|
+
**progress_kwargs,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
results: list[Any] = [None] * n
|
|
41
|
+
|
|
42
|
+
async with bar:
|
|
43
|
+
tasks: list[asyncio.Task[Any]] = []
|
|
44
|
+
for coro_or_task in coros_or_tasks:
|
|
45
|
+
if isinstance(coro_or_task, asyncio.Task):
|
|
46
|
+
tasks.append(coro_or_task)
|
|
47
|
+
else:
|
|
48
|
+
tasks.append(asyncio.create_task(coro_or_task))
|
|
49
|
+
|
|
50
|
+
async def _run_and_update(
|
|
51
|
+
idx: int, task: asyncio.Task[Any]
|
|
52
|
+
) -> None:
|
|
53
|
+
try:
|
|
54
|
+
results[idx] = await task
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
if return_exceptions:
|
|
57
|
+
results[idx] = exc
|
|
58
|
+
else:
|
|
59
|
+
raise
|
|
60
|
+
finally:
|
|
61
|
+
await bar.update()
|
|
62
|
+
|
|
63
|
+
wrappers = [
|
|
64
|
+
asyncio.create_task(_run_and_update(i, t))
|
|
65
|
+
for i, t in enumerate(tasks)
|
|
66
|
+
]
|
|
67
|
+
await asyncio.gather(*wrappers, return_exceptions=return_exceptions)
|
|
68
|
+
|
|
69
|
+
return results
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""aprogress(): async generator wrapper for sync and async iterables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import AsyncIterable, AsyncIterator, Iterable
|
|
7
|
+
from typing import TypeVar, Union
|
|
8
|
+
|
|
9
|
+
from asyncprogress._bar import ProgressBar
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def aprogress(
|
|
15
|
+
iterable: Union[AsyncIterable[T], Iterable[T]],
|
|
16
|
+
*,
|
|
17
|
+
total: int | None = None,
|
|
18
|
+
description: str = "",
|
|
19
|
+
bar_width: int = 40,
|
|
20
|
+
fill_char: str = "█",
|
|
21
|
+
empty_char: str = "░",
|
|
22
|
+
color: str | None = None,
|
|
23
|
+
show_eta: bool = True,
|
|
24
|
+
show_elapsed: bool = True,
|
|
25
|
+
show_count: bool = True,
|
|
26
|
+
show_percentage: bool = True,
|
|
27
|
+
update_interval: float = 0.1,
|
|
28
|
+
ewma_alpha: float = 0.3,
|
|
29
|
+
file: object = sys.stderr,
|
|
30
|
+
disable: bool = False,
|
|
31
|
+
) -> AsyncIterator[T]:
|
|
32
|
+
"""
|
|
33
|
+
Async generator wrapper that tracks progress over an iterable.
|
|
34
|
+
|
|
35
|
+
Supports both sync iterables (e.g., lists, ranges) and async iterables
|
|
36
|
+
(e.g., async generators). Auto-detects total from ``__len__`` if not
|
|
37
|
+
provided.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
iterable: Any sync or async iterable to wrap.
|
|
41
|
+
total: Total items. Auto-detected from ``__len__`` if available.
|
|
42
|
+
description: Label shown before the bar.
|
|
43
|
+
bar_width: Width of the bar graphic in characters.
|
|
44
|
+
fill_char: Character for completed portion.
|
|
45
|
+
empty_char: Character for remaining portion.
|
|
46
|
+
color: ANSI color name or None.
|
|
47
|
+
show_eta: Whether to show ETA.
|
|
48
|
+
show_elapsed: Whether to show elapsed time.
|
|
49
|
+
show_count: Whether to show item count.
|
|
50
|
+
show_percentage: Whether to show percentage.
|
|
51
|
+
update_interval: Seconds between terminal redraws.
|
|
52
|
+
ewma_alpha: EWMA smoothing factor (0 < alpha ≤ 1).
|
|
53
|
+
file: Output file (default: sys.stderr).
|
|
54
|
+
disable: If True, suppress all output.
|
|
55
|
+
|
|
56
|
+
Yields:
|
|
57
|
+
Items from the wrapped iterable.
|
|
58
|
+
"""
|
|
59
|
+
# Auto-detect total from __len__
|
|
60
|
+
if total is None and hasattr(iterable, "__len__"):
|
|
61
|
+
total = len(iterable) # type: ignore[arg-type]
|
|
62
|
+
|
|
63
|
+
from typing import TextIO # noqa: PLC0415
|
|
64
|
+
|
|
65
|
+
bar = ProgressBar(
|
|
66
|
+
total=total,
|
|
67
|
+
description=description,
|
|
68
|
+
bar_width=bar_width,
|
|
69
|
+
fill_char=fill_char,
|
|
70
|
+
empty_char=empty_char,
|
|
71
|
+
color=color,
|
|
72
|
+
show_eta=show_eta,
|
|
73
|
+
show_elapsed=show_elapsed,
|
|
74
|
+
show_count=show_count,
|
|
75
|
+
show_percentage=show_percentage,
|
|
76
|
+
update_interval=update_interval,
|
|
77
|
+
ewma_alpha=ewma_alpha,
|
|
78
|
+
file=file, # type: ignore[arg-type]
|
|
79
|
+
disable=disable,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async with bar:
|
|
83
|
+
if isinstance(iterable, AsyncIterable):
|
|
84
|
+
async for item in iterable:
|
|
85
|
+
yield item
|
|
86
|
+
await bar.update()
|
|
87
|
+
else:
|
|
88
|
+
for item in iterable: # type: ignore[union-attr]
|
|
89
|
+
yield item
|
|
90
|
+
await bar.update()
|
asyncprogress/_multi.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""MultiProgressBar: manages multiple concurrent progress bars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from asyncprogress._bar import ProgressBar
|
|
10
|
+
from asyncprogress._terminal import ERASE_LINE, is_tty, move_cursor_up
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MultiProgressBar:
|
|
14
|
+
"""
|
|
15
|
+
Manages multiple concurrent progress bars with a shared render loop.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
file: Output file (default: sys.stderr).
|
|
19
|
+
update_interval: Seconds between terminal redraws.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
file: TextIO = sys.stderr,
|
|
25
|
+
update_interval: float = 0.1,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.file = file
|
|
28
|
+
self.update_interval = update_interval
|
|
29
|
+
self._bars: list[ProgressBar] = []
|
|
30
|
+
self._render_task: asyncio.Task | None = None # type: ignore[type-arg]
|
|
31
|
+
self._is_tty = is_tty(file)
|
|
32
|
+
self._lines_written: int = 0
|
|
33
|
+
|
|
34
|
+
async def __aenter__(self) -> "MultiProgressBar":
|
|
35
|
+
self._bars = []
|
|
36
|
+
self._lines_written = 0
|
|
37
|
+
self._render_task = asyncio.get_event_loop().create_task(
|
|
38
|
+
self._render_loop()
|
|
39
|
+
)
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, *args: object) -> None:
|
|
43
|
+
if self._render_task is not None:
|
|
44
|
+
self._render_task.cancel()
|
|
45
|
+
try:
|
|
46
|
+
await self._render_task
|
|
47
|
+
except asyncio.CancelledError:
|
|
48
|
+
pass
|
|
49
|
+
self._render_task = None
|
|
50
|
+
# Final render
|
|
51
|
+
self._render_all(final=True)
|
|
52
|
+
|
|
53
|
+
async def _render_loop(self) -> None:
|
|
54
|
+
"""Background task that periodically redraws all progress bars."""
|
|
55
|
+
try:
|
|
56
|
+
while True:
|
|
57
|
+
self._render_all()
|
|
58
|
+
await asyncio.sleep(self.update_interval)
|
|
59
|
+
except asyncio.CancelledError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def _render_all(self, final: bool = False) -> None:
|
|
63
|
+
"""Render all bars to the output file."""
|
|
64
|
+
if not self._bars:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
lines: list[str] = []
|
|
68
|
+
for bar in self._bars:
|
|
69
|
+
import time # noqa: PLC0415
|
|
70
|
+
|
|
71
|
+
bar._start_time = bar._start_time or time.monotonic()
|
|
72
|
+
line = bar._renderer.render(
|
|
73
|
+
description=bar.description,
|
|
74
|
+
completed=bar._completed,
|
|
75
|
+
total=bar.total,
|
|
76
|
+
elapsed=bar.elapsed,
|
|
77
|
+
eta=bar.eta,
|
|
78
|
+
rate=bar.rate,
|
|
79
|
+
bar_width=bar.bar_width,
|
|
80
|
+
fill_char=bar.fill_char,
|
|
81
|
+
empty_char=bar.empty_char,
|
|
82
|
+
color=bar.color,
|
|
83
|
+
show_eta=bar.show_eta,
|
|
84
|
+
show_elapsed=bar.show_elapsed,
|
|
85
|
+
show_count=bar.show_count,
|
|
86
|
+
show_percentage=bar.show_percentage,
|
|
87
|
+
)
|
|
88
|
+
lines.append(line)
|
|
89
|
+
|
|
90
|
+
if self._is_tty and self._lines_written > 0:
|
|
91
|
+
# Move cursor up to overwrite previous output
|
|
92
|
+
self.file.write(move_cursor_up(self._lines_written))
|
|
93
|
+
|
|
94
|
+
output = ""
|
|
95
|
+
for line in lines:
|
|
96
|
+
if self._is_tty:
|
|
97
|
+
output += f"{ERASE_LINE}{line}\n"
|
|
98
|
+
else:
|
|
99
|
+
output += line + "\n"
|
|
100
|
+
|
|
101
|
+
self.file.write(output)
|
|
102
|
+
self.file.flush()
|
|
103
|
+
self._lines_written = len(lines)
|
|
104
|
+
|
|
105
|
+
def add(
|
|
106
|
+
self,
|
|
107
|
+
description: str = "",
|
|
108
|
+
total: int | None = None,
|
|
109
|
+
**bar_kwargs: object,
|
|
110
|
+
) -> ProgressBar:
|
|
111
|
+
"""
|
|
112
|
+
Register a new progress bar.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
description: Label for this bar.
|
|
116
|
+
total: Total items, or None for indeterminate.
|
|
117
|
+
**bar_kwargs: Additional kwargs forwarded to ProgressBar.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
A ProgressBar instance managed by this MultiProgressBar.
|
|
121
|
+
"""
|
|
122
|
+
import time # noqa: PLC0415
|
|
123
|
+
|
|
124
|
+
bar = ProgressBar(
|
|
125
|
+
total=total,
|
|
126
|
+
description=description,
|
|
127
|
+
file=self.file,
|
|
128
|
+
disable=False,
|
|
129
|
+
_manager=self,
|
|
130
|
+
**bar_kwargs,
|
|
131
|
+
)
|
|
132
|
+
bar._start_time = time.monotonic()
|
|
133
|
+
self._bars.append(bar)
|
|
134
|
+
return bar
|
|
135
|
+
|
|
136
|
+
def remove(self, bar: ProgressBar) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Remove a bar from the display.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
bar: The ProgressBar instance to remove.
|
|
142
|
+
"""
|
|
143
|
+
if bar in self._bars:
|
|
144
|
+
self._bars.remove(bar)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Bar rendering: string formatting for progress bars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from asyncprogress._terminal import colorize
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _format_time(seconds: float) -> str:
|
|
9
|
+
"""Format seconds as H:MM:SS or M:SS."""
|
|
10
|
+
seconds = int(seconds)
|
|
11
|
+
h = seconds // 3600
|
|
12
|
+
m = (seconds % 3600) // 60
|
|
13
|
+
s = seconds % 60
|
|
14
|
+
if h > 0:
|
|
15
|
+
return f"{h}:{m:02d}:{s:02d}"
|
|
16
|
+
return f"{m}:{s:02d}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BarRenderer:
|
|
20
|
+
"""
|
|
21
|
+
Renders a single-line progress bar string.
|
|
22
|
+
|
|
23
|
+
All methods are pure (no I/O), making this fully unit-testable.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def render(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
description: str,
|
|
30
|
+
completed: int,
|
|
31
|
+
total: int | None,
|
|
32
|
+
elapsed: float,
|
|
33
|
+
eta: float | None,
|
|
34
|
+
rate: float | None,
|
|
35
|
+
bar_width: int,
|
|
36
|
+
fill_char: str,
|
|
37
|
+
empty_char: str,
|
|
38
|
+
color: str | None,
|
|
39
|
+
show_eta: bool,
|
|
40
|
+
show_elapsed: bool,
|
|
41
|
+
show_count: bool,
|
|
42
|
+
show_percentage: bool,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Render a single-line progress bar string.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
description: Label text shown before the bar.
|
|
49
|
+
completed: Number of completed items.
|
|
50
|
+
total: Total items, or None for indeterminate.
|
|
51
|
+
elapsed: Seconds elapsed since start.
|
|
52
|
+
eta: Estimated seconds remaining, or None.
|
|
53
|
+
rate: Current items/second rate, or None.
|
|
54
|
+
bar_width: Width of the bar graphic in characters.
|
|
55
|
+
fill_char: Character used for completed portion.
|
|
56
|
+
empty_char: Character used for remaining portion.
|
|
57
|
+
color: ANSI color name or None.
|
|
58
|
+
show_eta: Whether to show ETA.
|
|
59
|
+
show_elapsed: Whether to show elapsed time.
|
|
60
|
+
show_count: Whether to show item count.
|
|
61
|
+
show_percentage: Whether to show percentage.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A single-line string ready for terminal output.
|
|
65
|
+
"""
|
|
66
|
+
parts: list[str] = []
|
|
67
|
+
|
|
68
|
+
if description:
|
|
69
|
+
parts.append(description)
|
|
70
|
+
|
|
71
|
+
if total is not None and total > 0:
|
|
72
|
+
fraction = min(completed / total, 1.0)
|
|
73
|
+
filled = int(bar_width * fraction)
|
|
74
|
+
empty = bar_width - filled
|
|
75
|
+
bar_str = fill_char * filled + empty_char * empty
|
|
76
|
+
bar_str = colorize(f"[{bar_str}]", color)
|
|
77
|
+
parts.append(bar_str)
|
|
78
|
+
|
|
79
|
+
if show_percentage:
|
|
80
|
+
parts.append(f"{fraction * 100:.0f}%")
|
|
81
|
+
|
|
82
|
+
if show_count:
|
|
83
|
+
parts.append(f"[{completed}/{total}]")
|
|
84
|
+
else:
|
|
85
|
+
# Indeterminate mode — spinning indicator
|
|
86
|
+
spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
87
|
+
idx = int(elapsed * 10) % len(spinner_chars)
|
|
88
|
+
spinner = spinner_chars[idx]
|
|
89
|
+
parts.append(colorize(spinner, color))
|
|
90
|
+
if show_count:
|
|
91
|
+
parts.append(f"[{completed}]")
|
|
92
|
+
|
|
93
|
+
if show_elapsed:
|
|
94
|
+
parts.append(f"⏱ {_format_time(elapsed)}")
|
|
95
|
+
|
|
96
|
+
if show_eta:
|
|
97
|
+
if eta is not None:
|
|
98
|
+
parts.append(f"ETA: {_format_time(eta)}")
|
|
99
|
+
elif total is not None:
|
|
100
|
+
parts.append("ETA: ?")
|
|
101
|
+
|
|
102
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Terminal utilities: TTY detection, ANSI escape sequences, cursor control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
# ANSI color codes
|
|
9
|
+
ANSI_COLORS: dict[str, str] = {
|
|
10
|
+
"black": "\033[30m",
|
|
11
|
+
"red": "\033[31m",
|
|
12
|
+
"green": "\033[32m",
|
|
13
|
+
"yellow": "\033[33m",
|
|
14
|
+
"blue": "\033[34m",
|
|
15
|
+
"magenta": "\033[35m",
|
|
16
|
+
"cyan": "\033[36m",
|
|
17
|
+
"white": "\033[37m",
|
|
18
|
+
"bright_black": "\033[90m",
|
|
19
|
+
"bright_red": "\033[91m",
|
|
20
|
+
"bright_green": "\033[92m",
|
|
21
|
+
"bright_yellow": "\033[93m",
|
|
22
|
+
"bright_blue": "\033[94m",
|
|
23
|
+
"bright_magenta": "\033[95m",
|
|
24
|
+
"bright_cyan": "\033[96m",
|
|
25
|
+
"bright_white": "\033[97m",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ANSI_RESET = "\033[0m"
|
|
29
|
+
CURSOR_UP = "\033[{n}A"
|
|
30
|
+
ERASE_LINE = "\033[2K\r"
|
|
31
|
+
CURSOR_TO_COL0 = "\r"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_tty(file: TextIO = sys.stderr) -> bool:
|
|
35
|
+
"""Return True if the given file is a TTY."""
|
|
36
|
+
return hasattr(file, "isatty") and file.isatty()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def colorize(text: str, color: str | None) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Wrap text with ANSI color codes.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
text: The text to colorize.
|
|
45
|
+
color: Color name (e.g., "green", "cyan") or None for no color.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Colorized string, or original string if color is None/unknown.
|
|
49
|
+
"""
|
|
50
|
+
if color is None:
|
|
51
|
+
return text
|
|
52
|
+
code = ANSI_COLORS.get(color.lower())
|
|
53
|
+
if code is None:
|
|
54
|
+
return text
|
|
55
|
+
return f"{code}{text}{ANSI_RESET}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def move_cursor_up(n: int) -> str:
|
|
59
|
+
"""Return ANSI escape sequence to move cursor up n lines."""
|
|
60
|
+
return CURSOR_UP.format(n=n)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def erase_line() -> str:
|
|
64
|
+
"""Return ANSI escape sequence to erase current line."""
|
|
65
|
+
return ERASE_LINE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_line(file: TextIO, text: str) -> None:
|
|
69
|
+
"""Write a line to file, handling TTY vs non-TTY."""
|
|
70
|
+
file.write(text)
|
|
71
|
+
file.flush()
|
asyncprogress/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-async-aware-progress-bar
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async-aware progress bar for Python asyncio applications
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: asyncio,progress,progress-bar,async,tqdm
|
|
8
|
+
Author: AgentSoft
|
|
9
|
+
Author-email: agentsoft@example.com
|
|
10
|
+
Requires-Python: >=3.9,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Provides-Extra: color
|
|
25
|
+
Requires-Dist: colorama (>=0.4.4) ; extra == "color"
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# asyncprogress
|
|
29
|
+
|
|
30
|
+
An async-aware progress bar library for Python's asyncio ecosystem.
|
|
31
|
+
|
|
32
|
+
[](https://python.org)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
## Overview
|
|
36
|
+
|
|
37
|
+
`asyncprogress` provides first-class progress bar support for async Python code. Unlike existing solutions (tqdm, rich, alive-progress), it is designed from the ground up for `asyncio` with:
|
|
38
|
+
|
|
39
|
+
- Native `async for` support
|
|
40
|
+
- Accurate ETA using Exponential Weighted Moving Average (EWMA)
|
|
41
|
+
- Concurrent task pool tracking
|
|
42
|
+
- `await`-able context managers
|
|
43
|
+
- Multiple concurrent progress bars
|
|
44
|
+
- Zero mandatory runtime dependencies
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
### Using pip
|
|
49
|
+
|
|
50
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
asyncprogress/__init__.py,sha256=IDj9XPQWal7ek25D5aD9jufJObsqIsaogWBcu6nypFc,756
|
|
2
|
+
asyncprogress/_bar.py,sha256=d1alXhxYsGnOsTqkRJ5VkV_9yCls7IMx8kFMTaEV5Dc,7061
|
|
3
|
+
asyncprogress/_eta.py,sha256=Myx6LRU8FB2XV0jqfoLRvWn-Wd0tHJx5X4qWQEggpYU,2536
|
|
4
|
+
asyncprogress/_gather.py,sha256=KpsV7cbs8UDGc8L1S07ajn9I8wbrafAS0Yx1ZoKHTio,2021
|
|
5
|
+
asyncprogress/_iterator.py,sha256=ehkP1exBYKl38xo9HpBaujXm5KLLENf40ujybybrhe8,2874
|
|
6
|
+
asyncprogress/_multi.py,sha256=r5O2UqqTsbf6s_Of_rKzIONo3Ui1f1OnbFU_eTSrDbw,4282
|
|
7
|
+
asyncprogress/_renderer.py,sha256=y9fWpn0ZI6isl_bEN5cS1qbjbusuqS1JNbVwvZ5uSbs,3205
|
|
8
|
+
asyncprogress/_terminal.py,sha256=8Z59rt5-nBZxA8HzXDOZaB8nljVg6mygnJT_lKhU8B4,1774
|
|
9
|
+
asyncprogress/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
python_async_aware_progress_bar-0.1.0.dist-info/METADATA,sha256=1fN_RyrIQRydjVqYJNq_JxnztMM462CypMtT9PvUyf4,1762
|
|
11
|
+
python_async_aware_progress_bar-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
12
|
+
python_async_aware_progress_bar-0.1.0.dist-info/licenses/LICENSE,sha256=6Y47RSQ-wXvZrjkkdspmKQ7pAlT-Qq6N6eWBjFqOjoo,1066
|
|
13
|
+
python_async_aware_progress_bar-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 AgentSoft
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|