Coverage for moptipy / utils / table.py: 72%
267 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
1"""Classes for printing tables in a text format."""
3from contextlib import AbstractContextManager
4from io import TextIOBase
5from typing import Callable, Final, Iterable
7from pycommons.types import check_int_range, type_error
9from moptipy.utils.formatted_string import TEXT, FormattedStr
10from moptipy.utils.text_format import (
11 MODE_NORMAL,
12 MODE_SECTION_HEADER,
13 MODE_TABLE_HEADER,
14 TextFormatDriver,
15)
18class Table(AbstractContextManager):
19 """
20 The table context.
22 This class provides a simple and hierarchically structured way to write
23 tables in different formats. It only supports the most rudimentary
24 formatting and nothing fancy (such as references, etc.). However, it may
25 be totally enough to quickly produce tables with results of experiments.
27 Every table must have a table header (see :meth:`header`).
28 Every table then consists of a sequence of one or multiple sections
29 (see :meth:`section` and :class:`Section`).
30 Each table section itself may or may not have a header
31 (see :meth:`Section.header`) and must have at least one row (see
32 :meth:`Rows.row` and :class:`Row`).
33 Each row must have the exact right number of cells (see :meth:`Row.cell`).
34 """
36 def __init__(self, stream: TextIOBase, cols: str,
37 driver: TextFormatDriver) -> None:
38 """
39 Initialize the table context.
41 :param stream: the stream to which all output is written
42 :param cols: the columns of the table
43 :param driver: the table driver
44 """
45 super().__init__()
46 if not isinstance(stream, TextIOBase):
47 raise type_error(stream, "stream", TextIOBase)
48 if not isinstance(cols, str):
49 raise type_error(cols, "cols", str)
50 if not isinstance(driver, TextFormatDriver):
51 raise type_error(driver, "driver", TextFormatDriver)
53 cols = cols.strip()
54 if len(cols) <= 0:
55 raise ValueError(
56 "cols must not be empty to just composed of white space, "
57 f"but is {cols!r}.")
58 for c in cols:
59 if c not in {"c", "l", "r"}:
60 raise ValueError("each col must be c, l, or r, but "
61 f"encountered {c} in {cols}.")
62 #: the internal stream
63 self.__stream: TextIOBase = stream
64 #: the internal column definition
65 self.columns: Final[str] = cols
66 #: the internal table driver
67 self.__driver: Final[TextFormatDriver] = driver
68 #: the header state: 0=no header, 1=in header, 2=after header
69 self.__header_state: int = 0
70 #: the section index
71 self.__section_index: int = 0
72 #: the row index
73 self.__row_index: int = 0
74 #: the column index
75 self.__col_index: int = 0
76 #: the section state: 0 outside of section, 1 inside of section,
77 #: 2 after section
78 self.__section_state: int = 0
79 #: the section header state: 0=no header, 1=in header, 2=after header
80 self.__section_header_state: int = 0
81 #: the row state: 0=before row, 1=in row, 2=after row
82 self.__row_state: int = 0
84 def _begin_rows(self, mode: int) -> None:
85 """
86 Start a set of rows.
88 :param mode: the mode of the rows, will be one of `MODE_NORMAL`,
89 `MODE_TABLE_HEADER`, or `MODE_SECTION_HEADER`
90 """
91 if self.__stream is None:
92 raise ValueError("table already closed, cannot start section.")
94 if mode == MODE_NORMAL:
95 if self.__header_state <= 1:
96 raise ValueError("cannot start section before table body.")
97 if self.__section_state == 1:
98 raise ValueError("cannot start section inside section.")
99 if self.__section_header_state == 1:
100 raise ValueError(
101 "cannot start section inside or after section header.")
102 if self.__row_state == 1:
103 raise ValueError("cannot start section inside row.")
104 self.__section_state = 1
105 self.__section_header_state = 0
106 self.__driver.begin_table_section(self.__stream, self.columns,
107 self.__section_index)
108 self.__section_index += 1
110 elif mode == MODE_TABLE_HEADER:
111 if self.__header_state >= 1:
112 raise ValueError(
113 "cannot start table header inside or after table header.")
114 if self.__section_state >= 1:
115 raise ValueError(
116 "cannot start table header inside or after section.")
117 if self.__section_header_state >= 1:
118 raise ValueError("cannot start table header inside or "
119 "after section header.")
120 if self.__row_state >= 1:
121 raise ValueError("cannot start table header inside row.")
122 self.__header_state = 1
123 self.__driver.begin_table_header(self.__stream, self.columns)
125 elif mode == MODE_SECTION_HEADER:
126 if self.__header_state <= 1:
127 raise ValueError(
128 "cannot start section header before or in table header.")
129 if self.__section_state != 1:
130 raise ValueError(
131 "cannot start section header outside section.")
132 if self.__section_header_state > 1:
133 raise ValueError(
134 "cannot start section header after section header.")
135 if self.__row_state == 1:
136 raise ValueError(
137 "cannot start section header inside row.")
138 self.__section_header_state = 1
139 self.__driver.begin_table_section_header(
140 self.__stream, self.columns, self.__section_index)
141 else:
142 raise ValueError(f"invalid row group mode: {mode}")
144 self.__row_index = 0
145 self.__row_state = 0
147 def _end_rows(self, mode: int) -> None:
148 """
149 End a set of rows.
151 :param mode: the mode of the rows, will be one of `MODE_NORMAL`,
152 `MODE_TABLE_HEADER`, or `MODE_SECTION_HEADER`
153 """
154 if self.__stream is None:
155 raise ValueError("table already closed, cannot end section.")
157 if mode == MODE_NORMAL:
158 if self.__header_state <= 1:
159 raise ValueError(
160 "cannot end section before end of table header.")
161 if self.__section_state != 1:
162 raise ValueError("cannot end section outside section.")
163 if self.__section_header_state == 1:
164 raise ValueError("cannot end section inside section header.")
165 if self.__row_state == 1:
166 raise ValueError("cannot end section inside of row.")
167 if (self.__row_index <= 0) or (self.__row_state < 2):
168 raise ValueError("cannot end section before writing any row.")
169 self.__section_state = 2
170 self.__driver.end_table_section(
171 self.__stream, self.columns, self.__section_index,
172 self.__row_index)
174 elif mode == MODE_TABLE_HEADER:
175 if self.__header_state != 1:
176 raise ValueError(
177 "cannot end table header outside table header.")
178 if self.__section_state != 0:
179 raise ValueError(
180 "cannot end table header inside or after section.")
181 if self.__section_header_state >= 1:
182 raise ValueError(
183 "cannot end table header inside or after section header.")
184 if self.__row_state == 1:
185 raise ValueError("cannot end table header inside row.")
186 if (self.__row_state < 2) or (self.__row_index <= 0):
187 raise ValueError("cannot end table header before header row.")
188 self.__header_state = 2
189 self.__driver.end_table_header(self.__stream, self.columns)
191 elif mode == MODE_SECTION_HEADER:
192 if self.__header_state < 2:
193 raise ValueError(
194 "cannot end section header before table body.")
195 if self.__section_state != 1:
196 raise ValueError(
197 "cannot start section header outside section.")
198 if self.__section_header_state != 1:
199 raise ValueError(
200 "cannot end section header only inside section header.")
201 if self.__row_state == 1:
202 raise ValueError("cannot end section header inside row.")
203 if (self.__row_state < 2) or (self.__row_index <= 0):
204 raise ValueError(
205 "cannot end section header before section header row.")
206 self.__section_header_state = 2
207 self.__driver.end_table_section_header(
208 self.__stream, self.columns, self.__section_index)
209 else:
210 raise ValueError(f"invalid row group mode: {mode}")
212 self.__row_index = 0
214 def _begin_row(self, mode: int) -> None:
215 """
216 Start a row.
218 :param mode: the mode of the row, will be one of `MODE_NORMAL`,
219 `MODE_TABLE_HEADER`, or `MODE_SECTION_HEADER`
220 """
221 if self.__stream is None:
222 raise ValueError("table already closed, cannot start row.")
223 if self.__row_state == 1:
224 raise ValueError("cannot start row inside row.")
226 if mode == MODE_NORMAL:
227 if self.__section_state != 1:
228 raise ValueError("can only start section row in section.")
229 if self.__section_header_state == 1:
230 self.__section_header_state = 2
231 self.__row_index = 0
232 self.__driver.end_table_section_header(
233 self.__stream, self.columns, self.__section_index)
234 elif mode == MODE_TABLE_HEADER:
235 if self.__header_state != 1:
236 raise ValueError("can only start header row in table header.")
237 elif mode == MODE_SECTION_HEADER:
238 if self.__section_state != 1:
239 raise ValueError(
240 "can only start section header row in section.")
241 if self.__section_header_state > 1:
242 raise ValueError(
243 "cannot start section header row after section header.")
244 if self.__section_header_state < 1:
245 if self.__row_index > 0:
246 raise ValueError(
247 "cannot start section header after section row.")
248 self.__section_header_state = 1
249 self.__driver.begin_table_section_header(
250 self.__stream, self.columns, self.__section_index)
252 else:
253 raise ValueError(f"invalid row mode: {mode}")
255 self.__driver.begin_table_row(
256 self.__stream, self.columns, self.__section_index,
257 self.__row_index, mode)
258 self.__row_index += 1
259 self.__row_state = 1
260 self.__col_index = 0
262 def _end_row(self, mode: int) -> None:
263 """
264 End a row.
266 :param mode: the mode of the row, will be one of `MODE_NORMAL`,
267 `MODE_TABLE_HEADER`, or `MODE_SECTION_HEADER`
268 """
269 if self.__stream is None:
270 raise ValueError("table already closed, cannot start row.")
272 if not (MODE_NORMAL <= mode <= MODE_SECTION_HEADER):
273 raise ValueError(f"invalid row mode {mode}.")
274 if self.__header_state == 0:
275 raise ValueError(
276 "cannot end row before table header.")
277 if self.__section_state >= 2:
278 raise ValueError("cannot end row after section.")
279 if self.__row_state != 1:
280 raise ValueError("can end row only inside row.")
281 if self.__col_index != len(self.columns):
282 raise ValueError(
283 f"cannot end row after {self.__col_index} columns for table "
284 f"with column definition {self.columns}.")
285 self.__driver.end_table_row(self.__stream, self.columns,
286 self.__section_index, self.__row_index)
287 self.__row_state = 2
289 def _cell(self, text: str | Iterable[str] | None) -> None:
290 """
291 Render a cell.
293 :param text: the text to write
294 """
295 if self.__stream is None:
296 raise ValueError("table already closed, cannot start row.")
297 if self.__header_state == 0:
298 raise ValueError(
299 "cannot have a cell before the table header starts.")
300 if self.__section_state >= 2:
301 raise ValueError(
302 "cannot have cell after section end.")
303 if self.__row_state != 1:
304 raise ValueError(
305 "cells only permitted inside rows.")
306 col_index: Final[int] = self.__col_index
307 if col_index >= len(self.columns):
308 raise ValueError(
309 f"cannot add cell after {col_index} columns for table "
310 f"with column definition {self.columns}.")
312 mode: Final[int] = MODE_TABLE_HEADER if self.__header_state == 1 \
313 else (MODE_SECTION_HEADER if self.__section_header_state == 1
314 else MODE_NORMAL)
316 self.__driver.begin_table_cell(
317 self.__stream, self.columns, self.__section_index,
318 self.__row_index, col_index, mode)
319 self.__col_index = col_index + 1
321 def __printit(st, strm: TextIOBase = self.__stream,
322 wrt: Callable[[TextIOBase, str, bool, bool, bool, int],
323 None] = self.__driver.text) -> None:
324 if st is None:
325 return
326 if isinstance(st, str):
327 if isinstance(st, FormattedStr):
328 wrt(strm, st, st.bold, st.italic, st.code, st.mode)
329 else:
330 wrt(strm, st, False, False, False, TEXT)
331 elif isinstance(st, Iterable):
332 for ss in st:
333 __printit(ss)
334 else:
335 raise type_error(st, "text", (Iterable, str))
337 __printit(text)
339 self.__driver.end_table_cell(
340 self.__stream, self.columns, self.__section_index,
341 self.__row_index, col_index, mode)
343 def header(self) -> "Rows":
344 """
345 Construct the header of the table.
347 :returns: a new managed header row
348 """
349 return Rows(self, MODE_TABLE_HEADER)
351 def section(self) -> "Section":
352 """
353 Create a new section of rows.
355 :returns: a new managed row section
356 """
357 return Section(self)
359 def __enter__(self) -> "Table":
360 """
361 Enter the table in a `with` statement.
363 :return: `self`
364 """
365 if self.__stream is None:
366 raise ValueError("Table writing already finished!")
367 self.__driver.begin_table_body(self.__stream, self.columns)
368 return self
370 def __exit__(self, exception_type, exception_value, traceback) -> bool:
371 """
372 Close the table after leaving the `with` statement.
374 :param exception_type: ignored
375 :param exception_value: ignored
376 :param traceback: ignored
377 :returns: `True` to suppress an exception, `False` to rethrow it
378 """
379 if self.__stream is not None:
380 self.__driver.end_table_body(self.__stream, self.columns)
381 self.__stream = None
382 if self.__section_state <= 0:
383 raise ValueError("cannot end table before any section")
384 if self.__section_state <= 1:
385 raise ValueError("cannot end table inside a section")
386 if self.__header_state <= 0:
387 raise ValueError("cannot end table before table header")
388 if self.__header_state <= 1:
389 raise ValueError("cannot end table inside table header")
390 if self.__section_header_state == 1:
391 raise ValueError("cannot end table inside section header")
392 return exception_type is None
395class Rows(AbstractContextManager):
396 """A set of table rows."""
398 def __init__(self, owner: Table, mode: int) -> None:
399 """
400 Initialize the row section.
402 :param owner: the owning table
403 :param mode: the mode of the row group
404 """
405 if not isinstance(owner, Table):
406 raise type_error(owner, "owner", Table)
407 #: the owner
408 self._owner: Final[Table] = owner
409 #: the rows mode
410 self._mode: Final[int] = check_int_range(
411 mode, "mode", MODE_NORMAL, MODE_SECTION_HEADER)
413 def __enter__(self): # noqa
414 """
415 Enter the row section in a `with` statement.
417 :return: `self`
418 """
419 # noinspection PyProtectedMember
420 self._owner._begin_rows(self._mode)
421 return self
423 def __exit__(self, exception_type, exception_value, traceback) -> bool:
424 """
425 Close the row section after leaving the `with` statement.
427 :param exception_type: ignored
428 :param exception_value: ignored
429 :param traceback: ignored
430 :returns: `True` to suppress an exception, `False` to rethrow it
431 """
432 # noinspection PyProtectedMember
433 self._owner._end_rows(self._mode)
434 return exception_type is None
436 def row(self) -> "Row":
437 """
438 Create a row.
440 :return: the new row
441 """
442 return Row(self._owner, self._mode)
444 def full_row(self, cells: Iterable[str | None]) -> None:
445 """
446 Print a complete row with a single call.
448 :param cells: the iterable of strings for the cells.
449 """
450 if not isinstance(cells, Iterable):
451 raise type_error(cells, "cells", Iterable)
452 with self.row() as row:
453 for i, cell in enumerate(cells):
454 if (cell is not None) and (not isinstance(cell, str)):
455 raise type_error(cell, f"cell[{i}]", str)
456 row.cell(cell)
458 def cols(self, cols: list[list[str | None]]) -> None:
459 """
460 Print cells and rows column-by-column.
462 :param cols: an array which contains one list per column of the table.
463 """
464 if not isinstance(cols, list):
465 raise type_error(cols, "cols", list)
467 columns: Final[str] = self._owner.columns
468 if len(cols) != len(columns):
469 raise ValueError(
470 f"expected {len(columns)} columns ({columns}), "
471 f"but cols has length {len(cols)}.")
472 max_rows = max(len(col) for col in cols)
473 if max_rows <= 0:
474 raise ValueError("There are no rows in the cols array?")
475 for rowi in range(max_rows):
476 with self.row() as row:
477 for col in cols:
478 row.cell(None if rowi >= len(col) else col[rowi])
481class Section(Rows):
482 """A table section is a group of rows, potentially with a header."""
484 def __init__(self, owner: Table) -> None:
485 """
486 Initialize the row section.
488 :param owner: the owning table
489 """
490 super().__init__(owner, MODE_NORMAL)
492 def header(self) -> "Rows":
493 """
494 Print the section header.
496 :return: the header row
497 """
498 return Rows(self._owner, MODE_SECTION_HEADER)
501class Row(AbstractContextManager):
502 """A row class."""
504 def __init__(self, owner: Table, mode: int) -> None:
505 """
506 Initialize the row.
508 :param owner: the owning table
509 :param mode: the header mode
510 """
511 if not isinstance(owner, Table):
512 raise type_error(owner, "owner", Table)
513 #: the rows mode
514 self._mode: Final[int] = check_int_range(
515 mode, "mode", MODE_NORMAL, MODE_SECTION_HEADER)
516 #: the owner
517 self.__owner: Final[Table] = owner
519 def cell(self, text: str | Iterable[str] | None = None) -> None:
520 """
521 Render the text of a cell.
523 As parameter `text`, you can provide either a string or a sequence of
524 strings. You can also provide an instance of
525 :class:`moptipy.utils.formatted_string.FormattedStr` or a sequence
526 thereof. This allows you to render formatted text in a natural
527 fashion.
529 :param text: the text to write
530 """
531 # noinspection PyProtectedMember
532 self.__owner._cell(text)
534 def __enter__(self) -> "Row":
535 """
536 Enter the row in a `with` statement.
538 :return: `self`
539 """
540 # noinspection PyProtectedMember
541 self.__owner._begin_row(self._mode)
542 return self
544 def __exit__(self, exception_type, exception_value, traceback) -> bool:
545 """
546 Close the row after leaving the `with` statement.
548 :param exception_type: ignored
549 :param exception_value: ignored
550 :param traceback: ignored
551 :returns: `True` to suppress an exception, `False` to rethrow it
552 """
553 # noinspection PyProtectedMember
554 self.__owner._end_row(self._mode)
555 return exception_type is None