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