asyncprogress 0.3.4__tar.gz → 0.3.6__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,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asyncprogress
3
- Version: 0.3.4
4
- Summary: Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies.
3
+ Version: 0.3.6
4
+ Summary: Async-aware progress bars for Python's asyncio ecosystem
5
5
  License: MIT
6
6
  License-File: LICENSE
7
- Keywords: asyncio,progress,progress-bar,async,tqdm
7
+ Keywords: async,asyncio,progress,progress-bar,tqdm
8
8
  Author: AgentSoft
9
9
  Author-email: agentsoft@example.com
10
10
  Requires-Python: >=3.9,<4.0
@@ -21,7 +21,8 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Provides-Extra: color
24
- Requires-Dist: colorama (>=0.4.4) ; extra == "color"
24
+ Project-URL: Homepage, https://github.com/agentsoft/asyncprogress
25
+ Project-URL: Repository, https://github.com/agentsoft/asyncprogress
25
26
  Description-Content-Type: text/markdown
26
27
 
27
28
  # asyncprogress
@@ -34,19 +35,16 @@ Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory depende
34
35
 
35
36
  ## Why asyncprogress?
36
37
 
37
- Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were built for synchronous
38
- code. `asyncprogress` is designed from the ground up for `asyncio`:
38
+ Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were designed for
39
+ synchronous code. `asyncprogress` is built from the ground up for `asyncio`:
39
40
 
40
- - **Native `async for` support** — wrap any sync or async iterable
41
+ - **Native `async for` support** — wrap any sync or async iterable with one line
41
42
  - **Accurate ETA** — EWMA-based estimation handles bursty async I/O patterns
42
43
  - **Concurrent task tracking** — `gather()` and `aprogress_as_completed()` for task pools
43
44
  - **Spinner mode** — automatic fallback for unknown-length streams
45
+ - **Multiple concurrent bars** — `MultiProgressBar` for parallel pipelines
44
46
  - **Zero mandatory dependencies** — pure Python stdlib (`asyncio`, `sys`, `time`)
45
47
 
46
48
  ## Installation
47
49
 
48
50
 
49
-
50
- ```bash
51
- pip install asyncprogress
52
- ```
@@ -8,19 +8,15 @@ Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory depende
8
8
 
9
9
  ## Why asyncprogress?
10
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`:
11
+ Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were designed for
12
+ synchronous code. `asyncprogress` is built from the ground up for `asyncio`:
13
13
 
14
- - **Native `async for` support** — wrap any sync or async iterable
14
+ - **Native `async for` support** — wrap any sync or async iterable with one line
15
15
  - **Accurate ETA** — EWMA-based estimation handles bursty async I/O patterns
16
16
  - **Concurrent task tracking** — `gather()` and `aprogress_as_completed()` for task pools
17
17
  - **Spinner mode** — automatic fallback for unknown-length streams
18
+ - **Multiple concurrent bars** — `MultiProgressBar` for parallel pipelines
18
19
  - **Zero mandatory dependencies** — pure Python stdlib (`asyncio`, `sys`, `time`)
19
20
 
20
21
  ## Installation
21
22
 
22
-
23
-
24
- ```bash
25
- pip install asyncprogress
26
- ```
@@ -1,12 +1,13 @@
1
1
  [tool.poetry]
2
2
  name = "asyncprogress"
3
- version = "0.3.4"
4
- description = "Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies."
3
+ version = "0.3.6"
4
+ description = "Async-aware progress bars for Python's asyncio ecosystem"
5
5
  authors = ["AgentSoft <agentsoft@example.com>"]
6
6
  readme = "README.md"
7
7
  license = "MIT"
8
- packages = [{include = "asyncprogress", from = "src"}]
9
- keywords = ["asyncio", "progress", "progress-bar", "async", "tqdm"]
8
+ homepage = "https://github.com/agentsoft/asyncprogress"
9
+ repository = "https://github.com/agentsoft/asyncprogress"
10
+ keywords = ["async", "asyncio", "progress", "progress-bar", "tqdm"]
10
11
  classifiers = [
11
12
  "Development Status :: 4 - Beta",
12
13
  "Intended Audience :: Developers",
@@ -19,10 +20,10 @@ classifiers = [
19
20
  "Topic :: Software Development :: Libraries :: Python Modules",
20
21
  "Framework :: AsyncIO",
21
22
  ]
23
+ packages = [{include = "asyncprogress", from = "src"}]
22
24
 
23
25
  [tool.poetry.dependencies]
24
26
  python = "^3.9"
25
- colorama = {version = ">=0.4.4", optional = true}
26
27
 
27
28
  [tool.poetry.extras]
28
29
  color = ["colorama"]
@@ -40,19 +41,18 @@ build-backend = "poetry.core.masonry.api"
40
41
  [tool.pytest.ini_options]
41
42
  asyncio_mode = "auto"
42
43
  testpaths = ["tests"]
43
- addopts = "--tb=short"
44
44
 
45
45
  [tool.ruff]
46
46
  line-length = 100
47
47
  target-version = "py39"
48
48
 
49
49
  [tool.ruff.lint]
50
- select = ["E", "F", "W", "I", "UP"]
50
+ select = ["E", "F", "UP", "I"]
51
51
  ignore = ["E501"]
52
52
 
53
53
  [tool.coverage.run]
54
- source = ["src/asyncprogress"]
55
- omit = ["tests/*"]
54
+ source = ["asyncprogress"]
55
+ branch = true
56
56
 
57
57
  [tool.coverage.report]
58
58
  show_missing = true
@@ -22,4 +22,4 @@ __all__ = [
22
22
  "aprogress_as_completed",
23
23
  ]
24
24
 
25
- __version__ = "0.3.4"
25
+ __version__ = "0.3.6"
@@ -12,7 +12,7 @@ from typing import Any, TextIO, TypeVar
12
12
 
13
13
  from asyncprogress._eta import EWMACalculator
14
14
  from asyncprogress._renderer import BarRenderer
15
- from asyncprogress._terminal import erase_line, is_tty, move_cursor_up
15
+ from asyncprogress._terminal import erase_line, is_tty
16
16
 
17
17
  T = TypeVar("T")
18
18
 
@@ -21,11 +21,8 @@ class ProgressBar:
21
21
  """
22
22
  Async context manager for manual progress tracking.
23
23
 
24
- Usage:
25
- async with ProgressBar(total=100, description="Processing") as bar:
26
- for item in items:
27
- await process(item)
28
- await bar.update()
24
+ Provides a progress bar with EWMA-based ETA estimation, configurable
25
+ appearance, and non-blocking terminal output.
29
26
 
30
27
  Args:
31
28
  total: Total number of steps. None activates spinner mode.
@@ -34,17 +31,23 @@ class ProgressBar:
34
31
  fill_char: Character for completed portion.
35
32
  empty_char: Character for remaining portion.
36
33
  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.
34
+ show_eta: Whether to show estimated time remaining.
35
+ show_elapsed: Whether to show elapsed time.
36
+ show_count: Whether to show [completed/total] count.
37
+ show_percentage: Whether to show percentage.
41
38
  update_interval: Seconds between terminal redraws.
42
- ewma_alpha: EWMA smoothing factor (0 < alpha <= 1).
39
+ ewma_alpha: EWMA smoothing factor (0.1=smooth, 0.5=reactive).
43
40
  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).
41
+ disable: If True, suppress all output.
42
+ unit: Unit label ("it" for items, "B" for bytes, etc.).
43
+ unit_scale: If True, auto-apply SI prefix (K/M/G).
47
44
  unit_divisor: 1000 for SI, 1024 for binary.
45
+
46
+ Example:
47
+ async with ProgressBar(total=100, description="Processing") as bar:
48
+ for item in items:
49
+ await process(item)
50
+ await bar.update()
48
51
  """
49
52
 
50
53
  def __init__(
@@ -90,22 +93,16 @@ class ProgressBar:
90
93
  self._last_render_time: float = 0.0
91
94
  self._spinner_frame: int = 0
92
95
  self._render_task: asyncio.Task[None] | None = None
96
+ self._running: bool = False
93
97
 
94
98
  self._eta_calc = EWMACalculator(alpha=ewma_alpha)
95
99
  self._renderer = BarRenderer()
96
100
 
97
- # Track whether we've written any output (for cursor control)
98
- self._lines_written: int = 0
99
-
100
101
  async def __aenter__(self) -> ProgressBar:
101
102
  """Start the progress bar."""
102
103
  self._start_time = time.monotonic()
103
- self._last_render_time = 0.0
104
- self._finished = False
105
- self._completed = 0
106
- self._spinner_frame = 0
107
- self._eta_calc.reset()
108
- # Start background render loop
104
+ self._last_render_time = self._start_time
105
+ self._running = True
109
106
  self._render_task = asyncio.create_task(self._render_loop())
110
107
  await asyncio.sleep(0) # Yield so render task can initialize
111
108
  return self
@@ -113,7 +110,8 @@ class ProgressBar:
113
110
  async def __aexit__(self, *args: Any) -> None:
114
111
  """Stop the progress bar and render final state."""
115
112
  await self.finish()
116
- if self._render_task is not None and not self._render_task.done():
113
+ self._running = False
114
+ if self._render_task is not None:
117
115
  self._render_task.cancel()
118
116
  try:
119
117
  await self._render_task
@@ -122,13 +120,11 @@ class ProgressBar:
122
120
  self._render_task = None
123
121
 
124
122
  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
123
+ """Background task that periodically re-renders the bar."""
124
+ while self._running:
131
125
  await asyncio.sleep(self._update_interval)
126
+ if self._running and not self._finished:
127
+ self._write_line(self._get_render_string())
132
128
 
133
129
  def _get_render_string(self) -> str:
134
130
  """Build the current render string (spinner or bar)."""
@@ -143,6 +139,9 @@ class ProgressBar:
143
139
  color=self._color,
144
140
  show_elapsed=self._show_elapsed,
145
141
  show_count=self._show_count,
142
+ unit=self._unit,
143
+ unit_scale=self._unit_scale,
144
+ unit_divisor=self._unit_divisor,
146
145
  )
147
146
  return self._renderer.render(
148
147
  description=self._description,
@@ -164,36 +163,39 @@ class ProgressBar:
164
163
  unit_divisor=self._unit_divisor,
165
164
  )
166
165
 
167
- def _write_line(self, line: str) -> None:
166
+ def _write_line(self, text: str) -> None:
168
167
  """Write a progress line to the output file."""
169
168
  if self._disable:
170
169
  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")
170
+ if is_tty(self._file):
171
+ self._file.write(erase_line() + text)
172
+ else:
173
+ self._file.write("\r" + text)
176
174
  self._file.flush()
177
- self._lines_written = 1
178
175
 
179
176
  async def _render_final(self) -> None:
180
- """Render the final (completed) state of the bar."""
177
+ """Render the final completed state."""
181
178
  if self._disable:
182
179
  return
183
- self._write_line(self._get_render_string())
180
+ line = self._get_render_string()
181
+ if is_tty(self._file):
182
+ self._file.write(erase_line() + line + "\n")
183
+ else:
184
+ self._file.write("\r" + line + "\n")
185
+ self._file.flush()
184
186
 
185
187
  async def _schedule_render(self) -> None:
186
- """Schedule a render if enough time has passed."""
188
+ """Trigger an immediate render if enough time has passed."""
187
189
  now = time.monotonic()
188
190
  if now - self._last_render_time >= self._update_interval:
189
- self._write_line(self._get_render_string())
190
191
  self._last_render_time = now
192
+ self._write_line(self._get_render_string())
191
193
 
192
194
  async def update(self, n: int = 1) -> None:
193
195
  """
194
196
  Increment progress by n steps.
195
197
 
196
- No-op if the bar is already finished.
198
+ No-op if the bar has already been finished.
197
199
  Clamps to total if total is set.
198
200
  """
199
201
  if self._finished:
@@ -208,7 +210,7 @@ class ProgressBar:
208
210
  """
209
211
  Set absolute progress to value, clamped to [0, total].
210
212
 
211
- No-op if the bar is already finished.
213
+ No-op if the bar has already been finished.
212
214
  """
213
215
  if self._finished:
214
216
  return
@@ -237,23 +239,6 @@ class ProgressBar:
237
239
  self._completed = self._total
238
240
  await self._render_final()
239
241
 
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
242
  @property
258
243
  def elapsed(self) -> float:
259
244
  """Seconds since bar was started."""
@@ -274,7 +259,20 @@ class ProgressBar:
274
259
  """Current EWMA-smoothed items/second rate."""
275
260
  return self._eta_calc.rate()
276
261
 
277
- # ── Convenience classmethod ───────────────────────────────────────────────
262
+ @property
263
+ def description(self) -> str:
264
+ """The current description label for this progress bar."""
265
+ return self._description
266
+
267
+ @property
268
+ def completed(self) -> int:
269
+ """Number of steps completed so far."""
270
+ return self._completed
271
+
272
+ @property
273
+ def total(self) -> int | None:
274
+ """Total steps, or None if indeterminate."""
275
+ return self._total
278
276
 
279
277
  @classmethod
280
278
  def track(
@@ -295,7 +293,7 @@ class ProgressBar:
295
293
  iterable: Any sync or async iterable.
296
294
  total: Total item count. Inferred from __len__ if available.
297
295
  description: Label shown on the progress bar.
298
- **kwargs: Forwarded to ProgressBar (color, bar_width, show_eta, etc.)
296
+ **kwargs: Forwarded to ProgressBar constructor.
299
297
 
300
298
  Yields:
301
299
  Items from the iterable, in order.
@@ -1,7 +1,5 @@
1
1
  """
2
- EWMA-based ETA calculator for asyncprogress.
3
-
4
- Pure math module — no I/O, fully unit-testable.
2
+ EWMA-based ETA calculator for async progress tracking.
5
3
  """
6
4
 
7
5
  from __future__ import annotations
@@ -11,8 +9,8 @@ class EWMACalculator:
11
9
  """
12
10
  Exponential Weighted Moving Average calculator for ETA estimation.
13
11
 
14
- Uses EWMA over completion timestamps rather than simple linear regression,
15
- which better handles the bursty completion patterns common in async I/O.
12
+ Uses EWMA over completion timestamps to estimate items/second rate,
13
+ which handles bursty async I/O patterns better than simple linear regression.
16
14
 
17
15
  Args:
18
16
  alpha: Smoothing factor. Higher = more weight on recent samples.
@@ -20,41 +18,39 @@ class EWMACalculator:
20
18
  """
21
19
 
22
20
  def __init__(self, alpha: float = 0.3) -> None:
23
- if not (0 < alpha <= 1.0):
21
+ if not (0 < alpha <= 1):
24
22
  raise ValueError(f"alpha must be in (0, 1], got {alpha}")
25
23
  self._alpha = alpha
26
24
  self._ewma_rate: float | None = None
27
25
  self._last_timestamp: float | None = None
28
- self._last_completed: int = 0
26
+ self._last_completed: int | None = None
29
27
 
30
28
  def record(self, timestamp: float, completed: int) -> None:
31
29
  """Record a completion event at the given 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
-
30
+ if self._last_timestamp is not None and self._last_completed is not None:
31
+ dt = timestamp - self._last_timestamp
32
+ dc = completed - self._last_completed
33
+ if dt > 0 and dc >= 0:
34
+ instant_rate = dc / dt
35
+ if self._ewma_rate is None:
36
+ self._ewma_rate = instant_rate
37
+ else:
38
+ self._ewma_rate = (
39
+ self._alpha * instant_rate
40
+ + (1 - self._alpha) * self._ewma_rate
41
+ )
42
+ elif dt == 0:
43
+ # Same timestamp — rate stays unchanged, don't update
44
+ pass
49
45
  self._last_timestamp = timestamp
50
46
  self._last_completed = completed
51
47
 
52
48
  def rate(self) -> float | None:
53
- """Returns smoothed items/second, or None if insufficient data."""
49
+ """Return smoothed items/second, or None if insufficient data."""
54
50
  return self._ewma_rate
55
51
 
56
52
  def eta(self, remaining: int) -> float | None:
57
- """Returns estimated seconds to completion, or None if unknown."""
53
+ """Return estimated seconds to completion, or None if unknown."""
58
54
  if remaining == 0:
59
55
  return 0.0
60
56
  r = self.rate()
@@ -66,4 +62,4 @@ class EWMACalculator:
66
62
  """Reset all state."""
67
63
  self._ewma_rate = None
68
64
  self._last_timestamp = None
69
- self._last_completed = 0
65
+ self._last_completed = None
@@ -0,0 +1,72 @@
1
+ """gather() — drop-in replacement for asyncio.gather() 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 run concurrently.
25
+ description: Label shown on the progress bar.
26
+ return_exceptions: If True, exceptions are returned as values.
27
+ **progress_kwargs: Forwarded to ProgressBar (color, bar_width, etc.)
28
+
29
+ Returns:
30
+ List of results in submission order.
31
+
32
+ Example:
33
+ results = await gather(
34
+ *[fetch(url) for url in urls],
35
+ description="Fetching",
36
+ color="cyan",
37
+ )
38
+ """
39
+ if not coros_or_tasks:
40
+ return []
41
+
42
+ total = len(coros_or_tasks)
43
+
44
+ async with ProgressBar(
45
+ total=total, description=description, **progress_kwargs
46
+ ) as bar:
47
+ # Wrap each coroutine/task to update the bar on completion
48
+ results: list[Any] = [None] * total
49
+ exceptions: list[BaseException | None] = [None] * total
50
+
51
+ async def _run_one(idx: int, coro: Any) -> None:
52
+ try:
53
+ results[idx] = await coro
54
+ except Exception as exc: # noqa: BLE001
55
+ exceptions[idx] = exc
56
+ finally:
57
+ await bar.update()
58
+
59
+ tasks = [asyncio.create_task(_run_one(i, c)) for i, c in enumerate(coros_or_tasks)]
60
+ await asyncio.gather(*tasks, return_exceptions=True)
61
+
62
+ # Re-raise or return exceptions based on return_exceptions flag
63
+ final: list[Any] = []
64
+ for i in range(total):
65
+ if exceptions[i] is not None:
66
+ if return_exceptions:
67
+ final.append(exceptions[i])
68
+ else:
69
+ raise exceptions[i] # type: ignore[misc]
70
+ else:
71
+ final.append(results[i])
72
+ return final
@@ -1,5 +1,5 @@
1
1
  """
2
- aprogress() — async generator wrapper for progress tracking.
2
+ aprogress() — async generator wrapper for iterables.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -32,13 +32,12 @@ async def aprogress(
32
32
  unit: str = "it",
33
33
  unit_scale: bool = False,
34
34
  unit_divisor: int = 1000,
35
- **kwargs: Any,
36
35
  ) -> AsyncGenerator[T, None]:
37
36
  """
38
37
  Async generator wrapper that displays a progress bar while iterating.
39
38
 
40
- Accepts both sync and async iterables. Automatically infers total from
41
- __len__ when available. Falls back to spinner mode when total is unknown.
39
+ Automatically infers total from __len__ if available. Falls back to
40
+ spinner mode when total is unknown.
42
41
 
43
42
  Args:
44
43
  iterable: Any sync or async iterable.
@@ -48,35 +47,35 @@ async def aprogress(
48
47
  fill_char: Character for completed portion.
49
48
  empty_char: Character for remaining portion.
50
49
  color: ANSI color name (e.g., "green", "cyan").
51
- show_eta: Show estimated time remaining.
52
- show_elapsed: Show elapsed time.
53
- show_count: Show count display.
54
- show_percentage: Show percentage.
50
+ show_eta: Whether to show estimated time remaining.
51
+ show_elapsed: Whether to show elapsed time.
52
+ show_count: Whether to show [completed/total] count.
53
+ show_percentage: Whether to show percentage.
55
54
  update_interval: Seconds between terminal redraws.
56
55
  ewma_alpha: EWMA smoothing factor.
57
56
  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.
57
+ disable: If True, suppress all output.
58
+ unit: Unit label ("it" for items, "B" for bytes, etc.).
59
+ unit_scale: If True, auto-apply SI prefix (K/M/G).
61
60
  unit_divisor: 1000 for SI, 1024 for binary.
62
61
 
63
62
  Yields:
64
63
  Items from the iterable, in order.
65
64
 
66
65
  Example:
67
- async for item in aprogress(my_list, description="Processing"):
66
+ async for item in aprogress(range(100), description="Processing"):
68
67
  await process(item)
69
68
  """
70
69
  import sys as _sys
71
70
 
72
- # Infer total from __len__ if not provided
71
+ # Infer total from __len__ if available
73
72
  if total is None and hasattr(iterable, "__len__"):
74
73
  total = len(iterable) # type: ignore[arg-type]
75
74
 
76
- effective_total = total if total is not None else 0
75
+ effective_total = total if total is not None else None
77
76
 
78
- bar_kwargs: dict[str, Any] = {
79
- "total": effective_total if total is not None else None,
77
+ kwargs: dict[str, Any] = {
78
+ "total": effective_total,
80
79
  "description": description,
81
80
  "bar_width": bar_width,
82
81
  "fill_char": fill_char,
@@ -88,16 +87,17 @@ async def aprogress(
88
87
  "show_percentage": show_percentage,
89
88
  "update_interval": update_interval,
90
89
  "ewma_alpha": ewma_alpha,
91
- "file": file if file is not None else _sys.stderr,
92
90
  "disable": disable,
93
91
  "unit": unit,
94
92
  "unit_scale": unit_scale,
95
93
  "unit_divisor": unit_divisor,
96
94
  }
97
- # Merge any extra kwargs
98
- bar_kwargs.update(kwargs)
95
+ if file is not None:
96
+ kwargs["file"] = file
97
+ else:
98
+ kwargs["file"] = _sys.stderr
99
99
 
100
- async with ProgressBar(**bar_kwargs) as bar:
100
+ async with ProgressBar(**kwargs) as bar:
101
101
  count = 0
102
102
  if hasattr(iterable, "__aiter__"):
103
103
  async for item in iterable: # type: ignore[union-attr]
@@ -1,7 +1,5 @@
1
1
  """
2
- Terminal rendering for asyncprogress.
3
-
4
- String formatting only — no I/O, fully unit-testable.
2
+ Terminal rendering for progress bars and spinners.
5
3
  """
6
4
 
7
5
  from __future__ import annotations
@@ -33,7 +31,7 @@ _BINARY_PREFIXES = ["", "Ki", "Mi", "Gi", "Ti", "Pi"]
33
31
 
34
32
 
35
33
  def _apply_color(text: str, color: str) -> str:
36
- """Apply ANSI color to text, returning the colored string."""
34
+ """Apply ANSI color to text."""
37
35
  code = _ANSI_COLORS.get(color.lower(), "")
38
36
  if not code:
39
37
  return text
@@ -41,7 +39,7 @@ def _apply_color(text: str, color: str) -> str:
41
39
 
42
40
 
43
41
  def _format_duration(seconds: float) -> str:
44
- """Format a duration in seconds as H:MM:SS or M:SS."""
42
+ """Format seconds as H:MM:SS or M:SS."""
45
43
  m, s = divmod(int(seconds), 60)
46
44
  h, m = divmod(m, 60)
47
45
  if h:
@@ -61,15 +59,15 @@ def _scale_value(value: float, divisor: int) -> tuple[float, str]:
61
59
 
62
60
  class BarRenderer:
63
61
  """
64
- Renders progress bar strings for terminal output.
62
+ Renders a single-line progress bar string.
65
63
 
66
- String formatting onlyno I/O side effects.
64
+ No I/O is performed here only string formatting.
67
65
  """
68
66
 
69
67
  def _compute_percentage(self, completed: int, total: int) -> float:
70
- """Compute percentage, handling zero total gracefully."""
68
+ """Compute percentage, handling total=0 gracefully."""
71
69
  if total == 0:
72
- return 100.0 # Empty collection is trivially complete
70
+ return 100.0
73
71
  return min(100.0, (completed / total) * 100.0)
74
72
 
75
73
  def _format_count(
@@ -127,54 +125,43 @@ class BarRenderer:
127
125
  unit_scale: bool = False,
128
126
  unit_divisor: int = 1000,
129
127
  ) -> str:
130
- """
131
- Render a single-line progress bar string.
132
-
133
- Returns a string ready for terminal output (no newline).
134
- """
128
+ """Return a single-line progress bar string ready for terminal output."""
135
129
  parts: list[str] = []
136
130
 
137
- # Description
138
131
  if description:
139
132
  parts.append(description)
140
133
 
141
- # Bar graphic
142
134
  if total is not None:
143
135
  pct = self._compute_percentage(completed, total)
144
- filled = int(bar_width * pct / 100)
136
+ filled = int(bar_width * completed / total) if total > 0 else bar_width
145
137
  bar = fill_char * filled + empty_char * (bar_width - filled)
146
138
  parts.append(f" {bar} ")
147
139
 
148
- # Percentage
149
140
  if show_percentage:
150
141
  parts.append(f"{pct:.0f}%")
151
142
 
152
- # Count
153
143
  if show_count:
154
144
  parts.append(
155
145
  self._format_count(completed, total, unit, unit_scale, unit_divisor)
156
146
  )
157
147
  else:
158
- # No total — just show count
159
148
  if show_count:
160
149
  parts.append(
161
150
  self._format_count(completed, None, unit, unit_scale, unit_divisor)
162
151
  )
163
152
 
164
- # Elapsed
165
153
  if show_elapsed:
166
154
  parts.append(f"⏱ {_format_duration(elapsed)}")
167
155
 
168
- # Rate
169
- rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
170
- if rate_str:
171
- parts.append(rate_str)
172
-
173
- # ETA
174
156
  if show_eta and eta is not None:
175
157
  parts.append(f"ETA: {_format_duration(eta)}")
176
158
 
177
- line = " ".join(parts)
159
+ if rate is not None:
160
+ rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
161
+ if rate_str:
162
+ parts.append(rate_str)
163
+
164
+ line = " ".join(parts)
178
165
  if color:
179
166
  line = _apply_color(line, color)
180
167
  return line
@@ -190,6 +177,9 @@ class BarRenderer:
190
177
  color: str | None,
191
178
  show_elapsed: bool = True,
192
179
  show_count: bool = True,
180
+ unit: str = "it",
181
+ unit_scale: bool = False,
182
+ unit_divisor: int = 1000,
193
183
  ) -> str:
194
184
  """Render a single-line spinner for indeterminate progress."""
195
185
  spinner = SPINNER_FRAMES[frame_index % len(SPINNER_FRAMES)]
@@ -198,11 +188,19 @@ class BarRenderer:
198
188
  parts.append(description)
199
189
  parts.append(spinner)
200
190
  if show_count:
201
- parts.append(f"{completed} items")
191
+ if unit == "it":
192
+ parts.append(f"{completed} items")
193
+ else:
194
+ count_str = self._format_count(
195
+ completed, None, unit, unit_scale, unit_divisor
196
+ )
197
+ parts.append(count_str)
202
198
  if show_elapsed:
203
199
  parts.append(f"⏱ {_format_duration(elapsed)}")
204
200
  if rate is not None:
205
- parts.append(f"{rate:.1f} items/s")
201
+ rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
202
+ if rate_str:
203
+ parts.append(rate_str)
206
204
  line = " ".join(parts)
207
205
  if color:
208
206
  line = _apply_color(line, color)
@@ -1,5 +1,5 @@
1
1
  """
2
- Terminal utilities: TTY detection, ANSI escape sequences, cursor control.
2
+ Terminal utilities: TTY detection, ANSI cursor control.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -1,111 +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.
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
File without changes