processit 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Lightweight progress utilities for sync/async workloads
5
5
  Author: Vicente Ruiz
6
6
  License-Expression: MIT
@@ -79,8 +79,9 @@ def numbers():
79
79
 
80
80
  async def main():
81
81
  async with progress(numbers(), total=10, desc='Numbers') as p:
82
+ log = p.log_stream()
82
83
  async for n in p:
83
- p.write(f'value: {n}')
84
+ print(f'value: {n}', file=log)
84
85
  await asyncio.sleep(0.5)
85
86
 
86
87
  asyncio.run(main())
@@ -181,6 +182,23 @@ Need concurrency limit `Semaphore` + `track_as_completed(...)`
181
182
 
182
183
  ---
183
184
 
185
+ ### 3. TTY vs non-TTY output
186
+
187
+ When the output stream is a real TTY, `processit` redraws the same line.
188
+
189
+ When the output stream is not a TTY (for example CI logs, redirected output,
190
+ or `StringIO` in tests), `processit` emits line-based snapshots instead:
191
+
192
+ - It respects `refresh_interval` without periodic duplicate frames
193
+ - It prints a final `100%` snapshot before the summary when `total` is known
194
+ - Messages written with `p.write(...)` or `file=p.log_stream()` stay above the
195
+ live bar; the bar is re-rendered as the last line in TTY mode
196
+
197
+ Timing starts when iteration actually begins, not when the `Progress`
198
+ instance is created.
199
+
200
+ ---
201
+
184
202
  ## More Examples
185
203
 
186
204
  ### Asynchronous iteration over a data source
@@ -250,6 +268,17 @@ Name Type Description
250
268
 
251
269
  ---
252
270
 
271
+ ### Progress helpers inside `async with progress(...) as p`
272
+
273
+ - `p.write("message")`: prints a message above the live bar and re-renders it
274
+ - `p.log_stream()`: returns a file-like stream for `print(..., file=...)` or
275
+ `logging.StreamHandler`
276
+
277
+ If a command mixes progress output with logs, prefer one of those two
278
+ interfaces instead of calling `print(...)` directly on stdout/stderr.
279
+
280
+ ---
281
+
253
282
  ### track_as_completed(tasks, total=None, \*, desc='Processing', width=30, refresh_interval=0.1, show_summary=True)
254
283
 
255
284
  Tracks a collection of awaitables or tasks as they complete.
@@ -58,8 +58,9 @@ def numbers():
58
58
 
59
59
  async def main():
60
60
  async with progress(numbers(), total=10, desc='Numbers') as p:
61
+ log = p.log_stream()
61
62
  async for n in p:
62
- p.write(f'value: {n}')
63
+ print(f'value: {n}', file=log)
63
64
  await asyncio.sleep(0.5)
64
65
 
65
66
  asyncio.run(main())
@@ -160,6 +161,23 @@ Need concurrency limit `Semaphore` + `track_as_completed(...)`
160
161
 
161
162
  ---
162
163
 
164
+ ### 3. TTY vs non-TTY output
165
+
166
+ When the output stream is a real TTY, `processit` redraws the same line.
167
+
168
+ When the output stream is not a TTY (for example CI logs, redirected output,
169
+ or `StringIO` in tests), `processit` emits line-based snapshots instead:
170
+
171
+ - It respects `refresh_interval` without periodic duplicate frames
172
+ - It prints a final `100%` snapshot before the summary when `total` is known
173
+ - Messages written with `p.write(...)` or `file=p.log_stream()` stay above the
174
+ live bar; the bar is re-rendered as the last line in TTY mode
175
+
176
+ Timing starts when iteration actually begins, not when the `Progress`
177
+ instance is created.
178
+
179
+ ---
180
+
163
181
  ## More Examples
164
182
 
165
183
  ### Asynchronous iteration over a data source
@@ -229,6 +247,17 @@ Name Type Description
229
247
 
230
248
  ---
231
249
 
250
+ ### Progress helpers inside `async with progress(...) as p`
251
+
252
+ - `p.write("message")`: prints a message above the live bar and re-renders it
253
+ - `p.log_stream()`: returns a file-like stream for `print(..., file=...)` or
254
+ `logging.StreamHandler`
255
+
256
+ If a command mixes progress output with logs, prefer one of those two
257
+ interfaces instead of calling `print(...)` directly on stdout/stderr.
258
+
259
+ ---
260
+
232
261
  ### track_as_completed(tasks, total=None, \*, desc='Processing', width=30, refresh_interval=0.1, show_summary=True)
233
262
 
234
263
  Tracks a collection of awaitables or tasks as they complete.
@@ -0,0 +1,2 @@
1
+ version_info: tuple[int | str, ...] = (0, 2, 2)
2
+ __version__ = '.'.join(map(str, version_info))
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import contextlib
5
+ import io
5
6
  import sys
6
7
  import time
7
8
 
@@ -18,14 +19,68 @@ if TYPE_CHECKING:
18
19
  )
19
20
 
20
21
 
22
+ class _ProgressLogStream(io.TextIOBase):
23
+ __slots__ = ('_buffer', '_progress')
24
+
25
+ def __init__(self, progress: Progress[object]) -> None:
26
+ self._progress = progress
27
+ self._buffer = ''
28
+
29
+ @property
30
+ def encoding(self) -> str | None:
31
+ return getattr(self._progress.stream, 'encoding', None)
32
+
33
+ def isatty(self) -> bool:
34
+ return self._progress._is_tty
35
+
36
+ def writable(self) -> bool:
37
+ return True
38
+
39
+ def write(self, msg: str) -> int:
40
+ if not isinstance(msg, str):
41
+ msg = str(msg)
42
+
43
+ if not msg:
44
+ return 0
45
+
46
+ self._buffer += msg
47
+ self._flush_complete_lines()
48
+ return len(msg)
49
+
50
+ def flush(self) -> None:
51
+ self.flush_pending()
52
+
53
+ def close(self) -> None:
54
+ self.flush()
55
+
56
+ def flush_pending(self) -> None:
57
+ if not self._buffer:
58
+ return
59
+
60
+ pending = self._buffer.removesuffix('\r')
61
+ self._buffer = ''
62
+ self._progress._write_message(pending, allow_blank=True)
63
+
64
+ def _flush_complete_lines(self) -> None:
65
+ while '\n' in self._buffer:
66
+ line, self._buffer = self._buffer.split('\n', 1)
67
+ self._progress._write_message(
68
+ line.removesuffix('\r'),
69
+ allow_blank=True,
70
+ )
71
+
72
+
21
73
  class Progress[T]:
22
74
  __slots__ = (
23
75
  '_is_tty',
24
76
  '_last_line_len',
25
77
  '_last_refresh',
78
+ '_last_render_count',
79
+ '_log_stream',
26
80
  '_next_render_at',
27
81
  '_prefix',
28
82
  '_refresh_task',
83
+ '_started',
29
84
  '_stopped',
30
85
  '_summary_printed',
31
86
  'count',
@@ -65,22 +120,58 @@ class Progress[T]:
65
120
  self._refresh_task: asyncio.Task[None] | None = None
66
121
  self._summary_printed = False
67
122
  self._last_line_len = 0
123
+ self._last_render_count = 0
124
+ self._started = False
68
125
  self._stopped = False
69
126
  self._is_tty = bool(getattr(self.stream, 'isatty', lambda: False)())
127
+ self._log_stream: _ProgressLogStream | None = None
70
128
  self._prefix = f'{self.desc} '
71
129
 
130
+ def _start_rendering(self) -> None:
131
+ self._started = True
132
+ self._stopped = False
133
+ self.start_time = time.perf_counter()
134
+ self._last_refresh = 0.0
135
+ self._next_render_at = 0.0
136
+ self._last_line_len = 0
137
+ self._last_render_count = -1
138
+ self._render(force=True)
139
+ if self._is_tty:
140
+ self._refresh_task = asyncio.create_task(
141
+ self._refresh_periodically(),
142
+ )
143
+
72
144
  def write(self, msg: str = '') -> None:
73
- """Print a message below the bar and re-render it (TTY-safe)."""
145
+ """Print a message above the live bar and re-render it."""
146
+ self._write_message(msg)
147
+
148
+ def log_stream(self) -> TextIO:
149
+ """Return a file-like stream that logs above the live bar."""
150
+ if self._log_stream is None:
151
+ progress = cast('Progress[object]', self)
152
+ self._log_stream = _ProgressLogStream(progress)
153
+ return cast('TextIO', self._log_stream)
154
+
155
+ def _write_message(
156
+ self,
157
+ msg: str = '',
158
+ *,
159
+ allow_blank: bool = False,
160
+ ) -> None:
74
161
  if self._stopped:
75
162
  return
76
163
  self._clear_line()
77
- if msg:
164
+ if msg or allow_blank:
78
165
  if not msg.endswith('\n'):
79
166
  msg += '\n'
80
167
  self.stream.write(msg)
81
168
  self.stream.flush()
82
169
  self._render(force=True)
83
170
 
171
+ def _flush_log_stream(self) -> None:
172
+ if self._log_stream is not None:
173
+ self._log_stream.flush_pending()
174
+
84
175
  async def amap[R](
85
176
  self,
86
177
  mapper: Callable[[T], Awaitable[R]],
@@ -143,12 +234,8 @@ class Progress[T]:
143
234
  # Start the refresh task if it hasn't been started
144
235
  # yet (same as in __aiter__)
145
236
  started_here = False
146
- if self._refresh_task is None:
147
- self._stopped = False
148
- self._render(force=True)
149
- self._refresh_task = asyncio.create_task(
150
- self._refresh_periodically(),
151
- )
237
+ if not self._started:
238
+ self._start_rendering()
152
239
  started_here = True
153
240
 
154
241
  try:
@@ -195,6 +282,7 @@ class Progress[T]:
195
282
  await self._refresh_task
196
283
  self._refresh_task = None
197
284
  self._print_summary_if_needed()
285
+ self._started = False
198
286
 
199
287
  def _format_elapsed(self, seconds: float) -> str:
200
288
  """Return hh:mm:ss, mm:ss or ss.s depending on duration."""
@@ -217,7 +305,7 @@ class Progress[T]:
217
305
  # tests/logs
218
306
  self.stream.write(text + '\n')
219
307
  self.stream.flush()
220
- self._last_line_len = 0
308
+ self._last_line_len = len(text)
221
309
 
222
310
  def _clear_line(self) -> None:
223
311
  if self._is_tty:
@@ -268,11 +356,19 @@ class Progress[T]:
268
356
  )
269
357
 
270
358
  self._write_line(line)
359
+ self._last_render_count = self.count
271
360
  self._last_refresh = now
272
361
  self._next_render_at = now + self.refresh_interval
273
362
 
274
363
  def _print_summary_if_needed(self) -> None:
364
+ self._flush_log_stream()
275
365
  if self.show_summary and not self._summary_printed:
366
+ if (
367
+ self.total is not None
368
+ and self.count == self.total
369
+ and self._last_render_count != self.count
370
+ ):
371
+ self._render(force=True)
276
372
  self._stopped = True
277
373
  self._clear_line()
278
374
 
@@ -295,9 +391,8 @@ class Progress[T]:
295
391
  self._render()
296
392
 
297
393
  async def __aenter__(self) -> Progress[T]:
298
- self._stopped = False
299
- self._render(force=True) # feedback inmediato
300
- self._refresh_task = asyncio.create_task(self._refresh_periodically())
394
+ if not self._started:
395
+ self._start_rendering() # feedback inmediato
301
396
  return self
302
397
 
303
398
  async def __aexit__(self, *_: object) -> None:
@@ -307,18 +402,14 @@ class Progress[T]:
307
402
  await self._refresh_task
308
403
  self._refresh_task = None
309
404
  self._print_summary_if_needed()
405
+ self._started = False
310
406
 
311
407
  async def __aiter__(self) -> AsyncIterator[T]:
312
408
  started_here = False
313
409
 
314
- if self._refresh_task is None:
315
- self._stopped = False
316
- self._render(
317
- force=True,
318
- ) # feedback inmediato fuera de context manager
319
- self._refresh_task = asyncio.create_task(
320
- self._refresh_periodically(),
321
- )
410
+ if not self._started:
411
+ # feedback inmediato fuera de context manager
412
+ self._start_rendering()
322
413
  started_here = True
323
414
 
324
415
  try:
@@ -341,6 +432,7 @@ class Progress[T]:
341
432
  await self._refresh_task
342
433
  self._refresh_task = None
343
434
  self._print_summary_if_needed()
435
+ self._started = False
344
436
 
345
437
 
346
438
  def progress[T]( # noqa: PLR0913
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Lightweight progress utilities for sync/async workloads
5
5
  Author: Vicente Ruiz
6
6
  License-Expression: MIT
@@ -79,8 +79,9 @@ def numbers():
79
79
 
80
80
  async def main():
81
81
  async with progress(numbers(), total=10, desc='Numbers') as p:
82
+ log = p.log_stream()
82
83
  async for n in p:
83
- p.write(f'value: {n}')
84
+ print(f'value: {n}', file=log)
84
85
  await asyncio.sleep(0.5)
85
86
 
86
87
  asyncio.run(main())
@@ -181,6 +182,23 @@ Need concurrency limit `Semaphore` + `track_as_completed(...)`
181
182
 
182
183
  ---
183
184
 
185
+ ### 3. TTY vs non-TTY output
186
+
187
+ When the output stream is a real TTY, `processit` redraws the same line.
188
+
189
+ When the output stream is not a TTY (for example CI logs, redirected output,
190
+ or `StringIO` in tests), `processit` emits line-based snapshots instead:
191
+
192
+ - It respects `refresh_interval` without periodic duplicate frames
193
+ - It prints a final `100%` snapshot before the summary when `total` is known
194
+ - Messages written with `p.write(...)` or `file=p.log_stream()` stay above the
195
+ live bar; the bar is re-rendered as the last line in TTY mode
196
+
197
+ Timing starts when iteration actually begins, not when the `Progress`
198
+ instance is created.
199
+
200
+ ---
201
+
184
202
  ## More Examples
185
203
 
186
204
  ### Asynchronous iteration over a data source
@@ -250,6 +268,17 @@ Name Type Description
250
268
 
251
269
  ---
252
270
 
271
+ ### Progress helpers inside `async with progress(...) as p`
272
+
273
+ - `p.write("message")`: prints a message above the live bar and re-renders it
274
+ - `p.log_stream()`: returns a file-like stream for `print(..., file=...)` or
275
+ `logging.StreamHandler`
276
+
277
+ If a command mixes progress output with logs, prefer one of those two
278
+ interfaces instead of calling `print(...)` directly on stdout/stderr.
279
+
280
+ ---
281
+
253
282
  ### track_as_completed(tasks, total=None, \*, desc='Processing', width=30, refresh_interval=0.1, show_summary=True)
254
283
 
255
284
  Tracks a collection of awaitables or tasks as they complete.
@@ -34,6 +34,49 @@ def _last_nonempty_line(buf: str) -> str:
34
34
  return ''
35
35
 
36
36
 
37
+ class _TTYStringIO(io.StringIO):
38
+ def isatty(self) -> bool:
39
+ return True
40
+
41
+
42
+ def _render_tty_screen(buf: str) -> list[str]:
43
+ lines: list[str] = []
44
+ current: list[str] = []
45
+ cursor = 0
46
+ idx = 0
47
+
48
+ while idx < len(buf):
49
+ if buf.startswith('\x1b[2K', idx):
50
+ current = []
51
+ cursor = 0
52
+ idx += 4
53
+ continue
54
+
55
+ char = buf[idx]
56
+ idx += 1
57
+
58
+ if char == '\r':
59
+ cursor = 0
60
+ continue
61
+
62
+ if char == '\n':
63
+ lines.append(''.join(current))
64
+ current = []
65
+ cursor = 0
66
+ continue
67
+
68
+ if cursor == len(current):
69
+ current.append(char)
70
+ else:
71
+ current[cursor] = char
72
+ cursor += 1
73
+
74
+ if current:
75
+ lines.append(''.join(current))
76
+
77
+ return [line for line in lines if line.strip()]
78
+
79
+
37
80
  # -----------------------------
38
81
  # Synchronous iterable cases
39
82
  # -----------------------------
@@ -64,6 +107,107 @@ async def test_sync_iterable_basic_summary_only_once():
64
107
  assert 'it/s' in out
65
108
 
66
109
 
110
+ @pytest.mark.asyncio
111
+ async def test_non_tty_respects_refresh_interval():
112
+ stream = io.StringIO()
113
+ expected_bar_frames = 2
114
+
115
+ def numbers() -> Iterator[int]:
116
+ for i in range(20):
117
+ time.sleep(0.005)
118
+ yield i
119
+
120
+ async for _ in progress(
121
+ numbers(),
122
+ desc='NonTTY',
123
+ total=20,
124
+ stream=stream,
125
+ refresh_interval=10.0,
126
+ ):
127
+ await asyncio.sleep(0)
128
+
129
+ out = _strip_ansi(stream.getvalue())
130
+ assert out.count('NonTTY [') == expected_bar_frames
131
+ assert 'NonTTY [##############################] 100.00% (20/20)' in out
132
+ assert out.count('NonTTY: 20 it in ') == 1
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ async def test_non_tty_renders_final_complete_frame_before_summary():
137
+ stream = io.StringIO()
138
+ data = list(range(3))
139
+
140
+ async for _ in progress(
141
+ data,
142
+ desc='Final',
143
+ total=3,
144
+ stream=stream,
145
+ refresh_interval=10.0,
146
+ ):
147
+ await asyncio.sleep(0)
148
+
149
+ lines = [
150
+ line for line in _strip_ansi(stream.getvalue()).splitlines() if line
151
+ ]
152
+ expected_complete = 'Final [##############################] 100.00% (3/3)'
153
+ assert any(expected_complete in line for line in lines)
154
+ assert lines[-1].startswith('Final: 3 it in ')
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_non_tty_async_iterable_does_not_duplicate_periodic_frames():
159
+ stream = io.StringIO()
160
+ expected_lines = 5
161
+
162
+ async def agen() -> AsyncIterator[int]:
163
+ for i in range(3):
164
+ await asyncio.sleep(0.01)
165
+ yield i
166
+
167
+ async for _ in progress(
168
+ agen(),
169
+ desc='NoDupes',
170
+ total=3,
171
+ stream=stream,
172
+ refresh_interval=0.005,
173
+ ):
174
+ await asyncio.sleep(0)
175
+
176
+ lines = [
177
+ line for line in _strip_ansi(stream.getvalue()).splitlines() if line
178
+ ]
179
+ assert len(lines) == expected_lines
180
+ assert sum('(0/3)' in line for line in lines) == 1
181
+ assert sum('(1/3)' in line for line in lines) == 1
182
+ assert sum('(2/3)' in line for line in lines) == 1
183
+ assert sum('(3/3)' in line for line in lines) == 1
184
+ assert lines[-1].startswith('NoDupes: 3 it in ')
185
+
186
+
187
+ @pytest.mark.asyncio
188
+ async def test_elapsed_starts_on_first_use_not_object_creation():
189
+ stream = io.StringIO()
190
+ p = progress(
191
+ [1],
192
+ total=1,
193
+ desc='Delay',
194
+ stream=stream,
195
+ refresh_interval=10.0,
196
+ )
197
+
198
+ await asyncio.sleep(0.2)
199
+
200
+ async for _ in p:
201
+ await asyncio.sleep(0)
202
+
203
+ out = _strip_ansi(stream.getvalue())
204
+ expected_initial = (
205
+ 'Delay [..............................] 0.00% (0/1) 0.00 it/s 00.0s'
206
+ )
207
+ assert expected_initial in out
208
+ assert 'Delay: 1 it in 00.0s' in out
209
+
210
+
67
211
  @pytest.mark.asyncio
68
212
  async def test_sync_iterable_infers_total_from_len():
69
213
  stream = io.StringIO()
@@ -72,9 +216,9 @@ async def test_sync_iterable_infers_total_from_len():
72
216
  data,
73
217
  desc='Len inf',
74
218
  stream=stream,
75
- refresh_interval=1.0,
219
+ refresh_interval=0.001,
76
220
  ):
77
- await asyncio.sleep(0)
221
+ await asyncio.sleep(0.01)
78
222
  out = stream.getvalue()
79
223
  # En algún render debe aparecer (4/4)
80
224
  assert '(4/4)' in out
@@ -164,6 +308,60 @@ async def test_async_with_and_write_no_extra_bar_at_end():
164
308
  assert 'With [' not in last
165
309
 
166
310
 
311
+ @pytest.mark.asyncio
312
+ async def test_log_stream_flushes_partial_output_before_summary_non_tty():
313
+ stream = io.StringIO()
314
+
315
+ async with progress(
316
+ [1, 2],
317
+ total=2,
318
+ desc='Stream',
319
+ stream=stream,
320
+ refresh_interval=10.0,
321
+ ) as p:
322
+ log = p.log_stream()
323
+ print('alpha', file=log)
324
+ log.write('beta')
325
+
326
+ async for _ in p:
327
+ await asyncio.sleep(0)
328
+
329
+ lines = [
330
+ line for line in _strip_ansi(stream.getvalue()).splitlines() if line
331
+ ]
332
+ assert 'alpha' in lines
333
+ assert 'beta' in lines
334
+ assert lines[-1].startswith('Stream: 2 it in ')
335
+ assert lines.index('alpha') < lines.index('beta') < len(lines) - 1
336
+
337
+
338
+ @pytest.mark.asyncio
339
+ async def test_log_stream_keeps_messages_above_bar_in_tty():
340
+ stream = _TTYStringIO()
341
+
342
+ async with progress(
343
+ [1, 2],
344
+ total=2,
345
+ desc='TTY',
346
+ stream=stream,
347
+ refresh_interval=10.0,
348
+ ) as p:
349
+ log = p.log_stream()
350
+ print('alpha', file=log)
351
+ log.write('beta')
352
+
353
+ async for _ in p:
354
+ await asyncio.sleep(0)
355
+
356
+ raw = stream.getvalue()
357
+ screen = _render_tty_screen(raw)
358
+
359
+ assert 'alpha\n\r\x1b[2KTTY [' in raw
360
+ assert 'beta\n\r\x1b[2KTTY [' in raw
361
+ assert screen[:-1] == ['alpha', 'beta']
362
+ assert screen[-1].startswith('TTY: 2 it in ')
363
+
364
+
167
365
  # -----------------------------
168
366
  # track_as_completed cases
169
367
  # -----------------------------
@@ -312,18 +510,18 @@ async def test_eta_only_when_total_known():
312
510
  total=3,
313
511
  desc='HasTotal',
314
512
  stream=stream1,
315
- refresh_interval=1.0,
513
+ refresh_interval=0.001,
316
514
  ):
317
- pass
515
+ await asyncio.sleep(0.01)
318
516
 
319
517
  # Sin total -> preferimos que no aparezca "ETA" en la salida
320
518
  async for _ in progress(
321
519
  agen(),
322
520
  desc='NoTotal',
323
521
  stream=stream2,
324
- refresh_interval=1.0,
522
+ refresh_interval=0.001,
325
523
  ):
326
- pass
524
+ await asyncio.sleep(0.01)
327
525
 
328
526
  out1 = stream1.getvalue()
329
527
  out2 = stream2.getvalue()
@@ -1,2 +0,0 @@
1
- version_info: tuple[int | str, ...] = (0, 2, 0)
2
- __version__ = '.'.join(map(str, version_info))
File without changes
File without changes
File without changes