Source code for vuegen.streamlit_reportview

"""
StreamlitReportView class for generating Streamlit reports
based on a configuration file.
"""

import os
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import List

from streamlit.web import cli as stcli

from . import report as r
from . import table_utils
from .utils import (
    create_folder,
    generate_footer,
    get_relative_file_path,
    is_url,
    sort_imports,
)
from .utils.variables import make_valid_identifier


[docs] def write_python_file(fpath: str, imports: list[str], contents: list[str]) -> None: """Write a Python file with the given imports and contents.""" with open(fpath, "w", encoding="utf-8") 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))
[docs] class StreamlitReportView(r.WebAppReportView): """ A Streamlit-based implementation of the WebAppReportView abstract base class. """ BASE_DIR = "streamlit_report" SECTIONS_DIR = Path(BASE_DIR) / "sections" STATIC_FILES_DIR = Path(BASE_DIR) / "static" REPORT_MANAG_SCRIPT = "report_manager.py" def __init__( self, report: r.Report, report_type: r.ReportType, streamlit_autorun: bool = False, static_dir: str = STATIC_FILES_DIR, sections_dir: str = SECTIONS_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 if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): self.report.logger.info("running in a PyInstaller bundle") self.bundled_execution = True else: self.report.logger.info("running in a normal Python process") 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 self.section_dir = sections_dir
[docs] def generate_report(self, output_dir: str = None) -> None: """ Generates the Streamlit report and creates Python files for each section and its subsections and plots. Parameters ---------- output_dir : str, optional The folder where the generated report files will be saved (default is SECTIONS_DIR). """ if output_dir is not None: # ? does this imply changes to the static dir self.section_dir = Path(output_dir).resolve() output_dir = Path(self.section_dir) self.report.logger.debug( "Generating '%s' report in directory: '%s'", self.report_type, output_dir ) # Create the output folder if create_folder(output_dir, is_nested=True): self.report.logger.info("Created output directory: '%s'", output_dir) else: self.report.logger.info( "Output directory already existed: '%s'", 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: self.report.logger.debug("Processing app navigation code.") # Define the Streamlit imports and report manager content report_manag_content = [] report_manag_content.append(textwrap.dedent("""\ import os import time import psutil import streamlit as st """)) if self.report.logo: report_manag_content.append(textwrap.dedent(f"""\ st.set_page_config(layout="wide", page_title="{self.report.title}", page_icon="{self.report.logo}" ) st.logo("{self.report.logo}") """)) else: report_manag_content.append(textwrap.dedent(f"""\ st.set_page_config(layout="wide", page_title="{self.report.title}") """)) report_manag_content.append( self._format_text( text=self.report.title, type="header", level=1, color="#023858" ) ) # Initialize a dictionary to store the navigation structure report_manag_content.append("\nsections_pages = {}") # Generate the home page and update the report manager content self._generate_home_section( output_dir=output_dir, report_manag_content=report_manag_content, ) for section in self.report.sections: # Create a folder for each section subsection_page_vars = [] section_name_var = make_valid_identifier( section.title.replace(" ", "_") ) section_dir_path = Path(output_dir) / section_name_var if create_folder(section_dir_path): self.report.logger.debug( "Created section directory: %s", section_dir_path ) else: self.report.logger.debug( "Section directory already existed: %s", section_dir_path ) # add an overview page to section for it's section components # they will be written when the components are parsed # using `_generate_sections` if section.components: _fname = ( f"0_overview_{make_valid_identifier(section.title).lower()}.py" ) subsection_file_path = ( Path(section_name_var) / _fname ).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 = " f"st.Page('{subsection_file_path}'" f", 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 subsection_name_var = make_valid_identifier(subsection.title) if not subsection_name_var.isidentifier(): msg = ( "Subsection name is not a valid Python identifier: " f"{subsection_name_var}" ) self.report.logger.error(msg) raise ValueError( msg, ) 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}', " f"title='{subsection.title}')" ) subsection_page_vars.append(subsection_name_var) # Add all subsection Page objects to the corresponding section report_manag_content.append( f"sections_pages['{section.title}'] = " f"[{', '.join(subsection_page_vars)}]\n" ) # Add navigation object to the home page content report_manag_content.append(textwrap.dedent("""\ report_nav = st.navigation(sections_pages) # Following https://discuss.streamlit.io/t/\ close-streamlit-app-with-button-click/35132/5 exit_app = st.sidebar.button("Shut Down App", icon=":material/power_off:", use_container_width=True) if exit_app: st.toast("Shutting down the app...") time.sleep(1) # Terminate streamlit python process pid = os.getpid() p = psutil.Process(pid) p.terminate() report_nav.run() """)) # Write the navigation and general content to a Python file with open( Path(output_dir) / self.REPORT_MANAG_SCRIPT, "w", encoding="utf8" ) as nav_manager: nav_manager.write("\n".join(report_manag_content)) self.report.logger.info( "Created app navigation script: %s", self.REPORT_MANAG_SCRIPT ) # Create Python files for each section and its subsections and plots self._generate_sections(output_dir=output_dir) # Save README.md to the output directory fpath = self.section_dir.parent / "README.md" with open(fpath, "w", encoding="utf-8") as f: f.write(textwrap.dedent(f"""\ # Streamlit Report This report was generated using the Vuegen library: https://github.com/Multiomics-Analytics-Group/vuegen Executed from: `{Path.cwd()}` Written to: `{self.section_dir.resolve()}` Folder cannot be moved from above path, but can be executed from anywhere on the system. """)) 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: str = None) -> None: """ Runs the generated Streamlit report. Parameters ---------- output_dir : str, optional The folder where the report was generated (default is SECTIONS_DIR). """ if output_dir is not None: self.report.logger.warning("The output_dir parameter is deprecated.") output_dir = Path(self.section_dir) if self.streamlit_autorun: self.report.logger.info( "Running '%s' %s report.", self.report.title, self.report_type ) self.report.logger.debug( "Running Streamlit report from directory: %s", output_dir ) # ! using pyinstaller: vuegen main script as executable, # ! not the Python Interpreter msg = f"{sys.executable = }" self.report.logger.debug(msg) try: # ! streamlit command option is not known in packaged app target_file = os.path.join(output_dir, self.REPORT_MANAG_SCRIPT) self.report.logger.debug( "Running Streamlit report from file: %s", target_file ) if self.bundled_execution: args = [ "streamlit", "run", target_file, "--global.developmentMode=false", ] sys.argv = args sys.exit(stcli.main()) else: self.report.logger.debug("Run using subprocess.") subprocess.run( [sys.executable, "-m", "streamlit", "run", target_file], check=True, ) except KeyboardInterrupt: print("Streamlit process interrupted.") except subprocess.CalledProcessError as e: self.report.logger.error( "Error running Streamlit report: %s", e, exc_info=True ) raise else: # If autorun is False, print instructions for manual execution self.report.logger.info( "All the scripts to build the Streamlit app are available at %s", output_dir, ) self.report.logger.info( "To run the Streamlit app, use the following command:" ) self.report.logger.info( "streamlit run %s", Path(output_dir) / self.REPORT_MANAG_SCRIPT ) msg = ( "\nAll the scripts to build the Streamlit app are available at: " f"{output_dir}\n\n" "To run the Streamlit app, use the following command:\n\n" f"\tstreamlit run {Path(output_dir) / self.REPORT_MANAG_SCRIPT}" ) print(msg)
def _format_text( self, text: str, type: str, level: int = 1, color: str = "#000000", text_align: str = "center", ) -> str: """ Generates a Streamlit markdown text string with the specified level and color. Parameters ---------- text : str The text to be formatted. type : str The type of the text (e.g., 'header', 'paragraph'). level : int, optional If the text is a header, the level of the header (e.g., 1 for h1, 2 for h2, etc.). color : str, optional The color of the header text. text_align : str, optional The text alignment. Returns ------- str A formatted markdown string for the specified text. """ if type == "header": tag = f"h{level}" elif type == "paragraph" or type == "caption": tag = "p" else: raise ValueError( f"Unsupported text type: {type}. Supported types are 'header', " "'paragraph', and 'caption'." ) text = text.strip() # get rid of new lines text = textwrap.indent(text, " ") ret = textwrap.dedent(f"""\ st.markdown( ''' <{tag} style='text-align: {text_align}; color: {color};'>\n{text} </{tag}> ''', unsafe_allow_html=True) """) return ret def _generate_home_section( self, output_dir: str, report_manag_content: list, ) -> None: """ Generates the homepage for the report and updates the report manager content. Parameters ---------- output_dir : str The folder where the homepage files will be saved. report_manag_content : list A list to store the content that will be written to the report manager file. """ self.report.logger.debug("Processing home section.") try: # Create folder for the home page home_dir_path = Path(output_dir) / "Home" if create_folder(home_dir_path): self.report.logger.debug("Created home directory: %s", home_dir_path) else: self.report.logger.debug( "Home directory already existed: %s", home_dir_path ) # Create the home page content home_content = [] home_content.append("import streamlit as st") if self.report.description: home_content.append( self._format_text(text=self.report.description, type="paragraph") ) if self.report.graphical_abstract: home_content.append( f"\nst.image('{self.report.graphical_abstract}', " "use_column_width=True)" ) # add components content to page (if any) # 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") # Write the home page content to a Python file home_page_path = Path(home_dir_path) / "Homepage.py" with open(home_page_path, "w", encoding="utf-8") as home_page: home_page.write("\n".join(home_content)) self.report.logger.info( "Home page content written to '%s'.", home_page_path ) # Add the home page to the report manager content report_manag_content.append( # ! here Posix Path is hardcoded "homepage = st.Page('Home/Homepage.py', title='Homepage')" ) report_manag_content.append("sections_pages['Home'] = [homepage]\n") self.report.logger.info("Home page added to the report manager content.") except Exception as e: self.report.logger.error( "Error generating the home section: %s", e, exc_info=True ) raise 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). Parameters ---------- output_dir : str The folder where section files will be saved. """ self.report.logger.info("Starting to generate sections for the report.") try: for section in self.report.sections: self.report.logger.debug( # Continue "Processing section '%s': '%s' - %s subsection(s)", section.id, section.title, len(section.subsections), ) 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.debug( "No subsections found in section: '%s'.", section.title ) continue # Iterate through subsections and integrate them into the section file # ! subsection should have the subsection_file_path as file_path, # ! which is set when parsing the config in the main generate_sections # ! method for subsection in section.subsections: self.report.logger.debug( "Processing subsection '%s': '%s' - %s component(s)", subsection.id, subsection.title, len(subsection.components), ) try: # Create subsection file 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( "Subsection file created: '%s'", subsection_file_path ) except Exception as subsection_error: self.report.logger.error( "Error processing subsection '%s' '%s' " "in section '%s' '%s': %s", subsection.id, subsection.title, section.id, section.title, str(subsection_error), ) raise except Exception as e: self.report.logger.error("Error generating sections: %s", e, exc_info=True) raise 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( "Unsupported component type '%s' ", 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)) all_imports, setup_statements = sort_imports(all_imports) all_imports.extend(setup_statements) 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. Parameters ---------- subsection : Subsection The subsection containing the components. 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( self._format_text( text=subsection.title, type="header", level=3, color="#023558" ) ) if subsection.description: subsection_content.append( self._format_text(text=subsection.description, type="paragraph") ) 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 subsection_content.append("footer = '''" + generate_footer() + "'''\n") subsection_content.append("st.markdown(footer, unsafe_allow_html=True)\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 plot type (static or interactive). 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( self._format_text(text=plot.title, type="header", level=4, color="#2b8cbe") ) # Add content for the different plot types try: if plot.plot_type == r.PlotType.STATIC: # If the file path is a URL, keep the file path as is if is_url(plot.file_path): plot_file_path = plot.file_path plot_content.append(f"plot_file_path = '{plot_file_path}'") else: # If it's a local file plot_file_path = get_relative_file_path( plot.file_path, relative_to=self.section_dir ).as_posix() plot_content.append( f"plot_file_path = (section_dir / '{plot_file_path}')" ".resolve().as_posix()" ) plot_content.append( "st.image(plot_file_path," f" caption='{plot.caption}', use_column_width=True)\n" ) elif plot.plot_type == r.PlotType.PLOTLY: plot_content.append(self._generate_plot_code(plot)) elif plot.plot_type == r.PlotType.ALTAIR: plot_content.append(self._generate_plot_code(plot)) 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 else: # Otherwise, # create and save a new pyvis network from the netowrkx graph html_plot_file = ( Path(self.static_dir) / f"{plot.title.replace(' ', '_')}.html" ).resolve() _ = 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() # Determine whether the file path is a URL or a local file if is_url(html_plot_file): plot_content.append(textwrap.dedent(f""" response = requests.get('{html_plot_file}') response.raise_for_status() html_content = response.text """)) else: fpath = get_relative_file_path( html_plot_file, relative_to=self.section_dir ).as_posix() plot_content.append(textwrap.dedent(f""" file_path = (section_dir / '{fpath}').resolve().as_posix() with open(file_path, 'r') as html_file: html_content = html_file.read() """)) # Append the code for additional information (nodes and edges count) plot_content.append(textwrap.dedent(f""" st.markdown(("<p style='text-align: center; color: black;'> " "<b>Number of nodes:</b> {num_nodes} </p>"), unsafe_allow_html=True) st.markdown(("<p style='text-align: center; color: black;'>" " <b>Number of relationships:</b> {num_edges}" " </p>"), unsafe_allow_html=True) """)) # Add the specific code for visualization plot_content.append(self._generate_plot_code(plot)) 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 self.report.logger.info( "Successfully generated content for plot '%s': '%s'", plot.id, plot.title, ) return plot_content def _generate_plot_code(self, plot) -> 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. """ # 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 = json.loads(response.text)\n""") else: # If it's a local file plot_rel_path = get_relative_file_path( plot.file_path, relative_to=self.section_dir ).as_posix() plot_code = textwrap.dedent(f""" file_path = (section_dir / '{plot_rel_path}').resolve().as_posix() with open(file_path, 'r') as plot_file: plot_json = json.load(plot_file)\n""") # 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', [])] st.plotly_chart(plot_json, use_container_width=True)\n""") elif plot.plot_type == r.PlotType.ALTAIR: plot_code += textwrap.dedent(""" altair_plot = alt.Chart.from_dict(plot_json) st.vega_lite_chart(json.loads(altair_plot.to_json()), use_container_width=True)\n""") elif plot.plot_type == r.PlotType.INTERACTIVE_NETWORK: plot_code = textwrap.dedent("""\ # Streamlit checkbox for controlling the layout control_layout = st.checkbox('Add panel to control layout', value=True) net_html_height = 1200 if control_layout else 630 # Load HTML into HTML component for display on Streamlit st.components.v1.html(html_content, height=net_html_height)\n""") return plot_code def _generate_dataframe_content(self, dataframe) -> List[str]: """ Generate content for a DataFrame component. Parameters ---------- dataframe : DataFrame The dataframe component to generate content for. Returns ------- list : List[str] The list of content lines for the DataFrame. """ dataframe_content = [] # Add title dataframe_content.append( self._format_text( text=dataframe.title, type="header", level=4, color="#2b8cbe" ) ) # 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), ) # return [] # Skip execution if unsupported file extension # Should it not return here? # Can we even call the method with an unsupported file extension? # 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) if file_extension in [ r.DataFrameFormat.XLS.value_with_dot, r.DataFrameFormat.XLSX.value_with_dot, ]: dataframe_content.append("selected_sheet = 0") sheet_names = table_utils.get_sheet_names(df_file_path.as_posix()) if len(sheet_names) > 1: # If there are multiple sheets, ask the user to select one fpath = get_relative_file_path( dataframe.file_path, relative_to=self.section_dir ).as_posix() dataframe_content.append(textwrap.dedent(f"""\ file_path = (section_dir / '{fpath}').resolve().as_posix() sheet_names = table_utils.get_sheet_names(file_path) selected_sheet = st.selectbox("Select a sheet to display", options=sheet_names, ) """)) # Load the DataFrame using the correct function df_file_path = get_relative_file_path( dataframe.file_path, relative_to=self.section_dir ).as_posix() read_function = read_function_mapping[file_extension].__name__ if file_extension in [ r.DataFrameFormat.XLS.value_with_dot, r.DataFrameFormat.XLSX.value_with_dot, ]: dataframe_content.append(textwrap.dedent(f"""\ file_path = (section_dir / '{df_file_path}').resolve() df = pd.{read_function}(file_path, sheet_name=selected_sheet) """)) else: dataframe_content.append( f"file_path = (section_dir / '{df_file_path}'" ").resolve().as_posix()\n" f"df = pd.{read_function}(file_path)\n" ) # ! Alternative to select box: iterate over sheets in DataFrame # Displays a DataFrame using AgGrid with configurable options. dataframe_content.append(textwrap.dedent(""" # Displays a DataFrame using AgGrid with configurable options. grid_builder = GridOptionsBuilder.from_dataframe(df) grid_builder.configure_default_column(editable=True, groupable=True, filter=True, ) grid_builder.configure_side_bar(filters_panel=True, columns_panel=True) grid_builder.configure_selection(selection_mode="multiple") grid_builder.configure_pagination(enabled=True, paginationAutoPageSize=False, paginationPageSize=20, ) grid_options = grid_builder.build() AgGrid(df, gridOptions=grid_options, enable_enterprise_modules=True) # Button to download the df df_csv = df.to_csv(sep=',', header=True, index=False ).encode('utf-8') st.download_button( label="Download dataframe as CSV", data=df_csv, file_name=f"dataframe_{df_index}.csv", mime='text/csv', key=f"download_button_{df_index}") df_index += 1""")) 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 if dataframe.caption: dataframe_content.append( self._format_text( text=dataframe.caption, type="caption", text_align="left" ) ) self.report.logger.info( "Successfully generated content for DataFrame: '%s'", dataframe.title, ) return dataframe_content def _generate_markdown_content(self, markdown) -> List[str]: """ Generate content for a Markdown component. Parameters ---------- markdown : MARKDOWN The markdown component to generate content for. Returns ------- list : List[str] The list of content lines for the markdown. """ markdown_content = [] # Add title markdown_content.append( self._format_text( text=markdown.title, type="header", level=4, color="#2b8cbe" ) ) try: # 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.section_dir ).as_posix() markdown_content.append(textwrap.dedent(f""" file_path = (section_dir / '{md_rel_path}').resolve().as_posix() with open(file_path, 'r') as markdown_file: markdown_content = markdown_file.read() """)) # Code to display md content markdown_content.append( "st.markdown(markdown_content, unsafe_allow_html=True)\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( self._format_text( text=markdown.caption, type="caption", text_align="left" ) ) self.report.logger.info( "Successfully generated content for Markdown: '%s'", markdown.title, ) return markdown_content def _generate_html_content(self, html) -> List[str]: """ Generate content for an HTML component. Parameters ---------- html : HTML The HTML component to generate content for. Returns ------- list : List[str] The list of content lines for the HTML display. """ html_content = [] # Add title html_content.append( self._format_text(text=html.title, type="header", level=4, color="#2b8cbe") ) try: if is_url(html.file_path): # If it's a URL, fetch content dynamically textwrap.dedent(html_content.append(f""" response = requests.get('{html.file_path}') response.raise_for_status() html_content = response.text """)) else: # If it's a local file html_rel_path = get_relative_file_path( html.file_path, relative_to=self.section_dir ).as_posix() html_content.append(textwrap.dedent(f"""\ file_path = (section_dir / '{html_rel_path}').resolve().as_posix() with open(file_path, 'r', encoding='utf-8') as f: html_content = f.read() """)) # Display HTML content using Streamlit html_content.append( "st.components.v1.html(html_content, height=600, scrolling=True)\n" ) except Exception as e: self.report.logger.error( "Error generating content for HTML: %s. Error: %s", html.title, e, exc_info=True, ) raise # Add caption if available if html.caption: html_content.append( self._format_text(text=html.caption, type="caption", text_align="left") ) self.report.logger.info( "Successfully generated content for HTML: '%s'", html.title, ) return html_content def _generate_apicall_content(self, apicall) -> List[str]: """ Generate content for an API component. This method handles the API call and formats the response for display in the Streamlit app. Parameters ---------- apicall : APICall The apicall component to generate content for. Returns ------- list : List[str] The list of content lines for the apicall. """ apicall_content = [] # Add tile apicall_content.append( self._format_text( text=apicall.title, type="header", level=4, color="#2b8cbe" ) ) try: apicall_response = apicall.make_api_request() apicall_content.append(f"""st.write({apicall_response})\n""") except Exception as e: self.report.logger.error( "Error generating content for APICall: %s. Error: %s", apicall.title, e, exc_info=True, ) raise # Add caption if available if apicall.caption: apicall_content.append( self._format_text( text=apicall.caption, type="caption", text_align="left" ) ) self.report.logger.info( "Successfully generated content for APICall '%s' using method '%s'", apicall.title, apicall.method, ) return apicall_content def _generate_chatbot_content(self, chatbot) -> List[str]: """ Generate content to render a ChatBot component, supporting standard and Ollama-style streaming APIs. This method builds and returns a list of strings, which are later executed to create the chatbot interface in a Streamlit app. It includes user input handling, API interaction logic, response parsing, and conditional rendering of text, source links, and HTML subgraphs. The function distinguishes between two chatbot modes: - **Ollama-style streaming API**: Identified by the presence of `chatbot.model`. Uses streaming JSON chunks from the server to simulate a real-time response. - **Standard API**: Assumes a simple POST request with a prompt and a full JSON response with text, and other fields like links, HTML graphs, etc. Parameters ---------- chatbot : ChatBot The ChatBot component to generate content for, containing configuration such as title, model, API endpoint, headers, and caption. Returns ------- list : List[str] The list of content lines for the ChatBot. """ chatbot_content = [] # Add chatbot title as header chatbot_content.append( self._format_text( text=chatbot.title, type="header", level=4, color="#2b8cbe" ) ) # --- Shared code blocks (as strings) --- init_messages_block = textwrap.indent( """ # Init session state if 'messages' not in st.session_state: st.session_state['messages'] = [] """, " " * 4, ) render_messages_block = textwrap.indent( """ # Display chat history for message in st.session_state['messages']: with st.chat_message(message['role']): content = message['content'] if isinstance(content, dict): st.markdown(content.get('text', ''), unsafe_allow_html=True) if 'links' in content: st.markdown("**Sources:**") for link in content['links']: st.markdown(f"- [{link}]({link})") if 'subgraph_pyvis' in content: st.components.v1.html(content['subgraph_pyvis'], height=600) else: st.write(content) """, " " * 4, ) handle_prompt_block = textwrap.indent( """ # Capture and append new user prompt if prompt := st.chat_input("Enter your prompt here:"): st.session_state.messages.append({"role": "user", "content": prompt}) with st.chat_message("user"): st.write(prompt) """, " " * 4, ) if chatbot.model: # --- Ollama-style streaming chatbot --- # all other codeblocks pasted in need to be on this indentation level code_block = textwrap.dedent(f""" {init_messages_block} # Function to send prompt to Ollama API def generate_query(messages): response = requests.post( "{chatbot.api_call.api_url}", json={{"model": "{chatbot.model}", "messages": messages, "stream": True}}, ) response.raise_for_status() return response # Parse streaming response from Ollama def parse_api_response(response): try: output = "" for line in response.iter_lines(): body = json.loads(line) if "error" in body: raise Exception(f"API error: {{body['error']}}") if body.get("done", False): return {{"role": "assistant", "content": output}} output += body.get("message", {{}}).get("content", "") except Exception as e: return {{"role": "assistant", "content": f"Error while processing API response: {{str(e)}}"}} # Simulated typing effect for responses def response_generator(msg_content): for word in msg_content.split(): yield word + " " time.sleep(0.1) yield "\\n" {render_messages_block} {handle_prompt_block} # Retrieve question and generate answer combined = "\\n".join(msg["content"] for msg in st.session_state.messages if msg["role"] == "user") messages = [{{"role": "user", "content": combined}}] with st.spinner('Generating answer...'): response = generate_query(messages) parsed_response = parse_api_response(response) # Add the assistant's response to the session state and display it st.session_state.messages.append(parsed_response) with st.chat_message("assistant"): st.write_stream(response_generator(parsed_response["content"])) """) chatbot_content.append(code_block) else: # --- Standard (non-streaming) API chatbot --- code_block = textwrap.dedent(f""" {init_messages_block} # Function to send prompt to standard API def generate_query(prompt): try: response = requests.post( "{chatbot.api_call.api_url}", json={{"prompt": prompt}}, headers={chatbot.api_call.headers} ) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: st.error(f"API request failed: {{str(e)}}") if hasattr(e, 'response') and e.response: try: error_details = e.response.json() st.error(f"Error details: {{error_details}}") except ValueError: st.error(f"Response text: {{e.response.text}}") return None {render_messages_block} {handle_prompt_block} with st.spinner('Generating answer...'): response = generate_query(prompt) if response: # Append and display assistant response st.session_state.messages.append({{ "role": "assistant", "content": response }}) with st.chat_message("assistant"): st.markdown(response.get('text', ''), unsafe_allow_html=True) if 'links' in response: st.markdown("**Sources:**") for link in response['links']: st.markdown(f"- [{{link}}]({{link}})") if 'subgraph_pyvis' in response: st.components.v1.html( response['subgraph_pyvis'], height=600 ) else: st.error("Failed to get response from API") """) chatbot_content.append(code_block) if chatbot.caption: chatbot_content.append( self._format_text( text=chatbot.caption, type="caption", text_align="left" ) ) return chatbot_content 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 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 json", "import altair as alt", "import requests", ], r.PlotType.PLOTLY: ["import json", "import requests"], r.PlotType.INTERACTIVE_NETWORK: ["import requests"], }, "dataframe": [ "import pandas as pd", "from st_aggrid import AgGrid, GridOptionsBuilder", "from vuegen import table_utils", ], "markdown": ["import requests"], "chatbot": ["import time", "import json", "import requests"], } component_type = component.component_type component_imports = [ "import streamlit as st", "from pathlib import Path", "section_dir = Path(__file__).resolve().parent.parent", ] # 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.MARKDOWN: component_imports.extend(components_imports["markdown"]) elif component_type == r.ComponentType.CHATBOT: component_imports.extend(components_imports["chatbot"]) elif component_type == r.ComponentType.DATAFRAME: component_imports.extend(components_imports["dataframe"]) component_imports.append("df_index = 1") # Return the list of import statements return component_imports