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

1"""Classes for printing tables in a text format.""" 

2 

3from contextlib import AbstractContextManager 

4from io import TextIOBase 

5from typing import Callable, Final, Iterable 

6 

7from pycommons.types import check_int_range, type_error 

8 

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) 

16 

17 

18class Table(AbstractContextManager): 

19 """ 

20 The table context. 

21 

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. 

26 

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 """ 

35 

36 def __init__(self, stream: TextIOBase, cols: str, 

37 driver: TextFormatDriver) -> None: 

38 """ 

39 Initialize the table context. 

40 

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) 

52 

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 

83 

84 def _begin_rows(self, mode: int) -> None: 

85 """ 

86 Start a set of rows. 

87 

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.") 

93 

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 

109 

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) 

124 

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}") 

143 

144 self.__row_index = 0 

145 self.__row_state = 0 

146 

147 def _end_rows(self, mode: int) -> None: 

148 """ 

149 End a set of rows. 

150 

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.") 

156 

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) 

173 

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) 

190 

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}") 

211 

212 self.__row_index = 0 

213 

214 def _begin_row(self, mode: int) -> None: 

215 """ 

216 Start a row. 

217 

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.") 

225 

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) 

251 

252 else: 

253 raise ValueError(f"invalid row mode: {mode}") 

254 

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 

261 

262 def _end_row(self, mode: int) -> None: 

263 """ 

264 End a row. 

265 

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.") 

271 

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 

288 

289 def _cell(self, text: str | Iterable[str] | None) -> None: 

290 """ 

291 Render a cell. 

292 

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}.") 

311 

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) 

315 

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 

320 

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)) 

336 

337 __printit(text) 

338 

339 self.__driver.end_table_cell( 

340 self.__stream, self.columns, self.__section_index, 

341 self.__row_index, col_index, mode) 

342 

343 def header(self) -> "Rows": 

344 """ 

345 Construct the header of the table. 

346 

347 :returns: a new managed header row 

348 """ 

349 return Rows(self, MODE_TABLE_HEADER) 

350 

351 def section(self) -> "Section": 

352 """ 

353 Create a new section of rows. 

354 

355 :returns: a new managed row section 

356 """ 

357 return Section(self) 

358 

359 def __enter__(self) -> "Table": 

360 """ 

361 Enter the table in a `with` statement. 

362 

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 

369 

370 def __exit__(self, exception_type, exception_value, traceback) -> bool: 

371 """ 

372 Close the table after leaving the `with` statement. 

373 

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 

393 

394 

395class Rows(AbstractContextManager): 

396 """A set of table rows.""" 

397 

398 def __init__(self, owner: Table, mode: int) -> None: 

399 """ 

400 Initialize the row section. 

401 

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) 

412 

413 def __enter__(self): # noqa 

414 """ 

415 Enter the row section in a `with` statement. 

416 

417 :return: `self` 

418 """ 

419 # noinspection PyProtectedMember 

420 self._owner._begin_rows(self._mode) 

421 return self 

422 

423 def __exit__(self, exception_type, exception_value, traceback) -> bool: 

424 """ 

425 Close the row section after leaving the `with` statement. 

426 

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 

435 

436 def row(self) -> "Row": 

437 """ 

438 Create a row. 

439 

440 :return: the new row 

441 """ 

442 return Row(self._owner, self._mode) 

443 

444 def full_row(self, cells: Iterable[str | None]) -> None: 

445 """ 

446 Print a complete row with a single call. 

447 

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) 

457 

458 def cols(self, cols: list[list[str | None]]) -> None: 

459 """ 

460 Print cells and rows column-by-column. 

461 

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) 

466 

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]) 

479 

480 

481class Section(Rows): 

482 """A table section is a group of rows, potentially with a header.""" 

483 

484 def __init__(self, owner: Table) -> None: 

485 """ 

486 Initialize the row section. 

487 

488 :param owner: the owning table 

489 """ 

490 super().__init__(owner, MODE_NORMAL) 

491 

492 def header(self) -> "Rows": 

493 """ 

494 Print the section header. 

495 

496 :return: the header row 

497 """ 

498 return Rows(self._owner, MODE_SECTION_HEADER) 

499 

500 

501class Row(AbstractContextManager): 

502 """A row class.""" 

503 

504 def __init__(self, owner: Table, mode: int) -> None: 

505 """ 

506 Initialize the row. 

507 

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 

518 

519 def cell(self, text: str | Iterable[str] | None = None) -> None: 

520 """ 

521 Render the text of a cell. 

522 

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. 

528 

529 :param text: the text to write 

530 """ 

531 # noinspection PyProtectedMember 

532 self.__owner._cell(text) 

533 

534 def __enter__(self) -> "Row": 

535 """ 

536 Enter the row in a `with` statement. 

537 

538 :return: `self` 

539 """ 

540 # noinspection PyProtectedMember 

541 self.__owner._begin_row(self._mode) 

542 return self 

543 

544 def __exit__(self, exception_type, exception_value, traceback) -> bool: 

545 """ 

546 Close the row after leaving the `with` statement. 

547 

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