basic-report 0.1.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.
Files changed (38) hide show
  1. basic_report/__init__.py +10 -0
  2. basic_report/configs/default.yaml +185 -0
  3. basic_report/css_and_scripts/bootstrap.min.css +7 -0
  4. basic_report/css_and_scripts/bootstrap.min.js +7 -0
  5. basic_report/css_and_scripts/dataTables.bootstrap4.min.js +8 -0
  6. basic_report/css_and_scripts/jquery-3.3.1.slim.min.js +2 -0
  7. basic_report/css_and_scripts/jquery.dataTables.min.js +166 -0
  8. basic_report/css_and_scripts/popper.min.js +5 -0
  9. basic_report/page.py +827 -0
  10. basic_report/report.py +404 -0
  11. basic_report/templates/accordion.css +20 -0
  12. basic_report/templates/accordion.html +20 -0
  13. basic_report/templates/base.html +59 -0
  14. basic_report/templates/colors.css +30 -0
  15. basic_report/templates/columns.html +9 -0
  16. basic_report/templates/global_links.html +7 -0
  17. basic_report/templates/header.html +5 -0
  18. basic_report/templates/image.html +16 -0
  19. basic_report/templates/list.html +7 -0
  20. basic_report/templates/navbar.css +17 -0
  21. basic_report/templates/navbar_left.html +32 -0
  22. basic_report/templates/navbar_right.html +32 -0
  23. basic_report/templates/navbar_top.html +25 -0
  24. basic_report/templates/plot.html +4 -0
  25. basic_report/templates/report_ball_section.html +80 -0
  26. basic_report/templates/report_header.html +6 -0
  27. basic_report/templates/site.css +5 -0
  28. basic_report/templates/sublevel.html +7 -0
  29. basic_report/templates/table.css +51 -0
  30. basic_report/templates/table.html +55 -0
  31. basic_report/templates/tabs.css +20 -0
  32. basic_report/templates/tabs.html +29 -0
  33. basic_report/templates/text.html +3 -0
  34. basic_report/utils.py +246 -0
  35. basic_report-0.1.0.dist-info/METADATA +194 -0
  36. basic_report-0.1.0.dist-info/RECORD +38 -0
  37. basic_report-0.1.0.dist-info/WHEEL +4 -0
  38. basic_report-0.1.0.dist-info/licenses/LICENSE.md +21 -0
basic_report/page.py ADDED
@@ -0,0 +1,827 @@
1
+ import datetime
2
+ import numpy as np
3
+ import pandas as pd
4
+ import shutil
5
+
6
+ from collections import defaultdict
7
+ from jinja2 import Environment, FileSystemLoader
8
+ from loguru import logger
9
+ from pathlib import Path
10
+ from typing import Union, Optional, Any, Callable, Literal
11
+
12
+ from . import TEMPLATE_DIR
13
+ from .utils import default, verify_alignment, coerce_to_date, ColorMap
14
+
15
+ #----------------------------------------------------------------------------------------------------------------------#
16
+ # region REPORT PAGE
17
+ #----------------------------------------------------------------------------------------------------------------------#
18
+ class ReportPage:
19
+ """ Single page of a Report object, does all the heavy lifting """
20
+ def __init__(
21
+ self,
22
+ report_dir: Union[str, Path],
23
+ page_name: str,
24
+ config: dict[str, Any],
25
+ color_map: ColorMap,
26
+ subpage: Optional[bool]=None,
27
+ color_mode: str='light',
28
+ ):
29
+ """ Initialize a new Report Page object
30
+
31
+ Args:
32
+ report_dir (Union[str, Path]): Path to the report directory
33
+ page_name (str): Name of the page. If this is a subpage the final
34
+ file will be called {page_name}.html
35
+ config (dict[str, Any]): Containing report configuration
36
+ color_map (ColorMap): The color map used for the report
37
+ subpage (Optional[bool]): This is a subpage of the report, default is `True`
38
+ color_mode (str): Decide if the subpage is in `light` or `dark` mode
39
+ """
40
+ subpage = default(subpage, True)
41
+
42
+ self.page_name = page_name
43
+
44
+ if subpage:
45
+ self.file_name = f'{page_name}.html'
46
+ else:
47
+ self.file_name = 'index.html'
48
+
49
+ self.rel_dir = Path()
50
+ self.abs_dir = Path(report_dir)
51
+ self.img_dir = self.abs_dir / 'images'
52
+ self.rel_file = self.rel_dir / self.file_name
53
+ self.abs_file = self.abs_dir / self.file_name
54
+ self.config = config
55
+ self.color_map = color_map
56
+ self.color_mode = color_mode
57
+
58
+ # The following objects allow the nesting of commands and avoid lement conflicts
59
+ self.page_buffers = [PageBuffer()]
60
+ self.id_counter = 0
61
+
62
+ # Prepare the jinja templater
63
+ file_loader = FileSystemLoader(TEMPLATE_DIR)
64
+ self.jinja_env = Environment(loader=file_loader)
65
+
66
+ #-----#
67
+ # API #
68
+ #-----#
69
+
70
+ # region Report Header
71
+ # --------------------
72
+ def add_report_header(
73
+ self,
74
+ name: str,
75
+ date: Optional[Union[str, int, datetime.date, datetime.datetime]]=None,
76
+ include_date: bool=True,
77
+ include_created_at: bool=True,
78
+ color: Optional[str]=None,
79
+ ):
80
+ """ Add the main report header to the page
81
+
82
+ Args:
83
+ name (str): Report name
84
+ date (Optional[Union[str, int, datetime.date, datetime.datetime]]): Report date
85
+ include_date (bool): Include the date in the header, default is `True`
86
+ include_created_at (bool): Include a subheader with the exact creation time of the report
87
+ color (Optional[str]): Background color of header
88
+ """
89
+ if include_date:
90
+ date = '{:%Y-%m-%d}'.format(coerce_to_date(date))
91
+ name = f'{name} for {date}'
92
+
93
+ template = self.jinja_env.get_template('report_header.html')
94
+ now_loc = datetime.datetime.now()
95
+ if include_created_at:
96
+ now = 'Created on {0:%a, %d %b %Y} at {0:%H:%M:%S %Z%z}'.format(now_loc)
97
+ else:
98
+ now = ''
99
+ color = default(color, self.color_map.get_default_color('report_header'))
100
+ html = template.render(report_name=name,
101
+ report_creation_time=now,
102
+ header_color=color)
103
+ self._update_content(html)
104
+
105
+ # region Error, Warning, Info Section
106
+ # -----------------------------------
107
+ def add_error_warning_info_section(
108
+ self,
109
+ errors: Optional[list[str]]=None,
110
+ warnings: Optional[list[str]]=None,
111
+ info: Optional[list[str]]=None,
112
+ ):
113
+ """ Add a section containing errors/warnings/infos
114
+
115
+ If a report ball is provided it takes precedence over the other lists.
116
+
117
+ Args:
118
+ errors (Optional[list[str]]): List of errors encountered
119
+ warnings (Optional[list[str]]): List of warnings encountered
120
+ info (Optional[list[str]]): List of info encountered
121
+
122
+ """
123
+ errors = default(errors, [])
124
+ warnings = default(warnings, [])
125
+ info = default(info, [])
126
+
127
+ color = self.color_map.get_default_color('report_ball')
128
+ template = self.jinja_env.get_template('report_ball_section.html')
129
+ html = template.render(errors=errors, warnings=warnings, info=info, color=color)
130
+ self._update_content(html)
131
+
132
+ # region Link To Page
133
+ # -------------------
134
+ def get_link_to_page(self, link_name: Optional[str]=None, for_navbar: bool=False) -> str:
135
+ """ Get a relative link to the current page
136
+
137
+ Args:
138
+ link_name (Optional[str]): Clickable text for the link
139
+ for_navbar (bool): The link will be part of a navbar. This is used for global link bars. You will probably
140
+ never need this.
141
+
142
+ Returns:
143
+ str - HTML code for the link
144
+ """
145
+ link_name = default(link_name, self.page_name)
146
+ str_template = '<a {{ cls }} href="{{ target }}">{{ name }}</a>'
147
+ template = Environment().from_string(str_template)
148
+ target = self.rel_file
149
+ if for_navbar:
150
+ cls = 'class="nav-item nav-link"'
151
+ else:
152
+ cls = ''
153
+ return template.render(target=target, name=link_name, cls=cls)
154
+
155
+ # region Local Link
156
+ # -----------------
157
+ def add_local_link(self, link: str):
158
+ """ Add a clickable link to the page
159
+
160
+ Args:
161
+ link (str): HTML link to another page. You either created this link yourself or you used the Reporter to
162
+ get the link to another page.
163
+ """
164
+ str_template = '<div class="container">{{ link }}</div>'
165
+ template = Environment().from_string(str_template)
166
+ html = template.render(link=link)
167
+ self._update_content(html)
168
+
169
+ # region Header
170
+ # -------------
171
+ def add_header(self, header_text: str, color: Optional[str]=None):
172
+ """ Add a section header to the page
173
+
174
+ Args:
175
+ header_text (str): Header text
176
+ color (Optional[str]): Color of the header box
177
+ """
178
+ color = default(color, self.color_map.get_default_color('header'))
179
+
180
+ header_color = self.color_map[color]
181
+
182
+ template = self.jinja_env.get_template('header.html')
183
+ html = template.render(header_text=header_text,
184
+ header_color=header_color,
185
+ sub_level=12)
186
+ self._update_content(html)
187
+
188
+ # region Sub Header
189
+ # -----------------
190
+ def add_sub_header(self, header_text: str, color: Optional[str]=None, sub_level: Optional[int]=None):
191
+ """ Add a section header to the page
192
+
193
+ Args:
194
+ header_text (str): Header text
195
+ color (Optional[str]): Color of the header box
196
+ sub_level (Optional[int]): Shrinks the width of the header box. Levels can be 1 to 5. The higher the level
197
+ the smaller the box
198
+ """
199
+ sub_level = default(sub_level, 12)
200
+
201
+ color = default(color, self.color_map.get_default_color('sub-header'))
202
+ header_color = self.color_map[color]
203
+
204
+ allowed_levels = [1,12]
205
+ if not (allowed_levels[0] <= sub_level <= allowed_levels[1]):
206
+ msg = f'sub level {sub_level} is out of range, use {allowed_levels}'
207
+ logger.error(msg)
208
+ raise RuntimeError(msg)
209
+
210
+ self.open_sublevel(sub_level)
211
+ template = self.jinja_env.get_template('header.html')
212
+ html = template.render(header_text=header_text,
213
+ header_color=header_color,
214
+ sub_level=sub_level)
215
+ self._update_content(html)
216
+ self.close_sublevel()
217
+
218
+ # region Table
219
+ # ------------
220
+ def add_table(
221
+ self,
222
+ table_data: Union[pd.DataFrame, list[list[Any]]],
223
+ formatters: Optional[Union[str, Callable, list[str], list[Callable]]]=None,
224
+ options: Optional[list[str]]=None,
225
+ size: Optional[int]=None,
226
+ align: Optional[Literal['left','center','right']]=None,
227
+ caption: Optional[str]=None,
228
+ footers: Optional[list[list[Any]]]=None,
229
+ order: Optional[list[list[Any]]]=None,
230
+ color: Optional[str]=None,
231
+ ):
232
+ """ Add a table to the page.
233
+
234
+ ``table_data`` can either be a pandas ``DataFrame`` or a list of lists.
235
+ For the former we assume indices and column labels are already properly
236
+ formatted. For the latter we assume the first item contains the headers.
237
+
238
+ Args:
239
+ table_data (Union[DataFrame, list[list[Any]]]):
240
+ Data to tabelize.
241
+
242
+ formatters (Optional[Union[str, Callable, list[str], list[Callable]]]):
243
+ Formatters applied to each table element. If a list is provided it
244
+ must match the number of columns. A single formatter is applied
245
+ to all columns. The default formatter is ``str()``.
246
+
247
+ options (Optional[list[str]]):
248
+ Turn optional elements of the DataTable on. Supported options:
249
+
250
+ - ``page`` - Split long tables into pages of 10 (configurable)
251
+ - ``info`` - Show number of rows
252
+ - ``search`` - Add a search field
253
+ - ``no_sort`` - Disable initial sorting
254
+ - ``color_negative_values`` - Highlight values < 0 in red
255
+ - ``color_positive_values`` - Highlight values > 0 in green
256
+ - ``full_width`` - Table spans full window width
257
+
258
+ size (Optional[int]):
259
+ Sets the width of the table (1-12). ``12`` uses full container width.
260
+
261
+ align (Optional[str]):
262
+ Alignment of cell content.
263
+
264
+ caption (Optional[str]):
265
+ Text shown below the table.
266
+
267
+ footers (Optional[list[list[Any]]]):
268
+ Rows always shown at the bottom of the table.
269
+
270
+ order (Optional[list[list[Any]]]):
271
+ Default table order in the form ``[[col_idx, 'asc' | 'desc'], ...]``.
272
+
273
+ color (Optional[str]):
274
+ Text color. Automatically determined if omitted.
275
+ """
276
+ # Set defaults
277
+ caption = default(caption, '')
278
+ size = default(size, 12)
279
+ align = default(align, 'center')
280
+ options = default(options, [])
281
+ footers = default(footers, None)
282
+ order = default(order, None)
283
+ color = default(color, self.color_map.get_default_color('table'))
284
+ data, columns, footers = self._make_table(table_data, footers, formatters, options)
285
+
286
+ self.id_counter += 1
287
+ table_id = 'tableID_{}'.format(self.id_counter)
288
+
289
+ d_opts = ['page', 'info', 'search', 'no_sort', 'color_negative_values',
290
+ 'color_positive_values', 'full_width', 'order']
291
+
292
+ if not isinstance(options, list):
293
+ msg = 'The options keyword needs to be a list'
294
+ logger.error(msg)
295
+ raise RuntimeError(msg)
296
+
297
+ for opt in options:
298
+ if opt not in d_opts:
299
+ msg = f'{opt} is not supported {d_opts}'
300
+ logger.error(msg)
301
+ raise RuntimeError(msg)
302
+
303
+ if not isinstance(size, int):
304
+ msg = 'Size needs to be of type int'
305
+ logger.error(msg)
306
+ raise RuntimeError(msg)
307
+
308
+ allowed_size = [1,12]
309
+ if not (allowed_size[0] <= size <= allowed_size[1]):
310
+ msg = f'Size {size} not supported, allowed sizes are {allowed_size}'
311
+ logger.error(msg)
312
+ raise RuntimeError(msg)
313
+
314
+ template = self.jinja_env.get_template('table.html')
315
+ html = template.render(table_id=table_id,
316
+ table_data=data,
317
+ table_columns=columns,
318
+ footers=footers,
319
+ order=order,
320
+ options=options,
321
+ size=size,
322
+ align=align,
323
+ caption=caption,
324
+ color=color)
325
+ self._update_content(html)
326
+
327
+ # region Image
328
+ # ------------
329
+ def add_image(
330
+ self,
331
+ image_source: Union[str, Path],
332
+ remove_source: Optional[bool]=None,
333
+ responsive: Optional[bool]=None,
334
+ center: Optional[bool]=None,
335
+ ):
336
+ """ Add an image to the page
337
+
338
+ Args:
339
+ image_source (Union[str, Path]): Path to image that needs to be added The image will be copied to
340
+ report/page/images/
341
+ remove_source (Optional[bool]): If true the source will be deleted and we will only keep the copy in the
342
+ report dir
343
+ responsive (Optional[bool]): If true the image will automatically be resized to match the outer container
344
+ center (Optional[bool]): If true the image will be centered
345
+ """
346
+ self.img_dir.mkdir(parents=True, exist_ok=True)
347
+ remove_source = default(remove_source, False)
348
+ responsive = default(responsive, True)
349
+ center = default(center, True)
350
+
351
+ image_source = Path(image_source)
352
+ image_name = image_source.name
353
+ image_dest = self.img_dir / image_name
354
+ shutil.copyfile(image_source, image_dest)
355
+ if remove_source:
356
+ image_source.unlink()
357
+
358
+ rel_image_path = self.rel_dir / 'images' / image_name
359
+ template = self.jinja_env.get_template('image.html')
360
+ html = template.render(source=rel_image_path, responsive=responsive, center=center)
361
+ self._update_content(html)
362
+
363
+ # region Custom HTML Code
364
+ # -----------------------
365
+ def add_custom_html_code(self, html: str):
366
+ """ Add your own html code to the page
367
+
368
+ Args:
369
+ html (str): Whatever you want to add
370
+ """
371
+ self._update_content(html)
372
+
373
+ # region List
374
+ # -----------
375
+ def add_list(self, list_items: list[str]):
376
+ """ Add a list to the page
377
+
378
+ Args:
379
+ list_items (list[str]): Each item is converted to a bullet point
380
+ """
381
+ template = self.jinja_env.get_template('list.html')
382
+ html = template.render(list_items=list_items)
383
+ self._update_content(html)
384
+
385
+ # region Text
386
+ # -----------
387
+ def add_text(self, text: str, color: Optional[str]=None, align: Optional[Literal['left', 'center', 'right']]=None):
388
+ """ Add (colored) text to the page
389
+
390
+ This will create a new paragraph. So if you want to mix colors within
391
+ one paragraph you'll have to do this yourself for now
392
+
393
+ Args:
394
+ text (str): Your text
395
+ color (Optional[str]): One of the supported colors
396
+ align (Optional[Literal['left', 'center', 'right']]): Alignment of text
397
+ """
398
+ color = default(color, self.color_map.get_default_color('text'))
399
+ align = default(align, 'center')
400
+
401
+ color = self.color_map[color]
402
+ align = verify_alignment(align)
403
+
404
+ template = self.jinja_env.get_template('text.html')
405
+ html = template.render(text=text, color=color, align=align)
406
+ self._update_content(html)
407
+
408
+ # region Columns
409
+ # --------------
410
+ def open_columns(self):
411
+ """ Open a new columns environment """
412
+ self.page_buffers.append(PageBuffer('columns'))
413
+
414
+ def close_columns(self):
415
+ """ Close current columns environment """
416
+ self._is_page_in_status('columns')
417
+
418
+ column_items = []
419
+ for column in self.page_buffers[-1].content_ids:
420
+ content_html = '\n'.join(self.page_buffers[-1].content_buffer[column])
421
+ item = [content_html]
422
+ column_items.append(item)
423
+ template = self.jinja_env.get_template('columns.html')
424
+ html = template.render(column_items=column_items)
425
+
426
+ self.page_buffers = self.page_buffers[:-1]
427
+ self._update_content(html)
428
+
429
+ def add_column(self):
430
+ """ Add a new column """
431
+ self._is_page_in_status('columns')
432
+
433
+ self.id_counter += 1
434
+ column_id = 'columnID_{}'.format(self.id_counter)
435
+ self.page_buffers[-1].content_ids.append(column_id)
436
+
437
+ # region Tabs
438
+ # -----------
439
+ def open_tabs(self):
440
+ """ Open a tabs environment """
441
+ self.page_buffers.append(PageBuffer('tabs'))
442
+
443
+ def close_tabs(self):
444
+ """ Close current tabs environment """
445
+ self._is_page_in_status('tabs')
446
+
447
+ tabbed_items = []
448
+ for tab in self.page_buffers[-1].content_ids:
449
+ content_html = '\n'.join(self.page_buffers[-1].content_buffer[tab])
450
+ item = [tab[0], tab[1], content_html]
451
+ tabbed_items.append(item)
452
+ template = self.jinja_env.get_template('tabs.html')
453
+ html = template.render(tabbed_items=tabbed_items)
454
+
455
+ self.page_buffers = self.page_buffers[:-1]
456
+ self._update_content(html)
457
+
458
+ def add_tab(self, name: str):
459
+ """ Add a new tab to the tabs list
460
+
461
+ Args:
462
+ name (str): Name as shown in the tab list
463
+ """
464
+ self._is_page_in_status('tabs')
465
+
466
+ self.id_counter += 1
467
+ tab_id = 'tabID_{}'.format(self.id_counter)
468
+ self.page_buffers[-1].content_ids.append((name, tab_id))
469
+
470
+ # region Navbar
471
+ # -------------
472
+ def open_navbar(
473
+ self,
474
+ loc: Optional[Literal['left', 'right', 'top']]=None,
475
+ color: Optional[Literal['gray', 'red', 'blue', 'green']]=None,
476
+ ):
477
+ """ Open a navbar environment
478
+
479
+ Args:
480
+ loc (Optional[Literal['left', 'right', 'top']]): Position of navbar pills
481
+ color (Optional[Literal['gray', 'red', 'blue', 'green']]): Color of navbar pills
482
+ """
483
+ loc = default(loc, 'top')
484
+
485
+ supp_locs = ['left', 'right', 'top']
486
+ if loc not in supp_locs:
487
+ msg = f'Location {loc} is not supported, try {supp_locs}'
488
+ logger.error(msg)
489
+ raise RuntimeError(msg)
490
+
491
+ color = default(color, self.color_map.get_default_color('navbar'))
492
+ color = self.color_map.verify_and_get_color(color)
493
+
494
+ self.page_buffers.append(PageBuffer('navbar'))
495
+ self.page_buffers[-1].info = (loc, color)
496
+
497
+ def close_navbar(self):
498
+ """ Close current navbar environment """
499
+ self._is_page_in_status('navbar')
500
+
501
+ navbar_items = []
502
+ for navbar in self.page_buffers[-1].content_ids:
503
+ content_html = '\n'.join(self.page_buffers[-1].content_buffer[navbar])
504
+ item = [navbar[0], navbar[1], content_html]
505
+ navbar_items.append(item)
506
+
507
+ loc, color = self.page_buffers[-1].info
508
+ template = self.jinja_env.get_template('navbar_{}.html'.format(loc))
509
+ html = template.render(navbar_items=navbar_items, color=color)
510
+
511
+ self.page_buffers = self.page_buffers[:-1]
512
+ self._update_content(html)
513
+
514
+ def add_navbar_item(self, name: str):
515
+ """ Adds a new nav pill to the navbar
516
+
517
+ Args:
518
+ name (str): Name as shown in the pill
519
+ """
520
+ self._is_page_in_status('navbar')
521
+
522
+ self.id_counter += 1
523
+ nav_id = 'navCollapseID_{}'.format(self.id_counter)
524
+ self.page_buffers[-1].content_ids.append((name, nav_id))
525
+
526
+ # region Accordion
527
+ # ----------------
528
+ def open_accordion(self, color: Optional[str]=None):
529
+ """ Open an accordion environment
530
+
531
+ Args:
532
+ color (Optional[str]): Color of navbar pills
533
+ """
534
+ self.page_buffers.append(PageBuffer('accordion'))
535
+
536
+ self.id_counter += 1
537
+ accordion_id = 'accordionID_{}'.format(self.id_counter)
538
+
539
+ self.page_buffers[-1].info = accordion_id
540
+
541
+ def close_accordion(self):
542
+ """ Close current accordion environment """
543
+ self._is_page_in_status('accordion')
544
+
545
+ accordion_items = []
546
+ for acc in self.page_buffers[-1].content_ids:
547
+ content_html = '\n'.join(self.page_buffers[-1].content_buffer[acc])
548
+ item = [acc[0], acc[1], content_html]
549
+ accordion_items.append(item)
550
+
551
+ accordion_id = self.page_buffers[-1].info
552
+ template = self.jinja_env.get_template('accordion.html')
553
+ html = template.render(accordion_items=accordion_items,
554
+ accordion_id=accordion_id)
555
+
556
+ self.page_buffers = self.page_buffers[:-1]
557
+ self._update_content(html)
558
+
559
+ def add_accordion_item(self, name: str):
560
+ """ Adds a new card to the accordion
561
+
562
+ Args:
563
+ name (str): Name as shown in the card before opening
564
+ """
565
+ self._is_page_in_status('accordion')
566
+
567
+ self.id_counter += 1
568
+ acc_id = 'accCollapseID_{}'.format(self.id_counter)
569
+ self.page_buffers[-1].content_ids.append((name, acc_id))
570
+
571
+ # region Sublevels
572
+ # ----------------
573
+ def open_sublevel(self, size: Optional[int]=None, align: Optional[str]=None):
574
+ """ Opens a sub level environment
575
+
576
+ Args:
577
+ size (Optional[int]): Controls the width of the sublevel, 1-12
578
+ align (Optional[str]): Align things left, right or center
579
+ """
580
+ size = default(size, 11)
581
+ align = default(align, 'center')
582
+
583
+ align = verify_alignment(align)
584
+ allowed_size = [1,12]
585
+ if not (allowed_size[0] <= size <= allowed_size[1]):
586
+ msg = f'Size needs to be within {allowed_size}.'
587
+ logger.error(msg)
588
+ raise RuntimeError(msg)
589
+
590
+ self.page_buffers.append(PageBuffer('sublevel'))
591
+ self.page_buffers[-1].info = (size, align)
592
+ self.page_buffers[-1].content_ids.append('sublevel')
593
+
594
+ def close_sublevel(self):
595
+ """ Close current sub level environment """
596
+ self._is_page_in_status('sublevel')
597
+
598
+ size, align = self.page_buffers[-1].info
599
+ align = {'left': 'start', 'center': 'center', 'right': 'end'}[align]
600
+ content = '\n'.join(self.page_buffers[-1].content_buffer['sublevel'])
601
+
602
+ template = self.jinja_env.get_template('sublevel.html')
603
+ html = template.render(size=size, content=content, align=align)
604
+ self.page_buffers = self.page_buffers[:-1]
605
+ self._update_content(html)
606
+
607
+ ##-------------##
608
+ # Private #
609
+ ##-------------##
610
+ def _is_page_in_status(self, status: str):
611
+ """ Check if the page is really in the status we expect it to be
612
+
613
+ Args:
614
+ status (str): A page status like navbar or sublevel
615
+ """
616
+ page_status = self.page_buffers[-1].status
617
+ if page_status == 'default':
618
+ msg = (f'Cannot close {status} as page is in default mode. Did you forget to open it first?')
619
+ else:
620
+ msg = (f'Cannot close {status}, you need to close {self.page_buffers[-1].status} first')
621
+ if page_status != status:
622
+ logger.error(msg)
623
+ raise RuntimeError(msg)
624
+
625
+ def _update_content(self, html: str, prepend: bool=False):
626
+ """ Update the correct page buffer
627
+
628
+ Args:
629
+ html (str): html string to add to the buffer
630
+ prepend (bool): Prepend item to the html
631
+ """
632
+ if len(self.page_buffers) > 1:
633
+ self.page_buffers[-1].add_to_buffer(html, prepend)
634
+ else:
635
+ self.page_buffers[-1].add_to_html(html, prepend)
636
+
637
+ def _render_page(self):
638
+ """ Combine base template with the current HTML buffer """
639
+ header_content = ''
640
+ body_content = '\n'.join(self.page_buffers[-1].html)
641
+ template = self.jinja_env.get_template('base.html')
642
+ html = template.render(header_content=header_content,
643
+ body_content=body_content,
644
+ color_mode=self.color_mode)
645
+ return html
646
+
647
+ def _make_global_link_navbar(self, global_links: list[str]):
648
+ """ Take the list of global links and add a navbar to the page """
649
+ template = self.jinja_env.get_template('global_links.html')
650
+ html = template.render(links=global_links)
651
+ self._update_content(html, prepend=True)
652
+
653
+ def _dump(self):
654
+ """ Write HTML buffer to file """
655
+ page_status = self.page_buffers[-1].status
656
+ if page_status != 'default':
657
+ msg = f'Cannot write page {self.page_name}, {page_status} still open'
658
+ logger.error(msg)
659
+ raise RuntimeError(msg)
660
+
661
+ html = self._render_page()
662
+
663
+ if self.abs_file.exists():
664
+ msg = f'`{self.abs_file}` already exists within `{self.abs_dir}`. Aborting!'
665
+ raise RuntimeError(msg)
666
+
667
+ self.abs_dir.mkdir(parents=True, exist_ok=True)
668
+ with open(self.abs_file, 'w') as f_out:
669
+ f_out.write(html)
670
+
671
+ def _make_wrapper(self, v, options):
672
+ """ Wrapper for table entries """
673
+ if 'color_negative_values' in options and v < 0:
674
+ return '<span class="negative">{}</span>'
675
+ if 'color_positive_values' in options and v > 0:
676
+ return '<span class="positive">{}</span>'
677
+ return '{}'
678
+
679
+ def _make_table(self, table, footers, formatters, options):
680
+ """ Normalize the table data to work with DataTables """
681
+ if isinstance(table, pd.DataFrame):
682
+ dim = len(table.columns)
683
+ else:
684
+ dim = len(table[0])
685
+
686
+ if formatters is None:
687
+ formatters = [str]*dim
688
+ elif not isinstance(formatters, list):
689
+ if isinstance(formatters, str):
690
+ formatters = [formatters.format]*dim
691
+ else:
692
+ formatters = [formatters]*dim
693
+ else:
694
+ msg = ('If you specify a list of formatters their dim actually has to match the number of columns! If a'
695
+ ' pd.DataFrame is passed the index does not count here, as it is always formatted as str')
696
+ if len(formatters) != dim:
697
+ logger.error(msg)
698
+ raise RuntimeError(msg)
699
+
700
+ fmts = []
701
+ for fmt in formatters:
702
+ if isinstance(fmt, str):
703
+ fmts.append(fmt.format)
704
+ else:
705
+ fmts.append(fmt)
706
+ formatters = fmts
707
+
708
+ t_data = []
709
+ # Pandas dataframe
710
+ if isinstance(table, pd.DataFrame):
711
+ for i, r in table.iterrows():
712
+ if isinstance(i, tuple):
713
+ row = [str(s) for s in list(i)]
714
+ else:
715
+ row = [str(i)]
716
+
717
+ for j, v in enumerate(r):
718
+ if isinstance(v, str):
719
+ row.append(v)
720
+ else:
721
+ wrapper = self._make_wrapper(v, options)
722
+ row.append(wrapper.format(formatters[j](v)))
723
+ t_data.append(row)
724
+
725
+ if isinstance(table.index, pd.MultiIndex):
726
+ columns = table.index.names
727
+ elif table.index.name is not None:
728
+ columns = [table.index.name]
729
+ else:
730
+ columns = ['']
731
+ columns += table.columns.tolist()
732
+
733
+ # List of list
734
+ else:
735
+ for i, r in enumerate(table):
736
+ if i == 0:
737
+ columns = r
738
+ else:
739
+ row = []
740
+ for j, v in enumerate(r):
741
+ if isinstance(v, str):
742
+ row.append(v)
743
+ elif v is None:
744
+ row.append('')
745
+ else:
746
+ wrapper = self._make_wrapper(v, options)
747
+ row.append(wrapper.format(formatters[j](v)))
748
+ t_data.append(row)
749
+
750
+ f_data = []
751
+ if footers is None:
752
+ return t_data, columns, f_data
753
+
754
+ elif isinstance(footers, pd.DataFrame):
755
+ for i, r in footers.iterrows():
756
+ if isinstance(i, tuple):
757
+ row = [str(s) for s in list(i)]
758
+ else:
759
+ row = [str(i)]
760
+
761
+ for j, v in enumerate(r):
762
+ if isinstance(v, str):
763
+ row.append(v)
764
+ else:
765
+ wrapper = self._make_wrapper(v, options)
766
+ row.append(wrapper.format(formatters[j](v)))
767
+ f_data.append(row)
768
+ else:
769
+ # Make sure we have a list of lists
770
+ if not (isinstance(footers[0], list) or isinstance(footers[0], np.ndarray)): # noqa: SIM101
771
+ footers = [footers]
772
+
773
+ for _i, r in enumerate(footers):
774
+ row = []
775
+ for j, v in enumerate(r):
776
+ if isinstance(v, str):
777
+ row.append(v)
778
+ elif v is None:
779
+ row.append('')
780
+ else:
781
+ wrapper = self._make_wrapper(v, options)
782
+ row.append(wrapper.format(formatters[j](v)))
783
+ f_data.append(row)
784
+
785
+ return t_data, columns, f_data
786
+
787
+ #----------------------------------------------------------------------------------------------------------------------#
788
+ # region PageBuffer
789
+ #----------------------------------------------------------------------------------------------------------------------#
790
+ class PageBuffer(object):
791
+ """ Buffer used to accomplish nesting of elements - leave this alone """
792
+ __slots__ = ('content_buffer', 'content_ids', 'html', 'info', 'status')
793
+ def __init__(self, status: str='default'):
794
+ """ Initialize a new buffer object
795
+
796
+ Args:
797
+ status (str): The current status of the buffer.
798
+ """
799
+ self.status = status
800
+ self.info = None
801
+ self.content_buffer = defaultdict(list)
802
+ self.content_ids = []
803
+ self.html = []
804
+
805
+ def add_to_buffer(self, html: str, prepend: bool=False):
806
+ """ Add html to the content buffer
807
+
808
+ Args:
809
+ html (str): The HTML string to add
810
+ prepend (bool): Prepend the new HTML string to the buffer if `true` or append (default)
811
+ """
812
+ if prepend:
813
+ self.content_buffer[self.content_ids[-1]].insert(0, html)
814
+ else:
815
+ self.content_buffer[self.content_ids[-1]].append(html)
816
+
817
+ def add_to_html(self, html: str, prepend: bool=False):
818
+ """ Add html to the internal buffer
819
+
820
+ Args:
821
+ html (str): The HTML string to add
822
+ prepend (bool): Prepend the new HTML string to the buffer if `true` or append (default)
823
+ """
824
+ if prepend:
825
+ self.html.insert(0, html)
826
+ else:
827
+ self.html.append(html)