cs-progress 20250306__py2.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.
cs/progress.py ADDED
@@ -0,0 +1,1148 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Progress counting.
4
+ # - Cameron Simpson <cs@cskk.id.au> 15feb2015
5
+ #
6
+ # pylint: disable=too-many-lines
7
+ #
8
+
9
+ ''' A progress tracker with methods for throughput, ETA and update notification;
10
+ also a compound progress meter composed from other progress meters.
11
+ '''
12
+
13
+ from collections import namedtuple
14
+ from contextlib import contextmanager
15
+ import functools
16
+ import sys
17
+ from threading import RLock, Thread
18
+ import time
19
+ from typing import Callable, Optional
20
+
21
+ from icontract import ensure
22
+ from typeguard import typechecked
23
+
24
+ from cs.deco import decorator, uses_quiet
25
+ from cs.logutils import debug, exception
26
+ from cs.py.func import funcname
27
+ from cs.queues import IterableQueue, QueueIterator
28
+ from cs.seq import seq
29
+ from cs.threads import bg
30
+ from cs.units import (
31
+ transcribe_time,
32
+ transcribe as transcribe_units,
33
+ BINARY_BYTES_SCALE,
34
+ DECIMAL_SCALE,
35
+ TIME_SCALE,
36
+ UNSCALED_SCALE,
37
+ )
38
+ from cs.upd import Upd, uses_upd, print # pylint: disable=redefined-builtin
39
+
40
+ __version__ = '20250306'
41
+
42
+ DISTINFO = {
43
+ 'keywords': ["python2", "python3"],
44
+ 'classifiers': [
45
+ "Programming Language :: Python",
46
+ "Programming Language :: Python :: 3",
47
+ ],
48
+ 'install_requires': [
49
+ 'cs.deco',
50
+ 'cs.logutils',
51
+ 'cs.py.func',
52
+ 'cs.seq',
53
+ 'cs.threads',
54
+ 'cs.units',
55
+ 'cs.upd',
56
+ 'icontract',
57
+ 'typeguard',
58
+ ],
59
+ }
60
+
61
+ # default to 5s of position buffer for computing recent thoroughput
62
+ DEFAULT_THROUGHPUT_WINDOW = 5
63
+
64
+ # default update period
65
+ DEFAULT_UPDATE_PERIOD = 0.3
66
+
67
+ @functools.total_ordering
68
+ class BaseProgress(object):
69
+ ''' The base class for `Progress` and `OverProcess`
70
+ with various common methods.
71
+
72
+ Note that durations are in seconds
73
+ and that absolute time is in seconds since the UNIX epoch
74
+ (the basis of `time.time()`).
75
+ '''
76
+
77
+ def __init__(self, name=None, start_time=None, units_scale=None):
78
+ ''' Initialise a progress instance.
79
+
80
+ Parameters:
81
+ * `name`: optional name
82
+ * `start_time`: optional UNIX epoch start time, default from `time.time()`
83
+ * `units_scale`: a scale for use with `cs.units.transcribe`,
84
+ default `BINARY_BYTES_SCALE`
85
+ '''
86
+ now = time.time()
87
+ if name is None:
88
+ name = '-'.join((type(self).__name__, str(seq())))
89
+ if start_time is None:
90
+ start_time = now
91
+ elif start_time > now:
92
+ raise ValueError("start_time(%s) > now(%s)" % (start_time, now))
93
+ if units_scale is None:
94
+ units_scale = BINARY_BYTES_SCALE
95
+ self.name = name
96
+ self.start_time = start_time
97
+ self.units_scale = units_scale
98
+ self.notify_update = set()
99
+ self._warned = set()
100
+ self._lock = RLock()
101
+
102
+ def __str__(self):
103
+ return "%s[start=%s:pos=%s:total=%s]" \
104
+ % (self.name, self.start, self.position, self.total)
105
+
106
+ __repr__ = __str__
107
+
108
+ def __int__(self):
109
+ ''' `int(Progress)` returns the current position.
110
+ '''
111
+ return self.position
112
+
113
+ def __eq__(self, other):
114
+ ''' A Progress is equal to another object `other`
115
+ if its position equals `int(other)`.
116
+ '''
117
+ return int(self) == int(other)
118
+
119
+ def __lt__(self, other):
120
+ ''' A Progress is less then another object `other`
121
+ if its position is less than `int(other)`.
122
+ '''
123
+ return int(self) < int(other)
124
+
125
+ def __hash__(self):
126
+ return id(self)
127
+
128
+ @property
129
+ def elapsed_time(self):
130
+ ''' Time elapsed since `start_time`.
131
+ '''
132
+ return time.time() - self.start_time
133
+
134
+ @property
135
+ def ratio(self):
136
+ ''' The fraction of progress completed: `(position-start)/(total-start)`.
137
+ Returns `None` if `total` is `None` or `total<=start`.
138
+
139
+ Example:
140
+
141
+ >>> P = Progress()
142
+ P.ratio
143
+ >>> P.total = 16
144
+ >>> P.ratio
145
+ 0.0
146
+ >>> P.update(4)
147
+ >>> P.ratio
148
+ 0.25
149
+ '''
150
+ total = self.total
151
+ if total is None:
152
+ return None
153
+ start = self.start
154
+ if total <= start:
155
+ return None
156
+ return float(self.position - start) / (total - start)
157
+
158
+ def throughput_recent(self, time_window):
159
+ ''' The recent throughput. Implemented by subclasses.
160
+ '''
161
+ raise NotImplementedError(
162
+ "%s.throughtput_recent(time_window=%s): subclass must implement" %
163
+ (type(self).__name__, time_window)
164
+ )
165
+
166
+ def throughput_overall(self):
167
+ ''' The overall throughput from `start` to `position`
168
+ during `elapsed_time`.
169
+ '''
170
+ consumed = self.position - self.start
171
+ if consumed < 0:
172
+ debug(
173
+ "%s.throughput: self.position(%s) < self.start(%s)", self,
174
+ self.position, self.start
175
+ )
176
+ if consumed == 0:
177
+ return 0
178
+ elapsed = self.elapsed_time
179
+ if elapsed == 0:
180
+ return 0
181
+ if elapsed <= 0:
182
+ debug(
183
+ "%s.throughput: negative elapsed time since start_time=%s: %s", self,
184
+ self.start_time, elapsed
185
+ )
186
+ return 0
187
+ return float(consumed) / elapsed
188
+
189
+ @property
190
+ def throughput(self):
191
+ ''' The overall throughput: `self.throughput_overall()`.
192
+
193
+ By comparison,
194
+ the `Progress.throughput` property is `self.throughput_recent`
195
+ if the `throughput_window` is not `None`,
196
+ otherwise it falls back to `throughput_overall`.
197
+ '''
198
+ return self.throughput_overall()
199
+
200
+ @property
201
+ def remaining_time(self):
202
+ ''' The projected time remaining to end
203
+ based on the `throughput` and `total`.
204
+
205
+ If `total` is `None`, this is `None`.
206
+ '''
207
+ total = self.total
208
+ if total is None:
209
+ return None
210
+ remaining = total - self.position
211
+ if remaining < 0:
212
+ if "position>total" not in self._warned:
213
+ self._warned.add("position>total")
214
+ debug(
215
+ "%s.remaining_time: self.position(%s) > self.total(%s)", self,
216
+ self.position, self.total
217
+ )
218
+ return None
219
+ throughput = self.throughput
220
+ if throughput is None or throughput == 0:
221
+ return None
222
+ return remaining / throughput
223
+
224
+ @property
225
+ def eta(self):
226
+ ''' The projected time of completion: now + `remaining_time`.
227
+
228
+ If `remaining_time` is `None`, this is also `None`.
229
+ '''
230
+ remaining = self.remaining_time
231
+ if remaining is None:
232
+ return None
233
+ return time.time() + remaining
234
+
235
+ @ensure(lambda width, result: len(result) <= width)
236
+ def arrow(self, width, no_padding=False):
237
+ ''' Construct a progress arrow representing completion
238
+ to fit in the specified `width`.
239
+ '''
240
+ if width < 1:
241
+ return ''
242
+ ratio = self.ratio
243
+ if ratio is None or ratio <= 0:
244
+ arrow = ''
245
+ elif ratio < 1.0:
246
+ arrow_len = width * ratio
247
+ if arrow_len < 1:
248
+ arrow = '>'
249
+ else:
250
+ arrow = '=' * int(arrow_len - 1) + '>'
251
+ else:
252
+ arrow = '=' * width
253
+ if not no_padding:
254
+ arrow += ' ' * (width - len(arrow))
255
+ return arrow
256
+
257
+ def format_counter(self, value, scale=None, max_parts=2, sep=',', **kw):
258
+ ''' Format `value` accoridng to `scale` and `max_parts`
259
+ using `cs.units.transcribe`.
260
+ '''
261
+ if scale is None:
262
+ scale = self.units_scale
263
+ if scale is None:
264
+ return str(value)
265
+ return transcribe_units(value, scale, max_parts=max_parts, sep=sep, **kw)
266
+
267
+ def text_pos_of_total(
268
+ self, fmt=None, fmt_pos=None, fmt_total=None, pos_first=False
269
+ ):
270
+ ''' Return a "total:position" or "position/total" style progress string.
271
+
272
+ Parameters:
273
+ * `fmt`: format string interpolating `pos_text` and `total_text`.
274
+ Default: `"{pos_text}/{total_text}"` if `pos_first`,
275
+ otherwise `"{total_text}:{pos_text}"`
276
+ * `fmt_pos`: formatting function for `self.position`,
277
+ default `self.format_counter`
278
+ * `fmt_total`: formatting function for `self.total`,
279
+ default from `fmt_pos`
280
+ * `pos_first`: put the position first if true (default `False`),
281
+ only consulted if `fmt` is `None`
282
+ '''
283
+ if fmt_pos is None:
284
+ fmt_pos = self.format_counter
285
+ if fmt_total is None:
286
+ fmt_total = fmt_pos
287
+ if fmt is None:
288
+ fmt = "{pos_text}/{total_text}" if pos_first else "{total_text}:{pos_text}"
289
+ pos_text = fmt_pos(self.position)
290
+ total_text = fmt_pos(self.total)
291
+ return fmt.format(pos_text=pos_text, total_text=total_text)
292
+
293
+ # pylint: disable=too-many-branches,too-many-statements
294
+ def status(self, label, width, recent_window=None, stalled=None):
295
+ ''' A progress string of the form:
296
+ *label*`: `*pos*`/`*total*` ==> ETA '*time*
297
+
298
+ Parameters:
299
+ * `label`: the label for the status line;
300
+ if `None` use `self.name`
301
+ * `width`: the available width for the status line;
302
+ if not an `int` use `width.width`
303
+ * `recent_window`: optional timeframe to define "recent" in seconds,
304
+ default : `5`
305
+ * `stalled`: the label to indicate no throughput, default `'stalled'`;
306
+ for a worker this might often b better as `'idle'`
307
+ '''
308
+ if label is None:
309
+ label = self.name
310
+ if stalled is None:
311
+ stalled = 'stalled'
312
+ if not isinstance(width, int):
313
+ width = width.width
314
+ if recent_window is None:
315
+ recent_window = 5
316
+ leftv = []
317
+ rightv = []
318
+ throughput = self.throughput_recent(recent_window)
319
+ if throughput is not None:
320
+ if throughput == 0:
321
+ if self.total is not None and self.position >= self.total:
322
+ return 'idle'
323
+ rightv.append(stalled)
324
+ else:
325
+ if throughput >= 10:
326
+ throughput = int(throughput)
327
+ rightv.append(self.format_counter(throughput, max_parts=1) + '/s')
328
+ remaining = self.remaining_time
329
+ if remaining:
330
+ remaining = int(remaining)
331
+ if remaining is not None:
332
+ rightv.append('ETA ' + transcribe_time(remaining))
333
+ if self.total is not None and self.total > 0:
334
+ leftv.append(self.text_pos_of_total())
335
+ else:
336
+ leftv.append(self.format_counter(self.position))
337
+ # the n/m display
338
+ left = ' '.join(leftv)
339
+ # the throughput display
340
+ right = ' '.join(rightv)
341
+ if self.total is None:
342
+ arrow_field = ' '
343
+ else:
344
+ # how much room for an arrow? we would like:
345
+ # "label: left arrow right"
346
+ arrow_width = width - len(left) - len(right) - 2
347
+ if label: # allow for ': ' separator after label
348
+ arrow_width -= len(label) + 2
349
+ if arrow_width < 3: # no room for an arrow
350
+ arrow_field = ':'
351
+ else:
352
+ arrow_field = ' ' + self.arrow(arrow_width) + ' '
353
+ status_line = left + arrow_field + right
354
+ if label:
355
+ label_width = width - len(status_line)
356
+ if label_width >= len(label) + 2:
357
+ prefix = label + ': '
358
+ elif label_width == len(label) + 1:
359
+ prefix = label + ':'
360
+ elif label_width <= 0: # label_width<=len(label): need to crop the label
361
+ # no room
362
+ prefix = ''
363
+ elif label_width == 1: # just indicate the crop
364
+ prefix = '<'
365
+ elif label_width == 2: # just indicate the crop
366
+ prefix = '<:'
367
+ else:
368
+ # crop as "<tail-of-label:"
369
+ prefix = '<' + label[-label_width + 2:] + ':'
370
+ else:
371
+ prefix = ''
372
+ status_line = prefix + status_line
373
+ return status_line
374
+
375
+ # pylint: disable=blacklisted-name,too-many-arguments
376
+ @contextmanager
377
+ @uses_quiet
378
+ @uses_upd
379
+ def bar(
380
+ self,
381
+ label=None,
382
+ *,
383
+ statusfunc=None,
384
+ width=None,
385
+ recent_window=None,
386
+ stalled=None,
387
+ report_print=None,
388
+ insert_pos=1,
389
+ poll: Optional[Callable[["BaseProgress"], None]] = None,
390
+ update_period=DEFAULT_UPDATE_PERIOD,
391
+ quiet: bool,
392
+ upd: Upd,
393
+ ):
394
+ ''' A context manager to create and withdraw a progress bar.
395
+ It returns the `UpdProxy` which displays the progress bar.
396
+
397
+ Parameters:
398
+ * `label`: a label for the progress bar,
399
+ default from `self.name`.
400
+ * `statusfunc`: an optional function to compute the progress bar text
401
+ accepting `(self,label,width)`.
402
+ * `width`: an optional width expressing how wide the progress bar
403
+ text may be.
404
+ The default comes from the `proxy.width` property.
405
+ * `recent_window`: optional timeframe to define "recent" in seconds;
406
+ if the default `statusfunc` (`Progress.status`) is used
407
+ this is passed to it
408
+ * `report_print`: optional `print` compatible function
409
+ with which to write a report on completion;
410
+ this may also be a `bool`, which if true will use `Upd.print`
411
+ in order to interoperate with `Upd`.
412
+ * `stalled`: optional string to replace the word `'stalled'`
413
+ in the status line; for a worked this might be betteer as `'idle'`
414
+ * `insert_pos`: where to insert the progress bar, default `1`
415
+ * `poll`: an optional callable accepting a `BaseProgress`
416
+ which can be used to update the progress state before
417
+ updating the progress bar display
418
+
419
+ Example use:
420
+
421
+ # display progress reporting during upload_filename()
422
+ # which updates the supplied Progress instance
423
+ # during its operation
424
+ P = Progress(name=label)
425
+ with P.bar(report_print=True):
426
+ upload_filename(src, progress=P)
427
+
428
+ '''
429
+ if label is None:
430
+ label = self.name
431
+ if report_print is None:
432
+ report_print = not quiet
433
+ if statusfunc is None:
434
+
435
+ def statusfunc(P, label, width):
436
+ ''' Use the `Progress.status` method by default.
437
+ '''
438
+ return P.status(
439
+ label,
440
+ width,
441
+ recent_window=recent_window,
442
+ stalled=stalled,
443
+ )
444
+
445
+ def text_auto():
446
+ ''' The current state of the `Progress`, to fit `width` and `proxy.width`.
447
+ '''
448
+ if poll is not None:
449
+ poll(self)
450
+ return statusfunc(self, "", min((width or proxy.width), proxy.width))
451
+
452
+ # pylint: disable=unused-argument
453
+ def update(P: Progress, _):
454
+ ''' Update the status bar `UpdProxy` with the current state.
455
+ '''
456
+ proxy.text = None
457
+
458
+ cancel_ticker = False
459
+
460
+ def ticker():
461
+ ''' Worker to update the progress bar every `update_period` seconds.
462
+ '''
463
+ time.sleep(update_period)
464
+ while not cancel_ticker:
465
+ update(self, None)
466
+ time.sleep(update_period)
467
+
468
+ if update_period == 0:
469
+ self.notify_update.add(update)
470
+ try:
471
+ start_pos = self.position
472
+ with upd.insert(
473
+ insert_pos,
474
+ prefix=label + ' ',
475
+ text_auto=text_auto,
476
+ ) as proxy:
477
+ update(self, None)
478
+ if update_period > 0:
479
+ bg(ticker, daemon=True)
480
+ yield proxy
481
+ finally:
482
+ cancel_ticker = True
483
+ if update_period == 0:
484
+ self.notify_update.remove(update)
485
+ if report_print:
486
+ if isinstance(report_print, bool):
487
+ report_print = print
488
+ report_print(
489
+ label + ':', self.format_counter(self.position - start_pos), 'in',
490
+ transcribe_units(
491
+ self.elapsed_time, TIME_SCALE, max_parts=2, skip_zero=True
492
+ )
493
+ )
494
+
495
+ # pylint: disable=too-many-arguments,too-many-branches,too-many-locals
496
+ def iterbar(
497
+ self,
498
+ it,
499
+ label=None,
500
+ *,
501
+ itemlenfunc=None,
502
+ incfirst=False,
503
+ update_period=DEFAULT_UPDATE_PERIOD,
504
+ cancelled=None,
505
+ **bar_kw,
506
+ ):
507
+ ''' An iterable progress bar: a generator yielding values
508
+ from the iterable `it` while updating a progress bar.
509
+
510
+ Parameters:
511
+ * `it`: the iterable to consume and yield.
512
+ * `label`: a label for the progress bar,
513
+ default from `self.name`.
514
+ * `itemlenfunc`: an optional function returning the "size" of each item
515
+ from `it`, used to advance `self.position`.
516
+ The default is to assume a size of `1`.
517
+ A convenient alternative choice may be the builtin function `len`.
518
+ * `incfirst`: whether to advance `self.position` before we
519
+ `yield` an item from `it` or afterwards.
520
+ This reflects whether it is considered that progress is
521
+ made as items are obtained or only after items are processed
522
+ by whatever is consuming this generator.
523
+ The default is `False`, advancing after processing.
524
+ * `update_period`: default `DEFAULT_UPDATE_PERIOD`; if `0`
525
+ then update on every iteration, otherwise every `update_period`
526
+ seconds
527
+ * `cancelled`: an optional callable to test for iteration cancellation
528
+ Other parameters are passed to `Progress.bar`.
529
+
530
+ Example use:
531
+
532
+ from cs.units import DECIMAL_SCALE
533
+ rows = [some list of data]
534
+ P = Progress(total=len(rows), units_scale=DECIMAL_SCALE)
535
+ for row in P.iterbar(rows, incfirst=True):
536
+ ... do something with each row ...
537
+
538
+ f = open(data_filename, 'rb')
539
+ datalen = os.stat(f).st_size
540
+ def readfrom(f):
541
+ while True:
542
+ bs = f.read(65536)
543
+ if not bs:
544
+ break
545
+ yield bs
546
+ P = Progress(total=datalen)
547
+ for bs in P.iterbar(readfrom(f), itemlenfunc=len):
548
+ ... process the file data in bs ...
549
+ '''
550
+ with self.bar(label, update_period=update_period, **bar_kw) as proxy:
551
+ for item in it:
552
+ if cancelled and cancelled():
553
+ break
554
+ length = itemlenfunc(item) if itemlenfunc else 1
555
+ if incfirst:
556
+ self += length
557
+ if update_period == 0:
558
+ proxy.text = None
559
+ yield item
560
+ else:
561
+ yield item
562
+ self += length
563
+ if update_period == 0:
564
+ proxy.text = None
565
+
566
+ def qbar(self, label=None, **iterbar_kw) -> QueueIterator:
567
+ ''' Set up a progress bar, return a `QueueIterator` for receiving items.
568
+ This is a shim for `Progress.iterbar` which dispatches a
569
+ worker to iterate a queue which received items placed on
570
+ the queue.
571
+ '''
572
+ Q = IterableQueue(name=label)
573
+
574
+ def qbar_worker():
575
+ for _ in self.iterbar(Q, label=label, **iterbar_kw):
576
+ pass
577
+
578
+ T = Thread(target=qbar_worker, name=f'{self}.qbar.qbar_worker:{label}')
579
+ T.start()
580
+ return Q
581
+
582
+ CheckPoint = namedtuple('CheckPoint', 'time position')
583
+
584
+ class Progress(BaseProgress):
585
+ ''' A progress counter to track task completion with various utility methods.
586
+
587
+ Example:
588
+
589
+ >>> P = Progress(name="example")
590
+ >>> P #doctest: +ELLIPSIS
591
+ Progress(name='example',start=0,position=0,start_time=...,throughput_window=None,total=None):[CheckPoint(time=..., position=0)]
592
+ >>> P.advance(5)
593
+ >>> P #doctest: +ELLIPSIS
594
+ Progress(name='example',start=0,position=5,start_time=...,throughput_window=None,total=None):[CheckPoint(time=..., position=0), CheckPoint(time=..., position=5)]
595
+ >>> P.total = 100
596
+ >>> P #doctest: +ELLIPSIS
597
+ Progress(name='example',start=0,position=5,start_time=...,throughput_window=None,total=100):[CheckPoint(time=..., position=0), CheckPoint(time=..., position=5)]
598
+
599
+ A Progress instance has an attribute ``notify_update`` which
600
+ is a set of callables. Whenever the position is updated, each
601
+ of these will be called with the `Progress` instance and the
602
+ latest `CheckPoint`.
603
+
604
+ `Progress` objects also make a small pretense of being an integer.
605
+ The expression `int(progress)` returns the current position,
606
+ and `+=` and `-=` adjust the position.
607
+
608
+ This is convenient for coding, but importantly it is also
609
+ useful for discretionary use of a Progress with some other
610
+ object.
611
+ If you want to make a lightweight `Progress` capable class
612
+ you can set a position attribute to an `int`
613
+ and manipulate it carefully using `+=` and `-=` entirely.
614
+ If you decide to incur the cost of maintaining a `Progress` object
615
+ you can slot it in:
616
+
617
+ # initial setup with just an int
618
+ my_thing.amount = 0
619
+
620
+ # later, or on some option, use a Progress instance
621
+ my_thing.amount = Progress(my_thing.amount)
622
+ '''
623
+
624
+ # pylint: disable=too-many-arguments
625
+ @typechecked
626
+ def __init__(
627
+ self,
628
+ name: Optional[str] = None,
629
+ *,
630
+ position: Optional[float] = None,
631
+ start: Optional[float] = None,
632
+ start_time: Optional[float] = None,
633
+ throughput_window: Optional[int] = None,
634
+ total: Optional[float] = None,
635
+ units_scale=None,
636
+ ):
637
+ ''' Initialise the Progesss object.
638
+
639
+ Parameters:
640
+ * `position`: initial position, default `0`.
641
+ * `name`: optional name for this instance.
642
+ * `start`: starting position of progress range,
643
+ default from `position`.
644
+ * `start_time`: start time of the process, default now.
645
+ * `throughput_window`: length of throughput time window in seconds,
646
+ default None.
647
+ * `total`: expected completion value, default None.
648
+ '''
649
+ BaseProgress.__init__(
650
+ self, name=name, start_time=start_time, units_scale=units_scale
651
+ )
652
+ if position is None:
653
+ position = 0
654
+ if start is None:
655
+ start = position
656
+ if throughput_window is None:
657
+ throughput_window = DEFAULT_THROUGHPUT_WINDOW
658
+ elif throughput_window <= 0:
659
+ raise ValueError("throughput_window <= 0: %s" % (throughput_window,))
660
+ self.start = start
661
+ self._total = total
662
+ self.throughput_window = throughput_window
663
+ # history of positions, used to compute throughput
664
+ positions = [CheckPoint(self.start_time, start)]
665
+ if position != start:
666
+ positions.append(CheckPoint(time.time(), position))
667
+ self._positions = positions
668
+ self._flushed = True
669
+
670
+ def __repr__(self):
671
+ return "%s(name=%r,start=%s,position=%s,start_time=%s,throughput_window=%s,total=%s)" % (
672
+ type(self).__name__,
673
+ self.name,
674
+ self.start,
675
+ self.position,
676
+ self.start_time,
677
+ self.throughput_window,
678
+ self.total,
679
+ )
680
+
681
+ def _updated(self):
682
+ datum = self.latest
683
+ for notify in list(self.notify_update):
684
+ try:
685
+ notify(self, datum)
686
+ except Exception as e: # pylint: disable=broad-except
687
+ exception("%s: notify_update %s: %s", self, notify, e)
688
+
689
+ @property
690
+ def latest(self):
691
+ ''' Latest datum.
692
+ '''
693
+ return self._positions[-1]
694
+
695
+ @property
696
+ def position(self):
697
+ ''' Latest position.
698
+ '''
699
+ return self.latest.position
700
+
701
+ @position.setter
702
+ def position(self, new_position):
703
+ ''' Update the latest position.
704
+ '''
705
+ self.update(new_position)
706
+
707
+ @property
708
+ def total(self):
709
+ ''' Return the current total.
710
+ '''
711
+ return self._total
712
+
713
+ @total.setter
714
+ def total(self, new_total):
715
+ ''' Update the total.
716
+ '''
717
+ self._total = new_total
718
+ self._updated()
719
+
720
+ def advance_total(self, delta):
721
+ ''' Function form of addition to the total.
722
+ '''
723
+ self.total += delta
724
+
725
+ def update(self, new_position, update_time=None):
726
+ ''' Record more progress.
727
+
728
+ >>> P = Progress()
729
+ >>> P.position
730
+ 0
731
+ >>> P.update(12)
732
+ >>> P.position
733
+ 12
734
+ '''
735
+ if new_position < self.latest.position:
736
+ debug(
737
+ "%s.update: new position %s < latest position %s", self,
738
+ new_position, self.latest.position
739
+ )
740
+ if update_time is None:
741
+ update_time = time.time()
742
+ datum = CheckPoint(update_time, new_position)
743
+ self._positions.append(datum)
744
+ self._flushed = False
745
+ self._updated()
746
+
747
+ def advance(self, delta, update_time=None):
748
+ ''' Record more progress, return the advanced position.
749
+
750
+ >>> P = Progress()
751
+ >>> P.position
752
+ 0
753
+ >>> P.advance(4)
754
+ >>> P.position
755
+ 4
756
+ >>> P.advance(4)
757
+ >>> P.position
758
+ 8
759
+ '''
760
+ self.update(self.position + delta, update_time=update_time)
761
+
762
+ def __iadd__(self, delta):
763
+ ''' Operator += form of advance().
764
+
765
+ >>> P = Progress()
766
+ >>> P.position
767
+ 0
768
+ >>> P += 4
769
+ >>> P.position
770
+ 4
771
+ >>> P += 4
772
+ >>> P.position
773
+ 8
774
+ '''
775
+ self.advance(delta)
776
+ return self
777
+
778
+ def __isub__(self, delta):
779
+ ''' Operator -= form of advance().
780
+
781
+ >>> P = Progress()
782
+ >>> P.position
783
+ 0
784
+ >>> P += 4
785
+ >>> P.position
786
+ 4
787
+ >>> P -= 4
788
+ >>> P.position
789
+ 0
790
+ '''
791
+ self.advance(-delta)
792
+ return self
793
+
794
+ def _flush(self, oldest=None):
795
+ if oldest is None:
796
+ window = self.throughput_window
797
+ if window is None:
798
+ raise ValueError(
799
+ "oldest may not be None when throughput_window is None"
800
+ )
801
+ oldest = time.time() - window
802
+ positions = self._positions
803
+ # scan for first item still in time window,
804
+ # never discard the last 2 positions
805
+ for ndx in range(0, len(positions) - 1):
806
+ posn = positions[ndx]
807
+ if posn.time >= oldest:
808
+ # this is the first element to keep, discard preceeding (if any)
809
+ # note we can't just start at ndx=1 because ndx=0 might be in range
810
+ del positions[0:ndx]
811
+ break
812
+ self._flushed = True
813
+
814
+ @property
815
+ def throughput(self):
816
+ ''' Current throughput per second.
817
+
818
+ If `self.throughput_window` is not `None`,
819
+ calls `self.throughput_recent(throughput_window)`.
820
+ Otherwise call `self.throughput_overall()`.
821
+ '''
822
+ throughput_window = self.throughput_window
823
+ if throughput_window is None:
824
+ return self.throughput_overall()
825
+ return self.throughput_recent(throughput_window)
826
+
827
+ def throughput_recent(self, time_window):
828
+ ''' Recent throughput per second within a time window in seconds.
829
+
830
+ The time span overlapping the start of the window is included
831
+ on a flat pro rata basis.
832
+ '''
833
+ if time_window <= 0:
834
+ raise ValueError(
835
+ "%s.throughput_recent: invalid time_window <= 0: %s" %
836
+ (self, time_window)
837
+ )
838
+ if not self._flushed:
839
+ self._flush()
840
+ positions = self._positions
841
+ if len(positions) == 1 and positions[0].time == self.start_time:
842
+ # no throughput if we only have the starting position
843
+ return None
844
+ now = time.time()
845
+ time0 = now - time_window
846
+ if time0 < self.start_time:
847
+ time0 = self.start_time
848
+ # lowest time and position
849
+ # low_time will be time0
850
+ # low_pos will be the matching position, probably interpolated
851
+ low_time = None
852
+ low_pos = None
853
+ # walk forward through the samples, assumes monotonic
854
+ for t, p in self._positions:
855
+ if t >= time0:
856
+ low_time = t
857
+ low_pos = p
858
+ break
859
+ if low_time is None:
860
+ # no samples within the window; caller might infer stall
861
+ return 0
862
+ if low_time >= now:
863
+ # in the future? warn and return 0
864
+ debug('low_time=%s >= now=%s', low_time, now)
865
+ return 0
866
+ rate = float(self.position - low_pos) / (now - low_time)
867
+ if rate < 0:
868
+ debug('rate < 0 (%s)', rate)
869
+ return rate
870
+
871
+ class OverProgress(BaseProgress):
872
+ ''' A `Progress`-like class computed from a set of subsidiary `Progress`es.
873
+
874
+ AN OverProgress instance has an attribute ``notify_update`` which
875
+ is a set of callables.
876
+ Whenever the position of a subsidiary `Progress` is updated,
877
+ each of these will be called with the `Progress` instance and `None`.
878
+
879
+ Example:
880
+
881
+ >>> P = OverProgress(name="over")
882
+ >>> P1 = Progress(name="progress1", position=12)
883
+ >>> P1.total = 100
884
+ >>> P1.advance(7)
885
+ >>> P2 = Progress(name="progress2", position=20)
886
+ >>> P2.total = 50
887
+ >>> P2.advance(9)
888
+ >>> P.add(P1)
889
+ >>> P.add(P2)
890
+ >>> P1.total
891
+ 100
892
+ >>> P2.total
893
+ 50
894
+ >>> P.total
895
+ 150
896
+ >>> P1.start
897
+ 12
898
+ >>> P2.start
899
+ 20
900
+ >>> P.start
901
+ 0
902
+ >>> P1.position
903
+ 19
904
+ >>> P2.position
905
+ 29
906
+ >>> P.position
907
+ 16
908
+
909
+ '''
910
+
911
+ def __init__(
912
+ self, subprogresses=None, name=None, start_time=None, units_scale=None
913
+ ):
914
+ BaseProgress.__init__(
915
+ self, name=name, start_time=start_time, units_scale=units_scale
916
+ )
917
+ # we use these to to accrue removed subprogresses (optional)
918
+ self._base_total = 0
919
+ self._base_position = 0
920
+ self.subprogresses = set()
921
+ if subprogresses:
922
+ for subP in subprogresses:
923
+ self.add(subP)
924
+
925
+ def __repr__(self):
926
+ return "%s(name=%r,start=%s,position=%s,start_time=%s,total=%s)" \
927
+ % (
928
+ type(self).__name__, self.name,
929
+ self.start, self.position, self.start_time,
930
+ self.total)
931
+
932
+ def _updated(self):
933
+ with self._lock:
934
+ notifiers = list(self.notify_update)
935
+ for notify in notifiers:
936
+ try:
937
+ notify(self, None)
938
+ except Exception as e: # pylint: disable=broad-except
939
+ exception("%s: notify_update %s: %s", self, notify, e)
940
+
941
+ # pylint: disable=unused-argument
942
+ def _child_updated(self, child, _):
943
+ ''' Notify watchers if a child updates.
944
+ '''
945
+ self._updated()
946
+
947
+ def add(self, subprogress):
948
+ ''' Add a subsidairy `Progress` to the contributing set.
949
+ '''
950
+ with self._lock:
951
+ subprogress.notify_update.add(self._child_updated)
952
+ self.subprogresses.add(subprogress)
953
+ self._updated()
954
+
955
+ def remove(self, subprogress, accrue=False):
956
+ ''' Remove a subsidairy `Progress` from the contributing set.
957
+ '''
958
+ with self._lock:
959
+ subprogress.notify_update.remove(self._child_updated)
960
+ self.subprogresses.remove(subprogress)
961
+ if accrue:
962
+ self._base_position += subprogress.position - subprogress.start
963
+ self._base_total += subprogress.total
964
+ self._updated()
965
+
966
+ @property
967
+ def start(self):
968
+ ''' We always return a starting value of 0.
969
+ '''
970
+ return 0
971
+
972
+ def _overmax(self, fnP):
973
+ ''' Return the maximum of the non-`None` values
974
+ computed from the subsidiary `Progress`es.
975
+ Return the maximum, or `None` if there are no non-`None` values.
976
+ '''
977
+ with self._lock:
978
+ children = list(self.subprogresses)
979
+ maximum = None
980
+ for value in filter(fnP, children):
981
+ if value is not None:
982
+ maximum = value if maximum is None else max(maximum, value)
983
+ return maximum
984
+
985
+ def _oversum(self, fnP):
986
+ ''' Sum non-`None` values computed from the subsidiary `Progress`es.
987
+ Return the sum, or `None` if there are no non-`None` values.
988
+ '''
989
+ with self._lock:
990
+ children = list(self.subprogresses)
991
+ summed = None
992
+ for value in map(fnP, children):
993
+ if value is not None:
994
+ summed = value if summed is None else summed + value
995
+ return summed
996
+
997
+ @property
998
+ def position(self):
999
+ ''' The `position` is the sum off the subsidiary position offsets
1000
+ from their respective starts.
1001
+ '''
1002
+ pos = self._oversum(lambda P: P.position - P.start)
1003
+ if pos is None:
1004
+ pos = 0
1005
+ return self._base_position + pos
1006
+
1007
+ @property
1008
+ def total(self):
1009
+ ''' The `total` is the sum of the subsidiary totals.
1010
+ '''
1011
+ total = self._oversum(lambda P: P.total)
1012
+ if total is None:
1013
+ total = 0
1014
+ return self._base_total + total
1015
+
1016
+ @property
1017
+ def throughput(self):
1018
+ ''' The `throughput` is the sum of the subsidiary throughputs.
1019
+ '''
1020
+ return self._oversum(lambda P: P.throughput)
1021
+
1022
+ def throughput_recent(self, time_window):
1023
+ ''' The `throughput_recent` is the sum of the subsidiary throughput_recentss.
1024
+ '''
1025
+ return self._oversum(lambda P: P.throughput_recent(time_window))
1026
+
1027
+ @property
1028
+ def eta(self):
1029
+ ''' The `eta` is the maximum of the subsidiary etas.
1030
+ '''
1031
+ return self._overmax(lambda P: P.eta)
1032
+
1033
+ @uses_upd
1034
+ def progressbar(
1035
+ it,
1036
+ label=None,
1037
+ *,
1038
+ position=None,
1039
+ total=None,
1040
+ units_scale=UNSCALED_SCALE,
1041
+ upd: Upd,
1042
+ **iterbar_kw
1043
+ ):
1044
+ ''' Convenience function to construct and run a `Progress.iterbar`
1045
+ wrapping the iterable `it`,
1046
+ issuing and withdrawing a progress bar during the iteration.
1047
+
1048
+ Parameters:
1049
+ * `it`: the iterable to consume
1050
+ * `label`: optional label, doubles as the `Progress.name`
1051
+ * `position`: optional starting position
1052
+ * `total`: optional value for `Progress.total`,
1053
+ default from `len(it)` if supported.
1054
+ * `units_scale`: optional units scale for `Progress`,
1055
+ default `UNSCALED_SCALE`
1056
+
1057
+ If `total` is `None` and `it` supports `len()`
1058
+ then the `Progress.total` is set from it.
1059
+
1060
+ All arguments are passed through to `Progress.iterbar`.
1061
+
1062
+ Example use:
1063
+
1064
+ for row in progressbar(rows):
1065
+ ... do something with row ...
1066
+ '''
1067
+ if upd is None or upd.disabled:
1068
+ return it
1069
+ if total is None:
1070
+ try:
1071
+ total = len(it)
1072
+ except TypeError:
1073
+ total = None
1074
+ yield from Progress(
1075
+ name=label, position=position, total=total, units_scale=units_scale
1076
+ ).iterbar(it, **iterbar_kw)
1077
+
1078
+ @decorator
1079
+ def auto_progressbar(func, label=None, report_print=False):
1080
+ ''' Decorator for a function accepting an optional `progress`
1081
+ keyword parameter.
1082
+ If `progress` is not `None` and the default `Upd` is not disabled,
1083
+ run the function with a progress bar.
1084
+ '''
1085
+
1086
+ def wrapper(
1087
+ *a,
1088
+ progress=None,
1089
+ progress_name=None,
1090
+ progress_total=None,
1091
+ progress_report_print=None,
1092
+ **kw
1093
+ ):
1094
+ if progress_name is None:
1095
+ progress_name = label or funcname(func)
1096
+ if progress_report_print is None:
1097
+ progress_report_print = report_print
1098
+ if progress is None:
1099
+ upd = Upd()
1100
+ if not upd.disabled:
1101
+ progress = Progress(name=progress_name, total=progress_total)
1102
+ with progress.bar(upd=upd, report_print=progress_report_print):
1103
+ return func(*a, progress=progress, **kw)
1104
+ return func(*a, progress=progress, **kw)
1105
+
1106
+ return wrapper
1107
+
1108
+ # pylint: disable=unused-argument
1109
+ def selftest(argv):
1110
+ ''' Exercise some of the functionality.
1111
+ '''
1112
+ with open(__file__, encoding='utf8') as f:
1113
+ lines = f.readlines()
1114
+ lines += lines
1115
+ if True: # pylint: disable=using-constant-test
1116
+ for _ in progressbar(lines, "lines"):
1117
+ pass
1118
+ if True: # pylint: disable=using-constant-test
1119
+ for _ in progressbar(
1120
+ lines,
1121
+ "blines",
1122
+ units_scale=BINARY_BYTES_SCALE,
1123
+ itemlenfunc=len,
1124
+ total=sum(len(line) for line in lines),
1125
+ ):
1126
+ pass
1127
+ if True: # pylint: disable=using-constant-test
1128
+ for _ in progressbar(
1129
+ lines,
1130
+ "lines update 2s",
1131
+ update_period=2,
1132
+ report_print=True,
1133
+ ):
1134
+ pass
1135
+ if True: # pylint: disable=using-constant-test
1136
+ P = Progress(
1137
+ name=__file__,
1138
+ ##total=len(lines),
1139
+ units_scale=DECIMAL_SCALE,
1140
+ )
1141
+ with open(__file__, encoding='utf8') as f:
1142
+ for _ in P.iterbar(f):
1143
+ time.sleep(0.005)
1144
+ from cs.debug import selftest as runtests # pylint: disable=import-outside-toplevel
1145
+ runtests('cs.progress_tests')
1146
+
1147
+ if __name__ == '__main__':
1148
+ sys.exit(selftest(sys.argv))