asyncprogress 0.3.0__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,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: asyncprogress
3
+ Version: 0.3.0
4
+ Summary: Async-aware progress bars for Python's asyncio ecosystem
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: asyncio,progress,progress-bar,async,tqdm
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 :: Python Modules
23
+ Provides-Extra: color
24
+ Requires-Dist: colorama (>=0.4.4) ; extra == "color"
25
+ Description-Content-Type: text/markdown
26
+
27
+ # asyncprogress
28
+
29
+ Async-aware progress bars for Python's asyncio ecosystem. Zero dependencies, native `async for` support, accurate ETA with EWMA, and concurrent task tracking.
30
+
31
+ ## Features
32
+
33
+ - **Native `async for` support** — wrap any sync or async iterable
34
+ - **`async with` context manager** — manual progress tracking
35
+ - **`gather()` replacement** — drop-in for `asyncio.gather()` with progress
36
+ - **`aprogress_as_completed()`** — progress-tracked `as_completed` wrapper
37
+ - **Spinner mode** — automatic for indeterminate progress
38
+ - **EWMA-based ETA** — accurate estimates for bursty async workloads
39
+ - **Multiple concurrent bars** — `MultiProgressBar` for parallel streams
40
+ - **Zero mandatory dependencies** — stdlib only
41
+
42
+ ## Installation
43
+
44
+
45
+
46
+ ```bash
47
+ pip install asyncprogress
48
+ ```
@@ -0,0 +1,22 @@
1
+ # asyncprogress
2
+
3
+ Async-aware progress bars for Python's asyncio ecosystem. Zero dependencies, native `async for` support, accurate ETA with EWMA, and concurrent task tracking.
4
+
5
+ ## Features
6
+
7
+ - **Native `async for` support** — wrap any sync or async iterable
8
+ - **`async with` context manager** — manual progress tracking
9
+ - **`gather()` replacement** — drop-in for `asyncio.gather()` with progress
10
+ - **`aprogress_as_completed()`** — progress-tracked `as_completed` wrapper
11
+ - **Spinner mode** — automatic for indeterminate progress
12
+ - **EWMA-based ETA** — accurate estimates for bursty async workloads
13
+ - **Multiple concurrent bars** — `MultiProgressBar` for parallel streams
14
+ - **Zero mandatory dependencies** — stdlib only
15
+
16
+ ## Installation
17
+
18
+
19
+
20
+ ```bash
21
+ pip install asyncprogress
22
+ ```
@@ -0,0 +1,55 @@
1
+ [tool.poetry]
2
+ name = "asyncprogress"
3
+ version = "0.3.0"
4
+ description = "Async-aware progress bars for Python's asyncio ecosystem"
5
+ authors = ["AgentSoft <agentsoft@example.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ packages = [{include = "asyncprogress", from = "src"}]
9
+ keywords = ["asyncio", "progress", "progress-bar", "async", "tqdm"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "Framework :: AsyncIO",
21
+ ]
22
+
23
+ [tool.poetry.dependencies]
24
+ python = "^3.9"
25
+ colorama = {version = ">=0.4.4", optional = true}
26
+
27
+ [tool.poetry.extras]
28
+ color = ["colorama"]
29
+
30
+ [tool.poetry.group.dev.dependencies]
31
+ pytest = "^7.4.0"
32
+ pytest-asyncio = "^0.23.0"
33
+ pytest-cov = "^4.1.0"
34
+ ruff = "^0.1.0"
35
+
36
+ [build-system]
37
+ requires = ["poetry-core"]
38
+ build-backend = "poetry.core.masonry.api"
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
42
+ testpaths = ["tests"]
43
+ addopts = "--cov=src/asyncprogress --cov-report=term-missing --cov-fail-under=80"
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py39"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "W", "I", "UP"]
51
+ ignore = ["E501"]
52
+
53
+ [tool.coverage.run]
54
+ source = ["src/asyncprogress"]
55
+ omit = ["*/tests/*"]
@@ -0,0 +1,26 @@
1
+ """
2
+ asyncprogress — Async-aware progress bars for Python's asyncio ecosystem.
3
+
4
+ Zero dependencies, native async for support, accurate EWMA-based ETA,
5
+ and concurrent task tracking.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from asyncprogress._as_completed import aprogress_as_completed
11
+ from asyncprogress._bar import ProgressBar
12
+ from asyncprogress._eta import EWMACalculator
13
+ from asyncprogress._gather import gather
14
+ from asyncprogress._iterator import aprogress
15
+ from asyncprogress._multi import MultiProgressBar
16
+
17
+ __all__ = [
18
+ "aprogress",
19
+ "ProgressBar",
20
+ "MultiProgressBar",
21
+ "gather",
22
+ "EWMACalculator",
23
+ "aprogress_as_completed",
24
+ ]
25
+
26
+ __version__ = "0.3.0"
@@ -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,64 @@
1
+ """
2
+ EWMACalculator — Exponential Weighted Moving Average for ETA estimation.
3
+
4
+ Pure math module; 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 completion timestamps rather than simple linear regression,
15
+ which better accounts for burst patterns common in async I/O workloads.
16
+
17
+ Args:
18
+ alpha: Smoothing factor. Higher = more weight on recent samples.
19
+ 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
+ """Record a completion event at the given timestamp."""
32
+ if self._last_timestamp is not None and self._last_completed is not None:
33
+ dt = timestamp - self._last_timestamp
34
+ dc = completed - self._last_completed
35
+ if dt > 0:
36
+ instant_rate = dc / dt
37
+ if self._ewma_rate is None:
38
+ self._ewma_rate = instant_rate
39
+ else:
40
+ self._ewma_rate = (
41
+ self._alpha * instant_rate + (1 - self._alpha) * self._ewma_rate
42
+ )
43
+ # If dt == 0, skip this sample (same timestamp)
44
+ self._last_timestamp = timestamp
45
+ self._last_completed = completed
46
+
47
+ def rate(self) -> float | None:
48
+ """Return smoothed items/second, or None if insufficient data."""
49
+ return self._ewma_rate
50
+
51
+ def eta(self, remaining: int) -> float | None:
52
+ """Return estimated seconds to completion, or None if unknown."""
53
+ if remaining == 0:
54
+ return 0.0
55
+ r = self.rate()
56
+ if r is None or r <= 0.0:
57
+ return None
58
+ return remaining / r
59
+
60
+ def reset(self) -> None:
61
+ """Reset all state."""
62
+ self._ewma_rate = None
63
+ self._last_timestamp = None
64
+ 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,207 @@
1
+ """
2
+ BarRenderer — Terminal string formatting for progress bars.
3
+
4
+ String formatting only; no I/O. Fully unit-testable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ ANSI_COLORS: dict[str, str] = {
10
+ "black": "\033[30m",
11
+ "red": "\033[31m",
12
+ "green": "\033[32m",
13
+ "yellow": "\033[33m",
14
+ "blue": "\033[34m",
15
+ "magenta": "\033[35m",
16
+ "cyan": "\033[36m",
17
+ "white": "\033[37m",
18
+ "bright_black": "\033[90m",
19
+ "bright_red": "\033[91m",
20
+ "bright_green": "\033[92m",
21
+ "bright_yellow": "\033[93m",
22
+ "bright_blue": "\033[94m",
23
+ "bright_magenta": "\033[95m",
24
+ "bright_cyan": "\033[96m",
25
+ "bright_white": "\033[97m",
26
+ }
27
+
28
+ ANSI_RESET = "\033[0m"
29
+
30
+ SPINNER_FRAMES: list[str] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
31
+
32
+ _SI_PREFIXES = ["", "K", "M", "G", "T", "P"]
33
+ _BINARY_PREFIXES = ["", "Ki", "Mi", "Gi", "Ti", "Pi"]
34
+
35
+
36
+ def _apply_color(text: str, color: str) -> str:
37
+ """Apply ANSI color to text."""
38
+ code = ANSI_COLORS.get(color.lower(), "")
39
+ if not code:
40
+ return text
41
+ return f"{code}{text}{ANSI_RESET}"
42
+
43
+
44
+ def _format_duration(seconds: float) -> str:
45
+ """Format seconds as H:MM:SS or M:SS."""
46
+ m, s = divmod(int(seconds), 60)
47
+ h, m = divmod(m, 60)
48
+ if h:
49
+ return f"{h}:{m:02d}:{s:02d}"
50
+ return f"{m}:{s:02d}"
51
+
52
+
53
+ def _scale_value(value: float, divisor: int) -> tuple[float, str]:
54
+ """Return (scaled_value, prefix_string) for display."""
55
+ prefixes = _BINARY_PREFIXES if divisor == 1024 else _SI_PREFIXES
56
+ for prefix in prefixes[:-1]:
57
+ if abs(value) < divisor:
58
+ return value, prefix
59
+ value /= divisor
60
+ return value, prefixes[-1]
61
+
62
+
63
+ class BarRenderer:
64
+ """
65
+ Renders progress bar strings for terminal output.
66
+
67
+ No I/O performed here — returns strings only.
68
+ """
69
+
70
+ def _compute_percentage(self, completed: int, total: int) -> float:
71
+ """Compute percentage, handling zero total."""
72
+ if total == 0:
73
+ return 100.0
74
+ return min(100.0, (completed / total) * 100.0)
75
+
76
+ def _format_count(
77
+ self,
78
+ completed: int,
79
+ total: int | None,
80
+ unit: str = "it",
81
+ unit_scale: bool = False,
82
+ unit_divisor: int = 1000,
83
+ ) -> str:
84
+ """Format the count display string."""
85
+ if not unit_scale:
86
+ if total is not None:
87
+ return f"[{completed}/{total} {unit}]"
88
+ return f"[{completed} {unit}]"
89
+ c_val, c_prefix = _scale_value(float(completed), unit_divisor)
90
+ if total is not None:
91
+ t_val, t_prefix = _scale_value(float(total), unit_divisor)
92
+ return f"[{c_val:.1f}{c_prefix}{unit}/{t_val:.1f}{t_prefix}{unit}]"
93
+ return f"[{c_val:.1f}{c_prefix}{unit}]"
94
+
95
+ def _format_rate(
96
+ self,
97
+ rate: float | None,
98
+ unit: str = "it",
99
+ unit_scale: bool = False,
100
+ unit_divisor: int = 1000,
101
+ ) -> str:
102
+ """Format the rate display string."""
103
+ if rate is None:
104
+ return ""
105
+ if not unit_scale:
106
+ return f"{rate:.1f} {unit}/s"
107
+ val, prefix = _scale_value(rate, unit_divisor)
108
+ return f"{val:.1f} {prefix}{unit}/s"
109
+
110
+ def render(
111
+ self,
112
+ *,
113
+ description: str,
114
+ completed: int,
115
+ total: int | None,
116
+ elapsed: float,
117
+ eta: float | None,
118
+ rate: float | None,
119
+ bar_width: int,
120
+ fill_char: str,
121
+ empty_char: str,
122
+ color: str | None,
123
+ show_eta: bool,
124
+ show_elapsed: bool,
125
+ show_count: bool,
126
+ show_percentage: bool,
127
+ unit: str = "it",
128
+ unit_scale: bool = False,
129
+ unit_divisor: int = 1000,
130
+ ) -> str:
131
+ """
132
+ Render a single-line progress bar string.
133
+
134
+ Returns a string ready for terminal output (no newline).
135
+ """
136
+ parts: list[str] = []
137
+
138
+ # Description
139
+ if description:
140
+ parts.append(description)
141
+
142
+ # Bar graphic
143
+ if total is not None:
144
+ pct = self._compute_percentage(completed, total)
145
+ filled = int(bar_width * pct / 100)
146
+ bar = fill_char * filled + empty_char * (bar_width - filled)
147
+ parts.append(f" {bar} ")
148
+
149
+ # Percentage
150
+ if show_percentage:
151
+ parts.append(f"{pct:.0f}%")
152
+
153
+ # Count
154
+ if show_count:
155
+ parts.append(self._format_count(completed, total, unit, unit_scale, unit_divisor))
156
+ else:
157
+ # Indeterminate — no bar graphic
158
+ if show_count:
159
+ parts.append(self._format_count(completed, None, unit, unit_scale, unit_divisor))
160
+
161
+ # Elapsed
162
+ if show_elapsed:
163
+ parts.append(f"⏱ {_format_duration(elapsed)}")
164
+
165
+ # ETA
166
+ if show_eta and eta is not None:
167
+ parts.append(f"ETA: {_format_duration(eta)}")
168
+
169
+ # Rate
170
+ if rate is not None:
171
+ rate_str = self._format_rate(rate, unit, unit_scale, unit_divisor)
172
+ if rate_str:
173
+ parts.append(rate_str)
174
+
175
+ line = " ".join(parts)
176
+ if color:
177
+ line = _apply_color(line, color)
178
+ return line
179
+
180
+ def render_spinner(
181
+ self,
182
+ *,
183
+ description: str,
184
+ elapsed: float,
185
+ completed: int,
186
+ frame_index: int,
187
+ rate: float | None,
188
+ color: str | None,
189
+ show_elapsed: bool = True,
190
+ show_count: bool = True,
191
+ ) -> str:
192
+ """Render a single-line spinner for indeterminate progress."""
193
+ spinner = SPINNER_FRAMES[frame_index % len(SPINNER_FRAMES)]
194
+ parts: list[str] = []
195
+ if description:
196
+ parts.append(description)
197
+ parts.append(spinner)
198
+ if show_count:
199
+ parts.append(f"{completed} items")
200
+ if show_elapsed:
201
+ parts.append(f"⏱ {_format_duration(elapsed)}")
202
+ if rate is not None:
203
+ parts.append(f"{rate:.1f} items/s")
204
+ line = " ".join(parts)
205
+ if color:
206
+ line = _apply_color(line, color)
207
+ 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."""
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"\033[{n}A"
24
+
25
+
26
+ def erase_line() -> str:
27
+ """Return ANSI escape sequence to erase the current line."""
28
+ return "\033[2K\r"
29
+
30
+
31
+ def hide_cursor() -> str:
32
+ """Return ANSI escape sequence to hide the cursor."""
33
+ return "\033[?25l"
34
+
35
+
36
+ def show_cursor() -> str:
37
+ """Return ANSI escape sequence to show the cursor."""
38
+ return "\033[?25h"
File without changes