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.
- asyncprogress-0.2.1/LICENSE +21 -0
- asyncprogress-0.2.1/PKG-INFO +55 -0
- asyncprogress-0.2.1/README.md +26 -0
- asyncprogress-0.2.1/pyproject.toml +59 -0
- asyncprogress-0.2.1/src/asyncprogress/__init__.py +25 -0
- asyncprogress-0.2.1/src/asyncprogress/_as_completed.py +59 -0
- asyncprogress-0.2.1/src/asyncprogress/_bar.py +262 -0
- asyncprogress-0.2.1/src/asyncprogress/_eta.py +92 -0
- asyncprogress-0.2.1/src/asyncprogress/_gather.py +73 -0
- asyncprogress-0.2.1/src/asyncprogress/_iterator.py +113 -0
- asyncprogress-0.2.1/src/asyncprogress/_multi.py +163 -0
- asyncprogress-0.2.1/src/asyncprogress/_renderer.py +240 -0
- asyncprogress-0.2.1/src/asyncprogress/_terminal.py +38 -0
- asyncprogress-0.2.1/src/asyncprogress/py.typed +0 -0
|
@@ -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
|
+
[](https://pypi.org/project/asyncprogress/)
|
|
35
|
+
[](https://pypi.org/project/asyncprogress/)
|
|
36
|
+
[](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
|
+
[](https://pypi.org/project/asyncprogress/)
|
|
6
|
+
[](https://pypi.org/project/asyncprogress/)
|
|
7
|
+
[](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
|