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.
- progress_table/__init__.py +18 -0
- progress_table/common.py +51 -0
- progress_table/progress_table.py +1320 -0
- progress_table/styles.py +567 -0
- progress_table-3.0.0.dist-info/METADATA +25 -0
- progress_table-3.0.0.dist-info/RECORD +8 -0
- progress_table-3.0.0.dist-info/WHEEL +4 -0
- progress_table-3.0.0.dist-info/licenses/LICENSE.txt +7 -0
|
@@ -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
|