Repository: pegasystems/pega-datascientist-tools

Issue:
_delete_temp_files in generateReport is _way_ too aggressive.
### pdstools version checks

- [X] I have checked that this issue has not already been reported.

- [X] I have confirmed this bug exists on the [latest version](https://pypi.org/project/pdstools/) of pdstools.


### Issue description

The `_delete_temp_files` method in generateReport of the ADMDatamart class is _way_ too aggressive.

https://github.com/pegasystems/pega-datascientist-tools/blob/master/python/pdstools/adm/ADMDatamart.py#L1657

By default, it currently deletes _all_ [".yaml", ".qmd", ".ipynb", ".arrow"] files in the working directory. 

Considering that, by default, the working directory is the current directory, this will remove all files with these types from the local directory, and since it's using OS commands they're not put in the thrash either. It already accidentally removed a few notebooks and other cached files that i had there locally. 

### Reproducible example

```Python
Have files in your current directory, then run generateReport
```


### Expected behavior

We should make sure it only deletes the cached files that we write for the health check, and only the files used to generate this specific health check run. 

### Installed versions

<details>

```
Replace this line with the output of pdstools.show_versions(), leave the backticks in place
```

</details>

Hints from issue comments:
@StijnKas: Closed by https://github.com/pegasystems/pega-datascientist-tools/pull/253

Solution patch:
diff --git a/python/pdstools/adm/ADMDatamart.py b/python/pdstools/adm/ADMDatamart.py
index 63e7c305..a26f5202 100644
--- a/python/pdstools/adm/ADMDatamart.py
+++ b/python/pdstools/adm/ADMDatamart.py
@@ -1,12 +1,12 @@
 from __future__ import annotations
 
 import datetime
-import glob
 import logging
 import os
 import shutil
 import subprocess
-from collections import namedtuple
+import sys
+import tempfile
 from io import BytesIO
 from pathlib import Path
 from typing import Any, Dict, List, Literal, NoReturn, Optional, Tuple, Union
@@ -17,13 +17,17 @@ import yaml
 from .. import pega_io
 from ..plots.plot_base import Plots
 from ..plots.plots_plotly import ADMVisualisations as plotly_plot
-from ..utils import cdh_utils, NBAD
+from ..utils import NBAD, cdh_utils
 from ..utils.errors import NotEagerError
 from ..utils.types import any_frame
 from .ADMTrees import ADMTrees
 from .Tables import Tables
 
 
+class ReportGenerationError(Exception):
+    pass
+
+
 class ADMDatamart(Plots, Tables):
     """Main class for importing, preprocessing and structuring Pega ADM Datamart.
     Gets all available data, properly names and merges into one main dataframe.
@@ -222,7 +226,7 @@ class ADMDatamart(Plots, Tables):
         If model_df or predictor_df is supplied, it will use those instead
         If any filters are included in the the `query` argument of the ADMDatmart,
         those will be applied to the modeldata, and the predictordata will be
-        filtered such that it only contains the modelids leftover after filtering.
+        filtered such that it only contains the model_ids leftover after filtering.
         After reading, some additional values (such as success rate) are
         automatically computed.
         Lastly, if there are missing columns from both datasets,
@@ -698,9 +702,7 @@ class ADMDatamart(Plots, Tables):
         (os.PathLike, os.PathLike):
             The paths to the model and predictor data files
         """
-        from datetime import datetime
-
-        time = datetime.now().strftime("%Y%m%dT%H%M%S.%f")[:-3]
+        time = datetime.datetime.now().strftime("%Y%m%dT%H%M%S.%f")[:-3]
         if self.modelData is not None:
             modeldata_cache = pega_io.cache_to_file(
                 self.modelData, path, name=f"cached_modelData_{time}"
@@ -1319,7 +1321,10 @@ Meaning in total, {self.model_stats['models_n_nonperforming']} ({round(self.mode
 
         if by_period is not None:
             period_expr = [
-                pl.col("SnapshotTime").dt.truncate(by_period).cast(pl.Date).alias("Period")
+                pl.col("SnapshotTime")
+                .dt.truncate(by_period)
+                .cast(pl.Date)
+                .alias("Period")
             ]
         else:
             period_expr = []
@@ -1500,7 +1505,9 @@ Meaning in total, {self.model_stats['models_n_nonperforming']} ({round(self.mode
                     pl.when(pl.col("Channel").is_not_null() & (pl.col("Channel") != ""))
                     .then(pl.col("Channel"))
                     .otherwise(pl.lit("")),
-                    pl.when(pl.col("Direction").is_not_null() & (pl.col("Direction") != ""))
+                    pl.when(
+                        pl.col("Direction").is_not_null() & (pl.col("Direction") != "")
+                    )
                     .then(pl.col("Direction"))
                     .otherwise(pl.lit("")),
                 ),
@@ -1654,174 +1661,327 @@ Meaning in total, {self.model_stats['models_n_nonperforming']} ({round(self.mode
             .sort(["Period"] if by_period is not None else [])
         )
 
-    def generateReport(
+    def generate_model_reports(
         self,
         name: Optional[str] = None,
-        working_dir: Path = Path("."),
+        model_list: List[str] = None,
+        working_dir: Optional[Path] = None,
+        only_active_predictors: bool = False,
         *,
-        modelid: Optional[str] = "",
-        delete_temp_files: bool = True,
+        base_file_name: str = None,
         output_type: str = "html",
-        allow_collect: bool = True,
-        cached_data: bool = False,
-        predictordetails_activeonly: bool = False,
+        debug_mode: bool = False,
+        progress_callback=None,  #:  Callable[[int, int], None] = None,
         **kwargs,
-    ):
+    ) -> Path:
         """
-        Generates a report based on the provided parameters. If modelid is provided, a model report will be generated.
-        If not, an overall HealthCheck report will be generated.
+        Generates model reports.
 
         Parameters
         ----------
-        name : Optional[str], default = None
+        name : str, optional
             The name of the report.
-        working_dir : Path, default = Path(".")
-            The working directory. Cached files will be written here.
-        *
-        Keyword Arguments
-        -----------------
-        modelid : Optional[str], default = ""
-            The model id,
-        delete_temp_files : bool, default = True
-            Whether to delete temporary files.
-        output_type : str, default = "html"
-            The type of the output file.
-        allow_collect : bool, default = True
-            Whether to allow collection of data.
-        cached_data : bool, default = False
-            Whether to use cached data.
-        del_cache : bool, default = True
-            Whether to delete cache.
-        predictordetails_activeonly : bool, default = False
+        model_list : List[str]
+            The list of model IDs to generate reports for.
+        working_dir : Path, optional
+            The working directory for the output. If None, uses current working directory.
+        only_active_predictors : bool, default=False
             Whether to only include active predictor details.
-        **kwargs
+        base_file_name : str, optional
+            The base file name for the generated reports. Defaults to None.
+        output_type : str, default='html'
+            The type of the output file (e.g., "html", "pdf").
+        debug_mode : bool, optional
+            If True, the temporary directory will not be deleted after report generation.
+        **kwargs : dict
             Additional keyword arguments.
+
+        Returns
+        -------
+        Path
+            The path to the generated report file.
+
+        Raises
+        ------
+        ValueError
+            If there's an error in report generation or invalid parameters.
+        FileNotFoundError
+            If required files are not found.
+        subprocess.SubprocessError
+            If there's an error in running external commands.
         """
 
-        # Define helper functions
-        def _delete_temp_files(working_dir, del_log=False):
-            extensions = [".yaml", ".qmd", ".ipynb", ".arrow"]
-            for ext in extensions:
-                for f in glob.glob(f"{working_dir}/*{ext}"):
-                    try:
-                        os.remove(f)
-                    except:
-                        pass
-            if del_log:
-                try:
-                    os.remove(f"{working_dir}/log.txt")
-                except:
-                    pass
-
-        def get_report_files(modelid):
-            if modelid == "":
-                return "HealthCheck.qmd", "HealthCheck"
-            else:
-                return "ModelReport.qmd", "ModelReport"
-
-        def get_output_filename(name, report, modelid, output_type):
-            if name is not None:
-                name = name.replace(" ", "_")
-            if report == "ModelReport":
-                if modelid is not None:
-                    if name is not None:
-                        return f"{report}_{name}_{modelid}.{output_type}"
-                    else:
-                        return f"{report}_{modelid}.{output_type}"
-                else:
-                    raise ValueError("ModelID cannot be None for a ModelReport.")
-            elif report == "HealthCheck":
-                if name is not None:
-                    return f"{report}_{name}.{output_type}"
-                else:
-                    return f"{report}.{output_type}"
-            else:
-                raise ValueError("Invalid report type.")
-
-        def check_output_file(working_dir, output_filename, verbose, delete_temp_files):
-            if not os.path.exists(working_dir / output_filename):
-                msg = "Error when generating healthcheck."
-                if not verbose and not kwargs.get("output_to_file", False):
-                    msg += "Set 'verbose' to True to see the full output"
-                if delete_temp_files:
-                    _delete_temp_files(working_dir)
-                raise ValueError(msg)
-
-        def get_files(working_dir, cached_data):
-            if not cached_data:
-                return self.save_data(working_dir)
-            else:
-                modeldata_files = glob.glob(f"{working_dir}/cached_modelData*")
-                predictordata_files = glob.glob(f"{working_dir}/cached_predictorData*")
-                if modeldata_files:
-                    modeldata_cache = modeldata_files[0]
-                else:
-                    raise FileNotFoundError("No cached model data found.")
-                if predictordata_files:
-                    predictordata_cache = predictordata_files[0]
-                else:
-                    predictordata_cache = None
-                return modeldata_cache, predictordata_cache
-
-        def get_params(modelid, predictordetails_activeonly):
-            return {
-                "kwargs": {
-                    "subset": False,
-                    "modelid": modelid,
-                    "predictordetails_activeonly": predictordetails_activeonly,
-                },
-            }
+        if (
+            not model_list
+            or not isinstance(model_list, list)
+            or not all(isinstance(i, str) for i in model_list)
+        ):
+            raise ValueError(
+                "model_list argument is None, not a list, or contains non-string elements for generate_model_reports. Please provide a list of model_id strings to generate reports."
+            )
 
-        def run_bash_command(bashCommand, working_dir, **kwargs):
-            if not kwargs.get("verbose", self.verbose):
-                stdout, stderr = subprocess.DEVNULL, None
-            else:
-                print("Set verbose=False to hide output.")
-                print("Running:", bashCommand)
-                stdout, stderr = subprocess.PIPE, subprocess.STDOUT
-            if kwargs.get("output_to_file", False):
-                with open(f"{working_dir}/log.txt", "w") as outfile:
-                    process = subprocess.Popen(
-                        bashCommand.split(),
-                        stdout=outfile,
-                        stderr=stderr,
-                        cwd=working_dir,
-                    )
-                    process.communicate()
-            else:
-                process = subprocess.Popen(
-                    bashCommand.split(), stdout=stdout, stderr=stderr, cwd=working_dir
+        logger = logging.getLogger(__name__)
+        working_dir = Path(working_dir) if working_dir else Path.cwd()
+        working_dir.mkdir(parents=True, exist_ok=True)
+
+        # Create a unique temporary directory name
+        temp_dir_name = (
+            tempfile.mkdtemp(prefix=f"tmp_{name}_", dir=working_dir)
+            if name
+            else tempfile.mkdtemp(prefix="tmp_", dir=working_dir)
+        )
+        temp_dir_path = Path(temp_dir_name)
+
+        try:
+            qmd_file = "ModelReport.qmd"
+            self._copy_quarto_file(qmd_file, temp_dir_path)
+            self.save_data(temp_dir_path)
+            output_file_paths = []
+            for i, model_id in enumerate(model_list):
+                output_filename = self._get_output_filename(
+                    name, "ModelReport", model_id, output_type
+                )
+                self._write_params_file(temp_dir_path, model_id, only_active_predictors)
+                self._run_quarto_command(
+                    temp_dir_path, qmd_file, output_type, output_filename, debug_mode
                 )
-                process.communicate()
+                output_path = temp_dir_path / output_filename
+                if not output_path.exists():
+                    raise ValueError(f"Failed to write the report: {output_filename}")
+                output_file_paths.append(output_path)
+                if progress_callback:
+                    progress_callback(i + 1, len(model_list))
+            base_file_name = kwargs.get(
+                base_file_name, output_path
+            )  # either use the given name or the latest file name
+            file_data, file_name = cdh_utils.process_files_to_bytes(
+                output_file_paths, base_file_name
+            )
+            output_path = working_dir / file_name
+            with open(output_path, "wb") as f:
+                f.write(file_data)
+            if not output_path.exists():
+                raise ValueError(f"Failed to generate report: {output_filename}")
+
+            return output_path
+
+        except Exception as e:
+            logger.error(f"Error generating report: {str(e)}", exc_info=True)
+            raise
+        finally:
+            if not debug_mode:
+                if temp_dir_path.exists() and temp_dir_path.is_dir():
+                    shutil.rmtree(temp_dir_path, ignore_errors=True)
+
+    def generate_health_check(
+        self,
+        name: Optional[str] = None,
+        working_dir: Optional[Path] = None,
+        *,
+        output_type: str = "html",
+        **kwargs,
+    ) -> Path:
+        """
+        Generates a report based on the provided parameters.
 
-        # Main function logic
-        healthcheck_file, report = get_report_files(modelid)
-        verbose = kwargs.get("verbose", self.verbose)
+        Parameters
+        ----------
+        name : str, optional
+            The name of the report.
+        working_dir : Path, optional
+            The working directory for the output. If None, uses current working directory.
+        output_type : str, default='html'
+            The type of the output file (e.g., "html", "pdf").
+        debug_mode : bool, optional
+            If True, a temporary directory will be created and not deleted after report generation.
+        **kwargs : dict
+            Additional keyword arguments.
 
-        if self.import_strategy == "lazy" and not allow_collect:
-            raise NotEagerError(f"Generating {report}")
-        if not os.path.exists(working_dir):
-            os.mkdir(working_dir)
+        Returns
+        -------
+        Path
+            The path to the generated report file.
+
+        Raises
+        ------
+        ValueError
+            If there's an error in report generation or invalid parameters.
+        FileNotFoundError
+            If required files are not found.
+        subprocess.SubprocessError
+            If there's an error in running external commands.
+        """
+        logger = logging.getLogger(__name__)
+        debug_mode = kwargs.get("debug_mode", self.verbose)
+        working_dir = Path(working_dir) if working_dir else Path.cwd()
+        working_dir.mkdir(parents=True, exist_ok=True)
+
+        # Create a unique temporary directory name
+        temp_dir_name = (
+            tempfile.mkdtemp(prefix=f"tmp_{name}_", dir=working_dir)
+            if name
+            else tempfile.mkdtemp(prefix="tmp_", dir=working_dir)
+        )
+        temp_dir_path = Path(temp_dir_name)
+        try:
+            qmd_file = "HealthCheck.qmd"
+            output_filename = self._get_output_filename(
+                name, "HealthCheck", None, output_type
+            )
 
-        from pdstools import __reports__
+            self._copy_quarto_file(qmd_file, temp_dir_path)
+            self.save_data(temp_dir_path)
+            self._write_params_file(temp_dir_path, None, None)
+            self._run_quarto_command(
+                temp_dir_path, qmd_file, output_type, output_filename, debug_mode
+            )
+            # copy report to working dir
+            output_path = temp_dir_path / output_filename
+            if not output_path.exists():
+                raise ValueError(f"Failed to generate report: {output_filename}")
+
+            final_path = working_dir / output_filename
+            shutil.copy(output_path, final_path)
+            return final_path
+
+        except Exception as e:
+            logger.error(f"Error generating report: {str(e)}", exc_info=True)
+            raise
+        finally:
+            if not debug_mode:
+                if temp_dir_path.exists() and temp_dir_path.is_dir():
+                    shutil.rmtree(temp_dir_path, ignore_errors=True)
+
+    def _get_output_filename(self, name, report_type, model_id, output_type):
+        """Generate the output filename based on the report parameters."""
+        name = name.replace(" ", "_") if name else None
+        if report_type == "ModelReport":
+            if not model_id:
+                raise ValueError("model_id is required for a model report.")
+            return (
+                f"{report_type}_{name}_{model_id}.{output_type}"
+                if name
+                else f"{report_type}_{model_id}.{output_type}"
+            )
+        return (
+            f"{report_type}_{name}.{output_type}"
+            if name
+            else f"{report_type}.{output_type}"
+        )
 
-        shutil.copy(__reports__ / healthcheck_file, working_dir)
-        output_filename = get_output_filename(name, report, modelid, output_type)
-        files = get_files(working_dir, cached_data)
-        params = get_params(modelid, predictordetails_activeonly)
+    def _copy_quarto_file(self, qmd_file, temp_dir):
+        """Copy the report quarto file to the temporary directory."""
+        from pdstools import __reports__
 
-        with open(f"{working_dir}/params.yaml", "w") as f:
+        shutil.copy(__reports__ / qmd_file, temp_dir)
+
+    def _verify_cached_files(self, temp_dir):
+        """Verify that cached data files exist."""
+        modeldata_files = list(temp_dir.glob("cached_modelData*"))
+        predictordata_files = list(temp_dir.glob("cached_predictorData*"))
+
+        if not modeldata_files:
+            raise FileNotFoundError("No cached model data found.")
+        if not predictordata_files:
+            logging.warning("No cached predictor data found.")
+
+    def _write_params_file(self, temp_dir, model_id, only_active_predictors):
+        """Write parameters to a YAML file."""
+        params = {
+            "kwargs": {
+                "subset": False,
+                "model_id": model_id,
+                "only_active_predictors": only_active_predictors,
+            },
+        }
+        with open(temp_dir / "params.yaml", "w") as f:
             yaml.dump(params, f)
 
-        bashCommand = f"quarto render {healthcheck_file} --to {output_type} --output {output_filename} --execute-params params.yaml"
+    def _run_quarto_command(
+        self, temp_dir, qmd_file, output_type, output_filename, verbose
+    ):
+        """Run the Quarto command to generate the report."""
+        try:
+            quarto_exec = self._find_quarto_executable()
+        except FileNotFoundError as e:
+            logging.error(str(e))
+            raise
+
+        # Check Quarto version
+        try:
+            version_result = subprocess.run(
+                [str(quarto_exec), "--version"],
+                capture_output=True,
+                text=True,
+                check=True,
+            )
+            quarto_version = version_result.stdout.strip()
+            logging.info(f"Quarto version: {quarto_version}")
+            # add version compatibility check here if needed
+        except subprocess.CalledProcessError as e:
+            logging.warning(f"Failed to check Quarto version: {e}")
+
+        command = [
+            str(quarto_exec),
+            "render",
+            qmd_file,
+            "--to",
+            output_type,
+            "--output",
+            output_filename,
+            "--execute-params",
+            "params.yaml",
+        ]
+
+        try:
+            result = subprocess.run(
+                command, cwd=temp_dir, capture_output=True, text=True, check=True
+            )
+            if verbose:
+                print(result.stdout)
+            if result.stderr:
+                logging.warning(f"Quarto command warnings: {result.stderr}")
+        except subprocess.CalledProcessError as e:
+            logging.error(f"Quarto command failed: {e.stderr}")
+            raise subprocess.SubprocessError(
+                f"Failed to generate report. Quarto command error: {e}"
+            )
+
+    def _find_quarto_executable(self):
+        """Find the Quarto executable on the system."""
+        if sys.platform == "win32":
+            possible_paths = [
+                Path(os.environ.get("USERPROFILE", ""))
+                / "AppData"
+                / "Local"
+                / "Programs"
+                / "Quarto"
+                / "bin"
+                / "quarto.cmd",
+                Path(os.environ.get("PROGRAMFILES", ""))
+                / "Quarto"
+                / "bin"
+                / "quarto.cmd",
+            ]
+        else:
+            possible_paths = [
+                Path("/usr/local/bin/quarto"),
+                Path("/opt/quarto/bin/quarto"),
+                Path(os.environ.get("HOME", "")) / ".local" / "bin" / "quarto",
+            ]
 
-        run_bash_command(bashCommand, working_dir, **kwargs)
+        for path in possible_paths:
+            if path.exists():
+                return path
 
-        check_output_file(working_dir, output_filename, verbose, delete_temp_files)
-        if delete_temp_files:
-            _delete_temp_files(working_dir, files)
+        # If not found in common locations, try to find it in PATH
+        quarto_in_path = shutil.which("quarto")
+        if quarto_in_path:
+            return Path(quarto_in_path)
 
-        return f"{working_dir}/{output_filename}"
+        raise FileNotFoundError(
+            "Quarto executable not found. Please ensure Quarto is installed and in the system PATH."
+        )
 
     def exportTables(self, file: Path = "Tables.xlsx", predictorBinning=False):
         """Exports all tables from `pdstools.adm.Tables` into one Excel file.
@@ -1855,3 +2015,6 @@ Meaning in total, {self.model_stats['models_n_nonperforming']} ({round(self.mode
                 )
                 data.write_excel(workbook=wb, worksheet=tab)
         return file
+
+
+# %%
diff --git a/python/pdstools/app/health_check/Home.py b/python/pdstools/app/health_check/Home.py
index fb4669ff..55648dfc 100644
--- a/python/pdstools/app/health_check/Home.py
+++ b/python/pdstools/app/health_check/Home.py
@@ -1,5 +1,10 @@
+import shutil
+
 import streamlit as st
 
+import pdstools
+from pdstools.utils.streamlit_utils import st_get_latest_pdstools_version
+
 st.set_page_config(
     page_title="Home",
     menu_items={
@@ -15,3 +20,27 @@ export for self exploration of the data.
 
 To be up to date with changes in the app, make sure to regularly run a `pip install --upgrade pdstools`!
 """
+
+pandoc_available = shutil.which("pandoc") is not None
+
+if not pandoc_available:
+    st.warning(
+        """
+    ⚠️ Pandoc is not available on your system. Some features of this app may not work correctly.
+    
+    Please install Pandoc from https://pandoc.org/installing.html to ensure full functionality.
+    """
+    )
+
+
+current_version = pdstools.__version__
+latest_version = st_get_latest_pdstools_version()
+
+if latest_version and current_version != latest_version:
+    st.warning(
+        f"""
+    ⚠️ You are using pdstools version {current_version}, but version {latest_version} is available.
+    
+    To update, run: `pip install --upgrade pdstools`
+    """
+    )
diff --git a/python/pdstools/app/health_check/pages/3-Reports.py b/python/pdstools/app/health_check/pages/3-Reports.py
index 59f796ea..b6cc31f4 100644
--- a/python/pdstools/app/health_check/pages/3-Reports.py
+++ b/python/pdstools/app/health_check/pages/3-Reports.py
@@ -1,17 +1,15 @@
 import os
+import traceback
 from pathlib import Path
-import polars as pl
+
 import streamlit as st
-from pdstools.utils.streamlit_utils import model_selection_df, process_files
-import traceback
+
+from pdstools.utils.streamlit_utils import model_selection_df
 
 if "dm" not in st.session_state:
     st.warning("Please configure your files in the `data import` tab.")
     st.stop()
 
-if "data_is_cached" not in st.session_state:
-    st.session_state["data_is_cached"] = False
-
 health_check, model_report = st.tabs(
     [
         "Overall Health Check",
@@ -42,7 +40,7 @@ with health_check:
                 outfile = (
                     st.session_state["dm"]
                     .applyGlobalQuery(st.session_state.get("filters", None))
-                    .generateReport(
+                    .generate_health_check(
                         name=name,
                         output_type=output_type,
                         working_dir=working_dir,
@@ -146,7 +144,7 @@ if st.session_state["dm"].predictorData is not None:
                 disabled=st.session_state["dm"].context_keys + ["Name"],
                 use_container_width=True,
             )
-            st.session_state["predictordetails_activeonly"] = st.checkbox(
+            st.session_state["only_active_predictors"] = st.checkbox(
                 label="Show only active predictors",
                 help="The ADM service automatically determines which predictors are used by the models, based on the individual predictive performance and the correlation between predictors. For example, the predictors with a low predictive performance do not become active. When predictors are highly correlated, only the best-performing predictor is used.",
                 value=True,
@@ -157,64 +155,54 @@ if st.session_state["dm"].predictorData is not None:
             st.write(f"{len(st.session_state['selected_models'])} models are selected")
             if len(st.session_state["selected_models"]) > 0:
                 if st.button("Create Model Report(s) for selected model(s)"):
-                    row_count_bar = st.progress(0.0)
-                    files = []
                     with st.spinner("Running Model Reports..."):
-                        for i, modelid in enumerate(
-                            st.session_state["selected_models"]
-                        ):
-                            if i + 1 == len(st.session_state["selected_models"]):
-                                del_cache = True
-                            else:
-                                del_cache = False
-                            row_count_bar.progress(
-                                value=i / len(st.session_state["selected_models"]),
-                                text=f"Generating report for {modelid} ({i+1} / {len(st.session_state['selected_models'])})",
+                        progress_bar = st.progress(
+                            0,
+                            text=f"Generated {0} of {len(st.session_state['selected_models'])}",
+                        )
+                        progress_text = st.empty()
+
+                        def update_progress(current, total):
+                            progress = current / total
+                            progress_bar.progress(
+                                progress, text=f"Generated {current} of {total}"
                             )
 
-                            outfile = (
-                                st.session_state["dm"]
-                                .applyGlobalQuery(st.session_state.get("filters", None))
-                                .generateReport(
-                                    name="",
-                                    working_dir=working_dir,
-                                    modelid=modelid,
-                                    delete_temp_files=del_cache,
-                                    output_type="html",
-                                    allow_collect=True,
-                                    cached_data=st.session_state["data_is_cached"],
-                                    output_to_file=True,
-                                    del_cache=del_cache,
-                                    predictordetails_activeonly=st.session_state[
-                                        "predictordetails_activeonly"
-                                    ],
-                                    verbose=True,
-                                )
+                        outfile = (
+                            st.session_state["dm"]
+                            .applyGlobalQuery(st.session_state.get("filters", None))
+                            .generate_model_reports(
+                                name="",
+                                working_dir=working_dir,
+                                model_list=st.session_state["selected_models"],
+                                output_type="html",
+                                only_active_predictors=st.session_state[
+                                    "only_active_predictors"
+                                ],
+                                progress_callback=update_progress,
                             )
-                            files.append(outfile)
-                            if not del_cache:
-                                st.session_state["data_is_cached"] = True
-                            else:
-                                st.session_state["data_is_cached"] = False
-
-                        (
-                            st.session_state["model_report_files"],
-                            st.session_state["model_report_name"],
-                        ) = process_files(files, outfile)
-
-                        row_count_bar.progress(value=1.0, text="Finished")
+                        )
+                        if os.path.isfile(outfile):
+                            file = open(outfile, "rb")
+                        st.session_state["model_report_files"] = file
+                        st.session_state["model_report_name"] = (
+                            outfile.name
+                            if len(st.session_state["selected_models"]) == 1
+                            else "ModelReports.zip"
+                        )
+
                         btn = st.download_button(
                             label="Download Model Reports",
                             data=st.session_state["model_report_files"],
                             file_name=st.session_state["model_report_name"],
                             key="ModelReportDownload",
                         )
+                        progress_bar.empty()
+                        progress_text.empty()
                         st.balloons()
-                        st.session_state["data_is_cached"] = False
         except Exception as e:
             st.error(f"""An error occured: {e}""")
             traceback_str = traceback.format_exc()
-            st.session_state["data_is_cached"] = False
             with open(working_dir / "log.txt", "a") as f:
                 f.write(traceback_str)
             with open(working_dir / "log.txt", "rb") as f:
@@ -222,7 +210,7 @@ if st.session_state["dm"].predictorData is not None:
                     label="Download error log",
                     data=f,
                     file_name="errorlog.txt",
-                    key="ErrorLogDownload"
+                    key="ErrorLogDownload",
                 )
 
             for filename in os.listdir(working_dir):
diff --git a/python/pdstools/plots/plot_base.py b/python/pdstools/plots/plot_base.py
index 370156cc..fe6a6687 100644
--- a/python/pdstools/plots/plot_base.py
+++ b/python/pdstools/plots/plot_base.py
@@ -676,7 +676,8 @@ class Plots:
         if modelids is not None:
             df = df.filter(pl.col("ModelID").is_in(modelids))
         df = df.filter(pl.col("PredictorName") == "Classifier").collect()
-
+        if df.shape[0] == 0:
+            raise ValueError(f'There is no data for the provided modelids {modelids}')  
         if kwargs.pop("return_df", False):
             return df
 
diff --git a/python/pdstools/reports/ModelReport.qmd b/python/pdstools/reports/ModelReport.qmd
index fbce55e5..b5d6fe83 100644
--- a/python/pdstools/reports/ModelReport.qmd
+++ b/python/pdstools/reports/ModelReport.qmd
@@ -88,9 +88,9 @@ def polars_subset_to_existing_cols(cols):
 datafolder = ""
 modelfilename = ""
 predictorfilename = ""
-modelid = ""
+model_id = ""
 
-predictordetails_activeonly = True
+only_active_predictors = True
 
 title = "Demo Dataset"  # pass in customer name here
 subtitle = ""  # typically used to pass in a date range or other qualification of the data source
@@ -104,11 +104,11 @@ kwargs = dict()
 # Predictor data for one model ID
 if len(kwargs) > 0:
     # streamlit call
-    modelid = kwargs["modelid"]
+    model_id = kwargs["model_id"]
     datamart = ADMDatamart(
-        **kwargs, include_cols="pyFeatureImportance", query=pl.col("ModelID") == modelid
+        **kwargs, include_cols="pyFeatureImportance", query=pl.col("ModelID") == model_id
     ).fillMissing()
-    predictordetails_activeonly = kwargs["predictordetails_activeonly"]
+    only_active_predictors = kwargs["only_active_predictors"]
 elif len(datafolder) > 0 or len(modelfilename) > 0 or len(predictorfilename) > 0:
     # command line call
     datamart = ADMDatamart(
@@ -117,12 +117,12 @@ elif len(datafolder) > 0 or len(modelfilename) > 0 or len(predictorfilename) > 0
         predictor_filename=predictorfilename,
         extract_keys=True,
         include_cols=["pyFeatureImportance"],
-        query=pl.col("ModelID") == modelid,
+        query=pl.col("ModelID") == model_id,
     ).fillMissing()
 else:
     # fall back to sample data
-    modelid = "bd70a915-697a-5d43-ab2c-53b0557c85a0"
-    datamart = datasets.CDHSample(query=pl.col("ModelID") == modelid)
+    model_id = "bd70a915-697a-5d43-ab2c-53b0557c85a0"
+    datamart = datasets.CDHSample(query=pl.col("ModelID") == model_id)
 
 # TODO ensure this is only one predictor snapshot, just in case? although it would be
 # neat to show predictor evolution, if available
@@ -135,7 +135,7 @@ if polars_col_exists(datamart.modelData, "Treatment"):
 else:
     datamart.modelData = datamart.modelData.with_columns(pl.lit("-").alias("Treatment"))
 
-if predictordetails_activeonly:
+if only_active_predictors:
     status_filter = pl.col("EntryType") == "Active"
 else:
     status_filter = pl.col("EntryType") != "Classifier"
@@ -176,7 +176,7 @@ quarto_print(
 
 | Channel | ID |
 |:-----|------:|
-| {channel_name} | {modelid} |
+| {channel_name} | {model_id} |
 
 """
 )
@@ -184,7 +184,7 @@ quarto_print(
 
 
 ```{python}
-fig = datamart.plotScoreDistribution(modelids=[modelid])
+fig = datamart.plotScoreDistribution(modelids=[model_id])
 
 # Customize some of the default styling of the plot
 
@@ -606,7 +606,7 @@ The predictors are listed in the same order as in the summary above.
 ```{python}
 quarto_print(
     f"""
-Here we show **{'only the active' if predictordetails_activeonly else 'all'}**
+Here we show **{'only the active' if only_active_predictors else 'all'}**
 predictors. This can be configured via a parameter to this report.
 """
 )
@@ -770,7 +770,7 @@ def show_single_predictor(pred):
 
     # Predictor binning
 
-    fig = datamart.plotPredictorBinning(modelids=[modelid], predictors=[pred])
+    fig = datamart.plotPredictorBinning(modelids=[model_id], predictors=[pred])
 
     # Customize some of the default styling of the plot
 
diff --git a/python/pdstools/utils/cdh_utils.py b/python/pdstools/utils/cdh_utils.py
index aae534fa..aad2839b 100644
--- a/python/pdstools/utils/cdh_utils.py
+++ b/python/pdstools/utils/cdh_utils.py
@@ -6,18 +6,23 @@ Various utilities to access and manipulate data from Pega for purposes
 of data analysis, reporting and monitoring.
 """
 
-from typing import List, Union
-import polars as pl
-import re
-import numpy as np
 import datetime
+import io
+import re
 import warnings
-from .types import any_frame
-from .errors import NotEagerError
-from .table_definitions import PegaDefaultTables
+import zipfile
+import requests
+from pathlib import Path
+from typing import List, Tuple, Union
 
+import numpy as np
+import polars as pl
 import pytz
 
+from .errors import NotEagerError
+from .table_definitions import PegaDefaultTables
+from .types import any_frame
+
 
 def defaultPredictorCategorization(
     x: Union[str, pl.Expr] = pl.col("PredictorName"),
@@ -632,8 +637,12 @@ def weighted_average_polars(
         weights = pl.col(weights)
 
     return (
-        (vals * weights).filter(vals.is_not_nan() & vals.is_infinite().not_() & weights.is_not_null()).sum()
-    ) / weights.filter(vals.is_not_nan() & vals.is_infinite().not_() & weights.is_not_null()).sum()
+        (vals * weights)
+        .filter(vals.is_not_nan() & vals.is_infinite().not_() & weights.is_not_null())
+        .sum()
+    ) / weights.filter(
+        vals.is_not_nan() & vals.is_infinite().not_() & weights.is_not_null()
+    ).sum()
 
 
 def weighted_performance_polars() -> pl.Expr:
@@ -905,10 +914,11 @@ def sync_reports(checkOnly: bool = False, autoUpdate: bool = False):
     autoUpdate : bool, default = False
         If True, doensn't prompt for update and goes ahead
     """
-    from pdstools import __reports__
     import glob
     import urllib
 
+    from pdstools import __reports__
+
     files = [f for f in glob.glob(str(__reports__ / "*.qmd"))]
     latest = {}
     replacement = {}
@@ -935,3 +945,73 @@ def sync_reports(checkOnly: bool = False, autoUpdate: bool = False):
             return False
     else:
         return True
+
+
+def process_files_to_bytes(
+    file_paths: List[Union[str, Path]], base_file_name: Union[str, Path]
+) -> Tuple[bytes, str]:
+    """
+    Processes a list of file paths, returning file content as bytes and a corresponding file name.
+    Useful for zipping muliple model reports and the byte object is used for downloading files in
+    Streamlit app.
+
+    This function handles three scenarios:
+    1. Single file: Returns the file's content as bytes and the provided base file name.
+    2. Multiple files: Creates a zip file containing all files, returns the zip file's content as bytes
+       and a generated zip file name.
+    3. No files: Returns empty bytes and an empty string.
+
+    Parameters
+    ----------
+    file_paths : List[Union[str, Path]]
+        A list of file paths to process. Can be empty, contain a single path, or multiple paths.
+    base_file_name : Union[str, Path]
+        The base name to use for the output file. For a single file, this name is returned as is.
+        For multiple files, this is used as part of the generated zip file name.
+
+    Returns
+    -------
+    Tuple[bytes, str]
+        A tuple containing:
+        - bytes: The content of the single file or the created zip file, or empty bytes if no files.
+        - str: The file name (either base_file_name or a generated zip file name), or an empty string if no files.
+    """
+    file_paths = [Path(fp) for fp in file_paths]
+    base_file_name = Path(base_file_name)
+
+    if not file_paths:
+        return b"", ""
+
+    if len(file_paths) == 1:
+        try:
+            with file_paths[0].open("rb") as file:
+                return file.read(), base_file_name.name
+        except IOError as e:
+            print(f"Error reading file {file_paths[0]}: {e}")
+            return b"", ""
+
+    # Multiple files
+    in_memory_zip = io.BytesIO()
+    with zipfile.ZipFile(in_memory_zip, "w") as zipf:
+        for file_path in file_paths:
+            try:
+                zipf.write(
+                    file_path,
+                    file_path.name,
+                    compress_type=zipfile.ZIP_DEFLATED,
+                )
+            except IOError as e:
+                print(f"Error adding file {file_path} to zip: {e}")
+
+    time = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
+    zip_file_name = f"{base_file_name.stem}_{time}.zip"
+    in_memory_zip.seek(0)
+    return in_memory_zip.getvalue(), zip_file_name
+
+
+def get_latest_pdstools_version():
+    try:
+        response = requests.get("https://pypi.org/pypi/pdstools/json")
+        return response.json()["info"]["version"]
+    except:
+        return None
diff --git a/python/pdstools/utils/streamlit_utils.py b/python/pdstools/utils/streamlit_utils.py
index 4ff2de90..f1cb1e7b 100644
--- a/python/pdstools/utils/streamlit_utils.py
+++ b/python/pdstools/utils/streamlit_utils.py
@@ -1,17 +1,16 @@
 import os
-import zipfile
-import io
-import streamlit as st
-from typing import Optional, List, Tuple
-import polars as pl
 from pathlib import Path
-from datetime import datetime
-from . import cdh_utils
+from typing import Optional
+
+import plotly.express as px
+import polars as pl
+import streamlit as st
+
+from .. import pega_io
 from ..adm.ADMDatamart import ADMDatamart
 from ..utils import datasets
 from ..utils.types import any_frame
-import plotly.express as px
-from .. import pega_io
+from . import cdh_utils
 
 
 @st.cache_resource
@@ -346,44 +345,6 @@ def convert_df(df):
     return df.write_csv().encode("utf-8")
 
 
-def process_files(file_paths: List[str], file_name: str) -> Tuple[bytes, str]:
-    """
-    Processes a list of file paths. If there's only one file, returns the file's content as bytes
-    and the provided file name. If there are multiple files, creates a zip file containing all the files
-    and returns the zip file's data as bytes and the generated zip file name.
-
-    Parameters
-    ----------
-    file_paths : List[str]
-        A list of file paths to process.
-    file_name : str
-        The file name to use when returning the file or zip file's name.
-
-    Returns
-    -------
-    (bytes, str)
-        The content of the single file as bytes and the file name if there's only one file,
-        or the zip file's data as bytes and the zip file's name if there are multiple files.
-    """
-    if len(file_paths) == 1:
-        file_name = file_name.split("/")[-1] if "/" in file_name else file_name
-        with open(file_paths[0], "rb") as file:
-            return file.read(), file_name
-    elif len(file_paths) > 1:
-        in_memory_zip = io.BytesIO()
-        with zipfile.ZipFile(in_memory_zip, "w") as zipf:
-            for file_path in file_paths:
-                zipf.write(
-                    file_path,
-                    os.path.basename(file_path),
-                    compress_type=zipfile.ZIP_DEFLATED,
-                )
-        time = datetime.now().strftime("%Y%m%dT%H%M%S.%f")[:-3]
-        file_name = f"ModelReports_{time}.zip"
-        in_memory_zip.seek(0)
-        return in_memory_zip.read(), file_name
-
-
 # def newPredictorCategorizationFunc():
 
 #     def conditions():
@@ -413,3 +374,8 @@ def process_files(file_paths: List[str], file_name: str) -> Tuple[bytes, str]:
 #     {'func':'contains','value':'customer', 'then_func', 'head', 'then_value':[2]},
 #     {'func':'regex', 'value':'IH^', 'then_func':'regex', 'then_value':'IH^.^'}]
 #     """
+
+
+@st.cache_data
+def st_get_latest_pdstools_version():
+    return cdh_utils.get_latest_pdstools_version()


Test patch:
diff --git a/python/tests/test_healthcheck.py b/python/tests/test_healthcheck.py
index 659b493c..f15e4594 100644
--- a/python/tests/test_healthcheck.py
+++ b/python/tests/test_healthcheck.py
@@ -24,12 +24,13 @@ def sample_without_predictorbinning():
 
 
 def test_GenerateHealthCheck(sample):
-    hc = sample.generateReport(verbose=True)
-    assert hc == "./HealthCheck.html"
+    hc = sample.generate_health_check(verbose=True)
+    assert hc == pathlib.Path("./HealthCheck.html").resolve()
     assert pathlib.Path(hc).exists()
     pathlib.Path(hc).unlink()
     assert not pathlib.Path(hc).exists()
 
+
 def test_ExportTables(sample):
     excel = sample.exportTables(predictorBinning=True)
     assert excel == "Tables.xlsx"
@@ -45,6 +46,7 @@ def test_ExportTables(sample):
     pathlib.Path(excel).unlink()
     assert not pathlib.Path(excel).exists()
 
+
 def test_ExportTables_NoBinning(sample):
     excel = sample.exportTables(predictorBinning=False)
     assert excel == "Tables.xlsx"
@@ -59,11 +61,12 @@ def test_ExportTables_NoBinning(sample):
     pathlib.Path(excel).unlink()
     assert not pathlib.Path(excel).exists()
 
+
 def test_GenerateHealthCheck_ModelDataOnly(sample_without_predictorbinning):
-    hc = sample_without_predictorbinning.generateReport(
+    hc = sample_without_predictorbinning.generate_health_check(
         name="MyOrg", verbose=True, modelData_only=True
     )
-    assert hc == "./HealthCheck_MyOrg.html"
+    assert hc == pathlib.Path("./HealthCheck_MyOrg.html").resolve()
     assert pathlib.Path(hc).exists()
     pathlib.Path(hc).unlink()
     assert not pathlib.Path(hc).exists()
@@ -85,17 +88,26 @@ def test_ExportTables_ModelDataOnly(sample_without_predictorbinning):
 
 
 def test_GenerateModelReport(sample):
-    report = sample.generateReport(
-        name="MyOrg", modelid="bd70a915-697a-5d43-ab2c-53b0557c85a0"
+    report = sample.generate_model_reports(
+        name="MyOrg",
+        model_list=["bd70a915-697a-5d43-ab2c-53b0557c85a0"],
+        only_active_predictors=True,
     )
-    assert report == "./ModelReport_MyOrg_bd70a915-697a-5d43-ab2c-53b0557c85a0.html"
+    expected_path = pathlib.Path(
+        "ModelReport_MyOrg_bd70a915-697a-5d43-ab2c-53b0557c85a0.html"
+    ).resolve()
+    assert report == expected_path
     assert pathlib.Path(report).exists()
     pathlib.Path(report).unlink()
     assert not pathlib.Path(report).exists()
 
+
 def test_GenerateModelReport_Failing(sample_without_predictorbinning):
     with pytest.raises(Exception) as e_info:
-        sample_without_predictorbinning.generateReport(
-            name="MyOrg", modelid="bd70a915-697a-5d43-ab2c-53b0557c85a0"
+        sample_without_predictorbinning.generate_model_reports(
+            name="MyOrg", model_list="bd70a915-697a-5d43-ab2c-53b0557c85a0"
         )
-    assert "Error when generating healthcheck" in str(e_info)
+    assert (
+        "model_list argument is None, not a list, or contains non-string elements for generate_model_reports. Please provide a list of model_id strings to generate reports."
+        in str(e_info)
+    )