progress-table 3.0.0__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.
@@ -0,0 +1,1320 @@
1
+ # Copyright (c) 2022-2025 Szymon Mikler
2
+ # Licensed under the MIT License
3
+
4
+ """Progress Table provides an easy and pretty way to track your process."""
5
+
6
+ from __future__ import annotations # for PEP 563
7
+
8
+ import inspect
9
+ import logging
10
+ import math
11
+ import os
12
+ import shutil
13
+ import sys
14
+ import time
15
+ from collections.abc import Callable, Iterable, Iterator, Sized
16
+ from dataclasses import dataclass
17
+ from threading import Thread
18
+ from typing import TextIO
19
+
20
+ from colorama import Style
21
+
22
+ from progress_table import styles
23
+ from progress_table.common import CURSOR_UP, ColorFormat, ColorFormatTuple, maybe_convert_to_colorama
24
+
25
+ ######################
26
+ ## HELPER FUNCTIONS ##
27
+ ######################
28
+
29
+ logger = logging.getLogger("progress_table")
30
+
31
+
32
+ class TableClosedError(Exception):
33
+ """Raised when trying to update a closed table."""
34
+
35
+
36
+ def aggregate_dont(value, *_):
37
+ """Don't aggregate the values."""
38
+ return value
39
+
40
+
41
+ def aggregate_mean(value, old_value, weight, old_weight):
42
+ """Aggregate the values by keeping the mean."""
43
+ return (old_value * old_weight + value * weight) / (old_weight + weight)
44
+
45
+
46
+ def aggregate_sum(value, old_value, *_):
47
+ """Aggregate the values by keeping the sum."""
48
+ return old_value + value
49
+
50
+
51
+ def aggregate_max(value, old_value, *_):
52
+ """Aggregate the values by keeping the maximum."""
53
+ return max(old_value, value)
54
+
55
+
56
+ def aggregate_min(value, old_value, *_):
57
+ """Aggregate the values by keeping the minimum."""
58
+ return min(old_value, value)
59
+
60
+
61
+ def get_aggregate_fn(aggregate: None | str | Callable) -> Callable:
62
+ """Get the aggregate function from the provided value."""
63
+ if aggregate is None:
64
+ return aggregate_dont
65
+
66
+ if callable(aggregate):
67
+ num_parameters = len(inspect.signature(aggregate).parameters)
68
+ assert num_parameters == 4, f"Aggregate function has to take 4 arguments, not {num_parameters}!"
69
+ return aggregate
70
+
71
+ if isinstance(aggregate, str):
72
+ if aggregate == "mean":
73
+ return aggregate_mean
74
+ if aggregate == "sum":
75
+ return aggregate_sum
76
+ if aggregate == "max":
77
+ return aggregate_max
78
+ if aggregate == "min":
79
+ return aggregate_min
80
+ msg = f"Unknown aggregate type string: {aggregate}"
81
+ raise ValueError(msg)
82
+ msg = f"Unknown aggregate type: {type(aggregate)}"
83
+ raise ValueError(msg)
84
+
85
+
86
+ def get_default_format_fn(decimal_places: int) -> Callable[[object], str]:
87
+ def fmt(x: object) -> str:
88
+ if isinstance(x, int):
89
+ return str(x)
90
+ try:
91
+ return format(x, f".{decimal_places}f")
92
+ except ValueError:
93
+ return str(x)
94
+
95
+ return fmt
96
+
97
+
98
+ ################
99
+ ## MAIN CLASS ##
100
+ ################
101
+
102
+
103
+ @dataclass
104
+ class DataRow:
105
+ """Basic unit of data storage for the table."""
106
+
107
+ values: dict[str, object]
108
+ weights: dict[str, float]
109
+ colors: dict[str, str]
110
+
111
+ def is_empty(self) -> bool:
112
+ """Check if the row is empty."""
113
+ return not any(self.values)
114
+
115
+
116
+ class ProgressTable:
117
+ """Provides an easy and pretty way to track your process."""
118
+
119
+ DEFAULT_COLUMN_WIDTH = 8
120
+ DEFAULT_COLUMN_COLOR = None
121
+ DEFAULT_COLUMN_ALIGNMENT = "center"
122
+ DEFAULT_COLUMN_AGGREGATE = None
123
+ DEFAULT_ROW_COLOR = None
124
+
125
+ def __init__(
126
+ self,
127
+ *cols: str,
128
+ columns: Iterable[str] = (),
129
+ interactive: int = int(os.environ.get("PTABLE_INTERACTIVE", "2")),
130
+ refresh_rate: int = 20,
131
+ num_decimal_places: int = 4,
132
+ default_column_width: int | None = None,
133
+ default_column_color: ColorFormat = None,
134
+ default_column_alignment: str | None = None,
135
+ default_column_aggregate: str | None = None,
136
+ default_header_color: ColorFormat = None,
137
+ default_row_color: ColorFormat = None,
138
+ pbar_show_throughput: bool = True,
139
+ pbar_show_progress: bool = False,
140
+ pbar_show_percents: bool = False,
141
+ pbar_show_eta: bool = False,
142
+ pbar_embedded: bool = True,
143
+ pbar_style: str | styles.PbarStyleBase = "square",
144
+ pbar_style_embed: str | styles.PbarStyleBase = "cdots",
145
+ print_header_on_top: bool = True,
146
+ print_header_every_n_rows: int = 30,
147
+ custom_cell_format: Callable[[object], str] | None = None,
148
+ table_style: str | styles.TableStyleBase = "round",
149
+ file: TextIO | list[TextIO] | tuple[TextIO] | None = None,
150
+ # DEPRECATED ARGUMENTS
151
+ custom_format: None = None,
152
+ embedded_progress_bar: None = None,
153
+ print_row_on_update: None = None,
154
+ ) -> None:
155
+ """Progress Table instance.
156
+
157
+ Columns can be specified using `add_column` method, but they can be added on the fly as well.
158
+ Use `next_row` method to display the current row and reset the values for the next row.
159
+
160
+ Example:
161
+ >>> table = ProgressTable()
162
+ >>> table.add_column("b", width=10)
163
+ >>> table["a"] = 1
164
+ >>> table["b"] = "xyz"
165
+ >>> table.next_row()
166
+
167
+ Args:
168
+ cols: Columns that will be displayed in the header. Columns can be provided directly in `__init__` or
169
+ through methods `add_column` and `add_columns`. Columns added through `__init__` will have default
170
+ settings like alignment, color and width, while columns added through methods can have those
171
+ customized.
172
+ columns: Alias for `cols`.
173
+ interactive: Three interactivity levels are available: 2, 1 and 0. It's recommended to use 2, but some
174
+ terminal environments might not all features. When using decreased interactivity, some features
175
+ will be supressed. If something doesn't look right in your terminal, try to decrease the
176
+ interactivity.
177
+ On level 2 you can modify all rows, including the rows above the current one.
178
+ You can also add columns on-the-fly or reorder them. You can use nested progress bars.
179
+ On level 1 you can only operate on the current row, the old rows are frozen, but you still get
180
+ to use a progress bar, albeit not nested. On level 0 there are no progress bars and rows are
181
+ only printed after calling `next_row`.
182
+ refresh_rate: The maximal number of times per second to render the updates in the table.
183
+ num_decimal_places: This is only applicable when using the default formatting. This won't be used if
184
+ `custom_cell_repr` is set. If applicable, for every displayed value except integers
185
+ there will be an attempt to round it.
186
+ default_column_color: Color of the header and the data in the column.
187
+ This can be overwritten in columns by using an argument in `add_column` method.
188
+ default_column_width: Width of the column excluding cell padding.
189
+ This can be overwritten in columns by using an argument in `add_column` method.
190
+ default_column_alignment: Alignment in the column. Can be aligned either to `left`, `right` or `center`.
191
+ This can be overwritten by columns by using an argument in `add_column` method.
192
+ default_column_aggregate: By default, there's no aggregation. But if this is for example 'mean', then after
193
+ every update in the current row, the mean of the provided values will be
194
+ displayed. Aggregated values is reset at every new row. This can be overwritten
195
+ by columns by using an argument in `add_column` method.
196
+ default_header_color: Color of the header. This can be overwritten by column-specific color.
197
+ default_row_color: Color of the row. This can be overwritten by using an argument in `next_row` method.
198
+ pbar_show_throughput: Show throughput in the progress bar, for example `3.55 it/s`. Defaults to True.
199
+ pbar_show_progress: Show progress in the progress bar, for example 10/40. Defaults to True.
200
+ pbar_show_percents: Show percents in the progress bar, for example 25%. Defaults to False.
201
+ pbar_show_eta: Show estimated time of finishing in the progress bar, for example 10s. Defaults to False.
202
+ pbar_embedded: If True, changes the way the first (non-nested) progress bar looks.
203
+ Embedded version is more subtle, but does not prevent the current row from being displayed.
204
+ If False, the progress bar covers the current row, preventing the user from seeing values
205
+ that are being updated until the progress bar finishes. The default is True.
206
+ pbar_style: Change the style of the progress bar. Either a string or 'PbarStyleBase' type class.
207
+ pbar_style_embed: Change the style of the embedded progress bar. Same as pbar_style, but for embedded pbars.
208
+ print_header_on_top: If True, header will be displayed as the first row in the table.
209
+ print_header_every_n_rows: 30 by default. When table has a lot of rows, it can be useful to remind what the
210
+ header is. If True, hedaer will be displayed periodically after the selected
211
+ number of rows. 0 to supress.
212
+ custom_cell_format: A function that defines how to get str value to display from a cell content.
213
+ This function should be universal and work for all datatypes as inputs.
214
+ It takes one value as an input and returns string as an output.
215
+ table_style: Change the borders of the table. Either a string or 'TableStyleBase' type class.
216
+ file: Redirect the output to another stream. There can be multiple streams at once passed as list or tuple.
217
+ Defaults to None, which is interpreted as stdout.
218
+
219
+ """
220
+ # Deprecation handling
221
+ if custom_format is not None:
222
+ logger.warning("Argument `custom_format` is deprecated. Use `custom_cell_format` instead!")
223
+ if embedded_progress_bar is not None:
224
+ logger.warning("Argument `embedded_progress_bar` is deprecated. Use `pbar_embedded` instead!")
225
+ if print_row_on_update is not None:
226
+ logger.warning("Argument `print_row_on_update` is deprecated. Specify `interactive` instead!")
227
+
228
+ self.pbar_style = styles.parse_pbar_style(pbar_style)
229
+ self.table_style = styles.parse_table_style(table_style)
230
+ self.pbar_style_embed = styles.parse_pbar_style(pbar_style_embed)
231
+
232
+ assert isinstance(default_row_color, ColorFormatTuple), "Row color has to be a color format!"
233
+ assert isinstance(default_column_color, ColorFormatTuple), "Column color has to be a color format!"
234
+ assert isinstance(default_header_color, ColorFormatTuple), "Header color has to be a color format!"
235
+
236
+ # Default values for column and
237
+ self.column_width = default_column_width
238
+ self.column_color = default_column_color
239
+ self.column_alignment = default_column_alignment
240
+ self.column_aggregate = default_column_aggregate
241
+ self.row_color = default_row_color
242
+ self.header_color = default_header_color
243
+
244
+ # We are storing column configs
245
+ self.column_widths: dict[str, int] = {}
246
+ self.column_colors: dict[str, str] = {}
247
+ self.column_alignments: dict[str, str] = {}
248
+ self.column_aggregates: dict[str, Callable] = {}
249
+ self.column_names: list[str] = [] # Names serve as keys for column configs
250
+ self._closed = False
251
+
252
+ self.files = (file,) if not isinstance(file, (list, tuple)) else file
253
+
254
+ assert print_header_every_n_rows >= 0, "Value must be non-negative!"
255
+ self._print_header_on_top = print_header_on_top
256
+ self._print_header_every_n_rows = print_header_every_n_rows
257
+ self._previous_header_row_number = 0
258
+
259
+ self._data_rows: list[DataRow] = []
260
+ self._display_rows: list[str | int] = []
261
+ self._pending_display_rows: list[int] = []
262
+
263
+ self._REFRESH_PENDING: bool = False
264
+ self._RENDERER_RUNNING: bool = False
265
+
266
+ self._active_pbars: list[TableProgressBar] = []
267
+ self._cleaning_pbar_instructions: list[tuple[int, str]] = []
268
+
269
+ self._latest_row_decorations: list[str]
270
+ if self._print_header_on_top:
271
+ self._latest_row_decorations = ["SPLIT TOP", "HEADER", "SPLIT MID"]
272
+ else:
273
+ self._latest_row_decorations = ["SPLIT TOP"]
274
+
275
+ self.custom_cell_format = custom_cell_format or get_default_format_fn(num_decimal_places)
276
+ self.pbar_show_throughput: bool = pbar_show_throughput
277
+ self.pbar_show_progress: bool = pbar_show_progress
278
+ self.pbar_show_percents: bool = pbar_show_percents
279
+ self.pbar_show_eta: bool = pbar_show_eta
280
+ self.pbar_embedded: bool = pbar_embedded
281
+
282
+ self.refresh_rate: int = refresh_rate
283
+ self._frame_time: float = 1 / self.refresh_rate if self.refresh_rate else 0.0
284
+
285
+ self._CURSOR_ROW = 0
286
+
287
+ # Interactivity settings
288
+ self.interactive = interactive
289
+ assert self.interactive in (2, 1, 0)
290
+
291
+ self._printing_buffer: list[str] = []
292
+ self.add_columns(*cols, *columns)
293
+
294
+ self._append_new_empty_data_row()
295
+ self._at_indexer = TableAtIndexer(self)
296
+
297
+ ####################
298
+ ## PUBLIC METHODS ##
299
+ ####################
300
+
301
+ def add_column(
302
+ self,
303
+ name: str,
304
+ *,
305
+ width: int | None = None,
306
+ color: ColorFormat = None,
307
+ alignment: str | None = None,
308
+ aggregate: None | str | Callable = None,
309
+ ) -> None:
310
+ """Add column to the table.
311
+
312
+ You can re-add existing columns to modify their properties.
313
+
314
+ Args:
315
+ name: Name of the column. Will be displayed in the header. Must be unique.
316
+ width: Width of the column. If width is smaller than the name, width will be automatically
317
+ increased to the smallest possible value. This means setting `width=0` will set the column
318
+ to the smallest possible width allowed by `name`.
319
+ alignment: Alignment of the data in the column.
320
+ color: Color of the header and the data in the column.
321
+ aggregate: By default, there's no aggregation. But if this is for example 'mean', then
322
+ after every update in the current row, the mean of the provided values will be
323
+ displayed. Aggregated values is reset at every new row.
324
+
325
+ """
326
+ assert isinstance(name, str), f"Column name has to be a string, not {type(name)}!"
327
+ if name in self.column_names:
328
+ logger.info("Column '%s' already exists!", name)
329
+ else:
330
+ self.column_names.append(name)
331
+
332
+ resolved_width = width or self.column_width or self.DEFAULT_COLUMN_WIDTH
333
+ if not width and resolved_width < len(str(name)):
334
+ resolved_width = len(str(name))
335
+ self.column_widths[name] = resolved_width
336
+ self.column_colors[name] = maybe_convert_to_colorama(color or self.column_color or self.DEFAULT_COLUMN_COLOR)
337
+ self.column_alignments[name] = alignment or self.column_alignment or self.DEFAULT_COLUMN_ALIGNMENT
338
+ self.column_aggregates[name] = get_aggregate_fn(
339
+ aggregate or self.column_aggregate or self.DEFAULT_COLUMN_AGGREGATE,
340
+ )
341
+ self._set_all_display_rows_as_pending()
342
+
343
+ def add_columns(self, *columns, **kwds) -> None:
344
+ """Add multiple columns to the table.
345
+
346
+ This can be an integer - then the given number of columns will be created.
347
+ In this case their names will be integers starting from 0.
348
+
349
+ Args:
350
+ columns: Names of the columns or a number of columns to create.
351
+ kwds: Additional arguments for the columns. Column properties will be identical for all added columns.
352
+
353
+ """
354
+ # Create the given number of columns
355
+ if len(columns) == 1 and isinstance(columns[0], int):
356
+ num_to_create = columns[0]
357
+ col_idx = 0
358
+ while num_to_create > 0:
359
+ column_name = str(col_idx)
360
+ if column_name not in self.column_names:
361
+ self.add_column(column_name, **kwds)
362
+ num_to_create -= 1
363
+ col_idx += 1
364
+ else:
365
+ for column in columns:
366
+ self.add_column(column, **kwds)
367
+
368
+ def reorder_columns(self, *column_names) -> None:
369
+ """Reorder columns in the table.
370
+
371
+ Args:
372
+ column_names: Names of the columns in the desired order.
373
+
374
+ """
375
+ if all(x == y for x, y in zip(column_names, self.column_names)):
376
+ return
377
+
378
+ assert isinstance(column_names, (list, tuple))
379
+ assert all(x in self.column_names for x in column_names), f"Columns {column_names} not in {self.column_names}"
380
+ self.column_names = list(column_names)
381
+ self.column_widths = {k: self.column_widths[k] for k in column_names}
382
+ self.column_colors = {k: self.column_colors[k] for k in column_names}
383
+ self.column_alignments = {k: self.column_alignments[k] for k in column_names}
384
+ self.column_aggregates = {k: self.column_aggregates[k] for k in column_names}
385
+ self._set_all_display_rows_as_pending()
386
+
387
+ def update(
388
+ self,
389
+ name: str,
390
+ value: object,
391
+ *,
392
+ row: int = -1,
393
+ weight: float = 1.0,
394
+ cell_color: ColorFormat = None,
395
+ **column_kwds,
396
+ ) -> None:
397
+ """Update value in the current row. More powerful than __setitem__.
398
+
399
+ Args:
400
+ name: Name of the column.
401
+ value: Value to be set.
402
+ row: Index of the row. By default, it's the last row.
403
+ weight: Weight of the value. This is used for aggregation.
404
+ cell_color: Optionally override color for specific cell, independent of rows and columns.
405
+ column_kwds: Additional arguments for the column. They will be only used for column creation.
406
+ If column already exists, they will have no effect.
407
+
408
+ """
409
+ if name not in self.column_names:
410
+ self.add_column(name, **column_kwds)
411
+
412
+ data_row_index = row if row >= 0 else len(self._data_rows) + row
413
+ if data_row_index >= len(self._data_rows):
414
+ msg = f"Row {data_row_index} out of range! Number of rows: {len(self._data_rows)}"
415
+ raise IndexError(msg)
416
+
417
+ # Set default values for rows without values in this column
418
+ data_row = self._data_rows[row]
419
+ data_row.values.setdefault(name, 0)
420
+ data_row.weights.setdefault(name, 0)
421
+
422
+ fn = self.column_aggregates[name]
423
+ data_row.values[name] = fn(value, data_row.values[name], weight, data_row.weights[name])
424
+ data_row.weights[name] += weight
425
+
426
+ if cell_color is not None:
427
+ data_row.colors[name] = maybe_convert_to_colorama(cell_color)
428
+
429
+ if self.interactive > 0:
430
+ self._append_or_update_display_row(data_row_index)
431
+
432
+ def __setitem__(self, key: str | tuple[str, int], value: object) -> None:
433
+ """Update value in the current row. Calls 'update'."""
434
+ if isinstance(key, tuple):
435
+ name, row = key
436
+ if isinstance(name, int) and isinstance(row, str):
437
+ name, row = row, name
438
+ else:
439
+ name = key
440
+ row = -1
441
+ assert isinstance(row, int), f"Row {row} has to be an integer, not {type(row)}!"
442
+ self.update(name, value, row=row, weight=1)
443
+
444
+ def __getitem__(self, key: str | tuple[str, int]) -> object | None:
445
+ """Get the value from the current row in table."""
446
+ if isinstance(key, tuple):
447
+ name, row = key
448
+ if isinstance(name, int) and isinstance(row, int):
449
+ name, row = row, name
450
+ else:
451
+ name = key
452
+ row = -1
453
+ assert name in self.column_names, f"Column {name} not in {self.column_names}"
454
+ assert isinstance(row, int), f"Row {row} has to be an integer, not {type(row)}!"
455
+ return self._data_rows[row].values.get(name, None)
456
+
457
+ def update_from_dict(self, dictionary: dict[str, object]) -> None:
458
+ """Update multiple values in the current row."""
459
+ for key, value in dictionary.items():
460
+ self.update(key, value)
461
+
462
+ @property
463
+ def at(self) -> TableAtIndexer:
464
+ """Advanced indexing and splicing for the table.
465
+
466
+ Indexing with respect to data rows.
467
+ Headers and decorations are ignored.
468
+
469
+ Example:
470
+ >>> table.at[:] = 0.0 # Initialize all values to 0.0
471
+ >>> table.at[0, :] = 2.0 # Set all values in the first row to 2.0
472
+ >>> table.at[:, 1] = 2.0 # Set all values in the second column to 2.0
473
+ >>> table.at[-2, 0] = 3.0 # Set the first column in the second-to-last row to 3.0
474
+
475
+ """
476
+ return self._at_indexer
477
+
478
+ def next_row(
479
+ self,
480
+ color: ColorFormat | dict[str, ColorFormat] = None,
481
+ split: bool | None = None,
482
+ header: bool | None = None,
483
+ ) -> None:
484
+ """End the current row."""
485
+ # Force header if it wasn't printed for a long enough time
486
+ if (
487
+ header is None
488
+ and len(self._data_rows) - self._previous_header_row_number >= self._print_header_every_n_rows
489
+ and self._print_header_every_n_rows > 0
490
+ ):
491
+ header = True
492
+ header = header or False
493
+ split = split or False
494
+
495
+ row = self._data_rows[-1]
496
+ data_row_index = len(self._data_rows) - 1
497
+
498
+ # Color is applied to the existing row - not the new one!
499
+ # Existing colors applied by `update` get the priority over row color
500
+ row.colors = {**self._resolve_row_color_dict(color), **row.colors}
501
+
502
+ # Refreshing the existing row is necessary to apply colors
503
+ # Or - if row is empty, this will cause the first addition to display rows
504
+ self._append_or_update_display_row(data_row_index)
505
+
506
+ self._append_new_empty_data_row()
507
+ # Add decorations and a new row
508
+ if header:
509
+ self._previous_header_row_number = len(self._data_rows) - 1
510
+ self._latest_row_decorations.extend(["SPLIT MID", "HEADER", "SPLIT MID"])
511
+ elif split:
512
+ self._latest_row_decorations.append("SPLIT MID")
513
+
514
+ def add_row(self, *values, **kwds) -> None:
515
+ """Mimicking rich.table behavior for adding full rows in one call."""
516
+ for key, value in zip(self.column_names, values):
517
+ self.update(key, value)
518
+ self.next_row(**kwds)
519
+
520
+ def add_rows(self, *rows, **kwds) -> None:
521
+ """Like `add_row` but adds multiple rows at once.
522
+
523
+ Optionally, it accepts an integer as the first argument, which will create that number of empty rows.
524
+ """
525
+ if len(rows) == 1 and isinstance(rows[0], int):
526
+ rows = [{} for _ in range(rows[0])]
527
+
528
+ for row in rows:
529
+ self.add_row(*row, **kwds)
530
+
531
+ def num_rows(self) -> int:
532
+ """Get the number of rows in the table."""
533
+ return len(self._data_rows)
534
+
535
+ def num_columns(self) -> int:
536
+ """Get the number of columns in the table."""
537
+ return len(self.column_names)
538
+
539
+ def close(self) -> None:
540
+ """Close the table gracefully. Closed table cannot be updated anymore."""
541
+ if self._closed:
542
+ return
543
+ for pbar in self._active_pbars:
544
+ pbar.close()
545
+ if "SPLIT TOP" in self._display_rows:
546
+ self._append_or_update_display_row("SPLIT BOT")
547
+ self._refresh()
548
+ self._closed = True
549
+
550
+ def write(self, *args, sep: str = " ") -> None:
551
+ """Write a text message gracefully when the table is opened.
552
+
553
+ Example:
554
+ >>> ┌─────────┬─────────┐
555
+ >>> │ H1 │ H2 │
556
+ >>> ├─────────┼─────────┤
557
+ >>> │ V1 │ V2 │
558
+ >>> │ Your message here │
559
+ >>> │ V3 │ V4 │
560
+ >>> └─────────┴─────────┘
561
+
562
+ """
563
+ full_message = [str(arg) for arg in args]
564
+ full_message_str = sep.join(full_message)
565
+ message_lines = full_message_str.split("\n")
566
+
567
+ tot_width = self._get_outer_inner_width()
568
+ for raw_line in message_lines:
569
+ line = self.table_style.vertical + raw_line
570
+
571
+ if len(line) < tot_width:
572
+ n_spaces = tot_width - len(line) - 1
573
+ line += " " * n_spaces + self.table_style.vertical
574
+
575
+ self._append_or_update_display_row("USER WRITE " + line)
576
+
577
+ def to_list(self) -> list[list[object]]:
578
+ """Convert to Python nested list."""
579
+ values = [[row.values.get(col, None) for col in self.column_names] for row in self._data_rows]
580
+ if self._data_rows[-1].is_empty():
581
+ values.pop(-1)
582
+ return values
583
+
584
+ def to_numpy(self) -> object:
585
+ """Convert to numpy array.
586
+
587
+ Numpy library is required.
588
+ """
589
+ import numpy as np
590
+
591
+ return np.array(self.to_list())
592
+
593
+ def to_df(self) -> object:
594
+ """Convert to pandas DataFrame.
595
+
596
+ Pandas library is required.
597
+ """
598
+ import pandas as pd
599
+
600
+ return pd.DataFrame(self.to_list(), columns=pd.Series(self.column_names))
601
+
602
+ def show(self) -> None:
603
+ """Show the full table in the console."""
604
+ self._CURSOR_ROW = 0
605
+ self._set_all_display_rows_as_pending()
606
+
607
+ #####################
608
+ ## PRIVATE METHODS ##
609
+ #####################
610
+
611
+ def _trigger_refresh(self) -> None:
612
+ """Trigger refresh event.
613
+
614
+ If fps>0 the refresh won't happen immediately.
615
+ """
616
+ if self.refresh_rate == 0:
617
+ return self._refresh()
618
+
619
+ # Inform the renderer to refresh
620
+ self._REFRESH_PENDING = True
621
+
622
+ if not self._RENDERER_RUNNING:
623
+ # Spawn the renderer thread
624
+ self._RENDERER_RUNNING = True
625
+ Thread(target=self._rendering_loop).start()
626
+ return None
627
+ return None
628
+
629
+ def _rendering_loop(self) -> None:
630
+ """Render in a loop.
631
+
632
+ Renderer loop that runs as long as there's something to display.
633
+ If no external events happen, the rendering will stop after a while.
634
+ """
635
+ while True:
636
+ time.sleep(self._frame_time)
637
+
638
+ if not self._REFRESH_PENDING:
639
+ # No triggers during wait time.
640
+ # Waiting some more to be sure...
641
+
642
+ time.sleep(self._frame_time)
643
+ if not self._REFRESH_PENDING:
644
+ self._RENDERER_RUNNING = False
645
+ return # Kill the unused renderer
646
+
647
+ self._REFRESH_PENDING = False
648
+ self._refresh()
649
+
650
+ def _refresh(self) -> None:
651
+ """Immediate refresh of the table."""
652
+ if self._display_rows:
653
+ self._print_pending_rows_to_buffer()
654
+ self._flush_buffer()
655
+
656
+ def _append_or_update_display_row(self, element: int | str) -> None:
657
+ """Refresh or append a display row.
658
+
659
+ For integer - this adds the corresponding existing data row as pending.
660
+ For string - this appends a new row decoration.
661
+ """
662
+ if self._closed:
663
+ msg = "Table was closed! Updating closed tables is not supported."
664
+ raise TableClosedError(msg)
665
+
666
+ if isinstance(element, int):
667
+ if element not in self._display_rows:
668
+ for decoration in self._latest_row_decorations:
669
+ self._append_or_update_display_row(decoration)
670
+ self._latest_row_decorations.clear()
671
+ self._display_rows.append(element)
672
+ elif element != len(self._data_rows) - 1 and self.interactive < 2:
673
+ # Won't refresh non-current rows for interactive<2
674
+ return
675
+
676
+ display_index = self._display_rows.index(element)
677
+ if display_index not in self._pending_display_rows:
678
+ # Check if the row isn't already pending
679
+ self._pending_display_rows.append(display_index)
680
+ else:
681
+ self._display_rows.append(element)
682
+ self._pending_display_rows.append(len(self._display_rows) - 1)
683
+
684
+ self._trigger_refresh()
685
+
686
+ def _set_all_display_rows_as_pending(self) -> None:
687
+ """Set all display rows as pending.
688
+
689
+ This will refresh all rows in the next tick.
690
+ """
691
+ self._pending_display_rows = list(range(len(self._display_rows)))
692
+ self._trigger_refresh()
693
+
694
+ def _append_new_empty_data_row(self) -> None:
695
+ # Add a new data row - but don't add it as display row yet
696
+ row = DataRow(values={}, weights={}, colors={})
697
+ self._data_rows.append(row)
698
+
699
+ def _get_cursor_offset(self, row_index: int) -> int:
700
+ if row_index < 0:
701
+ row_index = len(self._display_rows) + row_index
702
+ return self._CURSOR_ROW - row_index
703
+
704
+ def _move_cursor_in_buffer(self, row_index: int) -> None:
705
+ if row_index < 0:
706
+ row_index = len(self._display_rows) + row_index
707
+ offset = self._CURSOR_ROW - row_index
708
+
709
+ if offset > 0:
710
+ self._print_to_buffer(CURSOR_UP * offset)
711
+ elif offset < 0:
712
+ self._print_to_buffer("\n" * (-offset))
713
+ self._CURSOR_ROW = row_index
714
+
715
+ def _get_item_str(self, display_row_index: int) -> str:
716
+ item: str | int = self._display_rows[display_row_index]
717
+ if isinstance(item, int):
718
+ row = self._data_rows[item] # item is the data row index
719
+ row_str = self._get_row_str(row) # here we pass the row item, not index
720
+ elif item == "HEADER":
721
+ row_str = self._get_header()
722
+ elif item == "SPLIT TOP":
723
+ row_str = self._get_bar_top()
724
+ elif item == "SPLIT BOT":
725
+ row_str = self._get_bar_bot() + "\n"
726
+ elif item == "SPLIT MID":
727
+ row_str = self._get_bar_mid()
728
+ elif item.startswith("USER WRITE"):
729
+ row_str = item.split("USER WRITE", 1)[1].strip()
730
+ else:
731
+ msg = f"Unknown item: {item}"
732
+ raise ValueError(msg)
733
+ return row_str
734
+
735
+ def _print_pending_rows_to_buffer(self) -> None:
736
+ # Clearing progress bars below the table happens here
737
+ for display_row_idx, cleaning_str in self._cleaning_pbar_instructions:
738
+ assert self.interactive >= 2, "Should not need to clean pbars when interactive < 2!"
739
+ self._move_cursor_in_buffer(display_row_idx)
740
+ self._print_to_buffer(cleaning_str, prefix="\r")
741
+ self._move_cursor_in_buffer(-1)
742
+ self._cleaning_pbar_instructions.clear()
743
+
744
+ # Remove duplicate pending and sort them
745
+ self._pending_display_rows = sorted(set(self._pending_display_rows))
746
+
747
+ for display_row_index in self._pending_display_rows:
748
+ offset = self._get_cursor_offset(display_row_index)
749
+
750
+ # Cannot use CURSOR_UP when interactivity is less than 2
751
+ # However, we can ALLOW going down with the cursor
752
+ if self.interactive < 2 and offset > 0:
753
+ continue
754
+
755
+ self._move_cursor_in_buffer(display_row_index)
756
+
757
+ # We don't need to print carriage return if cursor was moved DOWN.
758
+ # After moving, cursor is in the right position to overwrite text.
759
+ prefix = "" if offset < 0 else "\r"
760
+ row_str = self._get_item_str(display_row_index)
761
+ self._print_to_buffer(row_str, prefix=prefix)
762
+ self._pending_display_rows.clear()
763
+
764
+ # Printing progress bars happens here
765
+ for pbar in self._active_pbars:
766
+ if self.interactive == 0:
767
+ break # No progress bars in non-interactive mode
768
+
769
+ num_rows = len(self._display_rows)
770
+ pbar_display_row_idx = (
771
+ self._display_rows.index(pbar.position) if pbar.static else num_rows + pbar.position - 1
772
+ )
773
+
774
+ offset = self._get_cursor_offset(pbar_display_row_idx)
775
+ # Cannot use CURSOR_UP when interactivity is less than 2
776
+ # Here we DON'T ALLOW going down with the cursor
777
+ if self.interactive < 2 and offset != 0:
778
+ continue
779
+
780
+ # We add the display row to pending if we were writing over a row
781
+ # So in next tick the row will be printed again
782
+ if pbar_display_row_idx < num_rows:
783
+ self._pending_display_rows.append(pbar_display_row_idx)
784
+
785
+ self._move_cursor_in_buffer(pbar_display_row_idx)
786
+
787
+ row_str = None
788
+ if self.pbar_embedded and pbar_display_row_idx < num_rows:
789
+ data_row_idx = self._display_rows[pbar_display_row_idx]
790
+ if isinstance(data_row_idx, int):
791
+ data_row = self._data_rows[data_row_idx]
792
+ row_str = self._get_row_str(data_row, colored=False)
793
+
794
+ pbar_str = pbar.display(embed_str=row_str)
795
+
796
+ # We need to take care of clearing pbars that are printed below the table
797
+ if pbar_display_row_idx > num_rows:
798
+ self._cleaning_pbar_instructions.append((pbar_display_row_idx, pbar._cleaning_str))
799
+
800
+ self._print_to_buffer(pbar_str, prefix="\r")
801
+ self._move_cursor_in_buffer(-1)
802
+
803
+ def _resolve_row_color_dict(self, color: ColorFormat | dict[str, ColorFormat] = None) -> dict[str, str]:
804
+ color = color or self.row_color or {}
805
+ if isinstance(color, ColorFormatTuple):
806
+ color = dict.fromkeys(self.column_names, color)
807
+
808
+ color = {column: color.get(column) or self.DEFAULT_ROW_COLOR for column in self.column_names}
809
+ color_colorama = {column: maybe_convert_to_colorama(color) for column, color in color.items()}
810
+ return {col: self.column_colors[col] + color_colorama[col] for col in color}
811
+
812
+ def _apply_cell_formatting(self, value: object, column_name: str, color: str) -> str:
813
+ str_value = self.custom_cell_format(value)
814
+ width = self.column_widths[column_name]
815
+ alignment = self.column_alignments[column_name]
816
+
817
+ if alignment == "center":
818
+ str_value = str_value.center(width)
819
+ elif alignment == "left":
820
+ str_value = str_value.ljust(width)
821
+ elif alignment == "right":
822
+ str_value = str_value.rjust(width)
823
+ else:
824
+ allowed_alignments = ["center", "left", "right"]
825
+ msg = f"Alignment '{alignment}' not in {allowed_alignments}!"
826
+ raise KeyError(msg)
827
+
828
+ clipped = len(str_value) > width
829
+ str_value = "".join(
830
+ [
831
+ " ", # space at the beginning of the row
832
+ str_value[:width].center(width),
833
+ self.table_style.cell_overflow if clipped else " ",
834
+ ],
835
+ )
836
+ reset = Style.RESET_ALL if color else ""
837
+ return f"{color}{str_value}{reset}"
838
+
839
+ def _get_outer_inner_width(self) -> int:
840
+ return sum(self.column_widths.values()) + 3 * len(self.column_widths) + 1
841
+
842
+ def _get_inner_table_width(self) -> int:
843
+ return sum(self.column_widths.values()) + 3 * len(self.column_widths) - 1
844
+
845
+ #####################
846
+ ## DISPLAY HELPERS ##
847
+ #####################
848
+
849
+ def _print_to_buffer(self, msg: str = "", prefix: str = "", suffix: str = "") -> None:
850
+ """Prints to table's buffer.
851
+
852
+ Not displayed to stdout yet.
853
+ """
854
+ self._printing_buffer.append(prefix + msg + suffix)
855
+
856
+ def _flush_buffer(self) -> None:
857
+ """This is where table prints to the stdout."""
858
+ output = "".join(self._printing_buffer)
859
+
860
+ # Start by clearing the existing line
861
+ self._printing_buffer = []
862
+
863
+ for file in self.files:
864
+ print(output, file=file or sys.stdout, end="")
865
+
866
+ def _get_row_str(self, row: DataRow, *, colored: bool = True) -> str:
867
+ """Get the string representation of the data row."""
868
+ content = []
869
+ for column in self.column_names:
870
+ value = row.values.get(column, "")
871
+ color = row.colors.get(column, "") if colored else ""
872
+ value = self._apply_cell_formatting(value=value, column_name=column, color=color)
873
+ content.append(value)
874
+ return "".join(
875
+ [
876
+ self.table_style.vertical,
877
+ self.table_style.vertical.join(content),
878
+ self.table_style.vertical,
879
+ ],
880
+ )
881
+
882
+ def _get_bar(self, left: str, center: str, right: str) -> str:
883
+ content_list: list[str] = []
884
+ for column_name in self.column_names:
885
+ content_list.append(self.table_style.horizontal * (self.column_widths[column_name] + 2))
886
+
887
+ center = center.join(content_list)
888
+ content = [
889
+ left,
890
+ center,
891
+ right,
892
+ ]
893
+ return "".join(content)
894
+
895
+ def _get_bar_top(self) -> str:
896
+ return self._get_bar(
897
+ self.table_style.down_right,
898
+ self.table_style.no_up,
899
+ self.table_style.down_left,
900
+ )
901
+
902
+ def _get_bar_bot(self) -> str:
903
+ return self._get_bar(
904
+ self.table_style.up_right,
905
+ self.table_style.no_down,
906
+ self.table_style.up_left,
907
+ )
908
+
909
+ def _get_bar_mid(self) -> str:
910
+ return self._get_bar(self.table_style.no_left, self.table_style.all, self.table_style.no_right)
911
+
912
+ def _get_header(self) -> str:
913
+ content = []
914
+ colors = self.column_colors if self.header_color is None else self._resolve_row_color_dict(self.header_color)
915
+
916
+ for column in self.column_names:
917
+ value = self._apply_cell_formatting(column, column, color=colors[column])
918
+ content.append(value)
919
+ return "".join(
920
+ [
921
+ self.table_style.vertical,
922
+ self.table_style.vertical.join(content),
923
+ self.table_style.vertical,
924
+ ],
925
+ )
926
+
927
+ ##################
928
+ ## PROGRESS BAR ##
929
+ ##################
930
+
931
+ def pbar(
932
+ self,
933
+ iterable: Iterable | int,
934
+ *range_args,
935
+ position=None,
936
+ static=False,
937
+ total=None,
938
+ description="",
939
+ show_throughput: bool | None = None,
940
+ show_progress: bool | None = None,
941
+ show_percents: bool | None = None,
942
+ show_eta: bool | None = None,
943
+ style=None,
944
+ style_embed=None,
945
+ color=None,
946
+ color_empty=None,
947
+ ) -> TableProgressBar:
948
+ """Create iterable progress bar object.
949
+
950
+ Args:
951
+ iterable: Iterable to iterate over. If None, it will be created from as range(iterable, *range_args).
952
+ range_args: Optional arguments for range function.
953
+ position: Level of the progress bar. If not provided, it will be set automatically.
954
+ static: If True, the progress bar will stick to the row with index given by position.
955
+ If False, the position will be interpreted as the offset from the last row.
956
+ total: Total number of iterations. If not provided, it will be calculated from the length of the iterable.
957
+ description: Custom description of the progress bar that will be shown as prefix.
958
+ show_throughput: If True, the throughput will be displayed.
959
+ show_progress: If True, the progress will be displayed.
960
+ show_percents: If True, the percentage of the progress will be displayed.
961
+ show_eta: If True, the estimated time of finishing will be displayed.
962
+ style: Style of the progress bar. If None, the default style will be used.
963
+ style_embed: Style of the embedded progress bar. If None, the default style will be used.
964
+ color: Color of the progress bar. This overrides the default color.
965
+ color_empty: Color of the empty progress bar. This overrides the default color.
966
+
967
+ """
968
+ if isinstance(iterable, int):
969
+ iterable = range(iterable, *range_args)
970
+
971
+ if static is True and position is None:
972
+ msg = "For static pbar position cannot be None!"
973
+ raise ValueError(msg)
974
+ if position is None:
975
+ position = 0 if self.interactive < 2 else len(self._active_pbars) + 1 - self.pbar_embedded
976
+
977
+ total = total if total is not None else (len(iterable) if isinstance(iterable, Sized) else 0)
978
+
979
+ style = styles.parse_pbar_style(style) if style else self.pbar_style
980
+ style_embed = styles.parse_pbar_style(style_embed) if style_embed else self.pbar_style_embed
981
+
982
+ pbar = TableProgressBar(
983
+ iterable=iterable,
984
+ table=self,
985
+ total=total,
986
+ style=style,
987
+ style_embed=style_embed,
988
+ color=color,
989
+ color_empty=color_empty,
990
+ position=position,
991
+ static=static,
992
+ description=description,
993
+ show_throughput=(show_throughput if show_throughput is not None else self.pbar_show_throughput),
994
+ show_progress=(show_progress if show_progress is not None else self.pbar_show_progress),
995
+ show_percents=(show_percents if show_percents is not None else self.pbar_show_percents),
996
+ show_eta=show_eta if show_eta is not None else self.pbar_show_eta,
997
+ )
998
+ self._active_pbars.append(pbar)
999
+ return pbar
1000
+
1001
+ def __call__(self, *args, **kwds) -> TableProgressBar:
1002
+ """Creates iterable progress bar object using .pbar method and returns it."""
1003
+ return self.pbar(*args, **kwds)
1004
+
1005
+
1006
+ ##################
1007
+ ## PROGRESS BAR ##
1008
+ ##################
1009
+
1010
+
1011
+ class TableProgressBar:
1012
+ def __init__(
1013
+ self,
1014
+ iterable,
1015
+ *,
1016
+ table,
1017
+ total,
1018
+ style,
1019
+ style_embed,
1020
+ color,
1021
+ color_empty,
1022
+ position,
1023
+ static,
1024
+ description,
1025
+ show_throughput: bool,
1026
+ show_progress: bool,
1027
+ show_percents: bool,
1028
+ show_eta: bool,
1029
+ ) -> None:
1030
+ self.iterable: Iterable | None = iterable
1031
+
1032
+ self._step: int = 0
1033
+ self._total: int = total
1034
+ self._creation_time: float = time.perf_counter()
1035
+ self._last_refresh_time: float = -float("inf")
1036
+
1037
+ self.style = style
1038
+ self.style_embed = style_embed
1039
+
1040
+ # Modyfing styles
1041
+ if color:
1042
+ color = maybe_convert_to_colorama(color)
1043
+ self.style.color = color
1044
+ self.style_embed.color = color
1045
+ if color_empty:
1046
+ color_empty = maybe_convert_to_colorama(color_empty)
1047
+ self.style.color_empty = color_empty
1048
+ self.style_embed.color_empty = color_empty
1049
+
1050
+ self.table: ProgressTable = table
1051
+ self.position: int = position
1052
+ self.static: bool = static
1053
+ self.description: str = description
1054
+ self.show_throughput: bool = show_throughput
1055
+ self.show_progress: bool = show_progress
1056
+ self.show_percents: bool = show_percents
1057
+ self.show_eta: bool = show_eta
1058
+ self._is_active: bool = True
1059
+ self._cleaning_str: str = ""
1060
+
1061
+ self._modified_rows = []
1062
+
1063
+ def _get_infobar(self, step, total):
1064
+ self._last_refresh_time = time.perf_counter()
1065
+ time_passed = self._last_refresh_time - self._creation_time
1066
+ throughput = self._step / time_passed if time_passed > 0 else 0.0
1067
+ eta = (total - step) / throughput if throughput > 0 and total else None
1068
+
1069
+ inside_infobar = []
1070
+ if self.description:
1071
+ inside_infobar.append(self.description)
1072
+ if self.show_progress:
1073
+ if total:
1074
+ str_total = str(total)
1075
+ str_step = str(self._step).rjust(len(str(total - 1)))
1076
+ inside_infobar.append(f"{str_step}/{str_total}")
1077
+ else:
1078
+ inside_infobar.append(f"{self._step}")
1079
+
1080
+ if self.show_percents:
1081
+ if total and total > 0:
1082
+ if step / total < 0.1:
1083
+ percents_str = f"{100 * step / total: <.2f}%"
1084
+ elif step / total < 1:
1085
+ percents_str = f"{100 * step / total: <.1f}%"
1086
+ else:
1087
+ percents_str = f"{100 * step / total: <.0f}%"
1088
+ else:
1089
+ percents_str = "?%"
1090
+ inside_infobar.append(percents_str)
1091
+
1092
+ if self.show_throughput:
1093
+ if throughput < 10:
1094
+ throughput_str = f"{throughput: <.2f} it/s"
1095
+ elif throughput < 100:
1096
+ throughput_str = f"{throughput: <.1f} it/s"
1097
+ else:
1098
+ throughput_str = f"{throughput: <.0f} it/s"
1099
+
1100
+ inside_infobar.append(throughput_str)
1101
+
1102
+ if self.show_eta:
1103
+ if eta is None:
1104
+ inside_infobar.append("ETA ?")
1105
+ elif eta < 100:
1106
+ eta_str = f"ETA {eta:>2.0f}s"
1107
+ inside_infobar.append(eta_str)
1108
+ elif round(eta / 60) < 100:
1109
+ eta_str = f"ETA {eta / 60:>2.0f}m"
1110
+ inside_infobar.append(eta_str)
1111
+ else:
1112
+ eta_str = f"ETA {eta / 3600:>2.0f}h"
1113
+ inside_infobar.append(eta_str)
1114
+
1115
+ return "[" + ", ".join(inside_infobar) + "] " if inside_infobar else ""
1116
+
1117
+ def display(self, embed_str: str | None) -> str:
1118
+ assert self._is_active, "Progress bar was closed!"
1119
+ terminal_width = shutil.get_terminal_size(fallback=(0, 0)).columns or int(1e9)
1120
+
1121
+ total = self._total
1122
+ step = min(self._step, total) if total else self._step
1123
+ infobar = self._get_infobar(step, total)
1124
+ pbar = []
1125
+
1126
+ inner_width = self.table._get_inner_table_width()
1127
+ if inner_width >= terminal_width - 1:
1128
+ inner_width = terminal_width - 2
1129
+
1130
+ if len(infobar) > inner_width:
1131
+ infobar = "[…] "
1132
+
1133
+ inner_width = inner_width - len(infobar)
1134
+ if not total:
1135
+ step = self._step % inner_width
1136
+ total = inner_width
1137
+
1138
+ num_filled = math.ceil(step / total * inner_width)
1139
+ frac_missing = step / total * inner_width - num_filled
1140
+ num_empty = inner_width - num_filled
1141
+
1142
+ if embed_str is not None:
1143
+ row_str = embed_str[1 + len(infobar) :]
1144
+
1145
+ filled_part = row_str[:num_filled]
1146
+ if len(filled_part) > 0 and filled_part[-1] == " ":
1147
+ head = self.style_embed.head
1148
+ if isinstance(head, (tuple, list)):
1149
+ head = head[round(frac_missing * len(head))]
1150
+ filled_part = filled_part[:-1] + head
1151
+ filled_part = filled_part.replace(" ", self.style_embed.filled)
1152
+ empty_part = row_str[num_filled:-1]
1153
+ color_filled = self.style_embed.color
1154
+ color_empty = self.style_embed.color_empty
1155
+ else:
1156
+ filled_part = self.style.filled * num_filled
1157
+ if len(filled_part) > 0:
1158
+ head = self.style.head
1159
+ if isinstance(head, (tuple, list)):
1160
+ head = head[round(frac_missing * len(head))]
1161
+ filled_part = filled_part[:-1] + head
1162
+ empty_part = self.style.empty * num_empty
1163
+ color_filled = self.style.color
1164
+ color_empty = self.style.color_empty
1165
+
1166
+ pbar_body = "".join(
1167
+ [
1168
+ self.table.table_style.vertical,
1169
+ infobar,
1170
+ color_filled,
1171
+ filled_part,
1172
+ Style.RESET_ALL if color_filled else "",
1173
+ color_empty,
1174
+ empty_part,
1175
+ Style.RESET_ALL if color_empty else "",
1176
+ self.table.table_style.vertical,
1177
+ ],
1178
+ )
1179
+ pbar.append(pbar_body)
1180
+ self._cleaning_str = " " * len(pbar_body)
1181
+ return "".join(pbar)
1182
+
1183
+ def update(self, n: int = 1) -> None:
1184
+ """Update the progress bar steps.
1185
+
1186
+ Args:
1187
+ n: Number of steps to update the progress bar.
1188
+
1189
+ """
1190
+ self._step += n
1191
+ self.table._trigger_refresh()
1192
+
1193
+ def reset(self, total: int | None = None) -> None:
1194
+ """Reset the progress bar.
1195
+
1196
+ Args:
1197
+ total: Modify the total number of iterations. Optional.
1198
+
1199
+ """
1200
+ self._step = 0
1201
+
1202
+ if total:
1203
+ self._total = total
1204
+ self.table._trigger_refresh()
1205
+
1206
+ def set_step(self, step: int) -> None:
1207
+ """Overwrite the current step.
1208
+
1209
+ Args:
1210
+ step: New value of the current step.
1211
+
1212
+ """
1213
+ self._step = step
1214
+ self.table._trigger_refresh()
1215
+
1216
+ def set_total(self, total: int) -> None:
1217
+ """Overwrite the total number of iterations.
1218
+
1219
+ Args:
1220
+ total: New value of the total number of iterations
1221
+
1222
+ """
1223
+ self._total = total
1224
+ self.table._trigger_refresh()
1225
+
1226
+ def __iter__(self) -> Iterator:
1227
+ """Iterate over iterable while updating the progress bar."""
1228
+ try:
1229
+ assert self.iterable is not None, "No iterable provided!"
1230
+ for element in self.iterable:
1231
+ yield element
1232
+ self.update()
1233
+ finally:
1234
+ self.close()
1235
+
1236
+ def close(self) -> None:
1237
+ """Close the progress bar."""
1238
+ self.table._active_pbars.remove(self)
1239
+ self._is_active = False
1240
+
1241
+
1242
+ #############
1243
+ ## INDEXER ##
1244
+ #############
1245
+
1246
+
1247
+ class BadKeyError(Exception):
1248
+ """Raised when the TableAtIndexer key is not valid."""
1249
+
1250
+
1251
+ class TableAtIndexer:
1252
+ """Advanced indexing for the table."""
1253
+
1254
+ def __init__(self, table: ProgressTable) -> None:
1255
+ """Initialize the indexer."""
1256
+ self.table = table
1257
+ self.edit_mode_prefix_map: dict[str, str] = {}
1258
+ for word in ("values", "weights", "colors"):
1259
+ self.edit_mode_prefix_map.update({word[:i].lower(): word for i in range(1, len(word) + 1)})
1260
+ self.edit_mode_prefix_map.update({word[:i].upper(): word for i in range(1, len(word) + 1)})
1261
+
1262
+ def _parse_index(self, key: slice | tuple) -> tuple:
1263
+ if isinstance(key, slice):
1264
+ rows: slice | int = key
1265
+ cols: slice | int = slice(None)
1266
+ mode: str = "values"
1267
+ elif len(key) == 2:
1268
+ rows: slice | int = key[0]
1269
+ cols: slice | int = key[1]
1270
+ mode: str = "values"
1271
+ elif len(key) >= 3:
1272
+ rows: slice | int = key[0]
1273
+ cols: slice | int = key[1]
1274
+ mode: str = key[2]
1275
+ assert mode in self.edit_mode_prefix_map, f"Unknown mode `{mode}`. Available: values, weights, colors"
1276
+ mode = self.edit_mode_prefix_map[mode]
1277
+ else:
1278
+ msg = "Expected slice or tuple with 2 or 3 elements!"
1279
+ raise BadKeyError(msg)
1280
+
1281
+ assert isinstance(rows, (slice, int)), f"Rows have to be a slice or an integer, not {type(rows)}!"
1282
+ assert isinstance(cols, (slice, int)), f"Columns have to be a slice or an integer, not {type(cols)}!"
1283
+ row_indices = [rows] if isinstance(rows, int) else list(range(len(self.table._data_rows)))[rows]
1284
+ column_names = self.table.column_names[cols] if isinstance(cols, slice) else [self.table.column_names[cols]]
1285
+ return row_indices, column_names, mode
1286
+
1287
+ def __setitem__(self, key: slice | tuple, value: object) -> None:
1288
+ """Set the values, colors, or weights of a slice in the table."""
1289
+ row_indices, column_names, edit_mode = self._parse_index(key)
1290
+ if edit_mode == "colors":
1291
+ assert isinstance(value, ColorFormatTuple), f"Color must be compatible with ColorFormat, not {type(value)}!"
1292
+ value = maybe_convert_to_colorama(value)
1293
+
1294
+ for row_idx in row_indices:
1295
+ data_row = self.table._data_rows[row_idx]
1296
+
1297
+ for column in column_names:
1298
+ data_row.__getattribute__(edit_mode)[column] = value
1299
+
1300
+ # Displaying the update
1301
+ self.table._append_or_update_display_row(row_idx)
1302
+
1303
+ def __getitem__(self, key: slice | tuple) -> list:
1304
+ """Get the values of a slice and flatten before returning."""
1305
+ row_indices, column_names, edit_mode = self._parse_index(key)
1306
+ gathered_values = []
1307
+
1308
+ for row_idx in row_indices:
1309
+ row = self.table._data_rows[row_idx]
1310
+ row_values = [row.__getattribute__(edit_mode).get(c, None) for c in column_names]
1311
+ gathered_values.append(row_values)
1312
+
1313
+ # Flattening outputs
1314
+ if len(gathered_values) == 1 and len(gathered_values[0]) == 1:
1315
+ return gathered_values[0][0]
1316
+ if len(gathered_values) == 1:
1317
+ return gathered_values[0]
1318
+ if all(len(x) == 1 for x in gathered_values):
1319
+ return [x[0] for x in gathered_values]
1320
+ return gathered_values