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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from processit._version import __version__
4
+ from processit.progress import progress, track_as_completed
5
+
6
+
7
+ __all__ = ('__version__', 'progress', 'track_as_completed')
@@ -0,0 +1,2 @@
1
+ version_info: tuple[int | str, ...] = (0, 0, 1)
2
+ __version__ = '.'.join(map(str, version_info))
@@ -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,7 @@
1
+
2
+ [dev]
3
+ pytest>=8.0
4
+ pytest-asyncio>=0.24
5
+ pytest-cov>=5.0
6
+ ruff>=0.5.0
7
+ mypy>=1.10
@@ -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