asyncprogress 0.3.1__tar.gz → 0.3.5__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.1
4
- Summary: Async-aware progress bars for Python's asyncio ecosystem
3
+ Version: 0.3.5
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
@@ -19,11 +19,11 @@ Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Software Development :: Libraries
23
23
  Provides-Extra: color
24
24
  Requires-Dist: colorama (>=0.4.4) ; extra == "color"
25
- Project-URL: Homepage, https://github.com/example/asyncprogress
26
- Project-URL: Repository, https://github.com/example/asyncprogress
25
+ Project-URL: Homepage, https://github.com/agentsoft/asyncprogress
26
+ Project-URL: Repository, https://github.com/agentsoft/asyncprogress
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # asyncprogress
@@ -36,13 +36,15 @@ Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory depende
36
36
 
37
37
  ## Why asyncprogress?
38
38
 
39
- Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were built for synchronous code. `asyncprogress` is designed from the ground up for `asyncio`:
39
+ Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were designed for
40
+ synchronous code. `asyncprogress` is built from the ground up for `asyncio`:
40
41
 
41
- - **Native `async for` support** — wrap any sync or async iterable
42
+ - **Native `async for` support** — wrap any sync or async iterable with one line
42
43
  - **Accurate ETA** — EWMA-based estimation handles bursty async I/O patterns
43
44
  - **Concurrent task tracking** — `gather()` and `aprogress_as_completed()` for task pools
44
45
  - **Spinner mode** — automatic fallback for unknown-length streams
45
- - **Zero mandatory dependencies** — pure stdlib `asyncio`
46
+ - **Multiple concurrent bars** — `MultiProgressBar` for parallel pipelines
47
+ - **Zero mandatory dependencies** — pure Python stdlib (`asyncio`, `sys`, `time`)
46
48
 
47
49
  ## Installation
48
50
 
@@ -8,13 +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 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`:
12
13
 
13
- - **Native `async for` support** — wrap any sync or async iterable
14
+ - **Native `async for` support** — wrap any sync or async iterable with one line
14
15
  - **Accurate ETA** — EWMA-based estimation handles bursty async I/O patterns
15
16
  - **Concurrent task tracking** — `gather()` and `aprogress_as_completed()` for task pools
16
17
  - **Spinner mode** — automatic fallback for unknown-length streams
17
- - **Zero mandatory dependencies** — pure stdlib `asyncio`
18
+ - **Multiple concurrent bars** — `MultiProgressBar` for parallel pipelines
19
+ - **Zero mandatory dependencies** — pure Python stdlib (`asyncio`, `sys`, `time`)
18
20
 
19
21
  ## Installation
20
22
 
@@ -1,12 +1,12 @@
1
1
  [tool.poetry]
2
2
  name = "asyncprogress"
3
- version = "0.3.1"
4
- description = "Async-aware progress bars for Python's asyncio ecosystem"
3
+ version = "0.3.5"
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"
8
- homepage = "https://github.com/example/asyncprogress"
9
- repository = "https://github.com/example/asyncprogress"
8
+ homepage = "https://github.com/agentsoft/asyncprogress"
9
+ repository = "https://github.com/agentsoft/asyncprogress"
10
10
  keywords = ["asyncio", "progress", "progress-bar", "async", "tqdm"]
11
11
  classifiers = [
12
12
  "Development Status :: 4 - Beta",
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Programming Language :: Python :: 3.10",
18
18
  "Programming Language :: Python :: 3.11",
19
19
  "Programming Language :: Python :: 3.12",
20
- "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "Topic :: Software Development :: Libraries",
21
21
  "Framework :: AsyncIO",
22
22
  ]
23
23
  packages = [{include = "asyncprogress", from = "src"}]
@@ -42,23 +42,19 @@ build-backend = "poetry.core.masonry.api"
42
42
  [tool.pytest.ini_options]
43
43
  asyncio_mode = "auto"
44
44
  testpaths = ["tests"]
45
- addopts = "--cov=asyncprogress --cov-report=term-missing --cov-fail-under=80"
46
-
47
- [tool.coverage.run]
48
- source = ["src/asyncprogress"]
49
-
50
- [tool.coverage.report]
51
- exclude_lines = [
52
- "pragma: no cover",
53
- "def __repr__",
54
- "if TYPE_CHECKING:",
55
- "raise NotImplementedError",
56
- ]
45
+ addopts = "--tb=short"
57
46
 
58
47
  [tool.ruff]
59
48
  line-length = 100
60
49
  target-version = "py39"
61
50
 
62
51
  [tool.ruff.lint]
63
- select = ["E", "F", "W", "I", "UP"]
52
+ select = ["E", "F", "I", "UP"]
64
53
  ignore = ["E501"]
54
+
55
+ [tool.coverage.run]
56
+ source = ["src/asyncprogress"]
57
+ omit = ["tests/*"]
58
+
59
+ [tool.coverage.report]
60
+ show_missing = true
@@ -1,7 +1,7 @@
1
1
  """
2
2
  asyncprogress — Async-aware progress bars for Python's asyncio ecosystem.
3
3
 
4
- Zero mandatory dependencies. Pure stdlib asyncio.
4
+ Zero mandatory dependencies. Pure Python stdlib.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -13,13 +13,13 @@ from asyncprogress._gather import gather
13
13
  from asyncprogress._iterator import aprogress
14
14
  from asyncprogress._multi import MultiProgressBar
15
15
 
16
- __version__ = "0.3.1"
17
-
18
16
  __all__ = [
19
17
  "aprogress",
20
- "aprogress_as_completed",
21
- "EWMACalculator",
22
- "gather",
23
- "MultiProgressBar",
24
18
  "ProgressBar",
19
+ "MultiProgressBar",
20
+ "gather",
21
+ "EWMACalculator",
22
+ "aprogress_as_completed",
25
23
  ]
24
+
25
+ __version__ = "0.3.5"
@@ -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
 
@@ -17,29 +19,35 @@ T = TypeVar("T")
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
- Usage:
23
- async with ProgressBar(total=100, description="Processing") as bar:
24
- for item in data:
25
- await process(item)
26
- await bar.update()
24
+ Provides a progress bar with EWMA-based ETA estimation, configurable
25
+ appearance, and non-blocking terminal output.
27
26
 
28
27
  Args:
29
- total: Total number of steps. None for indeterminate (spinner) mode.
28
+ total: Total number of steps. None activates spinner mode.
30
29
  description: Label shown before the bar.
31
30
  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.
40
- ewma_alpha: EWMA smoothing factor (0 < alpha <= 1).
41
- file: Output file (default: sys.stderr).
31
+ fill_char: Character for completed portion.
32
+ empty_char: Character for remaining portion.
33
+ color: ANSI color name (e.g., "green", "cyan", "red").
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.
38
+ update_interval: Seconds between terminal redraws.
39
+ ewma_alpha: EWMA smoothing factor (0.1=smooth, 0.5=reactive).
40
+ file: Output stream (default: sys.stderr).
42
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).
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()
43
51
  """
44
52
 
45
53
  def __init__(
@@ -58,6 +66,9 @@ class ProgressBar:
58
66
  ewma_alpha: float = 0.3,
59
67
  file: TextIO = sys.stderr,
60
68
  disable: bool = False,
69
+ unit: str = "it",
70
+ unit_scale: bool = False,
71
+ unit_divisor: int = 1000,
61
72
  ) -> None:
62
73
  self._total = total
63
74
  self._description = description
@@ -72,101 +83,48 @@ class ProgressBar:
72
83
  self._update_interval = update_interval
73
84
  self._file = file
74
85
  self._disable = disable
86
+ self._unit = unit
87
+ self._unit_scale = unit_scale
88
+ self._unit_divisor = unit_divisor
75
89
 
76
90
  self._completed: int = 0
77
91
  self._finished: bool = False
78
92
  self._start_time: float = 0.0
79
93
  self._last_render_time: float = 0.0
80
94
  self._spinner_frame: int = 0
95
+ self._render_task: asyncio.Task[None] | None = None
96
+ self._running: bool = False
81
97
 
82
98
  self._eta_calc = EWMACalculator(alpha=ewma_alpha)
83
99
  self._renderer = BarRenderer()
84
- self._render_task: asyncio.Task[None] | None = None
85
100
 
86
101
  async def __aenter__(self) -> ProgressBar:
87
102
  """Start the progress bar."""
88
103
  self._start_time = time.monotonic()
89
- self._last_render_time = 0.0
90
- self._completed = 0
91
- self._finished = False
92
- self._spinner_frame = 0
93
- self._eta_calc.reset()
94
- await self._schedule_render()
104
+ self._last_render_time = self._start_time
105
+ self._running = True
106
+ self._render_task = asyncio.create_task(self._render_loop())
107
+ await asyncio.sleep(0) # Yield so render task can initialize
95
108
  return self
96
109
 
97
110
  async def __aexit__(self, *args: Any) -> None:
98
- """Finish the progress bar on context exit."""
111
+ """Stop the progress bar and render final state."""
99
112
  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()
113
+ self._running = False
114
+ if self._render_task is not None:
115
+ self._render_task.cancel()
116
+ try:
117
+ await self._render_task
118
+ except asyncio.CancelledError:
119
+ pass
120
+ self._render_task = None
121
+
122
+ async def _render_loop(self) -> None:
123
+ """Background task that periodically re-renders the bar."""
124
+ while self._running:
125
+ await asyncio.sleep(self._update_interval)
126
+ if self._running and not self._finished:
127
+ self._write_line(self._get_render_string())
170
128
 
171
129
  def _get_render_string(self) -> str:
172
130
  """Build the current render string (spinner or bar)."""
@@ -181,6 +139,9 @@ class ProgressBar:
181
139
  color=self._color,
182
140
  show_elapsed=self._show_elapsed,
183
141
  show_count=self._show_count,
142
+ unit=self._unit,
143
+ unit_scale=self._unit_scale,
144
+ unit_divisor=self._unit_divisor,
184
145
  )
185
146
  return self._renderer.render(
186
147
  description=self._description,
@@ -197,38 +158,122 @@ class ProgressBar:
197
158
  show_elapsed=self._show_elapsed,
198
159
  show_count=self._show_count,
199
160
  show_percentage=self._show_percentage,
161
+ unit=self._unit,
162
+ unit_scale=self._unit_scale,
163
+ unit_divisor=self._unit_divisor,
200
164
  )
201
165
 
202
- async def _schedule_render(self) -> None:
203
- """Write a render tick if enough time has passed."""
204
- if self._disable:
205
- return
206
- now = time.monotonic()
207
- if now - self._last_render_time >= self._update_interval:
208
- self._last_render_time = now
209
- self._write_line(self._get_render_string())
210
-
211
- def _write_line(self, line: str) -> None:
212
- """Write a single progress line to the output file."""
166
+ def _write_line(self, text: str) -> None:
167
+ """Write a progress line to the output file."""
213
168
  if self._disable:
214
169
  return
215
170
  if is_tty(self._file):
216
- self._file.write(erase_line() + line + "\r")
171
+ self._file.write(erase_line() + text)
217
172
  else:
218
- self._file.write(line + "\n")
173
+ self._file.write("\r" + text)
219
174
  self._file.flush()
220
175
 
221
176
  async def _render_final(self) -> None:
222
- """Write the final completed state."""
177
+ """Render the final completed state."""
223
178
  if self._disable:
224
179
  return
225
180
  line = self._get_render_string()
226
181
  if is_tty(self._file):
227
182
  self._file.write(erase_line() + line + "\n")
228
183
  else:
229
- self._file.write(line + "\n")
184
+ self._file.write("\r" + line + "\n")
230
185
  self._file.flush()
231
186
 
187
+ async def _schedule_render(self) -> None:
188
+ """Trigger an immediate render if enough time has passed."""
189
+ now = time.monotonic()
190
+ if now - self._last_render_time >= self._update_interval:
191
+ self._last_render_time = now
192
+ self._write_line(self._get_render_string())
193
+
194
+ async def update(self, n: int = 1) -> None:
195
+ """
196
+ Increment progress by n steps.
197
+
198
+ No-op if the bar has already been finished.
199
+ Clamps to total if total is set.
200
+ """
201
+ if self._finished:
202
+ return
203
+ self._completed += n
204
+ if self._total is not None:
205
+ self._completed = min(self._completed, self._total)
206
+ self._eta_calc.record(time.monotonic(), self._completed)
207
+ await self._schedule_render()
208
+
209
+ async def set(self, value: int) -> None:
210
+ """
211
+ Set absolute progress to value, clamped to [0, total].
212
+
213
+ No-op if the bar has already been finished.
214
+ """
215
+ if self._finished:
216
+ return
217
+ if self._total is not None:
218
+ self._completed = max(0, min(value, self._total))
219
+ else:
220
+ self._completed = max(0, value)
221
+ self._eta_calc.record(time.monotonic(), self._completed)
222
+ await self._schedule_render()
223
+
224
+ async def set_description(self, description: str) -> None:
225
+ """Update the description text dynamically."""
226
+ self._description = description
227
+ await self._schedule_render()
228
+
229
+ async def finish(self) -> None:
230
+ """
231
+ Mark as complete regardless of current count.
232
+
233
+ Idempotent — safe to call multiple times.
234
+ """
235
+ if self._finished:
236
+ return
237
+ self._finished = True
238
+ if self._total is not None:
239
+ self._completed = self._total
240
+ await self._render_final()
241
+
242
+ @property
243
+ def elapsed(self) -> float:
244
+ """Seconds since bar was started."""
245
+ if self._start_time == 0.0:
246
+ return 0.0
247
+ return time.monotonic() - self._start_time
248
+
249
+ @property
250
+ def eta(self) -> float | None:
251
+ """Estimated seconds remaining, or None if unknown."""
252
+ if self._total is None:
253
+ return None
254
+ remaining = self._total - self._completed
255
+ return self._eta_calc.eta(remaining)
256
+
257
+ @property
258
+ def rate(self) -> float | None:
259
+ """Current EWMA-smoothed items/second rate."""
260
+ return self._eta_calc.rate()
261
+
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
276
+
232
277
  @classmethod
233
278
  def track(
234
279
  cls,
@@ -241,14 +286,14 @@ class ProgressBar:
241
286
  """
242
287
  Convenience wrapper: iterate over iterable with a progress bar.
243
288
 
244
- Equivalent to aprogress(iterable, total=total, description=description).
245
- Provided as a classmethod for discoverability.
289
+ Equivalent to aprogress(iterable, total=total, description=description, **kwargs).
290
+ Provided as a classmethod for discoverability when users import only ProgressBar.
246
291
 
247
292
  Args:
248
293
  iterable: Any sync or async iterable.
249
294
  total: Total item count. Inferred from __len__ if available.
250
295
  description: Label shown on the progress bar.
251
- **kwargs: Forwarded to ProgressBar.
296
+ **kwargs: Forwarded to ProgressBar constructor.
252
297
 
253
298
  Yields:
254
299
  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 no I/O, fully unit-testable.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -11,16 +11,17 @@ class EWMACalculator:
11
11
  """
12
12
  Exponential Weighted Moving Average calculator for ETA estimation.
13
13
 
14
- Uses EWMA over completion rates rather than simple linear regression,
15
- which handles bursty async I/O patterns more accurately.
14
+ Uses EWMA over completion timestamps rather than simple linear regression,
15
+ which better handles the bursty completion patterns common in async I/O.
16
16
 
17
17
  Args:
18
- alpha: Smoothing factor. Higher = more weight on recent samples.
18
+ alpha: Smoothing factor (0 < alpha <= 1).
19
+ Higher values give more weight to recent samples.
19
20
  Recommended range: 0.1 (smooth) to 0.5 (reactive).
20
21
  """
21
22
 
22
23
  def __init__(self, alpha: float = 0.3) -> None:
23
- if not (0 < alpha <= 1.0):
24
+ if not (0 < alpha <= 1):
24
25
  raise ValueError(f"alpha must be in (0, 1], got {alpha}")
25
26
  self._alpha = alpha
26
27
  self._ewma_rate: float | None = None
@@ -40,16 +41,16 @@ class EWMACalculator:
40
41
  self._ewma_rate = (
41
42
  self._alpha * instant_rate + (1 - self._alpha) * self._ewma_rate
42
43
  )
43
- # If dt == 0, don't update rate (avoid division by zero)
44
+ # If dt == 0, we skip updating the rate (same timestamp)
44
45
  self._last_timestamp = timestamp
45
46
  self._last_completed = completed
46
47
 
47
48
  def rate(self) -> float | None:
48
- """Returns smoothed items/second, or None if insufficient data."""
49
+ """Return smoothed items/second, or None if insufficient data."""
49
50
  return self._ewma_rate
50
51
 
51
52
  def eta(self, remaining: int) -> float | None:
52
- """Returns estimated seconds to completion, or None if unknown."""
53
+ """Return estimated seconds to completion, or None if unknown."""
53
54
  if remaining == 0:
54
55
  return 0.0
55
56
  r = self.rate()
@@ -1,6 +1,4 @@
1
- """
2
- gather() — Drop-in replacement for asyncio.gather() with progress tracking.
3
- """
1
+ """gather() — drop-in replacement for asyncio.gather() with progress tracking."""
4
2
 
5
3
  from __future__ import annotations
6
4
 
@@ -20,22 +18,22 @@ async def gather(
20
18
  """
21
19
  Drop-in replacement for asyncio.gather() with progress tracking.
22
20
 
23
- Returns results in the same order as inputs, matching asyncio.gather() semantics.
21
+ Returns results in the same order as inputs.
24
22
 
25
23
  Args:
26
24
  *coros_or_tasks: Coroutines or Tasks to run concurrently.
27
25
  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).
26
+ return_exceptions: If True, exceptions are returned as values.
30
27
  **progress_kwargs: Forwarded to ProgressBar (color, bar_width, etc.)
31
28
 
32
29
  Returns:
33
- List of results in the same order as inputs.
30
+ List of results in submission order.
34
31
 
35
32
  Example:
36
33
  results = await gather(
37
34
  *[fetch(url) for url in urls],
38
- description="Downloading",
35
+ description="Fetching",
36
+ color="cyan",
39
37
  )
40
38
  """
41
39
  if not coros_or_tasks:
@@ -48,26 +46,27 @@ async def gather(
48
46
  ) as bar:
49
47
  # Wrap each coroutine/task to update the bar on completion
50
48
  results: list[Any] = [None] * total
49
+ exceptions: list[BaseException | None] = [None] * total
51
50
 
52
- async def _run_and_update(idx: int, coro: Any) -> None:
51
+ async def _run_one(idx: int, coro: Any) -> None:
53
52
  try:
54
53
  results[idx] = await coro
55
- except Exception as exc:
56
- if return_exceptions:
57
- results[idx] = exc
58
- else:
59
- raise
54
+ except Exception as exc: # noqa: BLE001
55
+ exceptions[idx] = exc
60
56
  finally:
61
57
  await bar.update()
62
58
 
63
- wrapped = [
64
- asyncio.create_task(_run_and_update(i, coro))
65
- for i, coro in enumerate(coros_or_tasks)
66
- ]
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)
67
61
 
68
- if return_exceptions:
69
- await asyncio.gather(*wrapped, return_exceptions=True)
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
70
  else:
71
- await asyncio.gather(*wrapped)
72
-
73
- return results
71
+ final.append(results[i])
72
+ return final
@@ -1,5 +1,5 @@
1
1
  """
2
- aprogress() — Async generator wrapper for iterables with progress tracking.
2
+ aprogress() — async generator wrapper for iterables.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -32,50 +32,49 @@ 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
- Async generator wrapper that shows a progress bar while iterating.
37
+ Async generator wrapper that displays a progress bar while iterating.
39
38
 
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.
39
+ Automatically infers total from __len__ if available. Falls back to
40
+ spinner mode when total is unknown.
43
41
 
44
42
  Args:
45
43
  iterable: Any sync or async iterable.
46
- total: Total item count. Inferred from `__len__` if available.
44
+ total: Total item count. Inferred from __len__ if available.
47
45
  description: Label shown on the progress bar.
48
46
  bar_width: Width of the bar graphic in characters.
49
47
  fill_char: Character for completed portion.
50
- empty_char: Character for incomplete portion.
48
+ empty_char: Character for remaining portion.
51
49
  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.
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.
56
54
  update_interval: Seconds between terminal redraws.
57
55
  ewma_alpha: EWMA smoothing factor.
58
- file: Output file (default: sys.stderr).
56
+ file: Output stream (default: sys.stderr).
59
57
  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.
58
+ unit: Unit label ("it" for items, "B" for bytes, etc.).
59
+ unit_scale: If True, auto-apply SI prefix (K/M/G).
60
+ unit_divisor: 1000 for SI, 1024 for binary.
63
61
 
64
62
  Yields:
65
63
  Items from the iterable, in order.
66
64
 
67
65
  Example:
68
- async for item in aprogress(items, description="Processing"):
66
+ async for item in aprogress(range(100), description="Processing"):
69
67
  await process(item)
70
68
  """
71
69
  import sys as _sys
72
70
 
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
75
  effective_total = total if total is not None else None
77
76
 
78
- bar_kwargs: dict[str, Any] = {
77
+ kwargs: dict[str, Any] = {
79
78
  "total": effective_total,
80
79
  "description": description,
81
80
  "bar_width": bar_width,
@@ -88,15 +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
- bar_kwargs.update(kwargs)
95
+ if file is not None:
96
+ kwargs["file"] = file
97
+ else:
98
+ kwargs["file"] = _sys.stderr
98
99
 
99
- async with ProgressBar(**bar_kwargs) as bar:
100
+ async with ProgressBar(**kwargs) as bar:
100
101
  count = 0
101
102
  if hasattr(iterable, "__aiter__"):
102
103
  async for item in iterable: # type: ignore[union-attr]
@@ -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,45 +1,37 @@
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
9
  SPINNER_FRAMES: list[str] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
10
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",
11
+ ANSI_COLORS: dict[str, str] = {
12
+ "black": "\033[30m",
13
+ "red": "\033[31m",
14
+ "green": "\033[32m",
15
+ "yellow": "\033[33m",
16
+ "blue": "\033[34m",
17
+ "magenta": "\033[35m",
18
+ "cyan": "\033[36m",
19
+ "white": "\033[37m",
20
+ "bright_black": "\033[90m",
21
+ "bright_red": "\033[91m",
22
+ "bright_green": "\033[92m",
23
+ "bright_yellow": "\033[93m",
24
+ "bright_blue": "\033[94m",
25
+ "bright_magenta": "\033[95m",
26
+ "bright_cyan": "\033[96m",
27
+ "bright_white": "\033[97m",
28
28
  }
29
- _ANSI_RESET = "\x1b[0m"
29
+ ANSI_RESET = "\033[0m"
30
30
 
31
31
  _SI_PREFIXES = ["", "K", "M", "G", "T", "P"]
32
32
  _BINARY_PREFIXES = ["", "Ki", "Mi", "Gi", "Ti", "Pi"]
33
33
 
34
34
 
35
- def _apply_color(text: str, color: str) -> str:
36
- """Apply ANSI color to text, returning reset at end."""
37
- code = _ANSI_COLORS.get(color.lower(), "")
38
- if not code:
39
- return text
40
- return f"{code}{text}{_ANSI_RESET}"
41
-
42
-
43
35
  def _format_duration(seconds: float) -> str:
44
36
  """Format seconds as H:MM:SS or M:SS."""
45
37
  m, s = divmod(int(seconds), 60)
@@ -49,6 +41,14 @@ def _format_duration(seconds: float) -> str:
49
41
  return f"{m}:{s:02d}"
50
42
 
51
43
 
44
+ def _apply_color(text: str, color: str) -> str:
45
+ """Apply ANSI color to text."""
46
+ code = ANSI_COLORS.get(color.lower(), "")
47
+ if not code:
48
+ return text
49
+ return f"{code}{text}{ANSI_RESET}"
50
+
51
+
52
52
  def _scale_value(value: float, divisor: int) -> tuple[float, str]:
53
53
  """Return (scaled_value, prefix_string) for display."""
54
54
  prefixes = _BINARY_PREFIXES if divisor == 1024 else _SI_PREFIXES
@@ -61,9 +61,9 @@ def _scale_value(value: float, divisor: int) -> tuple[float, str]:
61
61
 
62
62
  class BarRenderer:
63
63
  """
64
- Renders progress bar strings for terminal output.
64
+ Renders a single-line progress bar string.
65
65
 
66
- String formatting only no I/O performed here.
66
+ No I/Oreturns strings only. Fully unit-testable.
67
67
  """
68
68
 
69
69
  def _compute_percentage(self, completed: int, total: int) -> float:
@@ -80,7 +80,7 @@ class BarRenderer:
80
80
  unit_scale: bool,
81
81
  unit_divisor: int,
82
82
  ) -> str:
83
- """Format the count portion of the bar."""
83
+ """Format the count display string."""
84
84
  if not unit_scale:
85
85
  if total is not None:
86
86
  return f"[{completed}/{total} {unit}]"
@@ -98,7 +98,7 @@ class BarRenderer:
98
98
  unit_scale: bool,
99
99
  unit_divisor: int,
100
100
  ) -> str:
101
- """Format the rate portion of the bar."""
101
+ """Format the rate display string."""
102
102
  if rate is None:
103
103
  return ""
104
104
  if not unit_scale:
@@ -128,7 +128,7 @@ class BarRenderer:
128
128
  unit_divisor: int = 1000,
129
129
  ) -> str:
130
130
  """
131
- Render a single-line progress bar string.
131
+ Render a single-line deterministic progress bar string.
132
132
 
133
133
  Returns a string ready for terminal output (no newline).
134
134
  """
@@ -139,9 +139,9 @@ class BarRenderer:
139
139
 
140
140
  if total is not None:
141
141
  pct = self._compute_percentage(completed, total)
142
- filled = int(bar_width * pct / 100) if bar_width > 0 else 0
143
- bar_str = fill_char * filled + empty_char * (bar_width - filled)
144
- parts.append(f" {bar_str} ")
142
+ filled = int(bar_width * pct / 100)
143
+ bar = fill_char * filled + empty_char * (bar_width - filled)
144
+ parts.append(f" {bar} ")
145
145
 
146
146
  if show_percentage:
147
147
  parts.append(f"{pct:.0f}%")
@@ -151,7 +151,6 @@ class BarRenderer:
151
151
  self._format_count(completed, total, unit, unit_scale, unit_divisor)
152
152
  )
153
153
  else:
154
- # Indeterminate — no bar graphic
155
154
  if show_count:
156
155
  parts.append(
157
156
  self._format_count(completed, None, unit, unit_scale, unit_divisor)
@@ -163,12 +162,11 @@ class BarRenderer:
163
162
  if show_eta and eta is not None:
164
163
  parts.append(f"ETA: {_format_duration(eta)}")
165
164
 
166
- if rate is not None:
167
- rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
168
- if rate_str:
169
- parts.append(rate_str)
165
+ rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
166
+ if rate_str:
167
+ parts.append(rate_str)
170
168
 
171
- line = " ".join(p for p in parts if p)
169
+ line = " ".join(parts)
172
170
  if color:
173
171
  line = _apply_color(line, color)
174
172
  return line
@@ -184,19 +182,34 @@ class BarRenderer:
184
182
  color: str | None,
185
183
  show_elapsed: bool = True,
186
184
  show_count: bool = True,
185
+ unit: str = "it",
186
+ unit_scale: bool = False,
187
+ unit_divisor: int = 1000,
187
188
  ) -> str:
188
- """Render a single-line spinner for indeterminate progress."""
189
+ """
190
+ Render a single-line spinner for indeterminate progress.
191
+
192
+ Activates automatically when total is None.
193
+ """
189
194
  spinner = SPINNER_FRAMES[frame_index % len(SPINNER_FRAMES)]
190
195
  parts: list[str] = []
191
196
  if description:
192
197
  parts.append(description)
193
198
  parts.append(spinner)
194
199
  if show_count:
195
- parts.append(f"{completed} items")
200
+ if unit == "it":
201
+ parts.append(f"{completed} items")
202
+ else:
203
+ count_str = self._format_count(
204
+ completed, None, unit, unit_scale, unit_divisor
205
+ )
206
+ parts.append(count_str)
196
207
  if show_elapsed:
197
208
  parts.append(f"⏱ {_format_duration(elapsed)}")
198
209
  if rate is not None:
199
- parts.append(f"{rate:.1f} items/s")
210
+ rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
211
+ if rate_str:
212
+ parts.append(rate_str)
200
213
  line = " ".join(parts)
201
214
  if color:
202
215
  line = _apply_color(line, color)
@@ -1,5 +1,5 @@
1
1
  """
2
- Terminal utilities TTY detection and ANSI escape sequences.
2
+ Terminal utilities: TTY detection, ANSI cursor control.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -15,19 +15,19 @@ def is_tty(file: TextIO = sys.stderr) -> bool:
15
15
 
16
16
  def move_cursor_up(n: int = 1) -> str:
17
17
  """Return ANSI escape sequence to move cursor up n lines."""
18
- return f"\x1b[{n}A"
18
+ return f"\033[{n}A"
19
19
 
20
20
 
21
21
  def erase_line() -> str:
22
22
  """Return ANSI escape sequence to erase the current line."""
23
- return "\x1b[2K\r"
23
+ return "\033[2K\r"
24
24
 
25
25
 
26
26
  def hide_cursor() -> str:
27
27
  """Return ANSI escape sequence to hide the cursor."""
28
- return "\x1b[?25l"
28
+ return "\033[?25l"
29
29
 
30
30
 
31
31
  def show_cursor() -> str:
32
32
  """Return ANSI escape sequence to show the cursor."""
33
- return "\x1b[?25h"
33
+ return "\033[?25h"
File without changes