processit 0.0.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.
- processit-0.0.1/LICENSE +21 -0
- processit-0.0.1/PKG-INFO +35 -0
- processit-0.0.1/README.md +234 -0
- processit-0.0.1/pyproject.toml +144 -0
- processit-0.0.1/setup.cfg +4 -0
- processit-0.0.1/src/processit/__init__.py +7 -0
- processit-0.0.1/src/processit/_version.py +2 -0
- processit-0.0.1/src/processit/progress.py +360 -0
- processit-0.0.1/src/processit/py.typed +0 -0
- processit-0.0.1/src/processit.egg-info/PKG-INFO +35 -0
- processit-0.0.1/src/processit.egg-info/SOURCES.txt +13 -0
- processit-0.0.1/src/processit.egg-info/dependency_links.txt +1 -0
- processit-0.0.1/src/processit.egg-info/requires.txt +7 -0
- processit-0.0.1/src/processit.egg-info/top_level.txt +1 -0
- processit-0.0.1/tests/test_progress.py +331 -0
processit-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vicente Ruiz
|
|
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.
|
processit-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: processit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Process It
|
|
5
|
+
Author: Vicente Ruiz
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Vicente Ruiz
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# processit
|
|
2
|
+
|
|
3
|
+
A lightweight progress utility for Python — built for both synchronous and asynchronous iteration.
|
|
4
|
+
|
|
5
|
+
`processit` provides a simple, dependency-free progress bar for loops that may be either regular iterables or async iterables.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install processit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Example
|
|
18
|
+
|
|
19
|
+
### progress
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from processit import progress
|
|
26
|
+
|
|
27
|
+
def numbers():
|
|
28
|
+
for i in range(10):
|
|
29
|
+
time.sleep(0.3)
|
|
30
|
+
yield i
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
async for _ in progress(numbers(), total=10, desc="Numbers"):
|
|
34
|
+
await asyncio.sleep(0)
|
|
35
|
+
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Numbers [#############.................] 50.00% (5/10) 4.92 it/s 02.1s ETA 02.1s
|
|
41
|
+
Numbers: 10 it in 04.1s (2.43 it/s)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
import time
|
|
47
|
+
|
|
48
|
+
from processit import progress
|
|
49
|
+
|
|
50
|
+
def numbers():
|
|
51
|
+
for i in range(10):
|
|
52
|
+
time.sleep(0.3)
|
|
53
|
+
yield i
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
async with progress(numbers(), total=10, desc='Numbers') as p:
|
|
57
|
+
async for n in p:
|
|
58
|
+
p.write(f'value: {n}')
|
|
59
|
+
await asyncio.sleep(0.5)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### track_as_completed
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
import random
|
|
70
|
+
|
|
71
|
+
from processit import track_as_completed
|
|
72
|
+
|
|
73
|
+
async def work(n: int) -> int:
|
|
74
|
+
await asyncio.sleep(1.5 + random.random())
|
|
75
|
+
return n * 2
|
|
76
|
+
|
|
77
|
+
async def main():
|
|
78
|
+
tasks = [work(i) for i in range(10)]
|
|
79
|
+
async for fut in track_as_completed(tasks, desc="Parallel work"):
|
|
80
|
+
await fut
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
Parallel work [#####################.........] 70.00% (7/10) 68.68 it/s 00.1s ETA 00.0s14
|
|
87
|
+
Parallel work: 10 it in 00.1s (98.01 it/s)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import asyncio
|
|
92
|
+
|
|
93
|
+
from processit import track_as_completed
|
|
94
|
+
|
|
95
|
+
async def work(i: int) -> int:
|
|
96
|
+
await asyncio.sleep(2)
|
|
97
|
+
return i * 2
|
|
98
|
+
|
|
99
|
+
async def main():
|
|
100
|
+
tasks = [work(i) for i in range(10)]
|
|
101
|
+
async with track_as_completed(tasks, desc="Parallel work") as p:
|
|
102
|
+
async for task in p: # itera mientras re-renderiza
|
|
103
|
+
result = await task
|
|
104
|
+
p.write(f"done: {result}") # en vez de print(result)
|
|
105
|
+
await asyncio.sleep(2)
|
|
106
|
+
|
|
107
|
+
asyncio.run(main())
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## More examples
|
|
111
|
+
|
|
112
|
+
### Asynchronous iteration over a data source
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
import asyncio
|
|
116
|
+
|
|
117
|
+
from processit import progress
|
|
118
|
+
|
|
119
|
+
async def fetch_items():
|
|
120
|
+
for i in range(20):
|
|
121
|
+
await asyncio.sleep(0.05)
|
|
122
|
+
yield f"item-{i}"
|
|
123
|
+
|
|
124
|
+
async def main():
|
|
125
|
+
async for item in progress(fetch_items(), total=20, desc="Fetching"):
|
|
126
|
+
await asyncio.sleep(0.02)
|
|
127
|
+
|
|
128
|
+
asyncio.run(main())
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Processing a list without a defined total
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
import asyncio
|
|
135
|
+
|
|
136
|
+
from processit import progress
|
|
137
|
+
|
|
138
|
+
items = [x ** 2 for x in range(100)]
|
|
139
|
+
|
|
140
|
+
async def main():
|
|
141
|
+
async for value in progress(items, desc="Squaring"):
|
|
142
|
+
await asyncio.sleep(0.01)
|
|
143
|
+
|
|
144
|
+
asyncio.run(main())
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Using an async with context
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import asyncio
|
|
151
|
+
|
|
152
|
+
from processit import progress
|
|
153
|
+
|
|
154
|
+
async def numbers():
|
|
155
|
+
for i in range(8):
|
|
156
|
+
await asyncio.sleep(0.1)
|
|
157
|
+
yield i
|
|
158
|
+
|
|
159
|
+
async def main():
|
|
160
|
+
async with progress(numbers(), total=8, desc="Context mode") as p:
|
|
161
|
+
async for n in p:
|
|
162
|
+
await asyncio.sleep(0.05)
|
|
163
|
+
|
|
164
|
+
asyncio.run(main())
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Synchronous iterator in an asynchronous environment
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
import asyncio
|
|
171
|
+
import time
|
|
172
|
+
|
|
173
|
+
from processit import progress
|
|
174
|
+
|
|
175
|
+
def blocking_iter():
|
|
176
|
+
for i in range(5):
|
|
177
|
+
time.sleep(0.4)
|
|
178
|
+
yield i
|
|
179
|
+
|
|
180
|
+
async def main():
|
|
181
|
+
async for n in progress(blocking_iter(), total=5, desc="Blocking loop"):
|
|
182
|
+
await asyncio.sleep(0)
|
|
183
|
+
|
|
184
|
+
asyncio.run(main())
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Features
|
|
190
|
+
|
|
191
|
+
✅ Works with both **async** and **sync** iterables
|
|
192
|
+
✅ Displays **elapsed time**, **rate**, and **ETA** (when total is known)
|
|
193
|
+
✅ Automatically cleans up and prints a **final summary**
|
|
194
|
+
✅ **No dependencies** — pure Python, fully type-hinted
|
|
195
|
+
✅ Easy to use drop-in function: `progress(iterable, ...)`
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## API
|
|
200
|
+
|
|
201
|
+
### `progress(iterable, total=None, *, desc='Processing', width=30, refresh_interval=0.1, show_summary=True)`
|
|
202
|
+
|
|
203
|
+
Creates and returns a `Progress` instance.
|
|
204
|
+
|
|
205
|
+
#### Parameters
|
|
206
|
+
| Name | Type | Description |
|
|
207
|
+
|------|------|-------------|
|
|
208
|
+
| `iterable` | `Iterable[T] | AsyncIterable[T]` | The iterable or async iterable to track. |
|
|
209
|
+
| `total` | `int | None` | Total number of iterations (optional). |
|
|
210
|
+
| `desc` | `str` | A short description shown before the bar. |
|
|
211
|
+
| `width` | `int` | Width of the progress bar (default: 30). |
|
|
212
|
+
| `refresh_interval` | `float` | Time in seconds between updates. |
|
|
213
|
+
| `show_summary` | `bool` | Whether to show a final summary line (default: `True`). |
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Usage with Regular Iterables
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from processit import progress
|
|
221
|
+
import time
|
|
222
|
+
|
|
223
|
+
def numbers():
|
|
224
|
+
for i in range(5):
|
|
225
|
+
time.sleep(0.3)
|
|
226
|
+
yield i
|
|
227
|
+
|
|
228
|
+
async def main():
|
|
229
|
+
async for n in progress(numbers(), total=5, desc="Sync loop"):
|
|
230
|
+
await asyncio.sleep(0)
|
|
231
|
+
|
|
232
|
+
import asyncio
|
|
233
|
+
asyncio.run(main())
|
|
234
|
+
```
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "processit"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Process It"
|
|
9
|
+
authors = [{name = "Vicente Ruiz"}]
|
|
10
|
+
license = {file = "LICENSE"}
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[tool.setuptools.dynamic]
|
|
14
|
+
version = {attr = "processit._version.__version__"}
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=8.0",
|
|
19
|
+
"pytest-asyncio>=0.24",
|
|
20
|
+
"pytest-cov>=5.0",
|
|
21
|
+
"ruff>=0.5.0",
|
|
22
|
+
"mypy>=1.10",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
# Exclude directories.
|
|
27
|
+
exclude = [
|
|
28
|
+
".git",
|
|
29
|
+
".mypy_cache",
|
|
30
|
+
".ruff_cache",
|
|
31
|
+
".vscode",
|
|
32
|
+
"build",
|
|
33
|
+
"dist",
|
|
34
|
+
"site-packages",
|
|
35
|
+
"env",
|
|
36
|
+
]
|
|
37
|
+
respect-gitignore = true
|
|
38
|
+
|
|
39
|
+
line-length = 79
|
|
40
|
+
indent-width = 4
|
|
41
|
+
target-version = "py313"
|
|
42
|
+
src = ["src", "tests"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
|
|
47
|
+
select = [
|
|
48
|
+
"E", # pycodestyle
|
|
49
|
+
"D", # pydocstyle
|
|
50
|
+
"F", # Pyflakes
|
|
51
|
+
"I", # isort
|
|
52
|
+
"N", # pep8-naming
|
|
53
|
+
"PL", # pylint
|
|
54
|
+
"UP", # pyupgrade
|
|
55
|
+
"A", # flake8-builtins
|
|
56
|
+
"ASYNC", # flake8-async
|
|
57
|
+
"S", # flake8-bandit
|
|
58
|
+
"FBT", # flake8-boolean-trap
|
|
59
|
+
"B", # flake8-bugbear
|
|
60
|
+
"A", # flake8-builtins
|
|
61
|
+
"COM", # flake8-commas
|
|
62
|
+
"C4", # flake8-comprehensions
|
|
63
|
+
"DTZ", # flake8-datetimez
|
|
64
|
+
"T10", # flake8-debugger
|
|
65
|
+
"EM", # flake8-errmsg
|
|
66
|
+
"FA", # flake8-future-annotations
|
|
67
|
+
"ISC", # flake8-implicit-str-concat
|
|
68
|
+
"ICN", # flake8-import-conventions
|
|
69
|
+
"PIE", # flake8-pie
|
|
70
|
+
"T20", # flake8-print
|
|
71
|
+
"PYI", # flake8-pyi
|
|
72
|
+
"PT", # flake8-pytest-style
|
|
73
|
+
"RSE", # flake8-raise
|
|
74
|
+
"RET", # flake8-return
|
|
75
|
+
"SLOT", # flake8-slots
|
|
76
|
+
"SIM", # flake8-simplify
|
|
77
|
+
"TC", # flake8-type-checking
|
|
78
|
+
"INT", # flake8-gettext
|
|
79
|
+
"PTH", # flake8-use-pathlib
|
|
80
|
+
"PERF", # Perflint
|
|
81
|
+
"FURB", # refurb
|
|
82
|
+
"RUF", # Ruff specific
|
|
83
|
+
]
|
|
84
|
+
ignore = [
|
|
85
|
+
"D100", # Missing docstring in public module
|
|
86
|
+
"D104", # Missing docstring in public package
|
|
87
|
+
"D105", # Missing docstring in magic method
|
|
88
|
+
"D107", # Missing docstring in `__init__`
|
|
89
|
+
|
|
90
|
+
"D101", # Missing docstring in public class
|
|
91
|
+
"D102", # Missing docstring in public method
|
|
92
|
+
"D103", # Missing docstring in public function
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
# Allow fix for all enabled rules (when `--fix`) is provided.
|
|
96
|
+
fixable = ["ALL"]
|
|
97
|
+
unfixable = []
|
|
98
|
+
|
|
99
|
+
# Allow unused variables when underscore-prefixed.
|
|
100
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
101
|
+
|
|
102
|
+
[tool.ruff.lint.per-file-ignores]
|
|
103
|
+
"tests/*" = ["S101"]
|
|
104
|
+
"scripts/generate_avatar_dataset.py" = ["S320", "T201"]
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.flake8-pytest-style]
|
|
107
|
+
parametrize-names-type = "list" # @pytest.mark.parametrize(['name1', 'name2'], ...)
|
|
108
|
+
parametrize-values-row-type = "tuple" # @pytest.mark.parametrize(['name1', 'name2'], [(1, 2), (3, 4)])
|
|
109
|
+
parametrize-values-type = "list" # @pytest.mark.parametrize('name', [1, 2, 3])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
[tool.ruff.lint.pydocstyle]
|
|
113
|
+
convention = "google"
|
|
114
|
+
ignore-decorators = ["typing.overload"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
[tool.ruff.lint.isort]
|
|
118
|
+
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
|
119
|
+
combine-as-imports = true
|
|
120
|
+
lines-between-types = 1
|
|
121
|
+
lines-after-imports = 2
|
|
122
|
+
forced-separate = ["tests"]
|
|
123
|
+
force-sort-within-sections = false
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
[tool.ruff.format]
|
|
127
|
+
quote-style = "single"
|
|
128
|
+
indent-style = "space"
|
|
129
|
+
|
|
130
|
+
# Respect magic trailing commas.
|
|
131
|
+
skip-magic-trailing-comma = false
|
|
132
|
+
|
|
133
|
+
# Like Black, automatically detect the appropriate line ending.
|
|
134
|
+
line-ending = "auto"
|
|
135
|
+
|
|
136
|
+
# Enable auto-formatting of code examples in docstrings
|
|
137
|
+
docstring-code-format = false
|
|
138
|
+
|
|
139
|
+
# Set the line length limit used when formatting code snippets in
|
|
140
|
+
# docstrings.
|
|
141
|
+
#
|
|
142
|
+
# This only has an effect when the `docstring-code-format` setting is
|
|
143
|
+
# enabled.
|
|
144
|
+
docstring-code-line-length = "dynamic"
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, TextIO, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import (
|
|
13
|
+
AsyncIterable,
|
|
14
|
+
AsyncIterator,
|
|
15
|
+
Awaitable,
|
|
16
|
+
Iterable,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Progress[T]:
|
|
21
|
+
__slots__ = (
|
|
22
|
+
'_is_tty',
|
|
23
|
+
'_last_line_len',
|
|
24
|
+
'_last_refresh',
|
|
25
|
+
'_next_render_at',
|
|
26
|
+
'_prefix',
|
|
27
|
+
'_refresh_task',
|
|
28
|
+
'_stopped',
|
|
29
|
+
'_summary_printed',
|
|
30
|
+
'count',
|
|
31
|
+
'desc',
|
|
32
|
+
'iterable',
|
|
33
|
+
'refresh_interval',
|
|
34
|
+
'show_summary',
|
|
35
|
+
'start_time',
|
|
36
|
+
'stream',
|
|
37
|
+
'total',
|
|
38
|
+
'width',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def __init__( # noqa: PLR0913
|
|
42
|
+
self,
|
|
43
|
+
iterable: Iterable[T] | AsyncIterable[T],
|
|
44
|
+
total: int | None = None,
|
|
45
|
+
*,
|
|
46
|
+
desc: str = 'Processing',
|
|
47
|
+
width: int = 30,
|
|
48
|
+
stream: TextIO | None = None,
|
|
49
|
+
refresh_interval: float = 0.1,
|
|
50
|
+
show_summary: bool = True,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.iterable = iterable
|
|
53
|
+
self.total = total
|
|
54
|
+
self.desc = desc
|
|
55
|
+
self.width = width
|
|
56
|
+
self.stream = stream or sys.stderr
|
|
57
|
+
self.refresh_interval = refresh_interval
|
|
58
|
+
self.show_summary = show_summary
|
|
59
|
+
|
|
60
|
+
self.count = 0
|
|
61
|
+
self.start_time = time.perf_counter()
|
|
62
|
+
self._last_refresh = 0.0
|
|
63
|
+
self._next_render_at = 0.0 # render inmediato al inicio
|
|
64
|
+
self._refresh_task: asyncio.Task[None] | None = None
|
|
65
|
+
self._summary_printed = False
|
|
66
|
+
self._last_line_len = 0
|
|
67
|
+
self._stopped = False
|
|
68
|
+
self._is_tty = bool(getattr(self.stream, 'isatty', lambda: False)())
|
|
69
|
+
self._prefix = f'{self.desc} '
|
|
70
|
+
|
|
71
|
+
def write(self, msg: str = '') -> None:
|
|
72
|
+
"""Print a message below the bar and re-render it (TTY-safe)."""
|
|
73
|
+
if self._stopped:
|
|
74
|
+
return
|
|
75
|
+
self._clear_line()
|
|
76
|
+
if msg:
|
|
77
|
+
if not msg.endswith('\n'):
|
|
78
|
+
msg += '\n'
|
|
79
|
+
self.stream.write(msg)
|
|
80
|
+
self.stream.flush()
|
|
81
|
+
self._render(force=True)
|
|
82
|
+
|
|
83
|
+
def _format_elapsed(self, seconds: float) -> str:
|
|
84
|
+
"""Return hh:mm:ss, mm:ss or ss.s depending on duration."""
|
|
85
|
+
if seconds < 60: # noqa: PLR2004
|
|
86
|
+
return f'{seconds:04.1f}s'
|
|
87
|
+
minutes, secs = divmod(int(seconds), 60)
|
|
88
|
+
if minutes < 60: # noqa: PLR2004
|
|
89
|
+
return f'{minutes:02d}:{secs:02d}'
|
|
90
|
+
hours, minutes = divmod(minutes, 60)
|
|
91
|
+
return f'{hours:02d}:{minutes:02d}:{secs:02d}'
|
|
92
|
+
|
|
93
|
+
def _write_line(self, text: str) -> None:
|
|
94
|
+
if self._is_tty:
|
|
95
|
+
# \r = return, \x1b[2K = clear whole line (ANSI)
|
|
96
|
+
self.stream.write('\r\x1b[2K' + text)
|
|
97
|
+
self.stream.flush()
|
|
98
|
+
self._last_line_len = len(text)
|
|
99
|
+
else:
|
|
100
|
+
# non-TTY (StringIO/logs): one line per render for deterministic
|
|
101
|
+
# tests/logs
|
|
102
|
+
self.stream.write(text + '\n')
|
|
103
|
+
self.stream.flush()
|
|
104
|
+
self._last_line_len = 0
|
|
105
|
+
|
|
106
|
+
def _clear_line(self) -> None:
|
|
107
|
+
if self._is_tty:
|
|
108
|
+
self.stream.write('\r\x1b[2K')
|
|
109
|
+
self.stream.flush()
|
|
110
|
+
self._last_line_len = 0
|
|
111
|
+
else:
|
|
112
|
+
# non-TTY: renders are on separate lines; nothing to clear
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def _should_render(self, now: float) -> bool:
|
|
116
|
+
# Permite render al inicio (_next_render_at=0) o si ha
|
|
117
|
+
# pasado refresh_interval
|
|
118
|
+
return now >= self._next_render_at or self._last_line_len == 0
|
|
119
|
+
|
|
120
|
+
def _render(self, *, force: bool = False) -> None:
|
|
121
|
+
if self._stopped:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
now = time.perf_counter()
|
|
125
|
+
if not force and not self._should_render(now):
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
elapsed = now - self.start_time
|
|
129
|
+
rate = self.count / elapsed if elapsed > 0 else 0.0
|
|
130
|
+
elapsed_str = self._format_elapsed(elapsed)
|
|
131
|
+
|
|
132
|
+
eta_str = ''
|
|
133
|
+
if self.total is not None and self.count > 0 and rate > 0:
|
|
134
|
+
remaining = max(self.total - self.count, 0)
|
|
135
|
+
eta = remaining / rate
|
|
136
|
+
eta_str = f' ETA {self._format_elapsed(eta)}'
|
|
137
|
+
|
|
138
|
+
if self.total:
|
|
139
|
+
frac = min(self.count / self.total, 1.0)
|
|
140
|
+
filled = int(self.width * frac)
|
|
141
|
+
bar = f'[{"#" * filled}{"." * (self.width - filled)}]'
|
|
142
|
+
percent = f'{frac * 100:6.2f}%'
|
|
143
|
+
line = (
|
|
144
|
+
f'{self._prefix}{bar} {percent} '
|
|
145
|
+
f'({self.count}/{self.total}) {rate:.2f} it/s '
|
|
146
|
+
f'{elapsed_str}{eta_str}'
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
line = (
|
|
150
|
+
f'{self._prefix}{self.count} it '
|
|
151
|
+
f'({rate:.2f} it/s {elapsed_str})'
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self._write_line(line)
|
|
155
|
+
self._last_refresh = now
|
|
156
|
+
self._next_render_at = now + self.refresh_interval
|
|
157
|
+
|
|
158
|
+
def _print_summary_if_needed(self) -> None:
|
|
159
|
+
if self.show_summary and not self._summary_printed:
|
|
160
|
+
self._stopped = True
|
|
161
|
+
self._clear_line()
|
|
162
|
+
|
|
163
|
+
elapsed = time.perf_counter() - self.start_time
|
|
164
|
+
rate = self.count / elapsed if elapsed > 0 else 0.0
|
|
165
|
+
elapsed_str = self._format_elapsed(elapsed)
|
|
166
|
+
|
|
167
|
+
self.stream.write(
|
|
168
|
+
f'{self.desc}: {self.count} it in {elapsed_str} '
|
|
169
|
+
f'({rate:.2f} it/s)\n',
|
|
170
|
+
)
|
|
171
|
+
self.stream.flush()
|
|
172
|
+
self._summary_printed = True
|
|
173
|
+
|
|
174
|
+
async def _refresh_periodically(self) -> None:
|
|
175
|
+
while not self._stopped:
|
|
176
|
+
await asyncio.sleep(self.refresh_interval)
|
|
177
|
+
if self._stopped:
|
|
178
|
+
break
|
|
179
|
+
self._render()
|
|
180
|
+
|
|
181
|
+
async def __aenter__(self) -> Progress[T]:
|
|
182
|
+
self._stopped = False
|
|
183
|
+
self._render(force=True) # feedback inmediato
|
|
184
|
+
self._refresh_task = asyncio.create_task(self._refresh_periodically())
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
async def __aexit__(self, *_: object) -> None:
|
|
188
|
+
if self._refresh_task is not None:
|
|
189
|
+
self._refresh_task.cancel()
|
|
190
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
191
|
+
await self._refresh_task
|
|
192
|
+
self._refresh_task = None
|
|
193
|
+
self._print_summary_if_needed()
|
|
194
|
+
|
|
195
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
196
|
+
started_here = False
|
|
197
|
+
|
|
198
|
+
if self._refresh_task is None:
|
|
199
|
+
self._stopped = False
|
|
200
|
+
self._render(
|
|
201
|
+
force=True,
|
|
202
|
+
) # feedback inmediato fuera de context manager
|
|
203
|
+
self._refresh_task = asyncio.create_task(
|
|
204
|
+
self._refresh_periodically(),
|
|
205
|
+
)
|
|
206
|
+
started_here = True
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if hasattr(self.iterable, '__aiter__'):
|
|
210
|
+
async for item in cast('AsyncIterable[T]', self.iterable):
|
|
211
|
+
self.count += 1
|
|
212
|
+
self._render()
|
|
213
|
+
yield item
|
|
214
|
+
else:
|
|
215
|
+
for item in cast('Iterable[T]', self.iterable):
|
|
216
|
+
self.count += 1
|
|
217
|
+
self._render()
|
|
218
|
+
yield item
|
|
219
|
+
# cede el loop para no bloquear el refresco
|
|
220
|
+
await asyncio.sleep(0)
|
|
221
|
+
finally:
|
|
222
|
+
if started_here and self._refresh_task is not None: # type: ignore
|
|
223
|
+
self._refresh_task.cancel()
|
|
224
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
225
|
+
await self._refresh_task
|
|
226
|
+
self._refresh_task = None
|
|
227
|
+
self._print_summary_if_needed()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def progress[T]( # noqa: PLR0913
|
|
231
|
+
iterable: Iterable[T] | AsyncIterable[T],
|
|
232
|
+
total: int | None = None,
|
|
233
|
+
*,
|
|
234
|
+
desc: str = 'Processing',
|
|
235
|
+
width: int = 30,
|
|
236
|
+
refresh_interval: float = 0.1,
|
|
237
|
+
show_summary: bool = True,
|
|
238
|
+
stream: TextIO | None = None,
|
|
239
|
+
) -> Progress[T]:
|
|
240
|
+
"""Wrap an iterable or async iterable with a progress bar.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
iterable: Iterable[T] | AsyncIterable[T]
|
|
245
|
+
The iterable or async iterable to iterate over.
|
|
246
|
+
total: int | None, optional
|
|
247
|
+
Total number of elements. If not provided and `iterable` has
|
|
248
|
+
`__len__`, it will be inferred automatically. Otherwise, ETA
|
|
249
|
+
will not be displayed.
|
|
250
|
+
desc: str, optional
|
|
251
|
+
A short description displayed before the progress bar.
|
|
252
|
+
width: int, optional
|
|
253
|
+
The width (in characters) of the progress bar (default: 30).
|
|
254
|
+
refresh_interval: float, optional
|
|
255
|
+
Minimum time interval in seconds between display refreshes.
|
|
256
|
+
show_summary: bool, optional
|
|
257
|
+
Whether to print a final summary line showing total iterations,
|
|
258
|
+
total time, and iteration rate (default: True).
|
|
259
|
+
stream: TextIO | None, optional
|
|
260
|
+
Output stream to render the bar (default: sys.stderr).
|
|
261
|
+
|
|
262
|
+
Yields:
|
|
263
|
+
------
|
|
264
|
+
T
|
|
265
|
+
Each element from the iterable or async iterable, in order.
|
|
266
|
+
"""
|
|
267
|
+
# Infer total if possible
|
|
268
|
+
if total is None and hasattr(iterable, '__len__'):
|
|
269
|
+
try:
|
|
270
|
+
total = len(iterable) # type: ignore[arg-type]
|
|
271
|
+
except Exception:
|
|
272
|
+
total = None
|
|
273
|
+
|
|
274
|
+
return Progress(
|
|
275
|
+
iterable,
|
|
276
|
+
total,
|
|
277
|
+
desc=desc,
|
|
278
|
+
width=width,
|
|
279
|
+
refresh_interval=refresh_interval,
|
|
280
|
+
show_summary=show_summary,
|
|
281
|
+
stream=stream,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def track_as_completed[T]( # noqa: PLR0913
|
|
286
|
+
tasks: Iterable[Awaitable[T]],
|
|
287
|
+
*,
|
|
288
|
+
total: int | None = None,
|
|
289
|
+
desc: str = 'Processing',
|
|
290
|
+
width: int = 30,
|
|
291
|
+
refresh_interval: float = 0.1,
|
|
292
|
+
show_summary: bool = True,
|
|
293
|
+
stream: TextIO | None = None,
|
|
294
|
+
) -> Progress[asyncio.Future[T]]:
|
|
295
|
+
"""Iterate results as tasks complete, with a progress bar.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
tasks: Iterable[Awaitable[T]]
|
|
300
|
+
Awaitables or tasks to run/track.
|
|
301
|
+
total: int | None, optional
|
|
302
|
+
Total number of tasks. If not provided and `tasks` has `__len__`,
|
|
303
|
+
it will be inferred. Otherwise, ETA will not be shown.
|
|
304
|
+
desc: str
|
|
305
|
+
Short description prefix for the bar.
|
|
306
|
+
width: int
|
|
307
|
+
Progress bar width (characters).
|
|
308
|
+
refresh_interval: float
|
|
309
|
+
Seconds between refreshes.
|
|
310
|
+
show_summary: bool
|
|
311
|
+
Whether to print a final summary line.
|
|
312
|
+
stream: TextIO | None, optional
|
|
313
|
+
Output stream to render the bar (default: sys.stderr).
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
-------
|
|
317
|
+
Progress[Future[T]]
|
|
318
|
+
An async-iterable of **futures** (await them in the loop).
|
|
319
|
+
|
|
320
|
+
Notes:
|
|
321
|
+
-----
|
|
322
|
+
We *avoid* passing the synchronous iterator from
|
|
323
|
+
`asyncio.as_completed(...)` directly to `Progress`, since its `next()` can
|
|
324
|
+
block the event loop. Instead, we drive completion asynchronously using
|
|
325
|
+
`asyncio.wait(FIRST_COMPLETED)`.
|
|
326
|
+
"""
|
|
327
|
+
# Infer total if possible
|
|
328
|
+
if total is None and hasattr(tasks, '__len__'):
|
|
329
|
+
try:
|
|
330
|
+
total = len(tasks) # type: ignore[arg-type]
|
|
331
|
+
except Exception:
|
|
332
|
+
total = None
|
|
333
|
+
|
|
334
|
+
async def _as_completed_async() -> AsyncIterable[asyncio.Future[T]]:
|
|
335
|
+
pending: set[asyncio.Future[T]] = {
|
|
336
|
+
asyncio.ensure_future(t)
|
|
337
|
+
for t in tasks # type: ignore[arg-type]
|
|
338
|
+
}
|
|
339
|
+
try:
|
|
340
|
+
while pending:
|
|
341
|
+
done, pending = await asyncio.wait(
|
|
342
|
+
pending,
|
|
343
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
344
|
+
)
|
|
345
|
+
for fut in done:
|
|
346
|
+
yield fut
|
|
347
|
+
finally:
|
|
348
|
+
# No cancelamos 'pending' por defecto (el consumidor decide su
|
|
349
|
+
# ciclo de vida).
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
return progress(
|
|
353
|
+
_as_completed_async(),
|
|
354
|
+
total=total,
|
|
355
|
+
desc=desc,
|
|
356
|
+
width=width,
|
|
357
|
+
refresh_interval=refresh_interval,
|
|
358
|
+
show_summary=show_summary,
|
|
359
|
+
stream=stream,
|
|
360
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: processit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Process It
|
|
5
|
+
Author: Vicente Ruiz
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Vicente Ruiz
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/processit/__init__.py
|
|
5
|
+
src/processit/_version.py
|
|
6
|
+
src/processit/progress.py
|
|
7
|
+
src/processit/py.typed
|
|
8
|
+
src/processit.egg-info/PKG-INFO
|
|
9
|
+
src/processit.egg-info/SOURCES.txt
|
|
10
|
+
src/processit.egg-info/dependency_links.txt
|
|
11
|
+
src/processit.egg-info/requires.txt
|
|
12
|
+
src/processit.egg-info/top_level.txt
|
|
13
|
+
tests/test_progress.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
processit
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# tests/test_processit.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import io
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from processit.progress import Progress, progress, track_as_completed
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import AsyncIterator, Iterator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# -----------------------------
|
|
20
|
+
# Helpers
|
|
21
|
+
# -----------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _strip_ansi(s: str) -> str:
|
|
25
|
+
# El progreso usa \r y \x1b[2K; limpiamos para aserciones más simples
|
|
26
|
+
s = s.replace('\r', '')
|
|
27
|
+
return s.replace('\x1b[2K', '')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _last_nonempty_line(buf: str) -> str:
|
|
31
|
+
for line in reversed(buf.splitlines()):
|
|
32
|
+
if line.strip():
|
|
33
|
+
return line
|
|
34
|
+
return ''
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -----------------------------
|
|
38
|
+
# Synchronous iterable cases
|
|
39
|
+
# -----------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_sync_iterable_basic_summary_only_once():
|
|
44
|
+
stream = io.StringIO()
|
|
45
|
+
|
|
46
|
+
def numbers() -> Iterator[int]:
|
|
47
|
+
for i in range(5):
|
|
48
|
+
time.sleep(0.01)
|
|
49
|
+
yield i
|
|
50
|
+
|
|
51
|
+
# Consumo como async for (la clase cede el loop con await asyncio.sleep(0))
|
|
52
|
+
async for _ in progress(
|
|
53
|
+
numbers(),
|
|
54
|
+
desc='Numbers',
|
|
55
|
+
total=5,
|
|
56
|
+
stream=stream,
|
|
57
|
+
refresh_interval=1.0,
|
|
58
|
+
):
|
|
59
|
+
await asyncio.sleep(0)
|
|
60
|
+
|
|
61
|
+
out = _strip_ansi(stream.getvalue())
|
|
62
|
+
# Debe haber un único resumen final
|
|
63
|
+
assert out.count('Numbers: 5 it in ') == 1
|
|
64
|
+
assert 'it/s' in out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_sync_iterable_infers_total_from_len():
|
|
69
|
+
stream = io.StringIO()
|
|
70
|
+
data = list(range(4)) # tiene __len__
|
|
71
|
+
async for _ in progress(
|
|
72
|
+
data,
|
|
73
|
+
desc='Len inf',
|
|
74
|
+
stream=stream,
|
|
75
|
+
refresh_interval=1.0,
|
|
76
|
+
):
|
|
77
|
+
await asyncio.sleep(0)
|
|
78
|
+
out = stream.getvalue()
|
|
79
|
+
# En algún render debe aparecer (4/4)
|
|
80
|
+
assert '(4/4)' in out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -----------------------------
|
|
84
|
+
# Async iterable cases
|
|
85
|
+
# -----------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_async_iterable_basic():
|
|
90
|
+
stream = io.StringIO()
|
|
91
|
+
|
|
92
|
+
async def agen() -> AsyncIterator[int]:
|
|
93
|
+
for i in range(6):
|
|
94
|
+
await asyncio.sleep(0.01)
|
|
95
|
+
yield i
|
|
96
|
+
|
|
97
|
+
async for _ in progress(
|
|
98
|
+
agen(),
|
|
99
|
+
total=6,
|
|
100
|
+
desc='Agen',
|
|
101
|
+
stream=stream,
|
|
102
|
+
refresh_interval=1.0,
|
|
103
|
+
):
|
|
104
|
+
await asyncio.sleep(0)
|
|
105
|
+
|
|
106
|
+
out = _strip_ansi(stream.getvalue())
|
|
107
|
+
assert out.count('Agen: 6 it in ') == 1
|
|
108
|
+
assert 'it/s' in out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_async_iterable_without_total():
|
|
113
|
+
stream = io.StringIO()
|
|
114
|
+
|
|
115
|
+
async def agen() -> AsyncIterator[int]:
|
|
116
|
+
for i in range(3):
|
|
117
|
+
await asyncio.sleep(0.005)
|
|
118
|
+
yield i
|
|
119
|
+
|
|
120
|
+
# Sin total explícito; el resumen siempre debe estar
|
|
121
|
+
async for _ in progress(
|
|
122
|
+
agen(),
|
|
123
|
+
desc='No total',
|
|
124
|
+
stream=stream,
|
|
125
|
+
refresh_interval=1.0,
|
|
126
|
+
):
|
|
127
|
+
await asyncio.sleep(0)
|
|
128
|
+
|
|
129
|
+
out = _strip_ansi(stream.getvalue())
|
|
130
|
+
assert 'No total: 3 it in ' in out
|
|
131
|
+
# No forzamos presencia de '%' porque sin total no hay porcentaje en barra
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# -----------------------------
|
|
135
|
+
# Context manager + write()
|
|
136
|
+
# -----------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_async_with_and_write_no_extra_bar_at_end():
|
|
141
|
+
stream = io.StringIO()
|
|
142
|
+
|
|
143
|
+
def numbers() -> Iterator[int]:
|
|
144
|
+
for i in range(5):
|
|
145
|
+
time.sleep(0.01)
|
|
146
|
+
yield i
|
|
147
|
+
|
|
148
|
+
async with progress(
|
|
149
|
+
numbers(),
|
|
150
|
+
total=5,
|
|
151
|
+
desc='With',
|
|
152
|
+
stream=stream,
|
|
153
|
+
refresh_interval=1.0,
|
|
154
|
+
) as p:
|
|
155
|
+
async for n in p:
|
|
156
|
+
p.write(f'value: {n}')
|
|
157
|
+
await asyncio.sleep(0)
|
|
158
|
+
|
|
159
|
+
out = _strip_ansi(stream.getvalue())
|
|
160
|
+
# El último renglón no debe ser una barra, sino el resumen
|
|
161
|
+
last = _last_nonempty_line(out)
|
|
162
|
+
assert last.startswith('With: 5 it in ')
|
|
163
|
+
# Y que no haya texto de barra después del resumen
|
|
164
|
+
assert 'With [' not in last
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# -----------------------------
|
|
168
|
+
# track_as_completed cases
|
|
169
|
+
# -----------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.asyncio
|
|
173
|
+
async def test_track_as_completed_varied_durations():
|
|
174
|
+
stream = io.StringIO()
|
|
175
|
+
|
|
176
|
+
async def work(n: int) -> int:
|
|
177
|
+
# Duraciones variadas: 0.01..0.06
|
|
178
|
+
await asyncio.sleep(0.01 * (n % 6 + 1))
|
|
179
|
+
return n
|
|
180
|
+
|
|
181
|
+
tasks = [work(i) for i in range(10)]
|
|
182
|
+
|
|
183
|
+
# Itera devolviendo futuros; hacemos await dentro del bucle
|
|
184
|
+
async for fut in track_as_completed(
|
|
185
|
+
tasks,
|
|
186
|
+
desc='Parallel',
|
|
187
|
+
stream=stream,
|
|
188
|
+
refresh_interval=0.02,
|
|
189
|
+
):
|
|
190
|
+
_ = await fut # procesar resultado
|
|
191
|
+
|
|
192
|
+
out = _strip_ansi(stream.getvalue())
|
|
193
|
+
assert 'Parallel: 10 it in ' in out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pytest.mark.asyncio
|
|
197
|
+
async def test_track_as_completed_equal_durations_still_counts():
|
|
198
|
+
stream = io.StringIO()
|
|
199
|
+
|
|
200
|
+
async def work(n: int) -> int:
|
|
201
|
+
await asyncio.sleep(0.02) # todas igual
|
|
202
|
+
return n
|
|
203
|
+
|
|
204
|
+
tasks = [work(i) for i in range(8)]
|
|
205
|
+
async for fut in track_as_completed(
|
|
206
|
+
tasks,
|
|
207
|
+
desc='Equal',
|
|
208
|
+
stream=stream,
|
|
209
|
+
refresh_interval=0.02,
|
|
210
|
+
):
|
|
211
|
+
_ = await fut
|
|
212
|
+
|
|
213
|
+
out = _strip_ansi(stream.getvalue())
|
|
214
|
+
assert 'Equal: 8 it in ' in out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# -----------------------------
|
|
218
|
+
# show_summary flag
|
|
219
|
+
# -----------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_show_summary_false():
|
|
224
|
+
stream = io.StringIO()
|
|
225
|
+
data = [1, 2, 3]
|
|
226
|
+
async for _ in progress(
|
|
227
|
+
data,
|
|
228
|
+
desc='NoSum',
|
|
229
|
+
show_summary=False,
|
|
230
|
+
stream=stream,
|
|
231
|
+
refresh_interval=1.0,
|
|
232
|
+
):
|
|
233
|
+
await asyncio.sleep(0)
|
|
234
|
+
out = _strip_ansi(stream.getvalue())
|
|
235
|
+
# No debe haber resumen
|
|
236
|
+
assert 'NoSum: 3 it in ' not in out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# -----------------------------
|
|
240
|
+
# stream wiring & basic type use
|
|
241
|
+
# -----------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.asyncio
|
|
245
|
+
async def test_progress_stream_stdout_like():
|
|
246
|
+
# Simulamos sys.stdout con StringIO
|
|
247
|
+
stream = io.StringIO()
|
|
248
|
+
|
|
249
|
+
async def agen():
|
|
250
|
+
for i in range(2):
|
|
251
|
+
await asyncio.sleep(0.005)
|
|
252
|
+
yield i
|
|
253
|
+
|
|
254
|
+
async for _ in progress(
|
|
255
|
+
agen(),
|
|
256
|
+
total=2,
|
|
257
|
+
desc='Stdout',
|
|
258
|
+
stream=stream,
|
|
259
|
+
refresh_interval=1.0,
|
|
260
|
+
):
|
|
261
|
+
pass
|
|
262
|
+
out = _strip_ansi(stream.getvalue())
|
|
263
|
+
assert 'Stdout: 2 it in ' in out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# -----------------------------
|
|
267
|
+
# smoke test: Progress usable directamente
|
|
268
|
+
# -----------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@pytest.mark.asyncio
|
|
272
|
+
async def test_progress_class_direct_use():
|
|
273
|
+
stream = io.StringIO()
|
|
274
|
+
|
|
275
|
+
async def agen():
|
|
276
|
+
for i in range(3):
|
|
277
|
+
await asyncio.sleep(0.003)
|
|
278
|
+
yield i
|
|
279
|
+
|
|
280
|
+
p = Progress(
|
|
281
|
+
agen(),
|
|
282
|
+
total=3,
|
|
283
|
+
desc='Class',
|
|
284
|
+
stream=stream,
|
|
285
|
+
refresh_interval=1.0,
|
|
286
|
+
)
|
|
287
|
+
async with p:
|
|
288
|
+
async for _ in p:
|
|
289
|
+
await asyncio.sleep(0)
|
|
290
|
+
out = _strip_ansi(stream.getvalue())
|
|
291
|
+
assert 'Class: 3 it in ' in out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# -----------------------------
|
|
295
|
+
# ETA presence only when total is known (heurística simple)
|
|
296
|
+
# -----------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@pytest.mark.asyncio
|
|
300
|
+
async def test_eta_only_when_total_known():
|
|
301
|
+
stream1 = io.StringIO()
|
|
302
|
+
stream2 = io.StringIO()
|
|
303
|
+
|
|
304
|
+
async def agen():
|
|
305
|
+
for i in range(3):
|
|
306
|
+
await asyncio.sleep(0.005)
|
|
307
|
+
yield i
|
|
308
|
+
|
|
309
|
+
# Con total -> debería haber "ETA"
|
|
310
|
+
async for _ in progress(
|
|
311
|
+
agen(),
|
|
312
|
+
total=3,
|
|
313
|
+
desc='HasTotal',
|
|
314
|
+
stream=stream1,
|
|
315
|
+
refresh_interval=1.0,
|
|
316
|
+
):
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
# Sin total -> preferimos que no aparezca "ETA" en la salida
|
|
320
|
+
async for _ in progress(
|
|
321
|
+
agen(),
|
|
322
|
+
desc='NoTotal',
|
|
323
|
+
stream=stream2,
|
|
324
|
+
refresh_interval=1.0,
|
|
325
|
+
):
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
out1 = stream1.getvalue()
|
|
329
|
+
out2 = stream2.getvalue()
|
|
330
|
+
assert 'ETA' in out1
|
|
331
|
+
assert 'ETA' not in out2
|