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.
- basic_report/__init__.py +10 -0
- basic_report/configs/default.yaml +185 -0
- basic_report/css_and_scripts/bootstrap.min.css +7 -0
- basic_report/css_and_scripts/bootstrap.min.js +7 -0
- basic_report/css_and_scripts/dataTables.bootstrap4.min.js +8 -0
- basic_report/css_and_scripts/jquery-3.3.1.slim.min.js +2 -0
- basic_report/css_and_scripts/jquery.dataTables.min.js +166 -0
- basic_report/css_and_scripts/popper.min.js +5 -0
- basic_report/page.py +827 -0
- basic_report/report.py +404 -0
- basic_report/templates/accordion.css +20 -0
- basic_report/templates/accordion.html +20 -0
- basic_report/templates/base.html +59 -0
- basic_report/templates/colors.css +30 -0
- basic_report/templates/columns.html +9 -0
- basic_report/templates/global_links.html +7 -0
- basic_report/templates/header.html +5 -0
- basic_report/templates/image.html +16 -0
- basic_report/templates/list.html +7 -0
- basic_report/templates/navbar.css +17 -0
- basic_report/templates/navbar_left.html +32 -0
- basic_report/templates/navbar_right.html +32 -0
- basic_report/templates/navbar_top.html +25 -0
- basic_report/templates/plot.html +4 -0
- basic_report/templates/report_ball_section.html +80 -0
- basic_report/templates/report_header.html +6 -0
- basic_report/templates/site.css +5 -0
- basic_report/templates/sublevel.html +7 -0
- basic_report/templates/table.css +51 -0
- basic_report/templates/table.html +55 -0
- basic_report/templates/tabs.css +20 -0
- basic_report/templates/tabs.html +29 -0
- basic_report/templates/text.html +3 -0
- basic_report/utils.py +246 -0
- basic_report-0.1.0.dist-info/METADATA +194 -0
- basic_report-0.1.0.dist-info/RECORD +38 -0
- basic_report-0.1.0.dist-info/WHEEL +4 -0
- 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)
|