import datetime
import shutil
import yaml
from loguru import logger
from pandas import DataFrame
from pathlib import Path
from typing import Any, Union, Optional, Literal, Callable
from . import CONFIG_DIR, SCRIPTS_DIR
from .page import ReportPage
from .utils import default, verify_color_mode, ColorMap
#----------------------------------------------------------------------------------------------------------------------#
# region REPORT
#----------------------------------------------------------------------------------------------------------------------#
[docs]
class Report:
""" Main report object """
def __init__(
self,
report_dir: Union[str, Path],
report_title: Optional[str]=None,
report_date: Optional[Union[str, int, datetime.date, datetime.datetime]]=None,
config_file: Optional[Union[str, Path]]=None,
color_mode: str='light',
):
""" Initialize a new report
Args:
report_dir (Union[str, Path]): The main output dir which will contain the report
report_title (Optional[str]): The report title, if None is given it will be called `Unnamed Report`
report_date (Optional[Union[str, int, datetime.date, datetime.datetime]]): The report date, if none is given
today will be used
config_file (Optional[Union[str, Path]]): Path to the user config file that will be used. If none is given
the default config file will be read.
color_mode (str): Either `light` or `dark.
"""
self.report_date = default(report_date, datetime.date.today())
self.report_title = default(report_title, 'Unnamed Report')
self.report_dir = Path(report_dir)
self.config = self._load_config(config_file)
self.color_map = ColorMap(self.config)
self.color_mode = color_mode
self.pages: dict[str, ReportPage] = {}
self.add_page('main', subpage=False, color_mode=color_mode)
self.set_current_page('main')
self.global_links = []
#---------------#
# Magic Methods #
#---------------#
def __getitem__(self, key: str) -> ReportPage:
""" Simple access to the subpages
Args:
key (str): The page name to access
Returns:
ReportPage - The page object for the given page name
"""
return self.pages[key]
#---------#
# Private #
#---------#
def _load_config(self, cfg_file: Optional[Union[str, Path]]=None) -> dict[str, Any]:
""" Load a report config file
Args:
cfg_file (Optional[Union[str, Path]]): The config file to load. If none is given the default config file
will be read
Returns:
dict[str, Any] - The config file content as dict
"""
# Read default config
default_cfg_file = CONFIG_DIR / 'default.yaml'
if not default_cfg_file.exists():
msg = f'Could not find default config file {default_cfg_file}!'
logger.error(msg)
raise RuntimeError(msg)
with open(default_cfg_file, 'r') as f:
default_cfg = yaml.load(f, Loader=yaml.BaseLoader)
# Read custom config if given
if cfg_file is not None:
cfg_file = Path(cfg_file)
if not cfg_file.exists():
msg = f'Could not find default config file {default_cfg_file}!'
logger.error(msg)
raise RuntimeError(msg)
with open(cfg_file, 'r') as f:
custom_cfg = yaml.load(f, Loader=yaml.BaseLoader)
else:
custom_cfg = {}
cfg = default_cfg
for k,v in custom_cfg.items():
if k != 'custom_colors':
cfg[k] = v
else:
for ck,cv in v.items():
if ck not in cfg['custom_colors']:
cfg['custom_colors'][ck] = cv
else:
msg = (f'Tried to set color with name {ck}, which is already defined by the report class itself'
f'Please rename the color and try again.')
logger.error(msg)
raise RuntimeError(msg)
return cfg
#-----#
# API #
#-----#
# Report structure methods
# ------------------------
[docs]
def add_page(self, name: str, subpage: bool=True, color_mode: Optional[str]=None):
""" Add a new page to the report
Args:
name (str): The name of the report page
subpage (bool): Indicates this is a subpage of the report
color_mode (Optional[str]): Color mode of the page, either `light` or `dark`
"""
color_mode = verify_color_mode(default(color_mode, self.color_mode))
self.pages[name] = ReportPage(self.report_dir, name, self.config, self.color_map, subpage, color_mode)
[docs]
def dump(self):
""" Save the report to file and add the CSS files """
# Dump all of the report pages
for page in self.pages.values():
if self.global_links:
page._make_global_link_navbar(self.global_links) #noqa: SLF001
page._dump() #noqa: SLF001
# Add the css and script dir
src = SCRIPTS_DIR
dst = self.report_dir / 'css_and_scripts'
shutil.copytree(src, dst)
[docs]
def set_current_page(self, page_name: str):
""" Set the current page that is being worked on
Args:
page_name (str): The page you want to switch to
"""
if page_name not in self.pages:
msg = f'Unkown page {page_name}. Known: {self.pages.keys()}'
logger.error(msg)
raise RuntimeError(msg)
self.current: ReportPage = self.pages[page_name]
# Headers & special sections
# --------------------------
[docs]
def add_error_warning_info_section(self, **kwargs):
""" Add a section detailing errors, warnings, and info """
self.current.add_error_warning_info_section(**kwargs)
# Miscellaneous elements
# ----------------------
[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
"""
self.current.add_text(text, color, align)
[docs]
def add_table(
self,
table_data: Union[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.
"""
self.current.add_table(table_data, formatters, options, size, align, caption, footers, order, color)
[docs]
def add_list(self, list_items: list[str]):
""" Add a list to the current page
Args:
list_items (list[str]): A list to be added to the page
"""
self.current.add_list(list_items)
[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 current page
Args:
image_source (Union[str, Path]): The path to the image to add
remove_source (Optional[bool]): If true the source will be deleted and only acopy in the report dir is kept
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.current.add_image(image_source, remove_source, responsive, center)
[docs]
def add_local_link_to_page(self, page_name: str, link_name: str):
""" Add a link to another page to the current page
Args:
page_name (str): The key/name of the page to link to
link_name (str): The text that will be displayed
"""
link = self.get_local_link_to_page(page_name, link_name)
self.current.add_local_link(link)
[docs]
def get_local_link_to_page(self, page_name: str, link_name: str):
""" Get a link to another page to the current page
Args:
page_name (str): The key/name of the page to link to
link_name (str): The text that will be displayed
"""
return self.pages[page_name].get_link_to_page(link_name)
[docs]
def add_global_link_to_page(self, page_name: str, link_name: str):
""" Add a link to another page to the navbar
Args:
page_name (str): The key/name of the page to link to
link_name (str): The text that will be displayed
"""
link = self.pages[page_name].get_link_to_page(link_name, for_navbar=True)
self.global_links.append(link)
[docs]
def get_link_to_page(self, **kwargs):
""" Get a link to the current page """
self.current.get_link_to_page(**kwargs)
[docs]
def add_custom_html_code(self, html: str):
""" Add custom / generic html code
Args:
html (str): The HTML string.
"""
self.current.add_custom_html_code(html)
# Layouting
# ---------
[docs]
def open_columns(self):
""" Start a column layout """
self.current.open_columns()
[docs]
def add_column(self):
""" Add another column to the current column layout """
self.current.add_column()
[docs]
def close_columns(self):
""" Close the current column layout """
self.current.close_columns()
[docs]
def open_tabs(self):
""" Open a tab layout"""
self.current.open_tabs()
[docs]
def add_tab(self, name: str):
""" Add another tab to the current tab layout
Args:
name (str): The text shown on the tab
"""
self.current.add_tab(name)
[docs]
def close_tabs(self):
""" Close the current tab layout"""
self.current.close_tabs()
[docs]
def open_navbar(self, **kwargs):
""" Open a navbar layout """
self.current.open_navbar(**kwargs)
[docs]
def add_navbar_item(self, name: str):
""" Add another navbar item to the current navbar layout
Args:
name (str): The text shown on the navbar pill
"""
self.current.add_navbar_item(name)
[docs]
def close_navbar(self):
""" Close the current navbar layout """
self.current.close_navbar()
[docs]
def open_accordion(self, **kwargs):
""" Open a accordion layout"""
self.current.open_accordion(**kwargs)
[docs]
def add_accordion_item(self, name: str):
""" Add another accordion item to the current accordion layout
Args:
name (str): The text shown on the accordion
"""
self.current.add_accordion_item(name)
[docs]
def close_accordion(self):
""" Close the current accorion layout """
self.current.close_accordion()
[docs]
def open_sublevel(self, **kwargs):
""" Open a sub-level layout """
self.current.open_sublevel(**kwargs)
[docs]
def close_sublevel(self):
""" Close the current sub-level layout """
self.current.close_sublevel()