processit 0.0.1__py3-none-any.whl

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/__init__.py ADDED
@@ -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')
processit/_version.py ADDED
@@ -0,0 +1,2 @@
1
+ version_info: tuple[int | str, ...] = (0, 0, 1)
2
+ __version__ = '.'.join(map(str, version_info))
processit/progress.py ADDED
@@ -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
+ )
processit/py.typed ADDED
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,9 @@
1
+ processit/__init__.py,sha256=Yxs2DR5SYkiZhCAki5q8cvd2oZhCA_SPAEfej97luQ4,201
2
+ processit/_version.py,sha256=wwNaWq9QRCwZjjmdiUn3cN9CnkLNSKflulOdZE819gM,95
3
+ processit/progress.py,sha256=JP0iTwRoVggBhmR2cB-awI2Ofoc63W56r93fOz6EwRA,11613
4
+ processit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ processit-0.0.1.dist-info/licenses/LICENSE,sha256=z5zNysklw5miG2iYUPyPvTG-XalTzjUN399mUFl48WQ,1069
6
+ processit-0.0.1.dist-info/METADATA,sha256=ia9R86VQlOJjRPzgfZN-FCHD_z-yAt-v-jjCMyIp89w,1632
7
+ processit-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ processit-0.0.1.dist-info/top_level.txt,sha256=zQ9442BBggTNDrtwTRqJjNNP5U2JQ3dhc-kyJe4SJ9Q,10
9
+ processit-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ processit