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.
- asyncprogress-0.3.0/LICENSE +21 -0
- asyncprogress-0.3.0/PKG-INFO +48 -0
- asyncprogress-0.3.0/README.md +22 -0
- asyncprogress-0.3.0/pyproject.toml +55 -0
- asyncprogress-0.3.0/src/asyncprogress/__init__.py +26 -0
- asyncprogress-0.3.0/src/asyncprogress/_as_completed.py +59 -0
- asyncprogress-0.3.0/src/asyncprogress/_bar.py +262 -0
- asyncprogress-0.3.0/src/asyncprogress/_eta.py +64 -0
- asyncprogress-0.3.0/src/asyncprogress/_gather.py +73 -0
- asyncprogress-0.3.0/src/asyncprogress/_iterator.py +113 -0
- asyncprogress-0.3.0/src/asyncprogress/_multi.py +163 -0
- asyncprogress-0.3.0/src/asyncprogress/_renderer.py +207 -0
- asyncprogress-0.3.0/src/asyncprogress/_terminal.py +38 -0
- asyncprogress-0.3.0/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,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
|