import datetime
import numpy as np
import pandas as pd
import shutil
import json
from collections import defaultdict, ChainMap
from jinja2 import Environment, FileSystemLoader
from loguru import logger
from pathlib import Path
from typing import Union, Optional, Any, Callable, Literal
from . import TEMPLATE_DIR
from .utils import default, verify_alignment, coerce_to_date, ColorMap
from .heatmap import (
CalendarDomain,
CalendarLabel,
CalendarSubDomain,
CalendarDate,
CalendarColor,
CalendarMisc,
CalendarHeatmap,
)
#----------------------------------------------------------------------------------------------------------------------#
# region REPORT PAGE
#----------------------------------------------------------------------------------------------------------------------#
[docs]
class ReportPage:
""" Single page of a Report object, does all the heavy lifting """
def __init__(
self,
report_dir: Union[str, Path],
page_name: str,
config: dict[str, Any],
color_map: ColorMap,
subpage: Optional[bool]=None,
color_mode: str='light',
):
""" Initialize a new Report Page object
Args:
report_dir (Union[str, Path]): Path to the report directory
page_name (str): Name of the page. If this is a subpage the final
file will be called {page_name}.html
config (dict[str, Any]): Containing report configuration
color_map (ColorMap): The color map used for the report
subpage (Optional[bool]): This is a subpage of the report, default is `True`
color_mode (str): Decide if the subpage is in `light` or `dark` mode
"""
subpage = default(subpage, True)
self.page_name = page_name
if subpage:
self.file_name = f'{page_name}.html'
else:
self.file_name = 'index.html'
self.rel_dir = Path()
self.abs_dir = Path(report_dir)
self.img_dir = self.abs_dir / 'images'
self.rel_file = self.rel_dir / self.file_name
self.abs_file = self.abs_dir / self.file_name
self.config = config
self.color_map = color_map
self.color_mode = color_mode
self.cal_heatmaps = []
# The following objects allow the nesting of commands and avoid lement conflicts
self.page_buffers = [PageBuffer()]
self.id_counter = 0
# Prepare the jinja templater
file_loader = FileSystemLoader(TEMPLATE_DIR)
self.jinja_env = Environment(loader=file_loader)
#-----#
# API #
#-----#
# region Report Header
# --------------------
# region Error, Warning, Info Section
# -----------------------------------
[docs]
def add_error_warning_info_section(
self,
errors: Optional[list[str]]=None,
warnings: Optional[list[str]]=None,
info: Optional[list[str]]=None,
):
""" Add a section containing errors/warnings/infos
If a report ball is provided it takes precedence over the other lists.
Args:
errors (Optional[list[str]]): List of errors encountered
warnings (Optional[list[str]]): List of warnings encountered
info (Optional[list[str]]): List of info encountered
"""
errors = default(errors, [])
warnings = default(warnings, [])
info = default(info, [])
color = self.color_map.get_default_color('report_ball')
template = self.jinja_env.get_template('elements/report_ball_section.html')
html = template.render(errors=errors, warnings=warnings, info=info, color=color)
self._update_content(html)
# region Link To Page
# -------------------
[docs]
def get_link_to_page(self, link_name: Optional[str]=None, for_navbar: bool=False) -> str:
""" Get a relative link to the current page
Args:
link_name (Optional[str]): Clickable text for the link
for_navbar (bool): The link will be part of a navbar. This is used for global link bars. You will probably
never need this.
Returns:
str - HTML code for the link
"""
link_name = default(link_name, self.page_name)
str_template = '<a {{ cls }} href="{{ target }}">{{ name }}</a>'
template = Environment().from_string(str_template)
target = self.rel_file
if for_navbar:
cls = 'class="nav-item nav-link"'
else:
cls = ''
return template.render(target=target, name=link_name, cls=cls)
# region Local Link
# -----------------
[docs]
def add_local_link(self, link: str):
""" Add a clickable link to the page
Args:
link (str): HTML link to another page. You either created this link yourself or you used the Reporter to
get the link to another page.
"""
str_template = '<div class="container">{{ link }}</div>'
template = Environment().from_string(str_template)
html = template.render(link=link)
self._update_content(html)
# region Header
# -------------
# region Sub Header
# -----------------
# region Section Title
# --------------------
[docs]
def add_section_title(
self,
title_text: str,
text_color: Optional[str]=None,
bar_color: Optional[str]=None,
alignment: Optional[Literal['left', 'center', 'right']]=None,
):
""" Add a section title to the page
Args:
title_text (str): Title text
text_color (Optional[str]): Color of the title text
bar_color (Optional[str]): Color of the bars
alignment (Optional[str]): Alignment of the title. If left or right is chosen the bar will be omitted on the
chosen side
"""
valid_alignments = {'left', 'center', 'right', None}
if alignment not in valid_alignments :
msg = f'Unknown alignment `{alignment}`. Use one of {valid_alignments}'
logger.error(msg)
raise RuntimeError(msg)
alignment = default(alignment, 'center')
text_color = default(text_color, self.color_map.get_default_color('section_title.text'))
bar_color = default(bar_color, self.color_map.get_default_color('section_title.bars'))
template = self.jinja_env.get_template('elements/section_title.html')
html = template.render(title_text=title_text,
text_color=text_color,
bar_color=bar_color,
alignment=alignment)
self._update_content(html)
# region Dictionary
# -----------------
[docs]
def add_dictionary(
self,
dictionary: dict[str,str] | list[list[str]],
key_color: str | None=None,
key_width: str | None=None,
value_color: str | None=None,
):
""" Add a dictionary as key value list to the report
Args:
dictionary (dict[str, str] | list[list[str]]): The dictionary that should be added to the report. The key
will be right aligned in one column, whereas the values will be left aligned in the next column. If you
pass a list make sure it is a list of lists with 2 elements. The first will be assumed to be the key
the second represents the value
key_color (str | None): The text color for the keys
key_width (str | None): The width of the key column. Use any of the html specifications, e.g. 150px etc.
value_color (str | None): The color of the value text
"""
key_width = default(key_width, None)
key_color = default(key_color, self.color_map.get_default_color('dictionary.key'))
value_color = default(value_color, self.color_map.get_default_color('dictionary.value'))
if isinstance(dictionary, dict):
dictionary = [[k,v] for k,v in dictionary.items()]
template = self.jinja_env.get_template('elements/dictionary.html')
html = template.render(dictionary=dictionary,
key_color=key_color,
key_width=key_width,
value_color=value_color)
self._update_content(html)
# region Table
# ------------
[docs]
def add_table(
self,
table_data: Union[pd.DataFrame, list[list[Any]]],
formatters: Optional[Union[str, Callable, list[str], list[Callable]]]=None,
options: Optional[list[str]]=None,
size: Optional[int]=None,
align: Optional[Literal['left','center','right']]=None,
caption: Optional[str]=None,
footers: Optional[list[list[Any]]]=None,
order: Optional[list[list[Any]]]=None,
color: Optional[str]=None,
):
""" Add a table to the page.
``table_data`` can either be a pandas ``DataFrame`` or a list of lists.
For the former we assume indices and column labels are already properly
formatted. For the latter we assume the first item contains the headers.
Args:
table_data (Union[DataFrame, list[list[Any]]]):
Data to tabelize.
formatters (Optional[Union[str, Callable, list[str], list[Callable]]]):
Formatters applied to each table element. If a list is provided it
must match the number of columns. A single formatter is applied
to all columns. The default formatter is ``str()``.
options (Optional[list[str]]):
Turn optional elements of the DataTable on. Supported options:
- ``page`` - Split long tables into pages of 10 (configurable)
- ``info`` - Show number of rows
- ``search`` - Add a search field
- ``no_sort`` - Disable initial sorting
- ``color_negative_values`` - Highlight values < 0 in red
- ``color_positive_values`` - Highlight values > 0 in green
- ``full_width`` - Table spans full window width
size (Optional[int]):
Sets the width of the table (1-12). ``12`` uses full container width.
align (Optional[str]):
Alignment of cell content.
caption (Optional[str]):
Text shown below the table.
footers (Optional[list[list[Any]]]):
Rows always shown at the bottom of the table.
order (Optional[list[list[Any]]]):
Default table order in the form ``[[col_idx, 'asc' | 'desc'], ...]``.
color (Optional[str]):
Text color. Automatically determined if omitted.
"""
# Set defaults
caption = default(caption, '')
size = default(size, 12)
align = default(align, 'center')
options = default(options, [])
footers = default(footers, None)
order = default(order, None)
color = default(color, self.color_map.get_default_color('table'))
data, columns, footers = self._make_table(table_data, footers, formatters, options)
self.id_counter += 1
table_id = 'tableID_{}'.format(self.id_counter)
d_opts = ['page', 'info', 'search', 'no_sort', 'color_negative_values',
'color_positive_values', 'full_width', 'order']
if not isinstance(options, list):
msg = 'The options keyword needs to be a list'
logger.error(msg)
raise RuntimeError(msg)
for opt in options:
if opt not in d_opts:
msg = f'{opt} is not supported {d_opts}'
logger.error(msg)
raise RuntimeError(msg)
if not isinstance(size, int):
msg = 'Size needs to be of type int'
logger.error(msg)
raise RuntimeError(msg)
allowed_size = [1,12]
if not (allowed_size[0] <= size <= allowed_size[1]):
msg = f'Size {size} not supported, allowed sizes are {allowed_size}'
logger.error(msg)
raise RuntimeError(msg)
template = self.jinja_env.get_template('elements/table.html')
html = template.render(table_id=table_id,
table_data=data,
table_columns=columns,
footers=footers,
order=order,
options=options,
size=size,
align=align,
caption=caption,
color=color)
self._update_content(html)
# region Calendar Heatmap
# --------------
[docs]
def add_calendar_heatmap(
self,
*,
misc: CalendarMisc | None = None,
domain: CalendarDomain | None = None,
label: CalendarLabel | None = None,
subdomain: CalendarSubDomain | None = None,
date: CalendarDate | None = None,
color: CalendarColor | None = None,
x: list[str] | None = None,
y: list[float | int] | None = None,
custom_config: dict[str, Any] | None = None,
):
""" Add a calendar heatmap to the report
Args:
misc (CalendarMisc | None): The miscellaneous configuration for the calendar heatmap
domain (CalendarDomain | None): The domain configuration for the calendar heatmap
label (CalendarLabel | None): The label configuration for the calendar heatmap
subdomain: (CalendarSubDomain | None): The subdomain configuration for the calendar heatmap
date (CalendarDate | None): The date configuration for the calendar heatmap
color (CalendarColor | None): The color configuration for the calendar heatmap
x (list[str] | None): The time data array, e.g., dates
y (list[float | int] | None): The corresponding value array
custom_config (dict[str, Any] | None): A custom config dict to easily override any defaults set in the
report. See the documentation for the calendar heatmap for an in depth discussion of the available
parameters
"""
# Incorporate the custom config
if custom_config is None:
custom_config = {}
cfg = {}
for k, v in self.config['heatmap'].items():
cfg[k] = ChainMap(custom_config.get(k, {}), v)
# Load defaults if nothing is passed
misc = misc or CalendarMisc(**cfg['misc'])
domain = domain or CalendarDomain(**cfg['domain'])
label = label or CalendarLabel(**cfg['label'])
subdomain = subdomain or CalendarSubDomain(**cfg['subdomain'])
date = date or CalendarDate(**cfg['date'])
color = color or CalendarColor(**cfg['color'])
# Make sure we have data to plot
if x is None or y is None:
msg = 'Both x and y need to be set'
logger.error(msg)
raise RuntimeError(msg)
# Add to report
hm = CalendarHeatmap(misc, domain, label, subdomain, date, color)
hm.set_data(x=x, y=y)
# We need to keep track of the heatmaps so we can properly add the style sheets later
self.cal_heatmaps.append(hm)
self.add_custom_html_code(hm.make_heatmap())
# region Image
# ------------
[docs]
def add_image(
self,
image_source: Union[str, Path],
remove_source: Optional[bool]=None,
responsive: Optional[bool]=None,
center: Optional[bool]=None,
):
""" Add an image to the page
Args:
image_source (Union[str, Path]): Path to image that needs to be added The image will be copied to
report/page/images/
remove_source (Optional[bool]): If true the source will be deleted and we will only keep the copy in the
report dir
responsive (Optional[bool]): If true the image will automatically be resized to match the outer container
center (Optional[bool]): If true the image will be centered
"""
self.img_dir.mkdir(parents=True, exist_ok=True)
remove_source = default(remove_source, False)
responsive = default(responsive, True)
center = default(center, True)
image_source = Path(image_source)
image_name = image_source.name
image_dest = self.img_dir / image_name
shutil.copyfile(image_source, image_dest)
if remove_source:
image_source.unlink()
rel_image_path = self.rel_dir / 'images' / image_name
template = self.jinja_env.get_template('elements/image.html')
html = template.render(source=rel_image_path, responsive=responsive, center=center)
self._update_content(html)
# region Custom HTML Code
# -----------------------
[docs]
def add_custom_html_code(self, html: str):
""" Add your own html code to the page
Args:
html (str): Whatever you want to add
"""
self._update_content(html)
# region List
# -----------
[docs]
def add_list(self, list_items: list[str]):
""" Add a list to the page
Args:
list_items (list[str]): Each item is converted to a bullet point
"""
template = self.jinja_env.get_template('elements/list.html')
html = template.render(list_items=list_items)
self._update_content(html)
# region Text
# -----------
[docs]
def add_text(self, text: str, color: Optional[str]=None, align: Optional[Literal['left', 'center', 'right']]=None):
""" Add (colored) text to the page
This will create a new paragraph. So if you want to mix colors within
one paragraph you'll have to do this yourself for now
Args:
text (str): Your text
color (Optional[str]): One of the supported colors
align (Optional[Literal['left', 'center', 'right']]): Alignment of text
"""
color = default(color, self.color_map.get_default_color('text'))
align = default(align, 'center')
color = self.color_map[color]
align = verify_alignment(align)
template = self.jinja_env.get_template('elements/text.html')
html = template.render(text=text, color=color, align=align)
self._update_content(html)
# region Columns
# --------------
[docs]
def open_columns(self):
""" Open a new columns environment """
self.page_buffers.append(PageBuffer('columns'))
[docs]
def close_columns(self):
""" Close current columns environment """
self._is_page_in_status('columns')
column_items = []
for column in self.page_buffers[-1].content_ids:
content_html = '\n'.join(self.page_buffers[-1].content_buffer[column])
item = [content_html]
column_items.append(item)
template = self.jinja_env.get_template('elements/columns.html')
html = template.render(column_items=column_items)
self.page_buffers = self.page_buffers[:-1]
self._update_content(html)
[docs]
def add_column(self):
""" Add a new column """
self._is_page_in_status('columns')
self.id_counter += 1
column_id = 'columnID_{}'.format(self.id_counter)
self.page_buffers[-1].content_ids.append(column_id)
# region Tabs
# -----------
[docs]
def open_tabs(self):
""" Open a tabs environment """
self.page_buffers.append(PageBuffer('tabs'))
[docs]
def close_tabs(self):
""" Close current tabs environment """
self._is_page_in_status('tabs')
tabbed_items = []
for tab in self.page_buffers[-1].content_ids:
content_html = '\n'.join(self.page_buffers[-1].content_buffer[tab])
item = [tab[0], tab[1], content_html]
tabbed_items.append(item)
template = self.jinja_env.get_template('elements/tabs.html')
html = template.render(tabbed_items=tabbed_items)
self.page_buffers = self.page_buffers[:-1]
self._update_content(html)
[docs]
def add_tab(self, name: str):
""" Add a new tab to the tabs list
Args:
name (str): Name as shown in the tab list
"""
self._is_page_in_status('tabs')
self.id_counter += 1
tab_id = 'tabID_{}'.format(self.id_counter)
self.page_buffers[-1].content_ids.append((name, tab_id))
# region Cards
# ------------
[docs]
def open_cards(self, cards_type: Literal['group', 'deck', 'columns']):
""" Open a new cards environment
Args:
cards_type (Literal['group', 'deck', 'columns']): Define the layout of how cards are grouped together
"""
self.page_buffers.append(PageBuffer('cards'))
self.page_buffers[-1].info = cards_type
[docs]
def close_cards(self):
""" Close current cards environment """
self._is_page_in_status('cards')
card_items = []
for card in self.page_buffers[-1].content_ids:
card_body = '\n'.join(self.page_buffers[-1].content_buffer[card])
card_cfg = json.loads(card[0])
item = ChainMap(card_cfg, {'card_body': card_body})
card_items.append(item)
template = self.jinja_env.get_template('elements/cards.html')
html = template.render(
cards_type=self.page_buffers[-1].info,
card_items=card_items,
)
self.page_buffers = self.page_buffers[:-1]
self._update_content(html)
[docs]
def add_card(
self,
*,
background_color: str | None = None,
border_color: str | None = None,
image: str | None = None,
header_text: str | None = None,
header_text_color: str | None = None,
header_background_color: str | None = None,
header_border_color: str | None = None,
footer_text: str | None = None,
footer_text_color: str | None = None,
footer_background_color: str | None = None,
footer_border_color: str | None = None,
max_width: str='18rem',
):
""" Add a section title to the page
Args:
background_color (str | None): The name of the background color for the whole card
border_color (str | None): The name of the border color for the whole card
image (str | None): A header image for the card. The source image will not be deleted!
header_text (str | None): Text to be shown as header of the card
header_text_color (str | None): The name of the header text color
header_background_color (str | None): The name of the background color for the header section
header_border_color (str | None): The name of the border color for the header section, only really
footer_text (str | None): Text to be shown as footer of the card
footer_text_color (str | None): The name of the footer text color
footer_background_color (str | None): The name of the background color for the footer section
footer_border_color (str | None): The name of the border color for the footer section, only really
affects the line between the footer and the body
max_width (str): The maximum width of the card
"""
self._is_page_in_status('cards')
self.img_dir.mkdir(parents=True, exist_ok=True)
if image is not None:
image_source = Path(image)
image_name = image_source.name
image_dest = self.img_dir / image_name
shutil.copyfile(image_source, image_dest)
image = (self.rel_dir / 'images' / image_name).as_posix()
card_cfg = {
'image_source': image,
'max_width': max_width,
'background_color': default(background_color,
self.color_map.get_default_color('card.background')),
'border_color': default(border_color,
self.color_map.get_default_color('card.border')),
'header_text': header_text,
'header_text_color': default(header_text_color,
self.color_map.get_default_color('card.header_text')),
'header_background_color': default(header_background_color,
self.color_map.get_default_color('card.header_background')),
'header_border_color': default(header_border_color,
self.color_map.get_default_color('card.header_border')),
'footer_text': footer_text,
'footer_text_color': default(footer_text_color,
self.color_map.get_default_color('card.footer_text')),
'footer_background_color': default(footer_background_color,
self.color_map.get_default_color('card.footer_background')),
'footer_border_color': default(footer_border_color,
self.color_map.get_default_color('card.footer_border')),
}
card_cfg = json.dumps(card_cfg)
self.id_counter += 1
card_id = 'cardID_{}'.format(self.id_counter)
self.page_buffers[-1].content_ids.append((card_cfg, card_id))
# region Navbar
# -------------
[docs]
def open_navbar(
self,
loc: Optional[Literal['left', 'right', 'top']]=None,
color: Optional[Literal['gray', 'red', 'blue', 'green']]=None,
):
""" Open a navbar environment
Args:
loc (Optional[Literal['left', 'right', 'top']]): Position of navbar pills
color (Optional[Literal['gray', 'red', 'blue', 'green']]): Color of navbar pills
"""
loc = default(loc, 'top')
supp_locs = ['left', 'right', 'top']
if loc not in supp_locs:
msg = f'Location {loc} is not supported, try {supp_locs}'
logger.error(msg)
raise RuntimeError(msg)
color = default(color, self.color_map.get_default_color('navbar'))
color = self.color_map.verify_and_get_color(color)
self.page_buffers.append(PageBuffer('navbar'))
self.page_buffers[-1].info = (loc, color)
[docs]
def close_navbar(self):
""" Close current navbar environment """
self._is_page_in_status('navbar')
navbar_items = []
for navbar in self.page_buffers[-1].content_ids:
content_html = '\n'.join(self.page_buffers[-1].content_buffer[navbar])
item = [navbar[0], navbar[1], content_html]
navbar_items.append(item)
loc, color = self.page_buffers[-1].info
template = self.jinja_env.get_template('elements/navbar_{}.html'.format(loc))
html = template.render(navbar_items=navbar_items, color=color)
self.page_buffers = self.page_buffers[:-1]
self._update_content(html)
[docs]
def add_navbar_item(self, name: str):
""" Adds a new nav pill to the navbar
Args:
name (str): Name as shown in the pill
"""
self._is_page_in_status('navbar')
self.id_counter += 1
nav_id = 'navCollapseID_{}'.format(self.id_counter)
self.page_buffers[-1].content_ids.append((name, nav_id))
# region Accordion
# ----------------
[docs]
def open_accordion(self, color: Optional[str]=None):
""" Open an accordion environment
Args:
color (Optional[str]): Color of navbar pills
"""
self.page_buffers.append(PageBuffer('accordion'))
self.id_counter += 1
accordion_id = 'accordionID_{}'.format(self.id_counter)
self.page_buffers[-1].info = accordion_id
[docs]
def close_accordion(self):
""" Close current accordion environment """
self._is_page_in_status('accordion')
accordion_items = []
for acc in self.page_buffers[-1].content_ids:
content_html = '\n'.join(self.page_buffers[-1].content_buffer[acc])
item = [acc[0], acc[1], content_html]
accordion_items.append(item)
accordion_id = self.page_buffers[-1].info
template = self.jinja_env.get_template('elements/accordion.html')
html = template.render(accordion_items=accordion_items,
accordion_id=accordion_id)
self.page_buffers = self.page_buffers[:-1]
self._update_content(html)
[docs]
def add_accordion_item(self, name: str):
""" Adds a new card to the accordion
Args:
name (str): Name as shown in the card before opening
"""
self._is_page_in_status('accordion')
self.id_counter += 1
acc_id = 'accCollapseID_{}'.format(self.id_counter)
self.page_buffers[-1].content_ids.append((name, acc_id))
# region Sublevels
# ----------------
[docs]
def open_sublevel(self, size: Optional[int]=None, align: Optional[str]=None):
""" Opens a sub level environment
Args:
size (Optional[int]): Controls the width of the sublevel, 1-12
align (Optional[str]): Align things left, right or center
"""
size = default(size, 11)
align = default(align, 'center')
align = verify_alignment(align)
allowed_size = [1,12]
if not (allowed_size[0] <= size <= allowed_size[1]):
msg = f'Size needs to be within {allowed_size}.'
logger.error(msg)
raise RuntimeError(msg)
self.page_buffers.append(PageBuffer('sublevel'))
self.page_buffers[-1].info = (size, align)
self.page_buffers[-1].content_ids.append('sublevel')
[docs]
def close_sublevel(self):
""" Close current sub level environment """
self._is_page_in_status('sublevel')
size, align = self.page_buffers[-1].info
align = {'left': 'start', 'center': 'center', 'right': 'end'}[align]
content = '\n'.join(self.page_buffers[-1].content_buffer['sublevel'])
template = self.jinja_env.get_template('elements/sublevel.html')
html = template.render(size=size, content=content, align=align)
self.page_buffers = self.page_buffers[:-1]
self._update_content(html)
##-------------##
# Private #
##-------------##
def _is_page_in_status(self, status: str):
""" Check if the page is really in the status we expect it to be
Args:
status (str): A page status like navbar or sublevel
"""
page_status = self.page_buffers[-1].status
if page_status == 'default':
msg = (f'Cannot close {status} as page is in default mode. Did you forget to open it first?')
else:
msg = (f'Cannot close {status}, you need to close {self.page_buffers[-1].status} first')
if page_status != status:
logger.error(msg)
raise RuntimeError(msg)
def _update_content(self, html: str, prepend: bool=False):
""" Update the correct page buffer
Args:
html (str): html string to add to the buffer
prepend (bool): Prepend item to the html
"""
if len(self.page_buffers) > 1:
self.page_buffers[-1].add_to_buffer(html, prepend)
else:
self.page_buffers[-1].add_to_html(html, prepend)
def _render_page(self):
""" Combine base template with the current HTML buffer """
header_content = ''
body_content = '\n'.join(self.page_buffers[-1].html)
template = self.jinja_env.get_template('base.html')
html = template.render(header_content=header_content,
body_content=body_content,
color_mode=self.color_mode,
includes_calendar_heatmap=len(self.cal_heatmaps) > 0,
)
return html
def _make_global_link_navbar(self, global_links: list[str]):
""" Take the list of global links and add a navbar to the page """
template = self.jinja_env.get_template('elements/global_links.html')
html = template.render(links=global_links)
self._update_content(html, prepend=True)
def _dump(self):
""" Write HTML buffer to file """
page_status = self.page_buffers[-1].status
if page_status != 'default':
msg = f'Cannot write page {self.page_name}, {page_status} still open'
logger.error(msg)
raise RuntimeError(msg)
html = self._render_page()
if self.abs_file.exists():
msg = f'`{self.abs_file}` already exists within `{self.abs_dir}`. Aborting!'
raise RuntimeError(msg)
self.abs_dir.mkdir(parents=True, exist_ok=True)
with open(self.abs_file, 'w') as f_out:
f_out.write(html)
def _make_wrapper(self, v, options):
""" Wrapper for table entries """
if 'color_negative_values' in options and v < 0:
return '<span class="negative">{}</span>'
if 'color_positive_values' in options and v > 0:
return '<span class="positive">{}</span>'
return '{}'
def _make_table(self, table, footers, formatters, options):
""" Normalize the table data to work with DataTables """
if isinstance(table, pd.DataFrame):
dim = len(table.columns)
else:
dim = len(table[0])
if formatters is None:
formatters = [str]*dim
elif not isinstance(formatters, list):
if isinstance(formatters, str):
formatters = [formatters.format]*dim
else:
formatters = [formatters]*dim
else:
msg = ('If you specify a list of formatters their dim actually has to match the number of columns! If a'
' pd.DataFrame is passed the index does not count here, as it is always formatted as str')
if len(formatters) != dim:
logger.error(msg)
raise RuntimeError(msg)
fmts = []
for fmt in formatters:
if isinstance(fmt, str):
fmts.append(fmt.format)
else:
fmts.append(fmt)
formatters = fmts
t_data = []
# Pandas dataframe
if isinstance(table, pd.DataFrame):
for i, r in table.iterrows():
if isinstance(i, tuple):
row = [str(s) for s in list(i)]
else:
row = [str(i)]
for j, v in enumerate(r):
if isinstance(v, str):
row.append(v)
else:
wrapper = self._make_wrapper(v, options)
row.append(wrapper.format(formatters[j](v)))
t_data.append(row)
if isinstance(table.index, pd.MultiIndex):
columns = table.index.names
elif table.index.name is not None:
columns = [table.index.name]
else:
columns = ['']
columns += table.columns.tolist()
# List of list
else:
for i, r in enumerate(table):
if i == 0:
columns = r
else:
row = []
for j, v in enumerate(r):
if isinstance(v, str):
row.append(v)
elif v is None:
row.append('')
else:
wrapper = self._make_wrapper(v, options)
row.append(wrapper.format(formatters[j](v)))
t_data.append(row)
f_data = []
if footers is None:
return t_data, columns, f_data
elif isinstance(footers, pd.DataFrame):
for i, r in footers.iterrows():
if isinstance(i, tuple):
row = [str(s) for s in list(i)]
else:
row = [str(i)]
for j, v in enumerate(r):
if isinstance(v, str):
row.append(v)
else:
wrapper = self._make_wrapper(v, options)
row.append(wrapper.format(formatters[j](v)))
f_data.append(row)
else:
# Make sure we have a list of lists
if not (isinstance(footers[0], list) or isinstance(footers[0], np.ndarray)): # noqa: SIM101
footers = [footers]
for _i, r in enumerate(footers):
row = []
for j, v in enumerate(r):
if isinstance(v, str):
row.append(v)
elif v is None:
row.append('')
else:
wrapper = self._make_wrapper(v, options)
row.append(wrapper.format(formatters[j](v)))
f_data.append(row)
return t_data, columns, f_data
#----------------------------------------------------------------------------------------------------------------------#
# region PageBuffer
#----------------------------------------------------------------------------------------------------------------------#
[docs]
class PageBuffer(object):
""" Buffer used to accomplish nesting of elements - leave this alone """
__slots__ = ('content_buffer', 'content_ids', 'html', 'info', 'status')
def __init__(self, status: str='default'):
""" Initialize a new buffer object
Args:
status (str): The current status of the buffer.
"""
self.status = status
self.info = None
self.content_buffer = defaultdict(list)
self.content_ids = []
self.html = []
[docs]
def add_to_buffer(self, html: str, prepend: bool=False):
""" Add html to the content buffer
Args:
html (str): The HTML string to add
prepend (bool): Prepend the new HTML string to the buffer if `true` or append (default)
"""
if prepend:
self.content_buffer[self.content_ids[-1]].insert(0, html)
else:
self.content_buffer[self.content_ids[-1]].append(html)
[docs]
def add_to_html(self, html: str, prepend: bool=False):
""" Add html to the internal buffer
Args:
html (str): The HTML string to add
prepend (bool): Prepend the new HTML string to the buffer if `true` or append (default)
"""
if prepend:
self.html.insert(0, html)
else:
self.html.append(html)