"""QuartoReportView class for generating Quarto reports."""
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import List, Optional
import networkx as nx
from . import report as r
from . import table_utils
from .constants import GITHUB_ORG_URL, GITHUB_ORG_URL_BRACKETS, LOGO_URL, ORG, REPO_URL
from .utils import create_folder, get_relative_file_path, is_url, sort_imports
[docs]
class QuartoReportView(r.ReportView):
"""
A ReportView subclass for generating Quarto reports.
"""
BASE_DIR = Path("quarto_report")
STATIC_FILES_DIR = BASE_DIR / "static"
def __init__(
self,
report: r.Report,
report_type: r.ReportType,
quarto_checks: bool = False,
output_dir: Optional[Path] = BASE_DIR,
static_dir: str = STATIC_FILES_DIR,
):
"""_summary_
Parameters
----------
report : r.Report
Report dataclass with all the information to be included in the report.
Contains sections data needed to write the report python files.
report_type : r.ReportType
Enum of report type as definded by the ReportType Enum.
quarto_checks : bool, optional
Whether to test if all quarto dependencies are installed, by default False
static_dir : str
The folder where the static files will be saved.
"""
super().__init__(report=report, report_type=report_type)
self.quarto_checks = quarto_checks
self.static_dir = static_dir
self.output_dir = output_dir.resolve().absolute()
# self.BUNDLED_EXECUTION = False
self.quarto_path = "quarto"
# self.env_vars = os.environ.copy()
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
self.report.logger.info("running in a PyInstaller bundle")
# self.BUNDLED_EXECUTION = True
self.report.logger.debug("sys._MEIPASS: %s", sys._MEIPASS)
else:
self.report.logger.info("running in a normal Python process")
self.report.logger.debug("env_vars (QuartoReport): %s", os.environ)
self.report.logger.debug("PATH: %s", os.environ["PATH"])
self.report.logger.debug("sys.path: %s", sys.path)
self.is_report_static = self.report_type in {
r.ReportType.PDF,
r.ReportType.DOCX,
r.ReportType.ODT,
r.ReportType.PPTX,
}
self.components_fct_map = {
r.ComponentType.PLOT: self._generate_plot_content,
r.ComponentType.DATAFRAME: self._generate_dataframe_content,
r.ComponentType.MARKDOWN: self._generate_markdown_content,
r.ComponentType.HTML: self._generate_html_content,
}
[docs]
def generate_report(self, output_dir: Optional[Path] = None) -> None:
"""
Generates the qmd file of the quarto report. It creates code for rendering
each section and its subsections with all components.
Parameters
----------
output_dir : Path, optional
The folder where the generated report files will be saved.
Will overwrite value set on initialization of QuartoReportView.
"""
if output_dir is not None:
self.output_dir = Path(output_dir).resolve()
self.report.logger.debug(
"Generating '%s' report in directory: '%s'",
self.report_type,
self.output_dir,
)
# Create the output folder
if create_folder(self.output_dir, is_nested=True):
self.report.logger.debug("Created output directory: '%s'", self.output_dir)
else:
self.report.logger.debug(
"Output directory already existed: '%s'", self.output_dir
)
# Create the static folder
if create_folder(self.static_dir):
self.report.logger.info(
"Created output directory for static content: '%s'", self.static_dir
)
else:
self.report.logger.info(
"Output directory for static content already existed: '%s'",
self.static_dir,
)
try:
# Create variable to check if the report is static or revealjs
is_report_revealjs = self.report_type == r.ReportType.REVEALJS
# Define the YAML header for the quarto report
yaml_header = self._create_yaml_header()
# Create qmd content and imports for the report
qmd_content = []
# only one global import list for a single report (different to streamlit)
report_imports = []
# Add description of the report
if self.report.description:
qmd_content.append(self.report.description)
# If available add the graphical abstract
if self.report.graphical_abstract:
qmd_content.append(
self._generate_image_content(self.report.graphical_abstract)
)
# Add the sections and subsections to the report
self.report.logger.info("Starting to generate sections for the report.")
for section in self.report.sections:
self.report.logger.debug(
"Processing section: '%s' - %d subsection(s)",
section.title,
len(section.subsections),
)
# Add section header and description
qmd_content.append(f"# {section.title}")
if section.description:
qmd_content.append(f"""{section.description}\n""")
# Add components of section to the report
# ! description can be a Markdown component, but it is treated
# ! differently. It won't be added to the section content.
if section.components:
self.report.logger.debug(
"Adding components of section folder to the report."
)
section_content, section_imports = self._combine_components(
section.components
)
if section_content:
qmd_content.append(f"## Overview {section.title}".strip())
if is_report_revealjs:
# Add tabset for revealjs
section_content = [
"::: {.panel-tabset}\n",
*section_content,
":::",
]
qmd_content.extend(section_content)
report_imports.extend(section_imports)
if section.subsections:
# Iterate through subsections and
# integrate them into the section file
for subsection in section.subsections:
self.report.logger.debug(
"Processing subsection: '%s' - %d component(s)",
subsection.title,
len(subsection.components),
)
# Generate content for the subsection
subsection_content, subsection_imports = (
self._generate_subsection(
subsection,
is_report_revealjs,
)
)
qmd_content.extend(subsection_content)
report_imports.extend(
subsection_imports
) # even easier as it's global
else:
self.report.logger.warning(
"No subsections found in section: '%s'. To show content "
"in the report, add subsections to the section.",
section.title,
)
# Add globally set output folder
report_imports.append("from pathlib import Path")
report_imports.append("report_dir = Path().cwd()")
# Remove duplicated imports
report_unique_imports = set(report_imports)
# ! set leads to random import order
# ! separate and sort import statements, separate from setup code
report_unique_imports, setup_statements = sort_imports(
report_unique_imports
)
report_unique_imports += os.linesep
report_unique_imports.extend(setup_statements)
# Format imports
report_formatted_imports = "\n".join(report_unique_imports)
# Write the navigation and general content to a Python file
fname_qmd_report = self.output_dir / f"{self.BASE_DIR}.qmd"
with open(fname_qmd_report, "w", encoding="utf-8") as quarto_report:
quarto_report.write(yaml_header)
quarto_report.write(f"""\n```{{python}}
#| label: 'Imports'
{report_formatted_imports}
```\n\n""")
quarto_report.write("\n".join(qmd_content))
self.report.logger.info(
"Created qmd script to render the app: %s", fname_qmd_report
)
except Exception as e:
self.report.logger.error(
"An error occurred while generating the report: %s",
e,
exc_info=True,
)
raise
[docs]
def run_report(self, output_dir: Optional[Path] = None) -> None:
"""
Runs the generated quarto report.
Parameters
----------
output_dir : str, optional
The folder where the report was generated (default is 'sections').
"""
# from quarto_cli import run_quarto # entrypoint of quarto-cli not in module?
if output_dir is not None:
self.output_dir = Path(output_dir).resolve().absolute()
file_path_to_qmd = Path(self.output_dir) / f"{self.BASE_DIR}.qmd"
args = [self.quarto_path, "render", str(file_path_to_qmd)]
self.report.logger.info(
"Running '%s' '%s' report with %r",
self.report.title,
self.report_type,
args,
)
if (
self.report_type
in [
r.ReportType.PDF,
r.ReportType.DOCX,
r.ReportType.ODT,
]
and self.quarto_checks
):
subprocess.run(
[self.quarto_path, "install", "tinytex", "--no-prompt"],
check=True,
)
try:
subprocess.run(
args,
check=True,
)
if self.report_type == r.ReportType.REVEALJS:
out_path = file_path_to_qmd.with_name(
f"{file_path_to_qmd.stem}_revealjs.html"
)
elif self.report_type == r.ReportType.JUPYTER:
out_path = file_path_to_qmd.with_suffix(".html")
else:
out_path = file_path_to_qmd.with_suffix(f".{self.report_type.lower()}")
if not out_path.exists():
raise FileNotFoundError(f"Report file could not be created: {out_path}")
if self.report_type == r.ReportType.JUPYTER:
args = [self.quarto_path, "convert", str(file_path_to_qmd)]
subprocess.run(
args,
check=True,
)
self.report.logger.info(
"Converted '%s' '%s' report to Jupyter Notebook after execution",
self.report.title,
self.report_type,
)
self.report.logger.info(
"'%s' '%s' report rendered",
self.report.title,
self.report_type,
)
except subprocess.CalledProcessError as e:
self.report.logger.error(
"Error running '%s' %s report: %s",
self.report.title,
self.report_type,
e,
exc_info=True,
)
raise
def _create_yaml_header(self) -> str:
"""
Creates a YAML header for the Quarto report based on the specified eport type
and output format.
Returns
-------
str
A formatted YAML header string customized for the specified output format.
"""
# Base YAML header with title
yaml_header = textwrap.dedent(f"""\
---
title: {self.report.title}
fig-align: center
execute:
echo: false
output: asis
jupyter: python3
format:""")
# Define format-specific YAML configurations
# \u007b is { and \u007d is }
format_configs = {
r.ReportType.HTML: textwrap.dedent(f"""
html:
toc: true
toc-location: left
toc-depth: 3
page-layout: full
self-contained: true
include-in-header:
text: |
<style type="text/css">
.footer \u007b
position: relative;
left: 0;
width: 100%;
text-align: center;
margin-top: 20px;
\u007d
</style>
include-after-body:
text: |
<footer class="footer">
This report was generated with
<a href="{REPO_URL}" target="_blank">
<img src="{LOGO_URL}" alt="VueGen" width="65px">
</a>
| Copyright 2025 <a href="{GITHUB_ORG_URL}" target="_blank">
{ORG}</a>
</footer>"""),
# \u007b is { and \u007d is }
r.ReportType.PDF: textwrap.indent(
textwrap.dedent(f"""
pdf:
toc: false
fig-align: center
margin:
- bottom=40mm
include-in-header:
text: |
\\usepackage{{scrlayer-scrpage}}
\\usepackage{{hyperref}}
\\clearpairofpagestyles
\\lofoot\u007bThis report was generated with
\\href{{{REPO_URL}}}{{VueGen}} | \\copyright{{}} 2025
\\href{GITHUB_ORG_URL_BRACKETS}\u007b{ORG}\u007d\u007d
\\rofoot{{\\pagemark}}"""),
" ",
),
r.ReportType.DOCX: textwrap.indent(
textwrap.dedent("""
docx:
toc: false"""),
" ",
),
r.ReportType.ODT: textwrap.indent(
textwrap.dedent("""
odt:
toc: false"""),
" ",
),
r.ReportType.REVEALJS: textwrap.dedent(f"""
revealjs:
toc: false
smaller: true
controls: true
navigation-mode: vertical
controls-layout: bottom-right
output-file: quarto_report_revealjs.html
include-in-header:
text: |
<style type="text/css">
.footer \u007b
position: fixed;
left: 0;
bottom: 0;
width: 100%;
text-align: center;
\u007d
</style>
include-after-body:
text: |
<footer class="footer">
This report was generated with
<a href="{REPO_URL}" target="_blank">
<img src="{LOGO_URL}" alt="VueGen" width="65px">
</a>
| Copyright 2025 <a href="{GITHUB_ORG_URL}"
target="_blank">{ORG}</a>
</footer>"""),
r.ReportType.PPTX: textwrap.indent(
textwrap.dedent("""
pptx:
toc: false
output: true"""),
" ",
),
r.ReportType.JUPYTER: textwrap.dedent(f"""
html:
toc: true
toc-location: left
toc-depth: 3
page-layout: full
self-contained: true
include-in-header:
text: |
<style type="text/css">
.footer \u007b
position: relative;
left: 0;
width: 100%;
text-align: center;
margin-top: 20px;
\u007d
</style>
include-after-body:
text: |
<footer class="footer">
This report was generated with
<a href="{REPO_URL}" target="_blank">
<img src="{LOGO_URL}" alt="VueGen" width="65px">
</a>
| Copyright 2025 <a href="{GITHUB_ORG_URL}"
target="_blank">{ORG}</a>
</footer>"""),
}
# Create a key based on the report type and format
key = self.report_type
# Retrieve the configuration if it exists, or raise an error
if key in format_configs:
config = format_configs[key]
else:
raise ValueError(f"Unsupported report type: {self.report_type}")
# Add the specific configuration to the YAML header
yaml_header += config
yaml_header += "\n---\n"
return yaml_header
def _combine_components(self, components: list[dict]) -> tuple[list, list]:
"""combine a list of components."""
all_contents = []
all_imports = []
for component in components:
# Write imports if not already done
component_imports = self._generate_component_imports(component)
self.report.logger.debug("component_imports: %s", component_imports)
all_imports.extend(component_imports)
# Handle different types of components
fct = self.components_fct_map.get(component.component_type, None)
if fct is None:
self.report.logger.warning(
"Unsupported component type '%s'", component.component_type
)
elif (
component.component_type == r.ComponentType.MARKDOWN
and component.title.lower() == "description"
):
self.report.logger.debug("Skipping description.md markdown of section.")
elif (
component.component_type == r.ComponentType.HTML
and self.is_report_static
):
self.report.logger.debug("Skipping HTML component for static report.")
else:
content = fct(component)
all_contents.extend(content)
# remove duplicates
all_imports = list(set(all_imports))
return all_contents, all_imports
def _generate_subsection(
self,
subsection,
is_report_revealjs,
) -> tuple[List[str], List[str]]:
"""
Generate code to render components (plots, dataframes, markdown) in the given
subsection, creating imports and content for the subsection based on the
component type.
Parameters
----------
subsection : Subsection
The subsection containing the components.
is_report_revealjs : bool
A boolean indicating whether the report is in revealjs format.
Returns
-------
tuple : (List[str], List[str])
- list of subsection content lines (List[str])
- list of imports for the subsection (List[str])
"""
subsection_content = []
# Add subsection header and description
subsection_content.append(f"## {subsection.title}")
if subsection.description:
subsection_content.append(f"""{subsection.description}\n""")
if is_report_revealjs:
subsection_content.append("::: {.panel-tabset}\n")
(
all_components,
subsection_imports,
) = self._combine_components(subsection.components)
subsection_content.extend(all_components)
if is_report_revealjs:
subsection_content.append(":::\n")
self.report.logger.info(
"Generated content and imports for subsection: '%s'", subsection.title
)
return subsection_content, subsection_imports
def _generate_plot_content(self, plot) -> List[str]:
"""
Generate content for a plot component based on the report type.
Parameters
----------
plot : Plot
The plot component to generate content for.
Returns
-------
list : List[str]
The list of content lines for the plot.
"""
plot_content = []
# Add title
plot_content.append(f"### {plot.title}")
# Define plot path
if self.is_report_static:
# ? should that be in the output folder
static_plot_path = (
Path(self.static_dir) / f"{plot.title.replace(' ', '_')}.png"
).resolve()
self.report.logger.debug("Static plot path: %s", static_plot_path)
else:
html_plot_file = (
Path(self.static_dir) / f"{plot.title.replace(' ', '_')}.html"
).absolute()
# Add content for the different plot types
try:
if plot.plot_type == r.PlotType.STATIC:
plot_content.append(
self._generate_image_content(plot.file_path, width="90%")
)
elif plot.plot_type == r.PlotType.PLOTLY:
plot_content.append(self._generate_plot_code(plot))
if self.is_report_static:
fpath = static_plot_path.relative_to(self.output_dir).as_posix()
plot_content.append(f"""fig_plotly.write_image("{fpath}")\n```\n""")
plot_content.append(self._generate_image_content(static_plot_path))
else:
plot_content.append("""fig_plotly.show()\n```\n""")
elif plot.plot_type == r.PlotType.ALTAIR:
plot_content.append(self._generate_plot_code(plot))
if self.is_report_static:
fpath = static_plot_path.relative_to(self.output_dir).as_posix()
plot_content.append(f"""fig_altair.save("{fpath}")\n```\n""")
plot_content.append(self._generate_image_content(static_plot_path))
else:
plot_content.append("""fig_altair\n```\n""")
elif plot.plot_type == r.PlotType.INTERACTIVE_NETWORK:
networkx_graph = plot.read_network()
if isinstance(networkx_graph, tuple):
# If network_data is a tuple,
# separate the network and html file path
networkx_graph, html_plot_file = networkx_graph
elif isinstance(networkx_graph, nx.Graph) and not self.is_report_static:
# Get the pyvis object and create html
_ = plot.create_and_save_pyvis_network(
networkx_graph, html_plot_file
)
# Add number of nodes and edges to the plot content
num_nodes = networkx_graph.number_of_nodes()
num_edges = networkx_graph.number_of_edges()
plot_content.append(f"**Number of nodes:** {num_nodes}\n")
plot_content.append(f"**Number of edges:** {num_edges}\n")
# Add code to generate network depending on the report type
if self.is_report_static:
plot.save_network_image(networkx_graph, static_plot_path, "png")
plot_content.append(self._generate_image_content(static_plot_path))
else:
plot_content.append(self._generate_plot_code(plot, html_plot_file))
else:
self.report.logger.warning("Unsupported plot type: %s", plot.plot_type)
except Exception as e:
self.report.logger.error(
"Error generating content for '%s' plot '%s' '%s': %s",
plot.plot_type,
plot.id,
plot.title,
e,
exc_info=True,
)
raise
# Add caption if available
if plot.caption:
plot_content.append(f">{plot.caption}\n")
self.report.logger.info(
"Successfully generated content for plot: '%s'", plot.title
)
return plot_content
def _generate_plot_code(self, plot, output_file="") -> str:
"""
Create the plot code based on its visualization tool.
Parameters
----------
plot : Plot
The plot component to generate the code template for.
output_file: str, optional
The output html file name to be displayed with a pyvis plot.
Returns
-------
str
The generated plot code as a string.
"""
# Initialize plot code with common structure
plot_code = textwrap.dedent(f"""
```{{python}}
#| label: '{plot.title} {plot.id}'
#| fig-cap: ""
""")
# If the file path is a URL, generate code to fetch content via requests
if is_url(plot.file_path):
plot_code += textwrap.dedent(f"""
response = requests.get('{plot.file_path}')
response.raise_for_status()
plot_json = response.text
""")
else: # If it's a local file
plot_rel_path = get_relative_file_path(
plot.file_path, relative_to=self.output_dir
).as_posix()
plot_code += textwrap.dedent(f"""
with open(report_dir /'{plot_rel_path}', 'r') as plot_file:
plot_json = json.load(plot_file)
""")
# Add specific code for each visualization tool
if plot.plot_type == r.PlotType.PLOTLY:
plot_code += textwrap.dedent("""
# Keep only 'data' and 'layout' sections
plot_json = {key: plot_json[key] for key in plot_json
if key in ['data', 'layout']
}
# Remove 'frame' section in 'data'
plot_json['data'] = [{k: v for k, v in entry.items() if k != 'frame'}
for entry in plot_json.get('data', [])
]
# Convert JSON to string
plot_json_str = json.dumps(plot_json)
# Create the plotly plot
fig_plotly = pio.from_json(plot_json_str)
fig_plotly.update_layout(autosize=False, width=950, height=400,
margin=dict(b=50, t=50, l=50, r=50)
)
""")
elif plot.plot_type == r.PlotType.ALTAIR:
plot_code += textwrap.dedent("""
# Convert JSON to string
plot_json_str = json.dumps(plot_json)
# Create the altair plot
fig_altair = alt.Chart.from_json(plot_json_str
).properties(width=900, height=370)
""")
elif plot.plot_type == r.PlotType.INTERACTIVE_NETWORK:
# Generate the HTML embedding for interactive networks
if is_url(plot.file_path) and plot.file_path.endswith(".html"):
iframe_src = output_file
else:
iframe_src = get_relative_file_path(
output_file, relative_to=self.output_dir
)
# Embed the HTML file in an iframe
plot_code = textwrap.dedent(f"""
<div style="text-align: center;">
<iframe src="{iframe_src}" alt="{plot.title} plot"
width="800px" height="630px">
</iframe>
</div>
""")
return plot_code
def _generate_dataframe_content(self, dataframe) -> List[str]:
"""
Generate content for a DataFrame component based on the report type.
Parameters
----------
dataframe : DataFrame
The dataframe component to add to content.
Returns
-------
list : List[str]
The list of content lines for the DataFrame.
"""
dataframe_content = []
# Add title
dataframe_content.append(f"### {dataframe.title}")
# Append header for DataFrame loading
dataframe_content.append(textwrap.dedent(f"""\
```{{python}}
#| label: '{dataframe.title} {dataframe.id}'
#| fig-cap: ""
"""))
# Mapping of file extensions to read functions
read_function_mapping = table_utils.read_function_mapping
try:
# Check if the file extension matches any DataFrameFormat value
file_extension = Path(dataframe.file_path).suffix.lower()
if not any(
file_extension == fmt.value_with_dot for fmt in r.DataFrameFormat
):
self.report.logger.error(
"Unsupported file extension: %s. Supported extensions are: %s.",
file_extension,
", ".join(fmt.value for fmt in r.DataFrameFormat),
)
# Build the file path (URL or local file)
if is_url(dataframe.file_path):
df_file_path = dataframe.file_path
else:
df_file_path = get_relative_file_path(
dataframe.file_path,
)
sheet_names = None
# If the file is an Excel file, get the sheet names
if file_extension in [
r.DataFrameFormat.XLS.value_with_dot,
r.DataFrameFormat.XLSX.value_with_dot,
]:
sheet_names = table_utils.get_sheet_names(df_file_path)
if len(sheet_names) > 1:
# If there are multiple sheets, use the first one
self.report.logger.info(
"Multiple sheets found in the Excel file: %s. Sheets: %s",
df_file_path,
sheet_names,
)
else:
sheet_names = None
# Build the file path (URL or local file)
if is_url(dataframe.file_path):
df_file_path = dataframe.file_path
else:
df_file_path = get_relative_file_path(
dataframe.file_path, relative_to=self.output_dir
).as_posix()
# Load the DataFrame using the correct function
read_function = read_function_mapping[file_extension]
dataframe_content.append(
f"""df = pd.{read_function.__name__}(report_dir / '{df_file_path}')\n"""
)
# Display the dataframe
dataframe_content.extend(self._show_dataframe(dataframe))
# Add further sheets
if sheet_names:
for sheet_name in sheet_names[1:]:
dataframe_content.append(f"#### {sheet_name}")
dataframe_content.append(textwrap.dedent(f"""\
```{{python}}
#| label: '{dataframe.title} {dataframe.id} {sheet_name}'
#| fig-cap: ""
"""))
dataframe_content.append(
f"df = pd.{read_function.__name__}"
f"(report_dir / '{df_file_path}', "
f"sheet_name='{sheet_name}')\n"
)
# Display the dataframe
dataframe_content.extend(
self._show_dataframe(dataframe, suffix=sheet_name)
)
except Exception as e:
self.report.logger.error(
"Error generating content for DataFrame: %s. Error: %s",
dataframe.title,
e,
exc_info=True,
)
raise
# Add caption if available
# ? Where should this come from?
if dataframe.caption:
dataframe_content.append(f">{dataframe.caption}\n")
self.report.logger.info(
"Successfully generated content for DataFrame: '%s'", dataframe.title
)
return dataframe_content
def _generate_markdown_content(self, markdown) -> List[str]:
"""
Adds markdown content to the report.
Parameters
----------
markdown : Markdown
The markdown component to add to content.
Returns
-------
list : List[str]
The list of content lines for the markdown.
"""
markdown_content = []
# Add title
markdown_content.append(f"### {markdown.title}")
try:
# Initialize md code with common structure
markdown_content.append(textwrap.dedent(f"""
```{{python}}
#| label: '{markdown.title} {markdown.id}'
#| fig-cap: ""
"""))
# If the file path is a URL, generate code to fetch content via requests
if is_url(markdown.file_path):
markdown_content.append(textwrap.dedent(f"""\
response = requests.get('{markdown.file_path}')
response.raise_for_status()
markdown_content = response.text
"""))
else: # If it's a local file
md_rel_path = get_relative_file_path(
markdown.file_path, relative_to=self.output_dir
)
markdown_content.append(f"""
with open(report_dir / '{md_rel_path.as_posix()}', 'r') as markdown_file:
markdown_content = markdown_file.read()\n""")
# Code to display md content
markdown_content.append("""display.Markdown(markdown_content)\n```\n""")
except Exception as e:
self.report.logger.error(
"Error generating content for Markdown: %s. Error: %s",
markdown.title,
e,
exc_info=True,
)
raise
# Add caption if available
if markdown.caption:
markdown_content.append(f">{markdown.caption}\n")
self.report.logger.info(
"Successfully generated content for Markdown: '%s'", markdown.title
)
return markdown_content
def _show_dataframe(self, dataframe, suffix: Optional[str] = None) -> List[str]:
"""
Appends either a static image or an interactive representation of a DataFrame
to the content list.
Parameters
----------
dataframe : DataFrame
The DataFrame object containing the data to display.
suffix : str, optional
A suffix to append to the DataFrame image file name like a sheet name
or another identifier (default is None).
Returns
-------
list : List[str]
The list of content lines for the DataFrame.
"""
dataframe_content = []
if self.is_report_static:
# Generate path for the DataFrame image
fpath_df_image = Path(self.static_dir) / dataframe.title.replace(" ", "_")
if suffix:
fpath_df_image = fpath_df_image.with_stem(
fpath_df_image.stem + f"_{suffix.replace(' ', '_')}"
)
fpath_df_image = fpath_df_image.with_suffix(".png")
fpath_df_image_rel_static = get_relative_file_path(
fpath_df_image, relative_to=self.output_dir
)
dataframe_content.append(
f"df.dfi.export('{fpath_df_image_rel_static}',"
" max_rows=10, max_cols=5, table_conversion='matplotlib')\n```\n"
)
# Use helper method to add centered image content
dataframe_content.append(self._generate_image_content(fpath_df_image))
else:
# Append code to display the DataFrame interactively
dataframe_content.append(
'show(df, classes="display nowrap compact", '
"lengthMenu=[3, 5, 10])\n```\n"
)
return dataframe_content
def _generate_html_content(self, html) -> List[str]:
"""
Adds an HTML component to the report.
Parameters
----------
html : Html
The HTML component to add to the report. This could be a local file path
or a URL.
Returns
-------
list : List[str]
The list of content lines for embedding the HTML.
"""
html_content = []
# Add title
html_content.append(f"### {html.title}")
try:
# Embed the HTML in an iframe
if is_url(html.file_path):
html_file_path = html.file_path
else:
html_file_path = get_relative_file_path(
html.file_path, relative_to=self.output_dir
)
iframe_code = textwrap.dedent(f"""
<div style="text-align: center;">
<iframe src="{html_file_path.as_posix()}" alt="{html.title}"
width="950px" height="530px"></iframe>
</div>
""")
html_content.append(iframe_code)
except Exception as e:
self.report.logger.error(
"Error generating content for HTML: %s. Error: %s",
html.title,
e,
exc_info=True,
)
raise
self.report.logger.info(
"Successfully generated content for HTML: '%s'", html.title
)
return html_content
def _generate_image_content(
self, image_path: str, alt_text: str = "", width: str = "90%"
) -> str:
"""
Adds an image to the content list in an HTML format with a specified width
and height.
Parameters
----------
image_path : str
Path to the image file or a URL to the image.
width : int, optional
Width of the image in pixels (default is 650).
height : int, optional
Height of the image in pixels (default is 400).
alt_text : str, optional
Alternative text for the image (default is an empty string).
Returns
-------
str
The formatted image content.
"""
if is_url(image_path):
src = image_path
else:
src = get_relative_file_path(
image_path, relative_to=self.output_dir
).as_posix()
return f"""{{fig-alt={alt_text} width={width}}}\n"""
def _generate_component_imports(self, component: r.Component) -> List[str]:
"""
Generate necessary imports for a component of the report.
Parameters
----------
component : r.Component
The component for which to generate the required imports.
The component can be of type:
- PLOT
- DATAFRAME
- MARKDOWN
Returns
-------
list : List[str]
A list of import statements for the component.
"""
# Dictionary to hold the imports for each component type
components_imports = {
"plot": {
r.PlotType.ALTAIR: [
"import altair as alt",
"import requests",
"import json",
],
r.PlotType.PLOTLY: [
"import plotly.io as pio",
"import requests",
"import json",
],
},
"static_dataframe": [
"import pandas as pd",
"import dataframe_image as dfi",
],
"interactive_dataframe": [
"import pandas as pd",
"from itables import show, init_notebook_mode",
"init_notebook_mode(all_interactive=True)",
],
"markdown": ["import IPython.display as display", "import requests"],
}
# Iterate over sections and subsections to determine needed imports
component_type = component.component_type
component_imports = []
# Add relevant imports based on component type and visualization tool
if component_type == r.ComponentType.PLOT:
plot_type = getattr(component, "plot_type", None)
if plot_type in components_imports["plot"]:
component_imports.extend(components_imports["plot"][plot_type])
elif component_type == r.ComponentType.DATAFRAME:
if self.is_report_static:
component_imports.extend(components_imports["static_dataframe"])
else:
component_imports.extend(components_imports["interactive_dataframe"])
elif component_type == r.ComponentType.MARKDOWN:
component_imports.extend(components_imports["markdown"])
# Return the list of import statements
return component_imports