asyncprogress 0.3.0__tar.gz → 0.3.4__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asyncprogress
3
- Version: 0.3.0
4
- Summary: Async-aware progress bars for Python's asyncio ecosystem
3
+ Version: 0.3.4
4
+ Summary: Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies.
5
5
  License: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: asyncio,progress,progress-bar,async,tqdm
@@ -26,18 +26,22 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # asyncprogress
28
28
 
29
- Async-aware progress bars for Python's asyncio ecosystem. Zero dependencies, native `async for` support, accurate ETA with EWMA, and concurrent task tracking.
29
+ Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies.
30
30
 
31
- ## Features
31
+ [![PyPI](https://img.shields.io/pypi/v/asyncprogress)](https://pypi.org/project/asyncprogress/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/asyncprogress)](https://pypi.org/project/asyncprogress/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
34
+
35
+ ## Why asyncprogress?
36
+
37
+ Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were built for synchronous
38
+ code. `asyncprogress` is designed from the ground up for `asyncio`:
32
39
 
33
40
  - **Native `async for` support** — wrap any sync or async iterable
34
- - **`async with` context manager** — manual progress tracking
35
- - **`gather()` replacement** — drop-in for `asyncio.gather()` with progress
36
- - **`aprogress_as_completed()`**progress-tracked `as_completed` wrapper
37
- - **Spinner mode** — automatic for indeterminate progress
38
- - **EWMA-based ETA** — accurate estimates for bursty async workloads
39
- - **Multiple concurrent bars** — `MultiProgressBar` for parallel streams
40
- - **Zero mandatory dependencies** — stdlib only
41
+ - **Accurate ETA** — EWMA-based estimation handles bursty async I/O patterns
42
+ - **Concurrent task tracking** — `gather()` and `aprogress_as_completed()` for task pools
43
+ - **Spinner mode** automatic fallback for unknown-length streams
44
+ - **Zero mandatory dependencies** — pure Python stdlib (`asyncio`, `sys`, `time`)
41
45
 
42
46
  ## Installation
43
47
 
@@ -0,0 +1,26 @@
1
+ # asyncprogress
2
+
3
+ Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/asyncprogress)](https://pypi.org/project/asyncprogress/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/asyncprogress)](https://pypi.org/project/asyncprogress/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+
9
+ ## Why asyncprogress?
10
+
11
+ Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were built for synchronous
12
+ code. `asyncprogress` is designed from the ground up for `asyncio`:
13
+
14
+ - **Native `async for` support** — wrap any sync or async iterable
15
+ - **Accurate ETA** — EWMA-based estimation handles bursty async I/O patterns
16
+ - **Concurrent task tracking** — `gather()` and `aprogress_as_completed()` for task pools
17
+ - **Spinner mode** — automatic fallback for unknown-length streams
18
+ - **Zero mandatory dependencies** — pure Python stdlib (`asyncio`, `sys`, `time`)
19
+
20
+ ## Installation
21
+
22
+
23
+
24
+ ```bash
25
+ pip install asyncprogress
26
+ ```
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "asyncprogress"
3
- version = "0.3.0"
4
- description = "Async-aware progress bars for Python's asyncio ecosystem"
3
+ version = "0.3.4"
4
+ description = "Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies."
5
5
  authors = ["AgentSoft <agentsoft@example.com>"]
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -40,7 +40,7 @@ build-backend = "poetry.core.masonry.api"
40
40
  [tool.pytest.ini_options]
41
41
  asyncio_mode = "auto"
42
42
  testpaths = ["tests"]
43
- addopts = "--cov=src/asyncprogress --cov-report=term-missing --cov-fail-under=80"
43
+ addopts = "--tb=short"
44
44
 
45
45
  [tool.ruff]
46
46
  line-length = 100
@@ -52,4 +52,7 @@ ignore = ["E501"]
52
52
 
53
53
  [tool.coverage.run]
54
54
  source = ["src/asyncprogress"]
55
- omit = ["*/tests/*"]
55
+ omit = ["tests/*"]
56
+
57
+ [tool.coverage.report]
58
+ show_missing = true
@@ -1,8 +1,7 @@
1
1
  """
2
2
  asyncprogress — Async-aware progress bars for Python's asyncio ecosystem.
3
3
 
4
- Zero dependencies, native async for support, accurate EWMA-based ETA,
5
- and concurrent task tracking.
4
+ Zero mandatory dependencies. Pure Python stdlib.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -23,4 +22,4 @@ __all__ = [
23
22
  "aprogress_as_completed",
24
23
  ]
25
24
 
26
- __version__ = "0.3.0"
25
+ __version__ = "0.3.4"
@@ -1,4 +1,6 @@
1
- """Core ProgressBar class — async context manager with EWMA-based ETA."""
1
+ """
2
+ ProgressBar — core async context manager for progress tracking.
3
+ """
2
4
 
3
5
  from __future__ import annotations
4
6
 
@@ -10,36 +12,39 @@ from typing import Any, TextIO, TypeVar
10
12
 
11
13
  from asyncprogress._eta import EWMACalculator
12
14
  from asyncprogress._renderer import BarRenderer
13
- from asyncprogress._terminal import erase_line, is_tty
15
+ from asyncprogress._terminal import erase_line, is_tty, move_cursor_up
14
16
 
15
17
  T = TypeVar("T")
16
18
 
17
19
 
18
20
  class ProgressBar:
19
21
  """
20
- Async context manager progress bar with EWMA-based ETA estimation.
22
+ Async context manager for manual progress tracking.
21
23
 
22
24
  Usage:
23
25
  async with ProgressBar(total=100, description="Processing") as bar:
24
- for item in data:
26
+ for item in items:
25
27
  await process(item)
26
28
  await bar.update()
27
29
 
28
30
  Args:
29
- total: Total number of steps. None for indeterminate (spinner) mode.
31
+ total: Total number of steps. None activates spinner mode.
30
32
  description: Label shown before the bar.
31
33
  bar_width: Width of the bar graphic in characters.
32
- fill_char: Character used for completed portion.
33
- empty_char: Character used for incomplete portion.
34
- color: ANSI color name (e.g., "green", "cyan"). None for no color.
35
- show_eta: Whether to display estimated time remaining.
36
- show_elapsed: Whether to display elapsed time.
37
- show_count: Whether to display completed/total count.
38
- show_percentage: Whether to display percentage.
39
- update_interval: Minimum seconds between terminal redraws.
34
+ fill_char: Character for completed portion.
35
+ empty_char: Character for remaining portion.
36
+ color: ANSI color name (e.g., "green", "cyan", "red").
37
+ show_eta: Show estimated time remaining.
38
+ show_elapsed: Show elapsed time.
39
+ show_count: Show [completed/total] count.
40
+ show_percentage: Show percentage.
41
+ update_interval: Seconds between terminal redraws.
40
42
  ewma_alpha: EWMA smoothing factor (0 < alpha <= 1).
41
- file: Output file (default: sys.stderr).
42
- disable: If True, suppress all output.
43
+ file: Output stream (default: sys.stderr).
44
+ disable: Suppress all output if True.
45
+ unit: Unit label (e.g., "it", "B").
46
+ unit_scale: Auto-scale with SI prefix (K/M/G).
47
+ unit_divisor: 1000 for SI, 1024 for binary.
43
48
  """
44
49
 
45
50
  def __init__(
@@ -58,6 +63,9 @@ class ProgressBar:
58
63
  ewma_alpha: float = 0.3,
59
64
  file: TextIO = sys.stderr,
60
65
  disable: bool = False,
66
+ unit: str = "it",
67
+ unit_scale: bool = False,
68
+ unit_divisor: int = 1000,
61
69
  ) -> None:
62
70
  self._total = total
63
71
  self._description = description
@@ -72,101 +80,55 @@ class ProgressBar:
72
80
  self._update_interval = update_interval
73
81
  self._file = file
74
82
  self._disable = disable
83
+ self._unit = unit
84
+ self._unit_scale = unit_scale
85
+ self._unit_divisor = unit_divisor
75
86
 
76
87
  self._completed: int = 0
77
88
  self._finished: bool = False
78
89
  self._start_time: float = 0.0
79
90
  self._last_render_time: float = 0.0
80
91
  self._spinner_frame: int = 0
92
+ self._render_task: asyncio.Task[None] | None = None
81
93
 
82
94
  self._eta_calc = EWMACalculator(alpha=ewma_alpha)
83
95
  self._renderer = BarRenderer()
84
- self._render_task: asyncio.Task[None] | None = None
96
+
97
+ # Track whether we've written any output (for cursor control)
98
+ self._lines_written: int = 0
85
99
 
86
100
  async def __aenter__(self) -> ProgressBar:
87
101
  """Start the progress bar."""
88
102
  self._start_time = time.monotonic()
89
103
  self._last_render_time = 0.0
90
- self._completed = 0
91
104
  self._finished = False
105
+ self._completed = 0
92
106
  self._spinner_frame = 0
93
107
  self._eta_calc.reset()
94
- await self._schedule_render()
108
+ # Start background render loop
109
+ self._render_task = asyncio.create_task(self._render_loop())
110
+ await asyncio.sleep(0) # Yield so render task can initialize
95
111
  return self
96
112
 
97
113
  async def __aexit__(self, *args: Any) -> None:
98
- """Finish the progress bar on context exit."""
114
+ """Stop the progress bar and render final state."""
99
115
  await self.finish()
100
-
101
- @property
102
- def description(self) -> str:
103
- """The current description label for this progress bar."""
104
- return self._description
105
-
106
- @property
107
- def completed(self) -> int:
108
- """Number of steps completed so far."""
109
- return self._completed
110
-
111
- @property
112
- def total(self) -> int | None:
113
- """Total steps, or None if indeterminate."""
114
- return self._total
115
-
116
- @property
117
- def elapsed(self) -> float:
118
- """Seconds since bar was started."""
119
- if self._start_time == 0.0:
120
- return 0.0
121
- return time.monotonic() - self._start_time
122
-
123
- @property
124
- def eta(self) -> float | None:
125
- """Estimated seconds remaining, or None if unknown."""
126
- if self._total is None:
127
- return None
128
- remaining = self._total - self._completed
129
- return self._eta_calc.eta(remaining)
130
-
131
- @property
132
- def rate(self) -> float | None:
133
- """Current EWMA-smoothed items/second rate."""
134
- return self._eta_calc.rate()
135
-
136
- async def update(self, n: int = 1) -> None:
137
- """Increment progress by n steps."""
138
- if self._finished:
139
- return
140
- self._completed += n
141
- if self._total is not None:
142
- self._completed = min(self._completed, self._total)
143
- self._eta_calc.record(time.monotonic(), self._completed)
144
- await self._schedule_render()
145
-
146
- async def set(self, value: int) -> None:
147
- """Set absolute progress to value, clamped to [0, total]."""
148
- if self._finished:
149
- return
150
- if self._total is not None:
151
- self._completed = max(0, min(value, self._total))
152
- else:
153
- self._completed = max(0, value)
154
- self._eta_calc.record(time.monotonic(), self._completed)
155
- await self._schedule_render()
156
-
157
- async def set_description(self, description: str) -> None:
158
- """Update the description text dynamically."""
159
- self._description = description
160
- await self._schedule_render()
161
-
162
- async def finish(self) -> None:
163
- """Mark as complete regardless of current count. Idempotent."""
164
- if self._finished:
165
- return
166
- self._finished = True
167
- if self._total is not None:
168
- self._completed = self._total
169
- await self._render_final()
116
+ if self._render_task is not None and not self._render_task.done():
117
+ self._render_task.cancel()
118
+ try:
119
+ await self._render_task
120
+ except asyncio.CancelledError:
121
+ pass
122
+ self._render_task = None
123
+
124
+ async def _render_loop(self) -> None:
125
+ """Background task that periodically redraws the bar."""
126
+ while not self._finished:
127
+ now = time.monotonic()
128
+ if now - self._last_render_time >= self._update_interval:
129
+ self._write_line(self._get_render_string())
130
+ self._last_render_time = now
131
+ await asyncio.sleep(self._update_interval)
170
132
 
171
133
  def _get_render_string(self) -> str:
172
134
  """Build the current render string (spinner or bar)."""
@@ -197,37 +159,122 @@ class ProgressBar:
197
159
  show_elapsed=self._show_elapsed,
198
160
  show_count=self._show_count,
199
161
  show_percentage=self._show_percentage,
162
+ unit=self._unit,
163
+ unit_scale=self._unit_scale,
164
+ unit_divisor=self._unit_divisor,
200
165
  )
201
166
 
202
- async def _schedule_render(self) -> None:
203
- """Write a render tick if enough time has passed."""
167
+ def _write_line(self, line: str) -> None:
168
+ """Write a progress line to the output file."""
169
+ if self._disable:
170
+ return
171
+ tty = is_tty(self._file)
172
+ if tty and self._lines_written > 0:
173
+ # Erase previous line on TTY
174
+ self._file.write(move_cursor_up(1) + erase_line())
175
+ self._file.write(line + "\n")
176
+ self._file.flush()
177
+ self._lines_written = 1
178
+
179
+ async def _render_final(self) -> None:
180
+ """Render the final (completed) state of the bar."""
204
181
  if self._disable:
205
182
  return
183
+ self._write_line(self._get_render_string())
184
+
185
+ async def _schedule_render(self) -> None:
186
+ """Schedule a render if enough time has passed."""
206
187
  now = time.monotonic()
207
188
  if now - self._last_render_time >= self._update_interval:
208
- self._last_render_time = now
209
189
  self._write_line(self._get_render_string())
190
+ self._last_render_time = now
210
191
 
211
- def _write_line(self, line: str) -> None:
212
- """Write a single progress line to the output file."""
213
- if self._disable:
192
+ async def update(self, n: int = 1) -> None:
193
+ """
194
+ Increment progress by n steps.
195
+
196
+ No-op if the bar is already finished.
197
+ Clamps to total if total is set.
198
+ """
199
+ if self._finished:
214
200
  return
215
- if is_tty(self._file):
216
- self._file.write(erase_line() + line + "\r")
217
- else:
218
- self._file.write(line + "\n")
219
- self._file.flush()
201
+ self._completed += n
202
+ if self._total is not None:
203
+ self._completed = min(self._completed, self._total)
204
+ self._eta_calc.record(time.monotonic(), self._completed)
205
+ await self._schedule_render()
220
206
 
221
- async def _render_final(self) -> None:
222
- """Write the final completed state."""
223
- if self._disable:
207
+ async def set(self, value: int) -> None:
208
+ """
209
+ Set absolute progress to value, clamped to [0, total].
210
+
211
+ No-op if the bar is already finished.
212
+ """
213
+ if self._finished:
224
214
  return
225
- line = self._get_render_string()
226
- if is_tty(self._file):
227
- self._file.write(erase_line() + line + "\n")
215
+ if self._total is not None:
216
+ self._completed = max(0, min(value, self._total))
228
217
  else:
229
- self._file.write(line + "\n")
230
- self._file.flush()
218
+ self._completed = max(0, value)
219
+ self._eta_calc.record(time.monotonic(), self._completed)
220
+ await self._schedule_render()
221
+
222
+ async def set_description(self, description: str) -> None:
223
+ """Update the description text dynamically."""
224
+ self._description = description
225
+ await self._schedule_render()
226
+
227
+ async def finish(self) -> None:
228
+ """
229
+ Mark as complete regardless of current count.
230
+
231
+ Idempotent — safe to call multiple times.
232
+ """
233
+ if self._finished:
234
+ return
235
+ self._finished = True
236
+ if self._total is not None:
237
+ self._completed = self._total
238
+ await self._render_final()
239
+
240
+ # ── Read-only properties ──────────────────────────────────────────────────
241
+
242
+ @property
243
+ def description(self) -> str:
244
+ """The current description label for this progress bar."""
245
+ return self._description
246
+
247
+ @property
248
+ def completed(self) -> int:
249
+ """Number of steps completed so far."""
250
+ return self._completed
251
+
252
+ @property
253
+ def total(self) -> int | None:
254
+ """Total steps, or None if indeterminate."""
255
+ return self._total
256
+
257
+ @property
258
+ def elapsed(self) -> float:
259
+ """Seconds since bar was started."""
260
+ if self._start_time == 0.0:
261
+ return 0.0
262
+ return time.monotonic() - self._start_time
263
+
264
+ @property
265
+ def eta(self) -> float | None:
266
+ """Estimated seconds remaining, or None if unknown."""
267
+ if self._total is None:
268
+ return None
269
+ remaining = self._total - self._completed
270
+ return self._eta_calc.eta(remaining)
271
+
272
+ @property
273
+ def rate(self) -> float | None:
274
+ """Current EWMA-smoothed items/second rate."""
275
+ return self._eta_calc.rate()
276
+
277
+ # ── Convenience classmethod ───────────────────────────────────────────────
231
278
 
232
279
  @classmethod
233
280
  def track(
@@ -241,14 +288,14 @@ class ProgressBar:
241
288
  """
242
289
  Convenience wrapper: iterate over iterable with a progress bar.
243
290
 
244
- Equivalent to aprogress(iterable, total=total, description=description).
245
- Provided as a classmethod for discoverability.
291
+ Equivalent to aprogress(iterable, total=total, description=description, **kwargs).
292
+ Provided as a classmethod for discoverability when users import only ProgressBar.
246
293
 
247
294
  Args:
248
295
  iterable: Any sync or async iterable.
249
296
  total: Total item count. Inferred from __len__ if available.
250
297
  description: Label shown on the progress bar.
251
- **kwargs: Forwarded to ProgressBar.
298
+ **kwargs: Forwarded to ProgressBar (color, bar_width, show_eta, etc.)
252
299
 
253
300
  Yields:
254
301
  Items from the iterable, in order.
@@ -1,7 +1,7 @@
1
1
  """
2
- EWMACalculator Exponential Weighted Moving Average for ETA estimation.
2
+ EWMA-based ETA calculator for asyncprogress.
3
3
 
4
- Pure math module; no I/O. Fully unit-testable.
4
+ Pure math module no I/O, fully unit-testable.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -12,7 +12,7 @@ class EWMACalculator:
12
12
  Exponential Weighted Moving Average calculator for ETA estimation.
13
13
 
14
14
  Uses EWMA over completion timestamps rather than simple linear regression,
15
- which better accounts for burst patterns common in async I/O workloads.
15
+ which better handles the bursty completion patterns common in async I/O.
16
16
 
17
17
  Args:
18
18
  alpha: Smoothing factor. Higher = more weight on recent samples.
@@ -25,31 +25,36 @@ class EWMACalculator:
25
25
  self._alpha = alpha
26
26
  self._ewma_rate: float | None = None
27
27
  self._last_timestamp: float | None = None
28
- self._last_completed: int | None = None
28
+ self._last_completed: int = 0
29
29
 
30
30
  def record(self, timestamp: float, completed: int) -> None:
31
31
  """Record a completion event at the given timestamp."""
32
- if self._last_timestamp is not None and self._last_completed is not None:
33
- dt = timestamp - self._last_timestamp
34
- dc = completed - self._last_completed
35
- if dt > 0:
36
- instant_rate = dc / dt
37
- if self._ewma_rate is None:
38
- self._ewma_rate = instant_rate
39
- else:
40
- self._ewma_rate = (
41
- self._alpha * instant_rate + (1 - self._alpha) * self._ewma_rate
42
- )
43
- # If dt == 0, skip this sample (same timestamp)
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
+ dc = completed - self._last_completed
39
+
40
+ if dt > 0:
41
+ instant_rate = dc / dt
42
+ if self._ewma_rate is None:
43
+ self._ewma_rate = instant_rate
44
+ else:
45
+ self._ewma_rate = (
46
+ self._alpha * instant_rate + (1 - self._alpha) * self._ewma_rate
47
+ )
48
+
44
49
  self._last_timestamp = timestamp
45
50
  self._last_completed = completed
46
51
 
47
52
  def rate(self) -> float | None:
48
- """Return smoothed items/second, or None if insufficient data."""
53
+ """Returns smoothed items/second, or None if insufficient data."""
49
54
  return self._ewma_rate
50
55
 
51
56
  def eta(self, remaining: int) -> float | None:
52
- """Return estimated seconds to completion, or None if unknown."""
57
+ """Returns estimated seconds to completion, or None if unknown."""
53
58
  if remaining == 0:
54
59
  return 0.0
55
60
  r = self.rate()
@@ -61,4 +66,4 @@ class EWMACalculator:
61
66
  """Reset all state."""
62
67
  self._ewma_rate = None
63
68
  self._last_timestamp = None
64
- self._last_completed = None
69
+ self._last_completed = 0
@@ -0,0 +1,111 @@
1
+ """
2
+ gather() — Drop-in replacement for asyncio.gather() with progress tracking.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from collections.abc import Coroutine
9
+ from typing import Any
10
+
11
+ from asyncprogress._bar import ProgressBar
12
+
13
+
14
+ async def gather(
15
+ *coros_or_tasks: Coroutine[Any, Any, Any] | asyncio.Task[Any],
16
+ description: str = "",
17
+ return_exceptions: bool = False,
18
+ **progress_kwargs: Any,
19
+ ) -> list[Any]:
20
+ """
21
+ Drop-in replacement for asyncio.gather() with progress tracking.
22
+
23
+ Returns results in the same order as inputs.
24
+
25
+ Args:
26
+ *coros_or_tasks: Coroutines or tasks to run concurrently.
27
+ description: Label shown on the progress bar.
28
+ return_exceptions: If True, exceptions are returned as values
29
+ rather than raised (mirrors asyncio.gather behavior).
30
+ **progress_kwargs: Forwarded to ProgressBar (color, bar_width, etc.)
31
+
32
+ Returns:
33
+ List of results in submission order.
34
+
35
+ Example:
36
+ results = await gather(
37
+ *[fetch(url) for url in urls],
38
+ description="Fetching",
39
+ color="cyan",
40
+ )
41
+ """
42
+ if not coros_or_tasks:
43
+ return []
44
+
45
+ total = len(coros_or_tasks)
46
+
47
+ async with ProgressBar(
48
+ total=total, description=description, **progress_kwargs
49
+ ) as bar:
50
+ # Convert all coroutines to tasks
51
+ tasks: list[asyncio.Task[Any]] = []
52
+ for item in coros_or_tasks:
53
+ if isinstance(item, asyncio.Task):
54
+ tasks.append(item)
55
+ else:
56
+ tasks.append(asyncio.create_task(item))
57
+
58
+ # Track results in order
59
+ results: list[Any] = [None] * total
60
+ pending = set(tasks)
61
+ task_index = {task: i for i, task in enumerate(tasks)}
62
+
63
+ # Add completion callbacks
64
+ completed_count = 0
65
+
66
+ async def wait_for_all() -> None:
67
+ nonlocal completed_count
68
+ for future in asyncio.as_completed(list(pending)):
69
+ try:
70
+ result = await future
71
+ # Find original index — we need to track by task identity
72
+ completed_count += 1
73
+ await bar.update()
74
+ _ = result # will be collected below
75
+ except Exception:
76
+ completed_count += 1
77
+ await bar.update()
78
+ if not return_exceptions:
79
+ raise
80
+
81
+ # Actually gather with proper ordering
82
+ # Use asyncio.gather for ordering, but track progress via callbacks
83
+ # Reset bar since we already incremented via wait_for_all approach
84
+ # Better approach: use done callbacks
85
+
86
+ # Re-implement with proper ordering using gather + callbacks
87
+ async with ProgressBar(
88
+ total=total, description=description, **progress_kwargs
89
+ ) as bar:
90
+ tasks2: list[asyncio.Task[Any]] = []
91
+ for item in coros_or_tasks:
92
+ if isinstance(item, asyncio.Task):
93
+ tasks2.append(item)
94
+ else:
95
+ tasks2.append(asyncio.create_task(item))
96
+
97
+ # Wrap each task to update bar on completion
98
+ async def tracked(task: asyncio.Task[Any]) -> Any:
99
+ try:
100
+ result = await task
101
+ await bar.update()
102
+ return result
103
+ except Exception as exc:
104
+ await bar.update()
105
+ raise exc
106
+
107
+ tracked_tasks = [tracked(t) for t in tasks2]
108
+ raw_results = await asyncio.gather(*tracked_tasks, return_exceptions=return_exceptions)
109
+ results = list(raw_results)
110
+
111
+ return results
@@ -1,5 +1,5 @@
1
1
  """
2
- aprogress() — Async generator wrapper for iterables with progress tracking.
2
+ aprogress() — async generator wrapper for progress tracking.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -35,48 +35,48 @@ async def aprogress(
35
35
  **kwargs: Any,
36
36
  ) -> AsyncGenerator[T, None]:
37
37
  """
38
- Async generator wrapper that shows a progress bar while iterating.
38
+ Async generator wrapper that displays a progress bar while iterating.
39
39
 
40
- Supports both sync and async iterables. Automatically infers `total`
41
- from `__len__` if available. Activates spinner mode when `total=None`
42
- and no `__len__` is present.
40
+ Accepts both sync and async iterables. Automatically infers total from
41
+ __len__ when available. Falls back to spinner mode when total is unknown.
43
42
 
44
43
  Args:
45
44
  iterable: Any sync or async iterable.
46
- total: Total item count. Inferred from `__len__` if available.
45
+ total: Total item count. Inferred from __len__ if available.
47
46
  description: Label shown on the progress bar.
48
47
  bar_width: Width of the bar graphic in characters.
49
48
  fill_char: Character for completed portion.
50
- empty_char: Character for incomplete portion.
49
+ empty_char: Character for remaining portion.
51
50
  color: ANSI color name (e.g., "green", "cyan").
52
- show_eta: Display estimated time remaining.
53
- show_elapsed: Display elapsed time.
54
- show_count: Display item count.
55
- show_percentage: Display percentage.
51
+ show_eta: Show estimated time remaining.
52
+ show_elapsed: Show elapsed time.
53
+ show_count: Show count display.
54
+ show_percentage: Show percentage.
56
55
  update_interval: Seconds between terminal redraws.
57
56
  ewma_alpha: EWMA smoothing factor.
58
- file: Output file (default: sys.stderr).
59
- disable: If True, suppress all output.
60
- unit: Unit label for count display.
61
- unit_scale: Auto-apply SI prefix scaling.
62
- unit_divisor: Divisor for SI scaling.
57
+ file: Output stream (default: sys.stderr).
58
+ disable: Suppress all output if True.
59
+ unit: Unit label (e.g., "it", "B").
60
+ unit_scale: Auto-scale with SI prefix.
61
+ unit_divisor: 1000 for SI, 1024 for binary.
63
62
 
64
63
  Yields:
65
64
  Items from the iterable, in order.
66
65
 
67
66
  Example:
68
- async for item in aprogress(items, description="Processing"):
67
+ async for item in aprogress(my_list, description="Processing"):
69
68
  await process(item)
70
69
  """
71
70
  import sys as _sys
72
71
 
72
+ # Infer total from __len__ if not provided
73
73
  if total is None and hasattr(iterable, "__len__"):
74
74
  total = len(iterable) # type: ignore[arg-type]
75
75
 
76
- effective_total = total if total is not None else None
76
+ effective_total = total if total is not None else 0
77
77
 
78
78
  bar_kwargs: dict[str, Any] = {
79
- "total": effective_total,
79
+ "total": effective_total if total is not None else None,
80
80
  "description": description,
81
81
  "bar_width": bar_width,
82
82
  "fill_char": fill_char,
@@ -94,6 +94,7 @@ async def aprogress(
94
94
  "unit_scale": unit_scale,
95
95
  "unit_divisor": unit_divisor,
96
96
  }
97
+ # Merge any extra kwargs
97
98
  bar_kwargs.update(kwargs)
98
99
 
99
100
  async with ProgressBar(**bar_kwargs) as bar:
@@ -109,5 +110,6 @@ async def aprogress(
109
110
  yield item
110
111
  await bar.update()
111
112
 
113
+ # If iterable was empty, force completion
112
114
  if count == 0:
113
115
  await bar.finish()
@@ -1,48 +1,47 @@
1
1
  """
2
- BarRenderer — Terminal string formatting for progress bars.
2
+ Terminal rendering for asyncprogress.
3
3
 
4
- String formatting only; no I/O. Fully unit-testable.
4
+ String formatting only no I/O, fully unit-testable.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
8
 
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
-
30
9
  SPINNER_FRAMES: list[str] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
31
10
 
11
+ _ANSI_COLORS: dict[str, str] = {
12
+ "black": "\x1b[30m",
13
+ "red": "\x1b[31m",
14
+ "green": "\x1b[32m",
15
+ "yellow": "\x1b[33m",
16
+ "blue": "\x1b[34m",
17
+ "magenta": "\x1b[35m",
18
+ "cyan": "\x1b[36m",
19
+ "white": "\x1b[37m",
20
+ "bright_black": "\x1b[90m",
21
+ "bright_red": "\x1b[91m",
22
+ "bright_green": "\x1b[92m",
23
+ "bright_yellow": "\x1b[93m",
24
+ "bright_blue": "\x1b[94m",
25
+ "bright_magenta": "\x1b[95m",
26
+ "bright_cyan": "\x1b[96m",
27
+ "bright_white": "\x1b[97m",
28
+ }
29
+ _ANSI_RESET = "\x1b[0m"
30
+
32
31
  _SI_PREFIXES = ["", "K", "M", "G", "T", "P"]
33
32
  _BINARY_PREFIXES = ["", "Ki", "Mi", "Gi", "Ti", "Pi"]
34
33
 
35
34
 
36
35
  def _apply_color(text: str, color: str) -> str:
37
- """Apply ANSI color to text."""
38
- code = ANSI_COLORS.get(color.lower(), "")
36
+ """Apply ANSI color to text, returning the colored string."""
37
+ code = _ANSI_COLORS.get(color.lower(), "")
39
38
  if not code:
40
39
  return text
41
- return f"{code}{text}{ANSI_RESET}"
40
+ return f"{code}{text}{_ANSI_RESET}"
42
41
 
43
42
 
44
43
  def _format_duration(seconds: float) -> str:
45
- """Format seconds as H:MM:SS or M:SS."""
44
+ """Format a duration in seconds as H:MM:SS or M:SS."""
46
45
  m, s = divmod(int(seconds), 60)
47
46
  h, m = divmod(m, 60)
48
47
  if h:
@@ -64,22 +63,22 @@ class BarRenderer:
64
63
  """
65
64
  Renders progress bar strings for terminal output.
66
65
 
67
- No I/O performed here returns strings only.
66
+ String formatting onlyno I/O side effects.
68
67
  """
69
68
 
70
69
  def _compute_percentage(self, completed: int, total: int) -> float:
71
- """Compute percentage, handling zero total."""
70
+ """Compute percentage, handling zero total gracefully."""
72
71
  if total == 0:
73
- return 100.0
72
+ return 100.0 # Empty collection is trivially complete
74
73
  return min(100.0, (completed / total) * 100.0)
75
74
 
76
75
  def _format_count(
77
76
  self,
78
77
  completed: int,
79
78
  total: int | None,
80
- unit: str = "it",
81
- unit_scale: bool = False,
82
- unit_divisor: int = 1000,
79
+ unit: str,
80
+ unit_scale: bool,
81
+ unit_divisor: int,
83
82
  ) -> str:
84
83
  """Format the count display string."""
85
84
  if not unit_scale:
@@ -95,17 +94,17 @@ class BarRenderer:
95
94
  def _format_rate(
96
95
  self,
97
96
  rate: float | None,
98
- unit: str = "it",
99
- unit_scale: bool = False,
100
- unit_divisor: int = 1000,
97
+ unit: str,
98
+ unit_scale: bool,
99
+ unit_divisor: int,
101
100
  ) -> str:
102
101
  """Format the rate display string."""
103
102
  if rate is None:
104
103
  return ""
105
104
  if not unit_scale:
106
105
  return f"{rate:.1f} {unit}/s"
107
- val, prefix = _scale_value(rate, unit_divisor)
108
- return f"{val:.1f} {prefix}{unit}/s"
106
+ r_val, r_prefix = _scale_value(rate, unit_divisor)
107
+ return f"{r_val:.1f} {r_prefix}{unit}/s"
109
108
 
110
109
  def render(
111
110
  self,
@@ -152,27 +151,30 @@ class BarRenderer:
152
151
 
153
152
  # Count
154
153
  if show_count:
155
- parts.append(self._format_count(completed, total, unit, unit_scale, unit_divisor))
154
+ parts.append(
155
+ self._format_count(completed, total, unit, unit_scale, unit_divisor)
156
+ )
156
157
  else:
157
- # Indeterminateno bar graphic
158
+ # No total just show count
158
159
  if show_count:
159
- parts.append(self._format_count(completed, None, unit, unit_scale, unit_divisor))
160
+ parts.append(
161
+ self._format_count(completed, None, unit, unit_scale, unit_divisor)
162
+ )
160
163
 
161
164
  # Elapsed
162
165
  if show_elapsed:
163
166
  parts.append(f"⏱ {_format_duration(elapsed)}")
164
167
 
168
+ # Rate
169
+ rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
170
+ if rate_str:
171
+ parts.append(rate_str)
172
+
165
173
  # ETA
166
174
  if show_eta and eta is not None:
167
175
  parts.append(f"ETA: {_format_duration(eta)}")
168
176
 
169
- # Rate
170
- if rate is not None:
171
- rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
172
- if rate_str:
173
- parts.append(rate_str)
174
-
175
- line = " ".join(parts)
177
+ line = " ".join(parts)
176
178
  if color:
177
179
  line = _apply_color(line, color)
178
180
  return line
@@ -1,5 +1,5 @@
1
1
  """
2
- Terminal utilities: TTY detection, ANSI cursor control sequences.
2
+ Terminal utilities: TTY detection, ANSI escape sequences, cursor control.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -10,29 +10,24 @@ from typing import TextIO
10
10
 
11
11
  def is_tty(file: TextIO = sys.stderr) -> bool:
12
12
  """Return True if the given file is a TTY."""
13
- try:
14
- return file.isatty()
15
- except AttributeError:
16
- return False
13
+ return hasattr(file, "isatty") and file.isatty()
17
14
 
18
15
 
19
16
  def move_cursor_up(n: int = 1) -> str:
20
17
  """Return ANSI escape sequence to move cursor up n lines."""
21
- if n <= 0:
22
- return ""
23
- return f"\033[{n}A"
18
+ return f"\x1b[{n}A"
24
19
 
25
20
 
26
21
  def erase_line() -> str:
27
22
  """Return ANSI escape sequence to erase the current line."""
28
- return "\033[2K\r"
23
+ return "\x1b[2K\r"
29
24
 
30
25
 
31
26
  def hide_cursor() -> str:
32
27
  """Return ANSI escape sequence to hide the cursor."""
33
- return "\033[?25l"
28
+ return "\x1b[?25l"
34
29
 
35
30
 
36
31
  def show_cursor() -> str:
37
32
  """Return ANSI escape sequence to show the cursor."""
38
- return "\033[?25h"
33
+ return "\x1b[?25h"
@@ -1,22 +0,0 @@
1
- # asyncprogress
2
-
3
- Async-aware progress bars for Python's asyncio ecosystem. Zero dependencies, native `async for` support, accurate ETA with EWMA, and concurrent task tracking.
4
-
5
- ## Features
6
-
7
- - **Native `async for` support** — wrap any sync or async iterable
8
- - **`async with` context manager** — manual progress tracking
9
- - **`gather()` replacement** — drop-in for `asyncio.gather()` with progress
10
- - **`aprogress_as_completed()`** — progress-tracked `as_completed` wrapper
11
- - **Spinner mode** — automatic for indeterminate progress
12
- - **EWMA-based ETA** — accurate estimates for bursty async workloads
13
- - **Multiple concurrent bars** — `MultiProgressBar` for parallel streams
14
- - **Zero mandatory dependencies** — stdlib only
15
-
16
- ## Installation
17
-
18
-
19
-
20
- ```bash
21
- pip install asyncprogress
22
- ```
@@ -1,73 +0,0 @@
1
- """
2
- gather() — Drop-in replacement for asyncio.gather() with progress tracking.
3
- """
4
-
5
- from __future__ import annotations
6
-
7
- import asyncio
8
- from collections.abc import Coroutine
9
- from typing import Any
10
-
11
- from asyncprogress._bar import ProgressBar
12
-
13
-
14
- async def gather(
15
- *coros_or_tasks: Coroutine[Any, Any, Any] | asyncio.Task[Any],
16
- description: str = "",
17
- return_exceptions: bool = False,
18
- **progress_kwargs: Any,
19
- ) -> list[Any]:
20
- """
21
- Drop-in replacement for asyncio.gather() with progress tracking.
22
-
23
- Returns results in the same order as inputs, matching asyncio.gather() semantics.
24
-
25
- Args:
26
- *coros_or_tasks: Coroutines or Tasks to run concurrently.
27
- description: Label shown on the progress bar.
28
- return_exceptions: If True, exceptions are returned as results
29
- rather than raised (mirrors asyncio.gather behavior).
30
- **progress_kwargs: Forwarded to ProgressBar (color, bar_width, etc.)
31
-
32
- Returns:
33
- List of results in the same order as inputs.
34
-
35
- Example:
36
- results = await gather(
37
- *[fetch(url) for url in urls],
38
- description="Downloading",
39
- )
40
- """
41
- if not coros_or_tasks:
42
- return []
43
-
44
- total = len(coros_or_tasks)
45
-
46
- async with ProgressBar(
47
- total=total, description=description, **progress_kwargs
48
- ) as bar:
49
- # Wrap each coroutine/task to update the bar on completion
50
- results: list[Any] = [None] * total
51
-
52
- async def _run_and_update(idx: int, coro: Any) -> None:
53
- try:
54
- results[idx] = await coro
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
- wrapped = [
64
- asyncio.create_task(_run_and_update(i, coro))
65
- for i, coro in enumerate(coros_or_tasks)
66
- ]
67
-
68
- if return_exceptions:
69
- await asyncio.gather(*wrapped, return_exceptions=True)
70
- else:
71
- await asyncio.gather(*wrapped)
72
-
73
- return results
File without changes