progress-table 1.2.1__tar.gz → 1.2.3__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.1
2
2
  Name: progress-table
3
- Version: 1.2.1
3
+ Version: 1.2.3
4
4
  Summary: Display progress as a pretty table in the command line.
5
5
  Home-page: https://github.com/gahaalt/progress-table.git
6
6
  Author: Szymon Mikler
@@ -17,7 +17,6 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Requires-Python: >=3.7
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE.txt
20
- Requires-Dist: colorama
21
20
 
22
21
  > Note: versions 1.X introduced new features and changes of default behaviour.
23
22
  >
File without changes
@@ -0,0 +1,588 @@
1
+ # Copyright (c) 2022 Szymon Mikler
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ import math
8
+ import shutil
9
+ import sys
10
+ import time
11
+ from builtins import KeyError, staticmethod
12
+ from collections import defaultdict
13
+ from typing import Any, Callable, Dict, List, Tuple
14
+
15
+ from colorama import Fore, Style
16
+
17
+ from . import symbols
18
+
19
+ ALL_COLOR_NAMES = [x for x in dir(Fore) if not x.startswith("__")]
20
+ ALL_STYLE_NAMES = [x for x in dir(Style) if not x.startswith("__")]
21
+ ALL_COLORS_STYLES_NAMES = ALL_COLOR_NAMES + ALL_STYLE_NAMES
22
+
23
+ ALL_COLORS = [getattr(Fore, x) for x in ALL_COLOR_NAMES]
24
+ ALL_STYLES = [getattr(Style, x) for x in ALL_STYLE_NAMES]
25
+ ALL_COLORS_STYLES = ALL_COLORS + ALL_STYLES
26
+
27
+ ITERATOR_LENGTH_UNKNOWN_WARNED_ONCE = False
28
+ ITERATOR_LENGTH_CACHE: Dict[int, int] = {}
29
+
30
+
31
+ class ProgressTableV0:
32
+ def __init__(
33
+ self,
34
+ columns: Tuple | List = (),
35
+ refresh_rate: int = 10,
36
+ num_decimal_places: int = 4,
37
+ default_column_width: int = 8,
38
+ default_column_alignment: str = "center",
39
+ default_show_throughput: bool = True,
40
+ default_show_progress: bool = False,
41
+ print_row_on_update: bool = True,
42
+ reprint_header_every_n_rows: int = 30,
43
+ custom_format: Callable[[Any], Any] | None = None,
44
+ embedded_progress_bar: bool = False,
45
+ table_style: str = "normal",
46
+ file=sys.stdout,
47
+ ):
48
+ """Progress Table instance.
49
+
50
+ Columns have to be specified before displaying the first row or launching a progress bar!
51
+
52
+ Args:
53
+ columns: Columns that will be displayed in the header. Columns can be provided directly in
54
+ `__init__` or through methods `add_column` and `add_columns`. Columns added through
55
+ `__init__` will have default settings like alignment, color and width, while
56
+ columns added through methods can have those customized.
57
+ refresh_rate: The maximal number of times per second the last row of the Table will be refreshed.
58
+ This applies only when using Progress Bar or when `print_row_on_update = True`.
59
+ num_decimal_places: This is only applicable when using the default formatting. This won't be used
60
+ if `custom_format` is set. If applicable, for every displayed value except
61
+ integers there will be an attempt to round it.
62
+ default_column_width: Width of the column excluding cell padding. This can be overwritten by
63
+ columns by using an argument in `add_column` method.
64
+ default_column_alignment: Alignment in the column. Can be aligned either to `left`, `right` or
65
+ `center` (default). This can be overwritten by columns by using
66
+ an argument in `add_column` method.
67
+ print_row_on_update: True by default. If False, the current row will be displayed only when
68
+ it's ready. Row is considered ready after calling `.next_row` or `.close`
69
+ methods.
70
+ reprint_header_every_n_rows: 30 by default. When table has a lot of rows, it can be useful to
71
+ remind what the header is. If True, hedaer will be displayed
72
+ periodically after the selected number of rows. 0 to supress.
73
+ custom_format: A function that allows specyfing custom formatting for values in cells.
74
+ This function should be universal and work for all datatypes as inputs.
75
+ It takes one value as an input and returns one value as an output.
76
+ embedded_progress_bar: False by default. If True, changes the way the progress bar looks.
77
+ Embedded version is more subtle, but does not prevent the current row
78
+ from being displayed. If False, the progress bar covers the current
79
+ row, preventing the user from seeing values that are being updated
80
+ until the progress bar finishes.
81
+ table_style: Change the borders of the table. Cause KeyError to see all the available styles.
82
+ file: Redirect the output to another stream. There can be multiple streams at once passed as
83
+ a list or a tuple. Defaults to sys.stdout.
84
+ """
85
+ self.refresh_rate = refresh_rate
86
+ self.default_width = default_column_width
87
+ self.default_alignment = default_column_alignment
88
+ self.default_show_throughput = default_show_throughput
89
+ self.default_show_progress = default_show_progress
90
+ self.print_row_on_update = print_row_on_update
91
+ self.reprint_header_every_n_rows = reprint_header_every_n_rows
92
+ self.custom_format = custom_format or self.get_default_format_func(num_decimal_places)
93
+ self.embedded_progress_bar = embedded_progress_bar
94
+
95
+ if not isinstance(file, list) and not isinstance(file, tuple):
96
+ file = (file,)
97
+ self.files = file
98
+
99
+ # Helpers
100
+ self._widths: Dict[str, int] = {}
101
+ self._colors: Dict[str, str] = {}
102
+ self._alignment: Dict[str, str] = {}
103
+ self._aggregate: Dict[str, str | None] = {}
104
+ self._new_row: Dict[str, Any] = defaultdict(str)
105
+ self._aggregate_n: Dict[str, int] = defaultdict(int)
106
+
107
+ self.num_rows = 0
108
+ self.columns: List[str] = []
109
+ self.finished_rows: List[Dict[Any, Any]] = []
110
+
111
+ self._needs_line_ending = False
112
+ self._last_time_row_printed = 0
113
+ self._last_header_printed_at_row_count = 0
114
+
115
+ self.header_printed = False
116
+ self.progress_bar_active = False
117
+ self._refresh_progress_bar: Callable = lambda: None
118
+ self._needs_splitter = False
119
+
120
+ self._last_row_content = None
121
+
122
+ allowed_styles = [getattr(symbols, class_name) for class_name in dir(symbols)]
123
+ allowed_styles = [x for x in allowed_styles if inspect.isclass(x) and issubclass(x, symbols.Symbols)]
124
+ style_name_to_style = {x.name: x for x in allowed_styles if hasattr(x, "name")}
125
+
126
+ if table_style in style_name_to_style:
127
+ self._symbols = style_name_to_style[table_style]
128
+ else:
129
+ allowed_style_names = list(style_name_to_style)
130
+ raise KeyError(f"Style '{table_style}' not in {allowed_style_names}!")
131
+
132
+ for column in columns:
133
+ self.add_column(column)
134
+
135
+ @staticmethod
136
+ def get_default_format_func(decimal_places):
137
+ def fmt(x):
138
+ if isinstance(x, int):
139
+ return x
140
+ else:
141
+ try:
142
+ return format(x, f".{decimal_places}f")
143
+ except Exception:
144
+ return x
145
+
146
+ return fmt
147
+
148
+ def add_column(self, name, *, width=None, alignment=None, color=None, aggregate=None):
149
+ """Add column to the table.
150
+
151
+ Args:
152
+ name: Name of the column. Will be displayed in the header.
153
+ width: Width of the column. If width is smaller than the name, width will be automatically
154
+ increased to the smallest possible value. This means setting `width=0` will set the column
155
+ to the smallest possible width allowed by `name`.
156
+ alignment: Alignment of the data in the column.
157
+ color: Color of the header and the data in the column.
158
+ aggregate: By default, there's no aggregation. But if this is for example 'mean', then
159
+ after every update in the current row, the mean of the provided values will be
160
+ displayed. Aggregation is reset at every new row.
161
+ """
162
+ assert not self.header_printed, "Columns cannot be modified after displaying the first row!"
163
+
164
+ if name in self.columns:
165
+ logging.info(f"Column '{name}' already exists!")
166
+ else:
167
+ self.columns.append(name)
168
+
169
+ self._colors.setdefault(name, "")
170
+ if color is not None:
171
+ if isinstance(color, str):
172
+ color = [color]
173
+ for str_color in color:
174
+ byte_color = self._maybe_convert_to_colorama(str_color)
175
+ self._check_color(byte_color, str_color)
176
+ self._colors[name] += byte_color
177
+
178
+ alignment = alignment if alignment is not None else self.default_alignment
179
+ self._alignment[name] = alignment
180
+
181
+ assert aggregate in [
182
+ None,
183
+ "mean",
184
+ "sum",
185
+ "max",
186
+ "min",
187
+ ], "Allowed aggregate types: [None, 'mean', 'sum', 'max', 'min']"
188
+ self._aggregate[name] = aggregate
189
+
190
+ width = width if width is not None else self.default_width
191
+ if width < len(name):
192
+ width = len(name)
193
+ self._widths[name] = width
194
+
195
+ def add_columns(self, iterable, **kwds):
196
+ """Add multiple columns to the table."""
197
+ for column in iterable:
198
+ self.add_column(column, **kwds)
199
+
200
+ def next_row(self, save=True, split=False, header=False):
201
+ """End the current row."""
202
+ self._print_row()
203
+ self._maybe_line_ending()
204
+ if self.progress_bar_active:
205
+ self._refresh_progress_bar()
206
+ sys.stdout.flush()
207
+
208
+ if save and len(self._new_row) > 0:
209
+ self.finished_rows.append(self._new_row)
210
+ self.num_rows += 1
211
+
212
+ self._new_row = defaultdict(str)
213
+ self._aggregate_n = defaultdict(int)
214
+
215
+ if split:
216
+ self._needs_splitter = True
217
+
218
+ if header:
219
+ self._print_header(top=False)
220
+
221
+ def close(self):
222
+ """End the table and close it."""
223
+ self._needs_splitter = False
224
+
225
+ self.next_row()
226
+ self._print_bottom_bar()
227
+ self.header_printed = False
228
+ self._print()
229
+
230
+ def to_list(self):
231
+ """Convert to Python nested list."""
232
+ return [[row[col] for col in self.columns] for row in self.finished_rows]
233
+
234
+ def to_numpy(self):
235
+ """Convert to numpy array."""
236
+ import numpy as np
237
+
238
+ return np.array(self.to_list())
239
+
240
+ def to_df(self):
241
+ """Convert to pandas DataFrame."""
242
+ import pandas as pd
243
+
244
+ return pd.DataFrame(self.to_list(), columns=self.columns)
245
+
246
+ def display(self):
247
+ """Display the whole table. Can be used after closing the table."""
248
+ if self.header_printed:
249
+ self.close()
250
+ self._display_custom(self.to_list())
251
+
252
+ def update(self, key, value, *, weight=1):
253
+ """Update value in the current row."""
254
+ assert key in self.columns, f"Column '{key}' not in {self.columns}"
255
+
256
+ if self._aggregate[key] == "sum":
257
+ aggr_value = self._new_row[key] if self._aggregate_n[key] > 0 else 0
258
+ self._new_row[key] = aggr_value + value * weight
259
+ self._aggregate_n[key] += weight
260
+
261
+ elif self._aggregate[key] == "mean":
262
+ n = self._aggregate_n[key]
263
+ aggr_value = self._new_row[key] if n > 0 else 0
264
+ self._new_row[key] = (aggr_value * n + value * weight) / (n + weight)
265
+ self._aggregate_n[key] += weight
266
+
267
+ elif self._aggregate[key] == "max":
268
+ n = self._aggregate_n[key]
269
+ aggr_value = self._new_row[key] if n > 0 else -float("inf")
270
+ self._new_row[key] = max(aggr_value, value)
271
+ self._aggregate_n[key] += 1
272
+
273
+ elif self._aggregate[key] == "min":
274
+ n = self._aggregate_n[key]
275
+ aggr_value = self._new_row[key] if n > 0 else float("inf")
276
+ self._new_row[key] = min(aggr_value, value)
277
+ self._aggregate_n[key] += 1
278
+
279
+ else:
280
+ self._new_row[key] = value
281
+
282
+ t0 = time.time()
283
+ td = t0 - self._last_time_row_printed
284
+ if self.print_row_on_update and td > 1 / self.refresh_rate:
285
+ self._last_time_row_printed = t0
286
+
287
+ if not self.progress_bar_active:
288
+ self._print_row()
289
+ elif self.embedded_progress_bar:
290
+ self._refresh_progress_bar()
291
+
292
+ def update_from_dict(self, dictionary):
293
+ """Update multiple values in the current row."""
294
+ for key, value in dictionary.items():
295
+ self.update(key, value)
296
+
297
+ def _apply_cell_formatting(self, str_value: str, column: str):
298
+ width = self._widths[column]
299
+
300
+ alignment = self._alignment[column]
301
+ if alignment == "center":
302
+ str_value = str_value.center(width)
303
+ elif alignment == "left":
304
+ str_value = str_value.ljust(width)
305
+ elif alignment == "right":
306
+ str_value = str_value.rjust(width)
307
+ else:
308
+ allowed_alignments = ["center", "left", "right"]
309
+ raise KeyError(f"Alignment '{alignment}' not in {allowed_alignments}!")
310
+
311
+ clipped = len(str_value) > width
312
+ str_value = "".join(
313
+ [
314
+ " ", # space at the beginning of the row
315
+ str_value[:width].center(width),
316
+ self._symbols.dots if clipped else " ",
317
+ ]
318
+ )
319
+
320
+ if self._colors[column] is not None:
321
+ str_value = f"{self._colors[column]}{str_value}{Style.RESET_ALL}"
322
+ return str_value
323
+
324
+ def _bar(self, left: str, center: str, right: str):
325
+ content_list: List[str] = []
326
+ for col in self.columns:
327
+ content_list.append(self._symbols.horizontal * (self._widths[col] + 2))
328
+
329
+ center = center.join(content_list)
330
+ content = ["\r", left, center, right]
331
+ self._print("".join(content), end="")
332
+
333
+ def _bar_custom_center(self, left: str, center: List[str] | str, right: str):
334
+ """UNUSED"""
335
+ content_list: List[str] = []
336
+
337
+ if isinstance(center, str):
338
+ center = [center] * (len(self.columns) - 1)
339
+ assert len(center) == len(self.columns) - 1
340
+
341
+ for col in self.columns:
342
+ content_list.append(self._symbols.horizontal * (self._widths[col] + 2))
343
+ if center:
344
+ content_list.append(center.pop(0))
345
+
346
+ center = "".join(content_list)
347
+ content = ["\r", left, center, right]
348
+ self._print("".join(content), end="")
349
+
350
+ def _print_transition_bar(self, previous_n_cols, new_n_cols):
351
+ """UNUSED"""
352
+ assert previous_n_cols > 0
353
+ assert new_n_cols > previous_n_cols
354
+ added_cols = new_n_cols - previous_n_cols
355
+
356
+ symbols = [
357
+ *([self._symbols.all] * previous_n_cols),
358
+ *([self._symbols.no_up] * (added_cols - 1)),
359
+ ]
360
+ return self._bar_custom_center(left=self._symbols.no_left, center=symbols, right=self._symbols.down_left)
361
+
362
+ def _print_top_bar(self):
363
+ return self._bar(left=self._symbols.down_right, center=self._symbols.no_up, right=self._symbols.down_left)
364
+
365
+ def _print_bottom_bar(self):
366
+ return self._bar(left=self._symbols.up_right, center=self._symbols.no_down, right=self._symbols.up_left)
367
+
368
+ def _print_center_bar(self):
369
+ return self._bar(left=self._symbols.no_left, center=self._symbols.all, right=self._symbols.no_right)
370
+
371
+ @staticmethod
372
+ def _maybe_convert_to_colorama(color):
373
+ if isinstance(color, str):
374
+ if hasattr(Fore, color.upper()):
375
+ return getattr(Fore, color.upper())
376
+ if hasattr(Style, color.upper()):
377
+ return getattr(Style, color.upper())
378
+ return color
379
+
380
+ @staticmethod
381
+ def _check_color(color, color_str=None):
382
+ if color_str is None:
383
+ color_str = color
384
+ assert color in ALL_COLORS_STYLES, f"Only colorama colors are allowed, not '{color_str}'! Available: {ALL_COLORS_STYLES_NAMES}"
385
+
386
+ def _print_header(self, top=True):
387
+ assert self.columns, "Columns are required! Use .add_column method or specify them in __init__!"
388
+
389
+ self._needs_splitter = False
390
+
391
+ if top:
392
+ self._print_top_bar()
393
+ else:
394
+ self._print_center_bar()
395
+ self._print()
396
+
397
+ content = []
398
+ for column in self.columns:
399
+ value = self._apply_cell_formatting(column, column)
400
+ content.append(value)
401
+ s = "".join(["\r", self._symbols.vertical, self._symbols.vertical.join(content), self._symbols.vertical])
402
+ self._print(s)
403
+
404
+ self._last_header_printed_at_row_count = self.num_rows
405
+ self.header_printed = True
406
+ self._print_center_bar()
407
+ self._print()
408
+ self._last_row_content = "header"
409
+
410
+ def _print_splitter(self):
411
+ if self._last_row_content == "row":
412
+ self._print_center_bar()
413
+ self._print()
414
+
415
+ self._needs_splitter = False
416
+ self._last_row_content = "splitter"
417
+
418
+ def _get_row(self):
419
+ content = []
420
+ for column in self.columns:
421
+ value = self._new_row[column]
422
+ value = self.custom_format(value)
423
+ value = self._apply_cell_formatting(str_value=str(value), column=column)
424
+ content.append(value)
425
+ return "".join(["\r", self._symbols.vertical, self._symbols.vertical.join(content), self._symbols.vertical])
426
+
427
+ def _print_row(self):
428
+ if not self.header_printed:
429
+ self._print_header(top=True)
430
+
431
+ if self.reprint_header_every_n_rows != 0:
432
+ if self.num_rows - self._last_header_printed_at_row_count >= self.reprint_header_every_n_rows:
433
+ self._print_header(top=False)
434
+
435
+ if self._needs_splitter:
436
+ self._print_splitter()
437
+
438
+ if len(self._new_row) == 0:
439
+ return
440
+ self._needs_line_ending = True
441
+ self._print(self._get_row(), end="")
442
+
443
+ def _print_progress_bar(self, i, n, show_before=" ", show_after=" ", embedded=False):
444
+ i = min(i, n) # clip the iteration number to be not bigger than the total number of iterations
445
+ terminal_width = shutil.get_terminal_size(fallback=(0, 0)).columns or float("inf")
446
+
447
+ if not embedded:
448
+ tot_width = sum(self._widths.values()) + 3 * (len(self._widths) - 1) + 2
449
+ if tot_width >= terminal_width - 1:
450
+ tot_width = terminal_width - 2
451
+
452
+ tot_width = tot_width - len(show_before) - len(show_after)
453
+ num_hashes = math.ceil(i / n * tot_width)
454
+ num_empty = tot_width - num_hashes
455
+
456
+ self._print(
457
+ self._symbols.vertical,
458
+ show_before,
459
+ self._symbols.pbar_filled * num_hashes,
460
+ self._symbols.pbar_empty * num_empty,
461
+ show_after,
462
+ self._symbols.vertical,
463
+ end="\r",
464
+ sep="",
465
+ )
466
+ else:
467
+ row = self._get_row()
468
+ new_row = []
469
+ for idx, letter in enumerate(row):
470
+ if idx / len(row) <= i / n:
471
+ if letter == " ":
472
+ letter = self._symbols.embedded_pbar_filled
473
+ new_row.append(letter)
474
+ row = "".join(new_row)
475
+ self._print(row, end="\r")
476
+ sys.stdout.flush()
477
+
478
+ def _maybe_line_ending(self):
479
+ if self._needs_line_ending:
480
+ self._print()
481
+ self._needs_splitter = False
482
+ self._needs_line_ending = False
483
+ self._last_row_content = "row"
484
+
485
+ def _display_custom(self, data):
486
+ if self.header_printed:
487
+ self.close()
488
+
489
+ for row in data:
490
+ assert len(row) == len(self.columns)
491
+ for key, value in zip(self.columns, row):
492
+ self._new_row[key] = value
493
+ self._print_row()
494
+ self.next_row(save=False)
495
+ self.close()
496
+ self._last_row_content = "close"
497
+
498
+ def _print(self, *args, **kwds):
499
+ for file in self.files:
500
+ print(*args, **kwds, file=file)
501
+
502
+ def __call__(self, iterator, *range_args, length=None, prefix="", show_throughput=None, show_progress=None):
503
+ """Display progress bar over the iterator. Try to figure out the iterator length."""
504
+ global ITERATOR_LENGTH_UNKNOWN_WARNED_ONCE, ITERATOR_LENGTH_CACHE
505
+
506
+ if not self.header_printed:
507
+ self._print_header()
508
+
509
+ if show_throughput is None:
510
+ show_throughput = self.default_show_throughput
511
+ if show_progress is None:
512
+ show_progress = self.default_show_progress
513
+
514
+ if isinstance(iterator, int):
515
+ iterator = range(iterator, *range_args)
516
+ else:
517
+ assert len(range_args) == 0, "Unnamed args are not allowed here!"
518
+
519
+ if length is None and not hasattr(iterator, "__len__"):
520
+ if not ITERATOR_LENGTH_UNKNOWN_WARNED_ONCE:
521
+ logging.warning("Iterator length is unknown!")
522
+ ITERATOR_LENGTH_UNKNOWN_WARNED_ONCE = True
523
+
524
+ # We have a back-up mechanism in case of unknown-length iterators.
525
+ # If we progress over the same unknown length iterator more than once,
526
+ # we will use its length from the previous launch.
527
+ save_length_to_cache = True
528
+
529
+ if id(iterator) in ITERATOR_LENGTH_CACHE:
530
+ length = ITERATOR_LENGTH_CACHE[id(iterator)]
531
+ else:
532
+ length = 1
533
+ else:
534
+ save_length_to_cache = False
535
+ length = length or len(iterator)
536
+
537
+ t_last_printed = -float("inf")
538
+ t_beginning = time.time()
539
+
540
+ self.progress_bar_active = True
541
+
542
+ idx = 0
543
+ for idx, element in enumerate(iterator):
544
+ if time.time() - t_last_printed > 1 / self.refresh_rate:
545
+ # Reenable here, in case of nested progress bars
546
+ self.progress_bar_active = True
547
+
548
+ self._print(end="\r")
549
+ s = time.time() - t_beginning
550
+ throughput = idx / s if s > 0 else 0.0
551
+
552
+ full_prefix = [" [", prefix]
553
+
554
+ inside_brackets = []
555
+ if show_throughput:
556
+ inside_brackets.append(f"{throughput: <.2f} it/s")
557
+ if show_progress:
558
+ inside_brackets.append(f"{idx}/{length}")
559
+ inside_brackets = ", ".join(inside_brackets)
560
+ full_prefix.append(inside_brackets)
561
+
562
+ full_prefix.append("] ")
563
+ full_prefix = "".join(full_prefix)
564
+ full_prefix = full_prefix if full_prefix != " [] " else " "
565
+
566
+ self._refresh_progress_bar = lambda: self._print_progress_bar(
567
+ idx,
568
+ length,
569
+ show_before=full_prefix,
570
+ embedded=self.embedded_progress_bar,
571
+ )
572
+ self._refresh_progress_bar()
573
+
574
+ t_last_printed = time.time()
575
+ yield element
576
+ if save_length_to_cache:
577
+ ITERATOR_LENGTH_CACHE[id(iterator)] = idx
578
+
579
+ self.progress_bar_active = False
580
+ self._print_row()
581
+
582
+ def __setitem__(self, key, value):
583
+ self.update(key, value, weight=1)
584
+
585
+ def __getitem__(self, key):
586
+ assert key in self.columns, f"Column '{key}' not in {self.columns}"
587
+
588
+ return self._new_row[key]