diff --git a/src/vuegen/config_manager.py b/src/vuegen/config_manager.py index dac12b4..25fa86a 100644 --- a/src/vuegen/config_manager.py +++ b/src/vuegen/config_manager.py @@ -145,7 +145,7 @@ def _create_component_config_fromfile(self, file_path: Path) -> Dict[str, str]: component_config["component_type"] = r.ComponentType.MARKDOWN.value else: self.logger.error( - f"Unsupported file extension: {file_ext}. Skipping file: {file_path}\n" + f"Unsupported file extension: {file_ext}. Skipping file: {file_path}" ) return None @@ -227,6 +227,9 @@ def _create_subsect_config_fromdir( continue # Add component config to list components.append(component_config) + # ! if folder go into folder and pull files out? + # nesting level already at point 2 + # loop of components in a folder subsection_config = { "title": self._create_title_fromdir(subsection_dir_path.name), @@ -257,14 +260,25 @@ def _create_sect_config_fromdir( ) subsections = [] + components = [] for subsection_dir in sorted_subsections: if subsection_dir.is_dir(): subsections.append(self._create_subsect_config_fromdir(subsection_dir)) + else: + file_in_subsection_dir = ( + subsection_dir # ! maybe take more generic names? + ) + component_config = self._create_component_config_fromfile( + file_in_subsection_dir + ) + if component_config is not None: + components.append(component_config) section_config = { "title": self._create_title_fromdir(section_dir_path.name), "description": self._read_description_file(section_dir_path), "subsections": subsections, + "components": components, } return section_config @@ -301,12 +315,27 @@ def create_yamlconfig_fromdir( # Sort sections by their number prefix sorted_sections = self._sort_paths_by_numprefix(list(base_dir_path.iterdir())) + main_section_config = { + "title": "", # self._create_title_fromdir("home_components"), + "description": "Components added to homepage.", + "components": [], + } + yaml_config["sections"].append(main_section_config) + # Generate sections and subsections config for section_dir in sorted_sections: if section_dir.is_dir(): yaml_config["sections"].append( self._create_sect_config_fromdir(section_dir) ) + # could be single plots? + else: + file_in_main_section_dir = section_dir + component_config = self._create_component_config_fromfile( + file_in_main_section_dir + ) + if component_config is not None: + main_section_config["components"].append(component_config) return yaml_config, base_dir_path @@ -372,6 +401,10 @@ def _create_section(self, section_data: dict) -> r.Section: description=section_data.get("description"), ) + for component_data in section_data.get("components", []): + component = self._create_component(component_data) + section.components.append(component) + # Create subsections for subsection_data in section_data.get("subsections", []): subsection = self._create_subsection(subsection_data) diff --git a/src/vuegen/quarto_reportview.py b/src/vuegen/quarto_reportview.py index 9595f95..af18eff 100644 --- a/src/vuegen/quarto_reportview.py +++ b/src/vuegen/quarto_reportview.py @@ -1,4 +1,3 @@ -import logging import os import subprocess import sys @@ -25,15 +24,31 @@ def __init__( report: r.Report, report_type: r.ReportType, quarto_checks: bool = False, + 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.BUNDLED_EXECUTION = False + self.static_dir = static_dir + # 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.BUNDLED_EXECUTION = True self.report.logger.debug(f"sys._MEIPASS: {sys._MEIPASS}") else: self.report.logger.info("running in a normal Python process") @@ -42,9 +57,21 @@ def __init__( self.report.logger.debug(f"PATH: {os.environ['PATH']}") self.report.logger.debug(f"sys.path: {sys.path}") - def generate_report( - self, output_dir: Path = BASE_DIR, static_dir: Path = STATIC_FILES_DIR - ) -> None: + 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, + } + + def generate_report(self, output_dir: Path = BASE_DIR) -> None: """ Generates the qmd file of the quarto report. It creates code for rendering each section and its subsections with all components. @@ -52,8 +79,6 @@ def generate_report( ---------- output_dir : Path, optional The folder where the generated report files will be saved (default is BASE_DIR). - static_dir : Path, optional - The folder where the static files will be saved (default is STATIC_FILES_DIR). """ self.report.logger.debug( f"Generating '{self.report_type}' report in directory: '{output_dir}'" @@ -68,23 +93,17 @@ def generate_report( ) # Create the static folder - if create_folder(static_dir): + if create_folder(self.static_dir): self.report.logger.info( - f"Created output directory for static content: '{static_dir}'" + f"Created output directory for static content: '{self.static_dir}'" ) else: self.report.logger.info( - f"Output directory for static content already existed: '{static_dir}'" + f"Output directory for static content already existed: '{self.static_dir}'" ) try: # Create variable to check if the report is static or revealjs - is_report_static = self.report_type in { - r.ReportType.PDF, - r.ReportType.DOCX, - r.ReportType.ODT, - r.ReportType.PPTX, - } is_report_revealjs = self.report_type == r.ReportType.REVEALJS # Define the YAML header for the quarto report @@ -92,7 +111,9 @@ def generate_report( # Create qmd content and imports for the report qmd_content = [] - report_imports = [] + report_imports = ( + [] + ) # only one global import list for a single report (different to streamlit) # Add description of the report if self.report.description: @@ -103,9 +124,35 @@ def generate_report( qmd_content.append( self._generate_image_content(self.report.graphical_abstract) ) + # ? Do we need to handle overview separately? + main_section = self.report.sections[0] + + # ! description can be a Markdown component, but it is treated differently + # ! It won't be added to the section content. + if main_section.components: + self.report.logger.debug( + "Adding components of main section folder to the report as overall overview." + ) + section_content, section_imports = self._combine_components( + main_section.components + ) + if section_content: + qmd_content.append("# General Overview") + + 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) + # 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: + for section in self.report.sections[1:]: self.report.logger.debug( f"Processing section: '{section.title}' - {len(section.subsections)} subsection(s)" ) @@ -114,6 +161,30 @@ def generate_report( 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: @@ -124,25 +195,20 @@ def generate_report( subsection_content, subsection_imports = ( self._generate_subsection( subsection, - is_report_static, is_report_revealjs, - static_dir=static_dir, ) ) qmd_content.extend(subsection_content) - report_imports.extend(subsection_imports) + report_imports.extend( + subsection_imports + ) # even easier as it's global else: self.report.logger.warning( f"No subsections found in section: '{section.title}'. To show content in the report, add subsections to the section." ) - # Flatten the subsection_imports into a single list - flattened_report_imports = [ - imp for sublist in report_imports for imp in sublist - ] - # Remove duplicated imports - report_unique_imports = set(flattened_report_imports) + report_unique_imports = set(report_imports) # ! set leads to random import order # ! separate and sort import statements, separate from setup code @@ -389,12 +455,45 @@ def _create_yaml_header(self) -> str: 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( + f"Unsupported component type '{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_static, is_report_revealjs, - static_dir: str, ) -> tuple[List[str], List[str]]: """ Generate code to render components (plots, dataframes, markdown) in the given subsection, @@ -404,12 +503,9 @@ def _generate_subsection( ---------- subsection : Subsection The subsection containing the components. - is_report_static : bool - A boolean indicating whether the report is static or interactive. is_report_revealjs : bool A boolean indicating whether the report is in revealjs format. - static_dir : str - The folder where the static files will be saved. + Returns ------- tuple : (List[str], List[str]) @@ -417,7 +513,6 @@ def _generate_subsection( - list of imports for the subsection (List[str]) """ subsection_content = [] - subsection_imports = [] # Add subsection header and description subsection_content.append(f"## {subsection.title}") @@ -425,38 +520,13 @@ def _generate_subsection( subsection_content.append(f"""{subsection.description}\n""") if is_report_revealjs: - subsection_content.append(f"::: {{.panel-tabset}}\n") + subsection_content.append("::: {.panel-tabset}\n") - for component in subsection.components: - component_imports = self._generate_component_imports(component) - subsection_imports.append(component_imports) - - if component.component_type == r.ComponentType.PLOT: - subsection_content.extend( - self._generate_plot_content( - component, is_report_static, static_dir=static_dir - ) - ) - elif component.component_type == r.ComponentType.DATAFRAME: - subsection_content.extend( - self._generate_dataframe_content( - component, is_report_static, static_dir=static_dir - ) - ) - elif ( - component.component_type == r.ComponentType.MARKDOWN - and component.title.lower() != "description" - ): - subsection_content.extend(self._generate_markdown_content(component)) - elif ( - component.component_type == r.ComponentType.HTML - and not is_report_static - ): - subsection_content.extend(self._generate_html_content(component)) - else: - self.report.logger.warning( - f"Unsupported component type '{component.component_type}' in subsection: {subsection.title}" - ) + ( + all_components, + subsection_imports, + ) = self._combine_components(subsection.components) + subsection_content.extend(all_components) if is_report_revealjs: subsection_content.append(":::\n") @@ -466,9 +536,7 @@ def _generate_subsection( ) return subsection_content, subsection_imports - def _generate_plot_content( - self, plot, is_report_static, static_dir: str - ) -> List[str]: + def _generate_plot_content(self, plot) -> List[str]: """ Generate content for a plot component based on the report type. @@ -476,8 +544,6 @@ def _generate_plot_content( ---------- plot : Plot The plot component to generate content for. - static_dir : str - The folder where the static files will be saved. Returns ------- @@ -489,10 +555,14 @@ def _generate_plot_content( plot_content.append(f"### {plot.title}") # Define plot path - if is_report_static: - static_plot_path = Path(static_dir) / f"{plot.title.replace(' ', '_')}.png" + if self.is_report_static: + static_plot_path = ( + Path(self.static_dir) / f"{plot.title.replace(' ', '_')}.png" + ) else: - html_plot_file = Path(static_dir) / f"{plot.title.replace(' ', '_')}.html" + html_plot_file = ( + Path(self.static_dir) / f"{plot.title.replace(' ', '_')}.html" + ) # Add content for the different plot types try: @@ -502,7 +572,7 @@ def _generate_plot_content( ) elif plot.plot_type == r.PlotType.PLOTLY: plot_content.append(self._generate_plot_code(plot)) - if is_report_static: + if self.is_report_static: plot_content.append( f"""fig_plotly.write_image("{static_plot_path.resolve().as_posix()}")\n```\n""" ) @@ -511,7 +581,7 @@ def _generate_plot_content( plot_content.append(f"""fig_plotly.show()\n```\n""") elif plot.plot_type == r.PlotType.ALTAIR: plot_content.append(self._generate_plot_code(plot)) - if is_report_static: + if self.is_report_static: plot_content.append( f"""fig_altair.save("{static_plot_path.resolve().as_posix()}")\n```\n""" ) @@ -523,7 +593,7 @@ def _generate_plot_content( 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 is_report_static: + elif isinstance(networkx_graph, nx.Graph) and not self.is_report_static: # Get the pyvis object and create html pyvis_graph = plot.create_and_save_pyvis_network( networkx_graph, html_plot_file @@ -536,7 +606,7 @@ def _generate_plot_content( plot_content.append(f"**Number of edges:** {num_edges}\n") # Add code to generate network depending on the report type - if is_report_static: + 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: @@ -620,9 +690,7 @@ def _generate_plot_code(self, plot, output_file="") -> str: \n""" return plot_code - def _generate_dataframe_content( - self, dataframe, is_report_static, static_dir: str - ) -> List[str]: + def _generate_dataframe_content(self, dataframe) -> List[str]: """ Generate content for a DataFrame component based on the report type. @@ -630,10 +698,6 @@ def _generate_dataframe_content( ---------- dataframe : DataFrame The dataframe component to add to content. - is_report_static : bool - A boolean indicating whether the report is static or interactive. - static_dir : str - The folder where the static files will be saved. Returns ------- @@ -683,9 +747,7 @@ def _generate_dataframe_content( ) # Display the dataframe - dataframe_content.extend( - self._show_dataframe(dataframe, is_report_static, static_dir=static_dir) - ) + dataframe_content.extend(self._show_dataframe(dataframe)) except Exception as e: self.report.logger.error( @@ -836,9 +898,7 @@ def _generate_image_content( f"""![](/{src}){{fig-alt={alt_text} width={width} height={height}}}\n""" ) - def _show_dataframe( - self, dataframe, is_report_static, static_dir: str - ) -> List[str]: + def _show_dataframe(self, dataframe) -> List[str]: """ Appends either a static image or an interactive representation of a DataFrame to the content list. @@ -846,10 +906,6 @@ def _show_dataframe( ---------- dataframe : DataFrame The DataFrame object containing the data to display. - is_report_static : bool - Determines if the report is in a static format (e.g., PDF) or interactive (e.g., HTML). - static_dir : str - The folder where the static files will be saved. Returns ------- @@ -857,9 +913,11 @@ def _show_dataframe( The list of content lines for the DataFrame. """ dataframe_content = [] - if is_report_static: + if self.is_report_static: # Generate path for the DataFrame image - df_image = Path(static_dir) / f"{dataframe.title.replace(' ', '_')}.png" + df_image = ( + Path(self.static_dir) / f"{dataframe.title.replace(' ', '_')}.png" + ) dataframe_content.append( f"df.dfi.export('{Path(df_image).resolve().as_posix()}', max_rows=10, max_cols=5, table_conversion='matplotlib')\n```\n" ) diff --git a/src/vuegen/report.py b/src/vuegen/report.py index 147ec14..c3f6625 100644 --- a/src/vuegen/report.py +++ b/src/vuegen/report.py @@ -678,6 +678,9 @@ class Subsection: A list of components within the subsection. description : str, optional A description of the subsection (default is None). + file_path : str, optional + Relative file path to the section file in sections folder. + Used for building reports (default is None). """ _id_counter: ClassVar[int] = 0 @@ -685,6 +688,7 @@ class Subsection: title: str components: List["Component"] = field(default_factory=list) description: Optional[str] = None + file_path: Optional[str] = None def __post_init__(self): self.id = self._generate_id() @@ -695,6 +699,7 @@ def _generate_id(cls) -> int: return cls._id_counter +# ? Section is a subclass of Subsection (adding subsections). Destinction might not be necessary @dataclass class Section: """ @@ -710,15 +715,22 @@ class Section: Title of the section. subsections : List[Subsection] A list of subsections within the section. + components : List[Component] + A list of components within the subsection. description : str, optional A description of the section (default is None). + file_path : str, optional + Relative file path to the section file in sections folder. + Used for building reports (default is None). """ _id_counter: ClassVar[int] = 0 id: int = field(init=False) title: str subsections: List["Subsection"] = field(default_factory=list) + components: List["Component"] = field(default_factory=list) description: Optional[str] = None + file_path: Optional[str] = None def __post_init__(self): self.id = self._generate_id() diff --git a/src/vuegen/report_generator.py b/src/vuegen/report_generator.py index 4eedf19..7708571 100644 --- a/src/vuegen/report_generator.py +++ b/src/vuegen/report_generator.py @@ -81,9 +81,12 @@ def get_report( sections_dir = report_dir / "sections" static_files_dir = report_dir / "static" st_report = StreamlitReportView( - report=report, report_type=report_type, streamlit_autorun=streamlit_autorun + report=report, + report_type=report_type, + streamlit_autorun=streamlit_autorun, + static_dir=static_files_dir, ) - st_report.generate_report(output_dir=sections_dir, static_dir=static_files_dir) + st_report.generate_report(output_dir=sections_dir) st_report.run_report(output_dir=sections_dir) else: # Check if Quarto is installed @@ -99,11 +102,12 @@ def get_report( report_dir = output_dir / "quarto_report" static_files_dir = report_dir / "static" quarto_report = QuartoReportView( - report=report, report_type=report_type, quarto_checks=quarto_checks - ) - quarto_report.generate_report( - output_dir=report_dir, static_dir=static_files_dir + report=report, + report_type=report_type, + quarto_checks=quarto_checks, + static_dir=static_files_dir, ) + quarto_report.generate_report(output_dir=report_dir) quarto_report.run_report(output_dir=report_dir) # ? Could be also the path to the report file for quarto based reports return report_dir, config_path diff --git a/src/vuegen/streamlit_reportview.py b/src/vuegen/streamlit_reportview.py index a91314c..2e568fe 100644 --- a/src/vuegen/streamlit_reportview.py +++ b/src/vuegen/streamlit_reportview.py @@ -13,6 +13,15 @@ from .utils.variables import make_valid_identifier +def write_python_file(fpath: str, imports: list[str], contents: list[str]) -> None: + with open(fpath, "w", encoding="utf8") as f: + # Write imports at the top of the file + f.write("\n".join(imports) + "\n\n") + + # Write the subsection content (descriptions, plots) + f.write("\n".join(contents)) + + class StreamlitReportView(r.WebAppReportView): """ A Streamlit-based implementation of the WebAppReportView abstract base class. @@ -28,7 +37,22 @@ def __init__( report: r.Report, report_type: r.ReportType, streamlit_autorun: bool = False, + static_dir: str = STATIC_FILES_DIR, ): + """Initialize ReportView with the report and report type. + + 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. + streamlit_autorun : bool, optional + Wheather streamlit should be started after report generation, by default False + static_dir : str, optional + The folder where the static files will be saved, by default STATIC_FILES_DIR. + """ super().__init__(report=report, report_type=report_type) self.streamlit_autorun = streamlit_autorun self.BUNDLED_EXECUTION = False @@ -38,9 +62,18 @@ def __init__( else: self.report.logger.info("running in a normal Python process") - def generate_report( - self, output_dir: str = SECTIONS_DIR, static_dir: str = STATIC_FILES_DIR - ) -> None: + 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, + r.ComponentType.APICALL: self._generate_apicall_content, + r.ComponentType.CHATBOT: self._generate_chatbot_content, + } + + self.static_dir = static_dir + + def generate_report(self, output_dir: str = SECTIONS_DIR) -> None: """ Generates the Streamlit report and creates Python files for each section and its subsections and plots. @@ -48,8 +81,6 @@ def generate_report( ---------- output_dir : str, optional The folder where the generated report files will be saved (default is SECTIONS_DIR). - static_dir : str, optional - The folder where the static files will be saved (default is STATIC_FILES_DIR). """ self.report.logger.debug( f"Generating '{self.report_type}' report in directory: '{output_dir}'" @@ -62,13 +93,13 @@ def generate_report( self.report.logger.info(f"Output directory already existed: '{output_dir}'") # Create the static folder - if create_folder(static_dir): + if create_folder(self.static_dir): self.report.logger.info( - f"Created output directory for static content: '{static_dir}'" + f"Created output directory for static content: '{self.static_dir}'" ) else: self.report.logger.info( - f"Output directory for static content already existed: '{static_dir}'" + f"Output directory for static content already existed: '{self.static_dir}'" ) try: @@ -113,14 +144,19 @@ def generate_report( report_manag_content.append("\nsections_pages = {}") # Generate the home page and update the report manager content + # ! top level files (compontents) are added to the home page self._generate_home_section( - output_dir=output_dir, report_manag_content=report_manag_content + output_dir=output_dir, + report_manag_content=report_manag_content, + home_section=self.report.sections[0], ) - for section in self.report.sections: + for section in self.report.sections[1:]: # skip home section components # Create a folder for each section subsection_page_vars = [] - section_name_var = section.title.replace(" ", "_") + section_name_var = make_valid_identifier( + section.title.replace(" ", "_") + ) section_dir_path = Path(output_dir) / section_name_var if create_folder(section_dir_path): @@ -131,6 +167,18 @@ def generate_report( self.report.logger.debug( f"Section directory already existed: {section_dir_path}" ) + # add an overview page to section of components exist + if section.components: + subsection_file_path = ( + Path(section_name_var) + / f"0_overview_{make_valid_identifier(section.title).lower()}.py" + ).as_posix() # Make sure it's Posix Paths + section.file_path = subsection_file_path + # Create a Page object for each subsection and add it to the home page content + report_manag_content.append( + f"{section_name_var}_overview = st.Page('{subsection_file_path}', title='Overview {section.title}')" + ) + subsection_page_vars.append(f"{section_name_var}_overview") for subsection in section.subsections: # ! could add a non-integer to ensure it's a valid identifier @@ -145,7 +193,7 @@ def generate_report( subsection_file_path = ( Path(section_name_var) / f"{subsection_name_var}.py" ).as_posix() # Make sure it's Posix Paths - + subsection.file_path = subsection_file_path # Create a Page object for each subsection and add it to the home page content report_manag_content.append( f"{subsection_name_var} = st.Page('{subsection_file_path}', title='{subsection.title}')" @@ -189,7 +237,7 @@ def generate_report( ) # Create Python files for each section and its subsections and plots - self._generate_sections(output_dir=output_dir, static_dir=static_dir) + self._generate_sections(output_dir=output_dir) except Exception as e: self.report.logger.error( f"An error occurred while generating the report: {str(e)}" @@ -297,7 +345,10 @@ def _format_text( return f"""st.markdown('''<{tag} style='text-align: {text_align}; color: {color};'>{text}''', unsafe_allow_html=True)""" def _generate_home_section( - self, output_dir: str, report_manag_content: list + self, + output_dir: str, + report_manag_content: list, + home_section: r.Section, ) -> None: """ Generates the homepage for the report and updates the report manager content. @@ -310,6 +361,13 @@ def _generate_home_section( A list to store the content that will be written to the report manager file. """ self.report.logger.debug("Processing home section.") + all_components = [] + subsection_imports = [] + if home_section.components: + # some assert on title? + all_components, subsection_imports, _ = self._combine_components( + home_section.components + ) try: # Create folder for the home page @@ -324,6 +382,8 @@ def _generate_home_section( # Create the home page content home_content = [] home_content.append(f"import streamlit as st") + if subsection_imports: + home_content.extend(subsection_imports) if self.report.description: home_content.append( self._format_text(text=self.report.description, type="paragraph") @@ -333,6 +393,10 @@ def _generate_home_section( f"\nst.image('{self.report.graphical_abstract}', use_column_width=True)" ) + # add components content to page (if any) + if all_components: + home_content.extend(all_components) + # Define the footer variable and add it to the home page content home_content.append("footer = '''" + generate_footer() + "'''\n") home_content.append("st.markdown(footer, unsafe_allow_html=True)\n") @@ -353,7 +417,7 @@ def _generate_home_section( self.report.logger.error(f"Error generating the home section: {str(e)}") raise - def _generate_sections(self, output_dir: str, static_dir: str) -> None: + def _generate_sections(self, output_dir: str) -> None: """ Generates Python files for each section in the report, including subsections and its components (plots, dataframes, markdown). @@ -361,77 +425,103 @@ def _generate_sections(self, output_dir: str, static_dir: str) -> None: ---------- output_dir : str The folder where section files will be saved. - static_dir : str - The folder where the static files will be saved. """ self.report.logger.info("Starting to generate sections for the report.") try: - for section in self.report.sections: + for section in self.report.sections[1:]: section_name_var = section.title.replace(" ", "_") self.report.logger.debug( f"Processing section '{section.id}': '{section.title}' - {len(section.subsections)} subsection(s)" ) - if section.subsections: - # Iterate through subsections and integrate them into the section file - for subsection in section.subsections: - self.report.logger.debug( - f"Processing subsection '{subsection.id}': '{subsection.title} - {len(subsection.components)} component(s)'" - ) - try: - # Create subsection file - _subsection_name = make_valid_identifier(subsection.title) - subsection_file_path = ( - Path(output_dir) - / section_name_var - / f"{_subsection_name}.py" - ) - - # Generate content and imports for the subsection - subsection_content, subsection_imports = ( - self._generate_subsection( - subsection, static_dir=static_dir - ) - ) - - # Flatten the subsection_imports into a single list - flattened_subsection_imports = [ - imp for sublist in subsection_imports for imp in sublist - ] - - # Remove duplicated imports - unique_imports = list(set(flattened_subsection_imports)) - - # Write everything to the subsection file - with open(subsection_file_path, "w") as subsection_file: - # Write imports at the top of the file - subsection_file.write( - "\n".join(unique_imports) + "\n\n" - ) - - # Write the subsection content (descriptions, plots) - subsection_file.write("\n".join(subsection_content)) - - self.report.logger.info( - f"Subsection file created: '{subsection_file_path}'" - ) - except Exception as subsection_error: - self.report.logger.error( - f"Error processing subsection '{subsection.id}' '{subsection.title}' in section '{section.id}' '{section.title}': {str(subsection_error)}" - ) - raise - else: + if section.components: + # add an section overview page + section_content, section_imports, _ = self._combine_components( + section.components + ) + assert ( + section.file_path is not None + ), "Missing relative file path to overview page in section" + write_python_file( + fpath=Path(output_dir) / section.file_path, + imports=section_imports, + contents=section_content, + ) + + if not section.subsections: self.report.logger.warning( - f"No subsections found in section: '{section.title}'. To show content in the report, add subsections to the section." + f"No subsections found in section: '{section.title}'. " + "To show content in the report, add subsections to the section." + ) + continue + + # Iterate through subsections and integrate them into the section file + # subsection should have the subsection_file_path as file_path? + for subsection in section.subsections: + self.report.logger.debug( + f"Processing subsection '{subsection.id}': '{subsection.title} -" + f" {len(subsection.components)} component(s)'" ) + try: + # Create subsection file + _subsection_name = make_valid_identifier(subsection.title) + assert ( + subsection.file_path is not None + ), "Missing relative file path to subsection" + subsection_file_path = Path(output_dir) / subsection.file_path + # Generate content and imports for the subsection + subsection_content, subsection_imports = ( + self._generate_subsection(subsection) + ) + + write_python_file( + fpath=subsection_file_path, + imports=subsection_imports, + contents=subsection_content, + ) + self.report.logger.info( + f"Subsection file created: '{subsection_file_path}'" + ) + except Exception as subsection_error: + self.report.logger.error( + f"Error processing subsection '{subsection.id}' '{subsection.title}' " + f"in section '{section.id}' '{section.title}': {str(subsection_error)}" + ) + raise + except Exception as e: self.report.logger.error(f"Error generating sections: {str(e)}") raise - def _generate_subsection( - self, subsection, static_dir - ) -> tuple[List[str], List[str]]: + def _combine_components(self, components: list[dict]) -> tuple[list, list, bool]: + """combine a list of components.""" + + all_contents = [] + all_imports = [] + has_chatbot = False + + for component in components: + # Write imports if not already done + component_imports = self._generate_component_imports(component) + 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( + f"Unsupported component type '{component.component_type}' " + ) + else: + if component.component_type == r.ComponentType.CHATBOT: + has_chatbot = True + content = fct(component) + all_contents.extend(content) + # remove duplicates + all_imports = list(set(all_imports)) + return all_contents, all_imports, has_chatbot + + def _generate_subsection(self, subsection) -> 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. @@ -440,8 +530,6 @@ def _generate_subsection( ---------- subsection : Subsection The subsection containing the components. - static_dir : str - The folder where the static files will be saved. Returns ------- @@ -450,10 +538,6 @@ def _generate_subsection( - list of imports for the subsection (List[str]) """ subsection_content = [] - subsection_imports = [] - - # Track if there's a Chatbot component in this subsection - has_chatbot = False # Add subsection header and description subsection_content.append( @@ -465,36 +549,10 @@ def _generate_subsection( subsection_content.append( self._format_text(text=subsection.description, type="paragraph") ) - - for component in subsection.components: - # Write imports if not already done - component_imports = self._generate_component_imports(component) - subsection_imports.append(component_imports) - - # Handle different types of components - if component.component_type == r.ComponentType.PLOT: - subsection_content.extend( - self._generate_plot_content(component, static_dir=static_dir) - ) - elif component.component_type == r.ComponentType.DATAFRAME: - subsection_content.extend(self._generate_dataframe_content(component)) - # If md files is called "description.md", do not include it in the report - elif ( - component.component_type == r.ComponentType.MARKDOWN - and component.title.lower() != "description" - ): - subsection_content.extend(self._generate_markdown_content(component)) - elif component.component_type == r.ComponentType.HTML: - subsection_content.extend(self._generate_html_content(component)) - elif component.component_type == r.ComponentType.APICALL: - subsection_content.extend(self._generate_apicall_content(component)) - elif component.component_type == r.ComponentType.CHATBOT: - has_chatbot = True - subsection_content.extend(self._generate_chatbot_content(component)) - else: - self.report.logger.warning( - f"Unsupported component type '{component.component_type}' in subsection: {subsection.title}" - ) + all_components, subsection_imports, has_chatbot = self._combine_components( + subsection.components + ) + subsection_content.extend(all_components) if not has_chatbot: # Define the footer variable and add it to the home page content @@ -506,7 +564,7 @@ def _generate_subsection( ) return subsection_content, subsection_imports - def _generate_plot_content(self, plot, static_dir: str) -> List[str]: + def _generate_plot_content(self, plot) -> List[str]: """ Generate content for a plot component based on the plot type (static or interactive). @@ -519,8 +577,6 @@ def _generate_plot_content(self, plot, static_dir: str) -> List[str]: ------- list : List[str] The list of content lines for the plot. - static_dir : str - The folder where the static files will be saved. """ plot_content = [] # Add title @@ -546,7 +602,7 @@ def _generate_plot_content(self, plot, static_dir: str) -> List[str]: else: # Otherwise, create and save a new pyvis network from the netowrkx graph html_plot_file = ( - Path(static_dir) / f"{plot.title.replace(' ', '_')}.html" + Path(self.static_dir) / f"{plot.title.replace(' ', '_')}.html" ) pyvis_graph = plot.create_and_save_pyvis_network( networkx_graph, html_plot_file