python-async-aware-progress-bar 0.1.0__py3-none-any.whl → 0.2.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 CHANGED
@@ -1,25 +1,30 @@
1
1
  """
2
2
  asyncprogress — Async-aware progress bar for Python asyncio applications.
3
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
4
+ Public API:
5
+ aprogress: Async generator wrapper for iterables
6
+ ProgressBar: Manual async context manager progress bar
7
+ MultiProgressBar: Concurrent progress bars manager
8
+ gather: Drop-in replacement for asyncio.gather() with progress
9
+ EWMACalculator: Exponential weighted moving average ETA calculator
10
+ aprogress_as_completed: Progress-tracked as_completed wrapper
10
11
  """
11
12
 
13
+ from __future__ import annotations
14
+
15
+ from asyncprogress._as_completed import aprogress_as_completed
12
16
  from asyncprogress._bar import ProgressBar
13
17
  from asyncprogress._eta import EWMACalculator
14
18
  from asyncprogress._gather import gather
15
19
  from asyncprogress._iterator import aprogress
16
20
  from asyncprogress._multi import MultiProgressBar
17
21
 
18
- __version__ = "0.1.0"
22
+ __version__ = "0.2.0"
19
23
  __all__ = [
20
24
  "aprogress",
21
25
  "ProgressBar",
22
26
  "MultiProgressBar",
23
27
  "gather",
24
28
  "EWMACalculator",
29
+ "aprogress_as_completed",
25
30
  ]
@@ -0,0 +1,93 @@
1
+ """aprogress_as_completed() — progress-tracked asyncio.as_completed wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from collections.abc import AsyncIterator, Coroutine
8
+ from typing import Any, TextIO, TypeVar
9
+
10
+ from asyncprogress._bar import ProgressBar
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ async def aprogress_as_completed(
16
+ coros: list[Coroutine[Any, Any, T]],
17
+ *,
18
+ description: str = "",
19
+ return_exceptions: bool = False,
20
+ bar_width: int = 40,
21
+ fill_char: str = "█",
22
+ empty_char: str = "░",
23
+ color: str | None = None,
24
+ show_eta: bool = True,
25
+ show_elapsed: bool = True,
26
+ show_count: bool = True,
27
+ show_percentage: bool = True,
28
+ update_interval: float = 0.1,
29
+ ewma_alpha: float = 0.3,
30
+ file: TextIO = sys.stderr,
31
+ disable: bool = False,
32
+ ) -> AsyncIterator[T]:
33
+ """
34
+ Async generator that yields results as coroutines complete, with a progress bar.
35
+
36
+ Drop-in replacement for ``asyncio.as_completed()`` iteration patterns.
37
+
38
+ Args:
39
+ coros: List of coroutines to run concurrently.
40
+ description: Label shown on the progress bar.
41
+ return_exceptions: If True, exceptions are yielded as values
42
+ rather than raised.
43
+ bar_width: Width of the bar graphic.
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.
53
+ file: Output file. Defaults to sys.stderr.
54
+ disable: If True, suppress all output.
55
+
56
+ Yields:
57
+ Results in completion order (not submission order).
58
+
59
+ Example:
60
+ async for result in aprogress_as_completed(coros, description="Fetching"):
61
+ process(result)
62
+ """
63
+ if not coros:
64
+ return
65
+
66
+ total = len(coros)
67
+ async with ProgressBar(
68
+ total=total,
69
+ description=description,
70
+ bar_width=bar_width,
71
+ fill_char=fill_char,
72
+ empty_char=empty_char,
73
+ color=color,
74
+ show_eta=show_eta,
75
+ show_elapsed=show_elapsed,
76
+ show_count=show_count,
77
+ show_percentage=show_percentage,
78
+ update_interval=update_interval,
79
+ ewma_alpha=ewma_alpha,
80
+ file=file,
81
+ disable=disable,
82
+ ) as bar:
83
+ for future in asyncio.as_completed(coros):
84
+ try:
85
+ result = await future
86
+ await bar.update()
87
+ yield result
88
+ except Exception as exc:
89
+ await bar.update()
90
+ if return_exceptions:
91
+ yield exc # type: ignore[misc]
92
+ else:
93
+ raise
asyncprogress/_bar.py CHANGED
@@ -1,4 +1,4 @@
1
- """Core ProgressBar class: async context manager with update scheduling."""
1
+ """Core ProgressBar class async context manager with state machine."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -9,28 +9,21 @@ from typing import TextIO
9
9
 
10
10
  from asyncprogress._eta import EWMACalculator
11
11
  from asyncprogress._renderer import BarRenderer
12
- from asyncprogress._terminal import ERASE_LINE, is_tty
12
+ from asyncprogress._terminal import erase_line, is_tty, move_cursor_up
13
13
 
14
14
 
15
15
  class ProgressBar:
16
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.
17
+ Async context manager progress bar.
18
+
19
+ Tracks progress of async operations and renders a live progress bar
20
+ to the terminal (or any file-like object).
21
+
22
+ Example:
23
+ async with ProgressBar(total=100, description="Processing") as bar:
24
+ for item in items:
25
+ await process(item)
26
+ await bar.update()
34
27
  """
35
28
 
36
29
  def __init__(
@@ -49,156 +42,176 @@ class ProgressBar:
49
42
  ewma_alpha: float = 0.3,
50
43
  file: TextIO = sys.stderr,
51
44
  disable: bool = False,
52
- _manager: object = None,
53
45
  ) -> 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
46
+ self._total = total
47
+ self._description = description
48
+ self._bar_width = bar_width
49
+ self._fill_char = fill_char
50
+ self._empty_char = empty_char
51
+ self._color = color
52
+ self._show_eta = show_eta
53
+ self._show_elapsed = show_elapsed
54
+ self._show_count = show_count
55
+ self._show_percentage = show_percentage
56
+ self._update_interval = update_interval
57
+ self._file = file
58
+ self._disable = disable
68
59
 
69
60
  self._completed: int = 0
70
- self._start_time: float = 0.0
61
+ self._start_time: float | None = None
71
62
  self._finished: bool = False
63
+ self._spinner_frame: int = 0
64
+
72
65
  self._ewma = EWMACalculator(alpha=ewma_alpha)
73
66
  self._renderer = BarRenderer()
67
+
74
68
  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)
69
+ self._last_line_count: int = 0
77
70
 
78
- async def __aenter__(self) -> "ProgressBar":
71
+ # Whether this bar is managed by a MultiProgressBar
72
+ self._managed: bool = False
73
+
74
+ async def __aenter__(self) -> ProgressBar:
79
75
  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
- )
76
+ if not self._disable and not self._managed:
77
+ self._render_task = asyncio.create_task(self._render_loop())
87
78
  return self
88
79
 
89
80
  async def __aexit__(self, *args: object) -> None:
90
81
  await self.finish()
82
+ if self._render_task is not None:
83
+ self._render_task.cancel()
84
+ try:
85
+ await self._render_task
86
+ except asyncio.CancelledError:
87
+ pass
88
+ self._render_task = None
91
89
 
92
90
  async def _render_loop(self) -> None:
93
91
  """Background task that periodically redraws the progress bar."""
94
92
  try:
95
93
  while not self._finished:
96
- self._render()
97
- await asyncio.sleep(self.update_interval)
98
- # Final render after finish
99
- self._render(final=True)
94
+ self._write_frame()
95
+ await asyncio.sleep(self._update_interval)
100
96
  except asyncio.CancelledError:
101
- self._render(final=True)
97
+ pass
102
98
 
103
- def _render(self, final: bool = False) -> None:
104
- """Render the progress bar to the output file."""
105
- if self.disable:
99
+ def _write_frame(self) -> None:
100
+ """Write a single frame to the output file."""
101
+ if self._disable:
106
102
  return
107
- line = self._renderer.render(
108
- description=self.description,
103
+ line = self._get_render_string()
104
+ self._erase_previous()
105
+ self._file.write(line + "\n")
106
+ self._file.flush()
107
+ self._last_line_count = 1
108
+
109
+ def _erase_previous(self) -> None:
110
+ """Erase previously written lines."""
111
+ if self._last_line_count > 0 and is_tty(self._file):
112
+ self._file.write(move_cursor_up(self._last_line_count))
113
+ self._file.write(erase_line())
114
+
115
+ def _get_render_string(self) -> str:
116
+ """Build the render string for the current state."""
117
+ if self._total is None:
118
+ self._spinner_frame += 1
119
+ return self._renderer.render_spinner(
120
+ description=self._description,
121
+ elapsed=self.elapsed,
122
+ completed=self._completed,
123
+ frame_index=self._spinner_frame,
124
+ rate=self.rate,
125
+ color=self._color,
126
+ show_elapsed=self._show_elapsed,
127
+ show_count=self._show_count,
128
+ )
129
+ return self._renderer.render(
130
+ description=self._description,
109
131
  completed=self._completed,
110
- total=self.total,
132
+ total=self._total,
111
133
  elapsed=self.elapsed,
112
134
  eta=self.eta,
113
135
  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,
136
+ bar_width=self._bar_width,
137
+ fill_char=self._fill_char,
138
+ empty_char=self._empty_char,
139
+ color=self._color,
140
+ show_eta=self._show_eta,
141
+ show_elapsed=self._show_elapsed,
142
+ show_count=self._show_count,
143
+ show_percentage=self._show_percentage,
122
144
  )
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()
145
+
146
+ async def _render_final(self) -> None:
147
+ """Write the final state of the progress bar."""
148
+ if self._disable:
149
+ return
150
+ line = self._get_render_string()
151
+ self._erase_previous()
152
+ self._file.write(line + "\n")
153
+ self._file.flush()
154
+ self._last_line_count = 1
137
155
 
138
156
  async def update(self, n: int = 1) -> None:
139
157
  """
140
158
  Increment progress by n steps.
141
159
 
142
160
  Args:
143
- n: Number of steps to increment.
161
+ n: Number of steps to increment. Default is 1.
144
162
  """
145
163
  self._completed += n
146
164
  now = time.monotonic()
147
165
  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)
166
+
167
+ if self._total is not None and self._completed >= self._total:
168
+ await self.finish()
153
169
 
154
170
  async def set(self, value: int) -> None:
155
171
  """
156
172
  Set absolute progress to value.
157
173
 
158
174
  Args:
159
- value: Absolute progress value.
175
+ value: Absolute progress value to set.
160
176
  """
161
177
  self._completed = value
162
178
  now = time.monotonic()
163
179
  self._ewma.record(now, self._completed)
164
180
 
181
+ if self._total is not None and self._completed >= self._total:
182
+ await self.finish()
183
+
165
184
  async def set_description(self, description: str) -> None:
166
185
  """
167
186
  Update the description text dynamically.
168
187
 
169
188
  Args:
170
- description: New description string.
189
+ description: New description label.
171
190
  """
172
- self.description = description
191
+ self._description = description
173
192
 
174
193
  async def finish(self) -> None:
175
194
  """Mark as complete regardless of current count."""
176
195
  if self._finished:
177
196
  return
178
197
  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)
198
+ if self._total is not None:
199
+ self._completed = self._total
200
+ await self._render_final()
188
201
 
189
202
  @property
190
203
  def elapsed(self) -> float:
191
204
  """Seconds since bar was started."""
192
- if self._start_time == 0.0:
205
+ if self._start_time is None:
193
206
  return 0.0
194
207
  return time.monotonic() - self._start_time
195
208
 
196
209
  @property
197
210
  def eta(self) -> float | None:
198
211
  """Estimated seconds remaining, or None if unknown."""
199
- if self.total is None:
212
+ if self._total is None:
200
213
  return None
201
- remaining = max(0, self.total - self._completed)
214
+ remaining = self._total - self._completed
202
215
  return self._ewma.eta(remaining)
203
216
 
204
217
  @property
@@ -206,7 +219,17 @@ class ProgressBar:
206
219
  """Current EWMA-smoothed items/second rate."""
207
220
  return self._ewma.rate()
208
221
 
222
+ @property
223
+ def description(self) -> str:
224
+ """The current description label for this progress bar."""
225
+ return self._description
226
+
209
227
  @property
210
228
  def completed(self) -> int:
211
- """Number of completed items."""
229
+ """Number of steps completed so far."""
212
230
  return self._completed
231
+
232
+ @property
233
+ def total(self) -> int | None:
234
+ """Total steps, or None if indeterminate."""
235
+ return self._total
asyncprogress/_eta.py CHANGED
@@ -1,27 +1,25 @@
1
- """EWMA-based ETA calculator for asyncprogress."""
1
+ """Exponential Weighted Moving Average (EWMA) calculator for ETA estimation."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
 
6
6
  class EWMACalculator:
7
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.
8
+ Calculates smoothed rate and ETA using Exponential Weighted Moving Average.
12
9
 
13
10
  Args:
14
- alpha: Smoothing factor. Higher values weight recent samples more.
15
- Recommended range: 0.1 (smooth) to 0.5 (reactive).
11
+ alpha: Smoothing factor between 0 and 1. Higher values give more
12
+ weight to recent samples. Recommended range: 0.1 (smooth)
13
+ to 0.5 (reactive). Default is 0.3.
16
14
  """
17
15
 
18
16
  def __init__(self, alpha: float = 0.3) -> None:
19
17
  if not (0 < alpha <= 1):
20
18
  raise ValueError(f"alpha must be in (0, 1], got {alpha}")
21
- self.alpha = alpha
19
+ self._alpha = alpha
22
20
  self._ewma_rate: float | None = None
23
21
  self._last_timestamp: float | None = None
24
- self._last_completed: int | None = None
22
+ self._last_completed: int = 0
25
23
 
26
24
  def record(self, timestamp: float, completed: int) -> None:
27
25
  """
@@ -31,47 +29,56 @@ class EWMACalculator:
31
29
  timestamp: Current time in seconds (e.g., from time.monotonic()).
32
30
  completed: Total number of items completed so far.
33
31
  """
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
- )
32
+ if self._last_timestamp is None:
33
+ self._last_timestamp = timestamp
34
+ self._last_completed = completed
35
+ return
36
+
37
+ dt = timestamp - self._last_timestamp
38
+ if dt <= 0:
39
+ return
40
+
41
+ delta_completed = completed - self._last_completed
42
+ if delta_completed < 0:
43
+ # Reset if progress went backwards
44
+ self.reset()
45
+ self._last_timestamp = timestamp
46
+ self._last_completed = completed
47
+ return
48
+
49
+ instant_rate = delta_completed / dt
50
+
51
+ if self._ewma_rate is None:
52
+ self._ewma_rate = instant_rate
53
+ else:
54
+ self._ewma_rate = (
55
+ self._alpha * instant_rate + (1 - self._alpha) * self._ewma_rate
56
+ )
57
+
46
58
  self._last_timestamp = timestamp
47
59
  self._last_completed = completed
48
60
 
49
61
  def rate(self) -> float | None:
50
62
  """
51
- Return smoothed items/second rate, or None if insufficient data.
52
-
53
- Returns:
54
- Smoothed rate in items/second, or None.
63
+ Returns smoothed items/second rate, or None if insufficient data.
55
64
  """
56
65
  return self._ewma_rate
57
66
 
58
67
  def eta(self, remaining: int) -> float | None:
59
68
  """
60
- Return estimated seconds to completion.
69
+ Returns estimated seconds to completion, or None if rate is unknown.
61
70
 
62
71
  Args:
63
- remaining: Number of items remaining.
64
-
65
- Returns:
66
- Estimated seconds remaining, or None if rate is unknown.
72
+ remaining: Number of items remaining to complete.
67
73
  """
68
- r = self._ewma_rate
69
- if r is None or r <= 0:
74
+ if self._ewma_rate is None or self._ewma_rate <= 0:
70
75
  return None
71
- return remaining / r
76
+ if remaining <= 0:
77
+ return 0.0
78
+ return remaining / self._ewma_rate
72
79
 
73
80
  def reset(self) -> None:
74
81
  """Reset all state."""
75
82
  self._ewma_rate = None
76
83
  self._last_timestamp = None
77
- self._last_completed = None
84
+ self._last_completed = 0