asyncprogress 0.2.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 AgentSoft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: asyncprogress
3
+ Version: 0.2.1
4
+ Summary: Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: asyncio,progress,progress-bar,async,cli
8
+ Author: AgentSoft
9
+ Author-email: agentsoft@example.com
10
+ Requires-Python: >=3.9,<4.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Topic :: Terminals
24
+ Provides-Extra: color
25
+ Requires-Dist: colorama (>=0.4.4) ; extra == "color"
26
+ Project-URL: Homepage, https://github.com/agentsoft/asyncprogress
27
+ Project-URL: Repository, https://github.com/agentsoft/asyncprogress
28
+ Description-Content-Type: text/markdown
29
+
30
+ # asyncprogress
31
+
32
+ Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies.
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/asyncprogress)](https://pypi.org/project/asyncprogress/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/asyncprogress)](https://pypi.org/project/asyncprogress/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
37
+
38
+ ## Why asyncprogress?
39
+
40
+ Existing progress bar libraries (`tqdm`, `rich`, `alive-progress`) were built for synchronous
41
+ code. `asyncprogress` is designed from the ground up for `asyncio`:
42
+
43
+ - **Native `async for` support** — wrap any sync or async iterable
44
+ - **Accurate ETA** — EWMA-based estimation handles bursty async I/O patterns
45
+ - **Concurrent task tracking** — `gather()` and `aprogress_as_completed()` for task pools
46
+ - **Spinner mode** — automatic fallback for unknown-length streams
47
+ - **Zero mandatory dependencies** — pure Python stdlib
48
+
49
+ ## Installation
50
+
51
+
52
+
53
+ ```bash
54
+ pip install asyncprogress
55
+ ```
@@ -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
19
+
20
+ ## Installation
21
+
22
+
23
+
24
+ ```bash
25
+ pip install asyncprogress
26
+ ```
@@ -0,0 +1,59 @@
1
+ [tool.poetry]
2
+ name = "asyncprogress"
3
+ version = "0.2.1"
4
+ description = "Async-aware progress bars for Python's asyncio ecosystem. Zero mandatory dependencies."
5
+ authors = ["AgentSoft <agentsoft@example.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ homepage = "https://github.com/agentsoft/asyncprogress"
9
+ repository = "https://github.com/agentsoft/asyncprogress"
10
+ keywords = ["asyncio", "progress", "progress-bar", "async", "cli"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Software Development :: Libraries",
21
+ "Topic :: Terminals",
22
+ "Framework :: AsyncIO",
23
+ ]
24
+ packages = [{include = "asyncprogress", from = "src"}]
25
+
26
+ [tool.poetry.dependencies]
27
+ python = "^3.9"
28
+ colorama = {version = ">=0.4.4", optional = true}
29
+
30
+ [tool.poetry.extras]
31
+ color = ["colorama"]
32
+
33
+ [tool.poetry.group.dev.dependencies]
34
+ pytest = "^7.4.0"
35
+ pytest-asyncio = "^0.23.0"
36
+ pytest-cov = "^4.1.0"
37
+ ruff = "^0.1.0"
38
+
39
+ [build-system]
40
+ requires = ["poetry-core"]
41
+ build-backend = "poetry.core.masonry.api"
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
45
+ testpaths = ["tests"]
46
+ addopts = "--cov=src/asyncprogress --cov-report=term-missing --cov-fail-under=80"
47
+
48
+ [tool.ruff]
49
+ src = ["src"]
50
+ target-version = "py39"
51
+ line-length = 100
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "UP", "I", "W"]
55
+ ignore = ["E501"]
56
+
57
+ [tool.coverage.run]
58
+ source = ["src/asyncprogress"]
59
+ omit = ["*/tests/*"]
@@ -0,0 +1,25 @@
1
+ """
2
+ asyncprogress — Async-aware progress bars for Python's asyncio ecosystem.
3
+
4
+ Zero mandatory dependencies. Pure Python stdlib.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from asyncprogress._as_completed import aprogress_as_completed
10
+ from asyncprogress._bar import ProgressBar
11
+ from asyncprogress._eta import EWMACalculator
12
+ from asyncprogress._gather import gather
13
+ from asyncprogress._iterator import aprogress
14
+ from asyncprogress._multi import MultiProgressBar
15
+
16
+ __version__ = "0.2.1"
17
+
18
+ __all__ = [
19
+ "aprogress",
20
+ "aprogress_as_completed",
21
+ "EWMACalculator",
22
+ "gather",
23
+ "MultiProgressBar",
24
+ "ProgressBar",
25
+ ]
@@ -0,0 +1,59 @@
1
+ """
2
+ aprogress_as_completed() — Progress-tracked asyncio.as_completed() wrapper.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from collections.abc import AsyncIterator, Coroutine
9
+ from typing import Any, TypeVar
10
+
11
+ from asyncprogress._bar import ProgressBar
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ async def aprogress_as_completed(
17
+ coros: list[Coroutine[Any, Any, T]],
18
+ *,
19
+ description: str = "",
20
+ return_exceptions: bool = False,
21
+ **progress_kwargs: Any,
22
+ ) -> AsyncIterator[T]:
23
+ """
24
+ Async generator that yields results as coroutines complete,
25
+ with a progress bar tracking completion.
26
+
27
+ Drop-in replacement for asyncio.as_completed() iteration patterns.
28
+ Results are yielded in completion order (not submission order).
29
+
30
+ Args:
31
+ coros: List of coroutines to run concurrently.
32
+ description: Label shown on the progress bar.
33
+ return_exceptions: If True, exceptions are yielded as values
34
+ rather than raised (mirrors asyncio.gather behavior).
35
+ **progress_kwargs: Forwarded to ProgressBar (color, bar_width, etc.)
36
+
37
+ Yields:
38
+ Results in completion order.
39
+
40
+ Example:
41
+ async for result in aprogress_as_completed(coros, description="Fetching"):
42
+ process(result)
43
+ """
44
+ if not coros:
45
+ return
46
+
47
+ total = len(coros)
48
+ async with ProgressBar(total=total, description=description, **progress_kwargs) as bar:
49
+ for future in asyncio.as_completed(coros):
50
+ try:
51
+ result = await future
52
+ await bar.update()
53
+ yield result
54
+ except Exception as exc:
55
+ await bar.update() # Always advance — task completed (with error)
56
+ if return_exceptions:
57
+ yield exc # type: ignore[misc]
58
+ else:
59
+ raise
@@ -0,0 +1,262 @@
1
+ """Core ProgressBar class — async context manager with EWMA-based ETA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ import time
8
+ from collections.abc import AsyncGenerator, AsyncIterable, Iterable
9
+ from typing import Any, TextIO, TypeVar
10
+
11
+ from asyncprogress._eta import EWMACalculator
12
+ from asyncprogress._renderer import BarRenderer
13
+ from asyncprogress._terminal import erase_line, is_tty
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ class ProgressBar:
19
+ """
20
+ Async context manager progress bar with EWMA-based ETA estimation.
21
+
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()
27
+
28
+ Args:
29
+ total: Total number of steps. None for indeterminate (spinner) mode.
30
+ description: Label shown before the bar.
31
+ 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).
42
+ disable: If True, suppress all output.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ total: int | None = None,
48
+ description: str = "",
49
+ bar_width: int = 40,
50
+ fill_char: str = "█",
51
+ empty_char: str = "░",
52
+ color: str | None = None,
53
+ show_eta: bool = True,
54
+ show_elapsed: bool = True,
55
+ show_count: bool = True,
56
+ show_percentage: bool = True,
57
+ update_interval: float = 0.1,
58
+ ewma_alpha: float = 0.3,
59
+ file: TextIO = sys.stderr,
60
+ disable: bool = False,
61
+ ) -> None:
62
+ self._total = total
63
+ self._description = description
64
+ self._bar_width = bar_width
65
+ self._fill_char = fill_char
66
+ self._empty_char = empty_char
67
+ self._color = color
68
+ self._show_eta = show_eta
69
+ self._show_elapsed = show_elapsed
70
+ self._show_count = show_count
71
+ self._show_percentage = show_percentage
72
+ self._update_interval = update_interval
73
+ self._file = file
74
+ self._disable = disable
75
+
76
+ self._completed: int = 0
77
+ self._finished: bool = False
78
+ self._start_time: float = 0.0
79
+ self._last_render_time: float = 0.0
80
+ self._spinner_frame: int = 0
81
+
82
+ self._eta_calc = EWMACalculator(alpha=ewma_alpha)
83
+ self._renderer = BarRenderer()
84
+ self._render_task: asyncio.Task[None] | None = None
85
+
86
+ async def __aenter__(self) -> ProgressBar:
87
+ """Start the progress bar."""
88
+ 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()
95
+ return self
96
+
97
+ async def __aexit__(self, *args: Any) -> None:
98
+ """Finish the progress bar on context exit."""
99
+ 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()
170
+
171
+ def _get_render_string(self) -> str:
172
+ """Build the current render string (spinner or bar)."""
173
+ if self._total is None:
174
+ self._spinner_frame += 1
175
+ return self._renderer.render_spinner(
176
+ description=self._description,
177
+ elapsed=self.elapsed,
178
+ completed=self._completed,
179
+ frame_index=self._spinner_frame,
180
+ rate=self.rate,
181
+ color=self._color,
182
+ show_elapsed=self._show_elapsed,
183
+ show_count=self._show_count,
184
+ )
185
+ return self._renderer.render(
186
+ description=self._description,
187
+ completed=self._completed,
188
+ total=self._total,
189
+ elapsed=self.elapsed,
190
+ eta=self.eta,
191
+ rate=self.rate,
192
+ bar_width=self._bar_width,
193
+ fill_char=self._fill_char,
194
+ empty_char=self._empty_char,
195
+ color=self._color,
196
+ show_eta=self._show_eta,
197
+ show_elapsed=self._show_elapsed,
198
+ show_count=self._show_count,
199
+ show_percentage=self._show_percentage,
200
+ )
201
+
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."""
213
+ if self._disable:
214
+ 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()
220
+
221
+ async def _render_final(self) -> None:
222
+ """Write the final completed state."""
223
+ if self._disable:
224
+ return
225
+ line = self._get_render_string()
226
+ if is_tty(self._file):
227
+ self._file.write(erase_line() + line + "\n")
228
+ else:
229
+ self._file.write(line + "\n")
230
+ self._file.flush()
231
+
232
+ @classmethod
233
+ def track(
234
+ cls,
235
+ iterable: AsyncIterable[T] | Iterable[T],
236
+ *,
237
+ total: int | None = None,
238
+ description: str = "",
239
+ **kwargs: Any,
240
+ ) -> AsyncGenerator[T, None]:
241
+ """
242
+ Convenience wrapper: iterate over iterable with a progress bar.
243
+
244
+ Equivalent to aprogress(iterable, total=total, description=description).
245
+ Provided as a classmethod for discoverability.
246
+
247
+ Args:
248
+ iterable: Any sync or async iterable.
249
+ total: Total item count. Inferred from __len__ if available.
250
+ description: Label shown on the progress bar.
251
+ **kwargs: Forwarded to ProgressBar.
252
+
253
+ Yields:
254
+ Items from the iterable, in order.
255
+
256
+ Example:
257
+ async for item in ProgressBar.track(my_list, description="Processing"):
258
+ await process(item)
259
+ """
260
+ from asyncprogress._iterator import aprogress
261
+
262
+ return aprogress(iterable, total=total, description=description, **kwargs)
@@ -0,0 +1,92 @@
1
+ """
2
+ EWMACalculator — Exponential Weighted Moving Average for ETA estimation.
3
+
4
+ Pure math; no I/O. Fully unit-testable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class EWMACalculator:
11
+ """
12
+ Exponential Weighted Moving Average calculator for ETA estimation.
13
+
14
+ Uses EWMA over inter-completion intervals to smooth out bursty async
15
+ I/O patterns that would make simple linear regression inaccurate.
16
+
17
+ Args:
18
+ alpha: Smoothing factor in (0, 1]. Higher = more weight on recent
19
+ samples. Recommended range: 0.1 (smooth) to 0.5 (reactive).
20
+ """
21
+
22
+ def __init__(self, alpha: float = 0.3) -> None:
23
+ if not (0 < alpha <= 1.0):
24
+ raise ValueError(f"alpha must be in (0, 1], got {alpha}")
25
+ self._alpha = alpha
26
+ self._ewma_rate: float | None = None
27
+ self._last_timestamp: float | None = None
28
+ self._last_completed: int | None = None
29
+
30
+ def record(self, timestamp: float, completed: int) -> None:
31
+ """
32
+ Record a completion event.
33
+
34
+ Args:
35
+ timestamp: Monotonic timestamp (e.g., from time.monotonic()).
36
+ completed: Total number of items completed so far.
37
+ """
38
+ if self._last_timestamp is not None and self._last_completed is not None:
39
+ dt = timestamp - self._last_timestamp
40
+ dc = completed - self._last_completed
41
+ if dt > 0 and dc >= 0:
42
+ # Instantaneous rate for this interval
43
+ instant_rate = dc / dt
44
+ if self._ewma_rate is None:
45
+ self._ewma_rate = instant_rate
46
+ else:
47
+ # EWMA update
48
+ self._ewma_rate = (
49
+ self._alpha * instant_rate + (1 - self._alpha) * self._ewma_rate
50
+ )
51
+ elif dt == 0 and dc == 0:
52
+ # Same timestamp, same count — no new information
53
+ pass
54
+ elif dt == 0 and dc > 0:
55
+ # Instantaneous completion — rate is effectively infinite
56
+ # Don't update EWMA to avoid division by zero artifacts
57
+ pass
58
+
59
+ self._last_timestamp = timestamp
60
+ self._last_completed = completed
61
+
62
+ def rate(self) -> float | None:
63
+ """
64
+ Return smoothed items/second rate, or None if insufficient data.
65
+
66
+ Returns:
67
+ EWMA-smoothed rate in items/second, or None if not enough data.
68
+ """
69
+ return self._ewma_rate
70
+
71
+ def eta(self, remaining: int) -> float | None:
72
+ """
73
+ Return estimated seconds to completion, or None if unknown.
74
+
75
+ Args:
76
+ remaining: Number of items still to be completed.
77
+
78
+ Returns:
79
+ Estimated seconds remaining, or None if rate is unknown/zero.
80
+ """
81
+ if remaining == 0:
82
+ return 0.0
83
+ r = self.rate()
84
+ if r is None or r <= 0.0:
85
+ return None
86
+ return remaining / r
87
+
88
+ def reset(self) -> None:
89
+ """Clear all recorded data and reset the calculator."""
90
+ self._ewma_rate = None
91
+ self._last_timestamp = None
92
+ self._last_completed = None
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,113 @@
1
+ """
2
+ aprogress() — Async generator wrapper for iterables with progress tracking.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import AsyncGenerator, AsyncIterable, Iterable
8
+ from typing import Any, TypeVar
9
+
10
+ from asyncprogress._bar import ProgressBar
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ async def aprogress(
16
+ iterable: AsyncIterable[T] | Iterable[T],
17
+ *,
18
+ total: int | None = None,
19
+ description: str = "",
20
+ bar_width: int = 40,
21
+ fill_char: str = "█",
22
+ empty_char: str = "░",
23
+ color: str | None = None,
24
+ show_eta: bool = True,
25
+ show_elapsed: bool = True,
26
+ show_count: bool = True,
27
+ show_percentage: bool = True,
28
+ update_interval: float = 0.1,
29
+ ewma_alpha: float = 0.3,
30
+ file: Any = None,
31
+ disable: bool = False,
32
+ unit: str = "it",
33
+ unit_scale: bool = False,
34
+ unit_divisor: int = 1000,
35
+ **kwargs: Any,
36
+ ) -> AsyncGenerator[T, None]:
37
+ """
38
+ Async generator wrapper that shows a progress bar while iterating.
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.
43
+
44
+ Args:
45
+ iterable: Any sync or async iterable.
46
+ total: Total item count. Inferred from `__len__` if available.
47
+ description: Label shown on the progress bar.
48
+ bar_width: Width of the bar graphic in characters.
49
+ fill_char: Character for completed portion.
50
+ empty_char: Character for incomplete portion.
51
+ 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.
56
+ update_interval: Seconds between terminal redraws.
57
+ 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.
63
+
64
+ Yields:
65
+ Items from the iterable, in order.
66
+
67
+ Example:
68
+ async for item in aprogress(items, description="Processing"):
69
+ await process(item)
70
+ """
71
+ import sys as _sys
72
+
73
+ if total is None and hasattr(iterable, "__len__"):
74
+ total = len(iterable) # type: ignore[arg-type]
75
+
76
+ effective_total = total if total is not None else None
77
+
78
+ bar_kwargs: dict[str, Any] = {
79
+ "total": effective_total,
80
+ "description": description,
81
+ "bar_width": bar_width,
82
+ "fill_char": fill_char,
83
+ "empty_char": empty_char,
84
+ "color": color,
85
+ "show_eta": show_eta,
86
+ "show_elapsed": show_elapsed,
87
+ "show_count": show_count,
88
+ "show_percentage": show_percentage,
89
+ "update_interval": update_interval,
90
+ "ewma_alpha": ewma_alpha,
91
+ "file": file if file is not None else _sys.stderr,
92
+ "disable": disable,
93
+ "unit": unit,
94
+ "unit_scale": unit_scale,
95
+ "unit_divisor": unit_divisor,
96
+ }
97
+ bar_kwargs.update(kwargs)
98
+
99
+ async with ProgressBar(**bar_kwargs) as bar:
100
+ count = 0
101
+ if hasattr(iterable, "__aiter__"):
102
+ async for item in iterable: # type: ignore[union-attr]
103
+ count += 1
104
+ yield item
105
+ await bar.update()
106
+ else:
107
+ for item in iterable: # type: ignore[union-attr]
108
+ count += 1
109
+ yield item
110
+ await bar.update()
111
+
112
+ if count == 0:
113
+ await bar.finish()
@@ -0,0 +1,163 @@
1
+ """
2
+ MultiProgressBar — Concurrent multi-bar manager.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import sys
9
+ from typing import Any, TextIO
10
+
11
+ from asyncprogress._bar import ProgressBar
12
+ from asyncprogress._terminal import erase_line, is_tty, move_cursor_up
13
+
14
+
15
+ class MultiProgressBar:
16
+ """
17
+ Async context manager for displaying multiple concurrent progress bars.
18
+
19
+ Manages a shared render loop that redraws all registered bars together.
20
+
21
+ Args:
22
+ file: Output file (default: sys.stderr).
23
+ update_interval: Seconds between terminal redraws.
24
+ disable: If True, suppress all output.
25
+
26
+ Usage:
27
+ async with MultiProgressBar() as multi:
28
+ bar1 = multi.add("Downloads", total=50)
29
+ bar2 = multi.add("Processing", total=200)
30
+ # ... use bar1.update() and bar2.update() concurrently
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ file: TextIO = sys.stderr,
36
+ update_interval: float = 0.1,
37
+ disable: bool = False,
38
+ ) -> None:
39
+ self._file = file
40
+ self._update_interval = update_interval
41
+ self._disable = disable
42
+ self._bars: list[ProgressBar] = []
43
+ self._running: bool = False
44
+ self._render_task: asyncio.Task[None] | None = None
45
+ self._lines_rendered: int = 0
46
+
47
+ async def __aenter__(self) -> MultiProgressBar:
48
+ self._running = True
49
+ self._lines_rendered = 0
50
+ self._render_task = asyncio.create_task(self._render_loop())
51
+ await asyncio.sleep(0) # Yield once so task initializes before caller proceeds
52
+ return self
53
+
54
+ async def __aexit__(self, *args: Any) -> None:
55
+ self._running = False
56
+ if self._render_task is not None and not self._render_task.done():
57
+ self._render_task.cancel()
58
+ try:
59
+ await self._render_task
60
+ except asyncio.CancelledError:
61
+ pass
62
+ self._write_final_frame()
63
+
64
+ async def _render_loop(self) -> None:
65
+ """Background task that periodically redraws all bars."""
66
+ while self._running:
67
+ self._write_frame()
68
+ await asyncio.sleep(self._update_interval)
69
+
70
+ def _render_all_bars(self) -> str:
71
+ """Render all bars to a single string."""
72
+ lines = []
73
+ for bar in self._bars:
74
+ lines.append(bar._get_render_string()) # noqa: SLF001
75
+ return "\n".join(lines)
76
+
77
+ def _write_frame(self) -> None:
78
+ """Write the current frame to the output file."""
79
+ if self._disable or not self._bars:
80
+ return
81
+ output = self._render_all_bars()
82
+ if not output:
83
+ return
84
+
85
+ tty = is_tty(self._file)
86
+ if tty and self._lines_rendered > 0:
87
+ # Move cursor up to overwrite previous frame
88
+ self._file.write(move_cursor_up(self._lines_rendered))
89
+ for _ in range(self._lines_rendered):
90
+ self._file.write(erase_line())
91
+ self._file.write("\n")
92
+ self._file.write(move_cursor_up(self._lines_rendered))
93
+
94
+ self._file.write(output)
95
+ self._file.flush()
96
+ self._lines_rendered = len(self._bars)
97
+
98
+ def _write_final_frame(self) -> None:
99
+ """Write the final state of all bars."""
100
+ if self._disable or not self._bars:
101
+ return
102
+ output = self._render_all_bars()
103
+ if not output:
104
+ return
105
+
106
+ tty = is_tty(self._file)
107
+ if tty and self._lines_rendered > 0:
108
+ self._file.write(move_cursor_up(self._lines_rendered))
109
+ for _ in range(self._lines_rendered):
110
+ self._file.write(erase_line())
111
+ self._file.write("\n")
112
+ self._file.write(move_cursor_up(self._lines_rendered))
113
+
114
+ self._file.write(output)
115
+ self._file.write("\n")
116
+ self._file.flush()
117
+
118
+ def add(
119
+ self,
120
+ description: str = "",
121
+ total: int | None = None,
122
+ **bar_kwargs: Any,
123
+ ) -> ProgressBar:
124
+ """
125
+ Register a new progress bar.
126
+
127
+ Returns a ProgressBar instance that shares this manager's rendering loop.
128
+
129
+ Args:
130
+ description: Label for this bar.
131
+ total: Total steps for this bar.
132
+ **bar_kwargs: Additional ProgressBar parameters.
133
+
134
+ Returns:
135
+ A ProgressBar instance (not yet started as context manager).
136
+ """
137
+ bar = ProgressBar(
138
+ total=total,
139
+ description=description,
140
+ file=self._file,
141
+ disable=True, # Disable individual rendering; multi handles it
142
+ **bar_kwargs,
143
+ )
144
+ # Manually set start time since we're not using __aenter__
145
+ import time
146
+
147
+ bar._start_time = time.monotonic() # noqa: SLF001
148
+ self._bars.append(bar)
149
+ return bar
150
+
151
+ def remove(self, bar: ProgressBar) -> None:
152
+ """
153
+ Remove a bar from the display.
154
+
155
+ No-op if bar is not registered.
156
+
157
+ Args:
158
+ bar: The ProgressBar to remove.
159
+ """
160
+ try:
161
+ self._bars.remove(bar)
162
+ except ValueError:
163
+ pass # Already removed or never added — safe to ignore
@@ -0,0 +1,240 @@
1
+ """
2
+ Terminal bar renderer: string formatting only, no I/O.
3
+
4
+ Produces single-line progress bar strings and spinner strings
5
+ ready for terminal output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # ANSI color codes
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
+ }
29
+ _ANSI_RESET = "\033[0m"
30
+
31
+ SPINNER_FRAMES: list[str] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
32
+
33
+ _SI_PREFIXES = ["", "K", "M", "G", "T", "P"]
34
+ _BINARY_PREFIXES = ["", "Ki", "Mi", "Gi", "Ti", "Pi"]
35
+
36
+
37
+ def _apply_color(text: str, color: str) -> str:
38
+ """Wrap text in ANSI color codes."""
39
+ code = _ANSI_COLORS.get(color.lower(), "")
40
+ if not code:
41
+ return text
42
+ return f"{code}{text}{_ANSI_RESET}"
43
+
44
+
45
+ def _format_duration(seconds: float) -> str:
46
+ """Format a duration in seconds as H:MM:SS or M:SS."""
47
+ m, s = divmod(int(seconds), 60)
48
+ h, m = divmod(m, 60)
49
+ if h:
50
+ return f"{h}:{m:02d}:{s:02d}"
51
+ return f"{m}:{s:02d}"
52
+
53
+
54
+ def _scale_value(value: float, divisor: int) -> tuple[float, str]:
55
+ """Return (scaled_value, prefix_string) for SI or binary display."""
56
+ prefixes = _BINARY_PREFIXES if divisor == 1024 else _SI_PREFIXES
57
+ for prefix in prefixes[:-1]:
58
+ if abs(value) < divisor:
59
+ return value, prefix
60
+ value /= divisor
61
+ return value, prefixes[-1]
62
+
63
+
64
+ class BarRenderer:
65
+ """
66
+ Renders progress bar strings for terminal output.
67
+
68
+ This class is pure string formatting — no I/O is performed here.
69
+ All rendering logic is centralized here for testability.
70
+ """
71
+
72
+ def _compute_percentage(self, completed: int, total: int) -> float:
73
+ """Compute percentage, handling zero total gracefully."""
74
+ if total == 0:
75
+ return 100.0 # Empty collection is trivially complete
76
+ return min(100.0, (completed / total) * 100.0)
77
+
78
+ def _format_count(
79
+ self,
80
+ completed: int,
81
+ total: int | None,
82
+ unit: str,
83
+ unit_scale: bool,
84
+ unit_divisor: int,
85
+ ) -> str:
86
+ """Format the count portion of the progress bar."""
87
+ if not unit_scale:
88
+ if total is not None:
89
+ return f"[{completed}/{total} {unit}]"
90
+ return f"[{completed} {unit}]"
91
+ c_val, c_prefix = _scale_value(float(completed), unit_divisor)
92
+ if total is not None:
93
+ t_val, t_prefix = _scale_value(float(total), unit_divisor)
94
+ return f"[{c_val:.1f}{c_prefix}{unit}/{t_val:.1f}{t_prefix}{unit}]"
95
+ return f"[{c_val:.1f}{c_prefix}{unit}]"
96
+
97
+ def _format_rate(
98
+ self,
99
+ rate: float | None,
100
+ unit: str,
101
+ unit_scale: bool,
102
+ unit_divisor: int,
103
+ ) -> str:
104
+ """Format the rate/throughput portion."""
105
+ if rate is None:
106
+ return ""
107
+ if not unit_scale:
108
+ return f"{rate:.1f} {unit}/s"
109
+ r_val, r_prefix = _scale_value(rate, unit_divisor)
110
+ return f"{r_val:.1f} {r_prefix}{unit}/s"
111
+
112
+ def render(
113
+ self,
114
+ *,
115
+ description: str,
116
+ completed: int,
117
+ total: int | None,
118
+ elapsed: float,
119
+ eta: float | None,
120
+ rate: float | None,
121
+ bar_width: int,
122
+ fill_char: str,
123
+ empty_char: str,
124
+ color: str | None,
125
+ show_eta: bool,
126
+ show_elapsed: bool,
127
+ show_count: bool,
128
+ show_percentage: bool,
129
+ unit: str = "it",
130
+ unit_scale: bool = False,
131
+ unit_divisor: int = 1000,
132
+ ) -> str:
133
+ """
134
+ Render a single-line deterministic progress bar string.
135
+
136
+ Args:
137
+ description: Label text shown before the bar.
138
+ completed: Number of items completed.
139
+ total: Total items (must not be None for deterministic bar).
140
+ elapsed: Seconds elapsed since start.
141
+ eta: Estimated seconds remaining, or None.
142
+ rate: EWMA-smoothed items/second, or None.
143
+ bar_width: Width of the bar graphic in characters.
144
+ fill_char: Character for completed portion.
145
+ empty_char: Character for remaining portion.
146
+ color: ANSI color name, or None for no color.
147
+ show_eta: Whether to show ETA.
148
+ show_elapsed: Whether to show elapsed time.
149
+ show_count: Whether to show count.
150
+ show_percentage: Whether to show percentage.
151
+ unit: Unit label.
152
+ unit_scale: Whether to auto-scale units.
153
+ unit_divisor: Divisor for unit scaling.
154
+
155
+ Returns:
156
+ A single-line string ready for terminal output.
157
+ """
158
+ parts: list[str] = []
159
+
160
+ if description:
161
+ parts.append(description)
162
+
163
+ if total is not None:
164
+ pct = self._compute_percentage(completed, total)
165
+ filled = int(bar_width * pct / 100)
166
+ bar = fill_char * filled + empty_char * (bar_width - filled)
167
+ bar_str = f"|{bar}|"
168
+ if color:
169
+ bar_str = _apply_color(bar_str, color)
170
+ parts.append(bar_str)
171
+
172
+ if show_percentage:
173
+ parts.append(f"{pct:.0f}%")
174
+
175
+ if show_count:
176
+ parts.append(self._format_count(completed, total, unit, unit_scale, unit_divisor))
177
+ else:
178
+ if show_count:
179
+ parts.append(self._format_count(completed, None, unit, unit_scale, unit_divisor))
180
+
181
+ if show_elapsed:
182
+ parts.append(f"⏱ {_format_duration(elapsed)}")
183
+
184
+ if show_eta and eta is not None:
185
+ parts.append(f"ETA: {_format_duration(eta)}")
186
+
187
+ rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
188
+ if rate_str:
189
+ parts.append(rate_str)
190
+
191
+ return " ".join(parts)
192
+
193
+ def render_spinner(
194
+ self,
195
+ *,
196
+ description: str,
197
+ elapsed: float,
198
+ completed: int,
199
+ frame_index: int,
200
+ rate: float | None,
201
+ color: str | None,
202
+ show_elapsed: bool = True,
203
+ show_count: bool = True,
204
+ ) -> str:
205
+ """
206
+ Render a single-line spinner for indeterminate progress.
207
+
208
+ Args:
209
+ description: Label text.
210
+ elapsed: Seconds elapsed since start.
211
+ completed: Number of items completed so far.
212
+ frame_index: Current spinner frame index (cycles through SPINNER_FRAMES).
213
+ rate: EWMA-smoothed items/second, or None.
214
+ color: ANSI color name, or None.
215
+ show_elapsed: Whether to show elapsed time.
216
+ show_count: Whether to show item count.
217
+
218
+ Returns:
219
+ A single-line spinner string.
220
+ """
221
+ spinner = SPINNER_FRAMES[frame_index % len(SPINNER_FRAMES)]
222
+ parts: list[str] = []
223
+
224
+ if description:
225
+ parts.append(description)
226
+ parts.append(spinner)
227
+
228
+ if show_count:
229
+ parts.append(f"{completed} items")
230
+
231
+ if show_elapsed:
232
+ parts.append(f"⏱ {_format_duration(elapsed)}")
233
+
234
+ if rate is not None:
235
+ parts.append(f"{rate:.1f} items/s")
236
+
237
+ line = " ".join(parts)
238
+ if color:
239
+ line = _apply_color(line, color)
240
+ return line
@@ -0,0 +1,38 @@
1
+ """
2
+ Terminal utilities: TTY detection, ANSI cursor control sequences.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from typing import TextIO
9
+
10
+
11
+ def is_tty(file: TextIO = sys.stderr) -> bool:
12
+ """Return True if the given file is a TTY (interactive terminal)."""
13
+ try:
14
+ return file.isatty()
15
+ except AttributeError:
16
+ return False
17
+
18
+
19
+ def move_cursor_up(n: int = 1) -> str:
20
+ """Return ANSI escape sequence to move cursor up n lines."""
21
+ if n <= 0:
22
+ return ""
23
+ return f"\x1b[{n}A"
24
+
25
+
26
+ def erase_line() -> str:
27
+ """Return ANSI escape sequence to erase the current line."""
28
+ return "\x1b[2K\r"
29
+
30
+
31
+ def hide_cursor() -> str:
32
+ """Return ANSI escape sequence to hide the cursor."""
33
+ return "\x1b[?25l"
34
+
35
+
36
+ def show_cursor() -> str:
37
+ """Return ANSI escape sequence to show the cursor."""
38
+ return "\x1b[?25h"
File without changes