Source code for vuegen.utils

"""File system utilities, file conversion functions,
graph related utilities, config file writing, and
command line parser and logging messages (completion).

streamlit report footer is also in this file.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import sys
import textwrap
from datetime import datetime
from io import StringIO
from pathlib import Path
from typing import Iterable, Optional, Type
from urllib.parse import urlparse

import networkx as nx
import requests
import yaml
from bs4 import BeautifulSoup

try:
    from enum import StrEnum
except ImportError:
    from strenum import StrEnum

from vuegen.constants import GITHUB_ORG_URL, LOGO_URL, ORG, REPO_URL, TIMEOUT


# CHECKS
[docs] def check_path(filepath: Path) -> bool: """ Checks if the given file or folder path exists. Parameters --------- filepath : Path The file or folder path to check. Returns ------- bool True if the path exists, False otherwise. """ # Check if the path exists return os.path.exists(os.path.abspath(filepath))
[docs] def assert_enum_value( enum_class: Type[StrEnum], value: str, logger: logging.Logger ) -> StrEnum: """ Validate that the given value is a valid member of the specified enumeration class. Parameters ---------- enum_class : Type[StrEnum] The enumeration class to validate against. value : str The value to be validated. logger : logging.Logger A logger object to track warnings, errors, and info messages. Returns ------- StrEnum The corresponding member of the enumeration if valid. Raises ------ ValueError If the value is not a valid member of the enumeration class. """ try: return enum_class[value.upper()] except KeyError as e: expected_values = ", ".join([str(e.value) for e in enum_class]) logger.error( f"Invalid value for {enum_class.__name__}: '{value}'." f"Expected values are: {expected_values}" ) raise ValueError( f"Invalid {enum_class.__name__}: {value}. " f"Expected values are: {expected_values}" ) from e
[docs] def is_url(filepath: Path) -> bool: """ Check if the provided path is a valid URL. Parameters ---------- filepath : Path The filepath to check. Returns ------- bool True if the input path is a valid URL, meaning it contains both a scheme (e.g., http, https, ftp) and a network location (e.g., example.com). Returns False if either the scheme or the network location is missing or invalid. """ # Parse the url and return validation parsed_url = urlparse(str(filepath)) return bool(parsed_url.scheme and parsed_url.netloc)
[docs] def is_pyvis_html(filepath: str) -> bool: """ Check if the provided HTML file is a Pyvis network visualization. Parameters ---------- filepath : str The path to the HTML file to check. Returns ------- bool True if the input HTML file is a Pyvis network, meaning: - It contains a `<div>` element with `id="mynetwork"`. - The `<body>` only contains `<div>` and `<script>` elements. Returns False otherwise. """ # Parse the HTML file with open(filepath, "r", encoding="utf-8") as f: soup = BeautifulSoup(f, "html.parser") # Validate both conditions pyvis_identifier_valid = bool(soup.find("div", {"id": "mynetwork"})) # Count top-level elements inside <body> body_children = [tag.name for tag in soup.body.find_all(recursive=False)] # A pure Pyvis file should contain only "div" and "script" elements in <body> body_structure_valid = set(body_children) <= {"div", "script"} # Both conditions must be true return pyvis_identifier_valid and body_structure_valid
# FILE_SYSTEM
[docs] def create_folder(directory_path: str, is_nested: bool = False) -> bool: """ Create a folder. Optionally create nested directories if the specified path includes subdirectories. Parameters ---------- directory_path : str The path of the directory to create. is_nested : bool A flag indicating whether to create nested directories. True uses os.makedirs, False uses os.mkdir. Returns ------- bool True if the folder was created or False if it already existed. Raises ------ OSError If there is an error creating the directory. """ try: if not check_path(directory_path): if is_nested: # Create the directory and any necessary parent directories os.makedirs(directory_path, exist_ok=True) return True else: # Create only the final directory (not nested) os.mkdir(directory_path) return True else: return False except OSError as e: raise OSError(f"Error creating directory '{directory_path}'.") from e
[docs] def get_relative_file_path( file_path: str, base_path: str = "", relative_to: str = "." ) -> Path: """ Returns the relative file path of a given file with respect to the current working directory (CWD). This method will resolve the absolute path of the given file and return a relative path with respect to the directory where the script is being executed. Optionally, a base path can be added (e.g., "../"). Parameters ---------- file_path : str The full file path to be converted to a relative path. base_path : str, optional The base path to be prepended to the relative path, default is an empty string. relativ_to : str, optional The directory to which the file path should be relative, default is the current directory ("."). Returns ------- Path The file path relative to the CWD. """ if relative_to == ".": # Use the current working directory as the base relative_to = Path.cwd() elif isinstance(relative_to, str): # ensure path is a Path object relative_to = Path(relative_to) rel_path = os.path.relpath(Path(file_path).resolve(), relative_to) rel_path = Path(rel_path) # Ensure rel_path is a Path object if base_path: rel_path = Path(base_path) / rel_path return rel_path
[docs] def get_parser(prog_name: str, others: Optional[dict] = None) -> argparse.Namespace: """ Initiates argparse.ArgumentParser() and adds common arguments. Parameters ---------- prog_name : str The name of the program. others : dict, optional Additional keyword arguments for ArgumentParser initialization. Returns ------- argparse.Namespace Parsed command-line arguments. Raises ------ AssertionError If prog_name is not a string or others is not a dictionary. """ if others is None: others = {} # Preconditions assert isinstance(prog_name, str), f"prog_name should be a string: {prog_name}" assert isinstance(others, dict), f"others must be a dict: {others}" # Initialize argument parser parser = argparse.ArgumentParser(prog=prog_name, **others) # Add arguments parser.add_argument( "-c", "--config", type=str, default=None, help="Path to the YAML configuration file.", ) parser.add_argument( "-dir", "--directory", type=str, default=None, help="Path to the directory from which the YAML config will be inferred.", ) parser.add_argument( "-rt", "--report_type", type=str, default="streamlit", help=( "Type of the report to generate: streamlit, html, pdf, docx, odt, revealjs," " pptx, or jupyter." ), ) parser.add_argument( "-output_dir", "--output_directory", type=str, default=None, help="Path to the output directory for the generated report.", ) parser.add_argument( "-st_autorun", "--streamlit_autorun", action="store_true", # Automatically sets True if the flag is passed default=False, help="Automatically run the Streamlit app after report generation.", ) parser.add_argument( "-qt_checks", "--quarto_checks", action="store_true", # Automatically sets True if the flag is passed default=False, help="Check if Quarto is installed and available for report generation.", ) parser.add_argument( "-mdep", "--max_depth", type=int, default=2, help=( "Maximum depth for the recursive search of files in the input directory. " "Ignored if a config file is provided." ), ) # Parse arguments return parser
[docs] def fetch_file_stream(file_path: str, timeout: int = TIMEOUT) -> StringIO: """ Fetches a file-like stream from a given file path or URL. Parameters ---------- file_path : str The path to a local file or a URL to fetch content from. Returns ------- StringIO A file-like object containing the content of the file or URL. Raises ------ AssertionError If the file_path is not a valid string. FileNotFoundError If the file path does not exist for a local file. ValueError If an error occurs while fetching content from a URL. """ # Assert that the file_path is a string assert isinstance(file_path, str), f"File path must be a string: {file_path}" if is_url(file_path): # Handle URL input try: response = requests.get(file_path, timeout=timeout) response.raise_for_status() # Raise an exception for HTTP errors return StringIO(response.text) except requests.exceptions.RequestException as e: raise ValueError(f"Error fetching content from URL: {file_path}.") from e else: # Handle local file input if not os.path.exists(file_path): raise FileNotFoundError( f"The file at {file_path} was not found or cannot be accessed." ) with open(file_path, "r") as file: return StringIO(file.read())
# FILE_CONVERSION
[docs] def cyjs_to_networkx(file_path: str, name: str = "name", ident: str = "id") -> nx.Graph: """ Create a NetworkX graph from a `.cyjs` file in Cytoscape format, including all attributes present in the JSON data. This function is modified from the `cytoscape_graph` networkx function to handle the 'value' key explicitly and to include all additional attributes found in the JSON data for both nodes and edges. Parameters ---------- file_path : str The path to a `.cyjs` file (Cytoscape JSON format) containing the network data. name : str, optional A string which is mapped to the 'name' node element in Cytoscape JSON format. ident : str, optional A string which is mapped to the 'id' node element in Cytoscape JSON format. Must not have the same value as `name`. Default is "id". Returns ------- graph : networkx.Graph The graph created from the Cytoscape JSON data, including all node and edge attributes. Raises ------ NetworkXError If the `name` and `ident` attributes are identical. ValueError If the data format is invalid or missing required elements, such as 'id' or 'name' for nodes. """ try: # If file_path is a file-like object (e.g., StringIO), read from it if hasattr(file_path, "read"): data = json.load(file_path) else: # Otherwise, assume it's a file path and open the file with open(file_path, "r") as json_file: data = json.load(json_file) if name == ident: raise nx.NetworkXError("name and ident must be different.") multigraph = data.get("multigraph", False) directed = data.get("directed", False) if multigraph: graph = nx.MultiGraph() else: graph = nx.Graph() if directed: graph = graph.to_directed() graph.graph = dict(data.get("data", {})) # Add nodes with all attributes from the 'data' field of the JSON for d in data["elements"]["nodes"]: node_data = d["data"].copy() node = d["data"].get(ident) # Use 'id' (or other unique identifier) if node is None: raise ValueError("Each node must contain an 'id' key.") # Optionally include 'name' and 'id' attributes if present if name in d["data"]: node_data[name] = d["data"].get(name) graph.add_node(node) graph.nodes[node].update(node_data) # Add edges with all attributes from the 'data' field of the JSON for d in data["elements"]["edges"]: edge_data = d["data"].copy() sour = d["data"].get("source") targ = d["data"].get("target") if sour is None or targ is None: raise ValueError("Each edge must contain 'source' and 'target' keys.") if multigraph: key = d["data"].get("key", 0) graph.add_edge(sour, targ, key=key) graph.edges[sour, targ, key].update(edge_data) else: graph.add_edge(sour, targ) graph.edges[sour, targ].update(edge_data) return graph except KeyError as e: raise ValueError("Missing required key in data.") from e
[docs] def pyvishtml_to_networkx(html_file: str) -> nx.Graph: """ Converts a PyVis HTML file to a NetworkX graph. Parameters ---------- html_file : str Path to the PyVis HTML file. Returns ------- graph : nx.Graph NetworkX graph object reconstructed from the PyVis network data. Raises ------ ValueError If the HTML file does not contain the expected network data, or if nodes lack 'id' attribute. """ # Load the HTML file if isinstance(html_file, StringIO): # If the input is a StringIO, read its content html_content = html_file.getvalue() else: # Otherwise, treat it as a file path with open(html_file, "r", encoding="utf-8") as f: html_content = f.read() soup = BeautifulSoup(html_content, "html.parser") # Extract the network data from the JavaScript objects script_tag = soup.find( "script", text=lambda x: x and "nodes = new vis.DataSet" in x ) if not script_tag: raise ValueError("Could not find network data in the provided HTML file.") # Parse the nodes and edges script_text = script_tag.string nodes_json = json.loads( script_text.split("nodes = new vis.DataSet(")[1].split(");")[0] ) edges_json = json.loads( script_text.split("edges = new vis.DataSet(")[1].split(");")[0] ) # Create a NetworkX graph graph = nx.Graph() # Add nodes for node in nodes_json: node_id = node.pop("id", None) if node_id is None: raise ValueError("Node is missing an 'id' attribute.") graph.add_node(node_id, **node) # Add edges for edge in edges_json: source = edge.pop("from") target = edge.pop("to") graph.add_edge(source, target, **edge) # Relabel nodes to use 'name' as the identifier, or 'id' if 'name' is unavailable mapping = {} for node_id, data in graph.nodes(data=True): name = data.get("name") if name: mapping[node_id] = name else: # Fallback to the original ID if no 'name' exists mapping[node_id] = node_id graph = nx.relabel_nodes(graph, mapping) return graph
# CONFIG
[docs] def load_yaml_config(file_path: str) -> dict: """ Load a YAML configuration file and return its contents as a dictionary. Parameters ---------- file_path : str The path to the YAML configuration file. Returns ------- config : dict The contents of the YAML file as a dictionary. RAISES ------ FileNotFoundError If the file does not exist at the specified path. ValueError If there is an error parsing the YAML file. """ # Check the existence of the file_path if not os.path.exists(file_path): raise FileNotFoundError(f"The config file at {file_path} was not found.") # Load the YAML configuration file with open(file_path, "r") as file: try: config = yaml.safe_load(file) except yaml.YAMLError as exc: raise ValueError("Error parsing YAML file.") from exc return config
[docs] def write_yaml_config(yaml_data: dict, directory_path: Path) -> Path: """ Writes the generated YAML structure to a file. Parameters ---------- yaml_data : dict The YAML data to write. directory_path : Path The path where the YAML file should be saved. Returns ------- output_yaml : Path The path to the written YAML file. """ assert isinstance(yaml_data, dict), "YAML data must be a dictionary." assert isinstance(directory_path, Path), "directory_path must be a Path object." # Generate the output YAML file path based on the folder name _name = yaml_data["report"]["title"].replace(" ", "_").lower() output_yaml = directory_path / f"{_name}_config.yaml" # Ensure the directory exists (but don't create a new folder) if not directory_path.exists(): raise FileNotFoundError(f"The directory {directory_path} does not exist.") # Now write the YAML file with open(output_yaml, "w", encoding="utf-8") as yaml_file: yaml.dump(yaml_data, yaml_file, default_flow_style=False, sort_keys=False) # Return the path to the written file return output_yaml
# LOGGING
[docs] def get_basename(fname: None | str = None) -> str: """ - For a given filename, returns basename WITHOUT file extension - If no fname given (i.e., None) then return basename that the function is called in Parameters ---------- fname: str, optional The filename to get basename from. Default is None. Returns ------- str basename of given filepath or the current file the function is executed Examples --------- 1) >>> get_basename() utils 2) >>> get_basename('this/is-a-filepath.csv') is-a-filepath """ if fname is not None: # PRECONDITION if not check_path(fname): raise FileNotFoundError(f"The specified path does not exist: {fname}") # MAIN FUNCTIONS return os.path.splitext(os.path.basename(fname))[0] else: return os.path.splitext(os.path.basename(sys.argv[0]))[0]
[docs] def get_time(incl_time: bool = True, incl_timezone: bool = True) -> str: """ Gets current date, time (optional) and timezone (optional) for file naming Parameters ---------- - incl_time (bool): whether to include timestamp in the string - incl_timezone (bool): whether to include the timezone in the string Returns ------- str fname that includes date, timestamp and/or timezone connected by '_' in one string e.g. yyyyMMdd_hhmm_timezone Examples -------- 1) >>> get_time() '20231019_101758_CEST' 2) >>> get_time(incl_time=False) '20231019_CEST' """ # PRECONDITIONALS assert isinstance(incl_time, bool), "incl_time must be True or False" assert isinstance(incl_timezone, bool), "incl_timezone must be True or False" # MAIN FUNCTION # getting current time and timezone the_time = datetime.now() timezone = datetime.now().astimezone().tzname() # convert date parts to string # putting date parts into one string if incl_time and incl_timezone: fname = the_time.isoformat(sep="_", timespec="seconds") + "_" + timezone elif incl_time: fname = the_time.isoformat(sep="_", timespec="seconds") elif incl_timezone: fname = "_".join([the_time.isoformat(sep="_", timespec="hours")[:-3], timezone]) else: y = str(the_time.year) m = str(the_time.month) d = str(the_time.day) fname = y + m + d # optional fname = fname.replace(":", "-") # remove ':' from hours, minutes, seconds return fname
[docs] def generate_log_filename(folder: str = "logs", suffix: str = "") -> str: """ Creates log file name and path Parameters ---------- folder (str): name of the folder to put the log file in suffix (str): anything else you want to add to the log file name Returns ------- str The file path to the log file """ try: # PRECONDITIONS create_folder(folder) # ? Path(folder).mkdir(parents=True, exist_ok=True) except OSError as e: raise OSError(f"Error creating directory '{folder}'") from e # MAIN FUNCTION log_filename = get_time(incl_timezone=False) + "_" + suffix + ".log" log_filepath = os.path.join(folder, log_filename) return log_filepath
[docs] def init_log( filename: str, display: bool = False, logger_id: str | None = None ) -> logging.Logger: """ - Custom python logger configuration (basicConfig()) with two handlers (for stdout and for file) - from: https://stackoverflow.com/a/44760039 - Keeps a log record file of the python application, with option to display in stdout Parameters ---------- filename (str): filepath to log record file - display (bool): whether to print the logs to whatever standard output - logger_id (str): an optional identifier for yourself, if None then defaults to 'root' Returns ------- logging.Logger The logger object Examples ----- >>> logger = init_log('logs/tmp.log', display=True) >>> logger.info('Loading things') [2023-10-20 10:38:03,074] root: INFO - Loading things """ # PRECONDITIONALS assert isinstance(filename, str), "Filename must be a string" assert ( isinstance(logger_id, str) or logger_id is None ), "logger_id must be a string or None" # MAIN FUNCTION # init handlers file_handler = logging.FileHandler(filename=filename) stdout_handler = logging.StreamHandler(stream=sys.stdout) if display: handlers = [file_handler, stdout_handler] else: handlers = [file_handler] # instantiate the logger logger = logging.getLogger(logger_id) logger.setLevel(logging.DEBUG) # logger configuration # ! logging.basicConfig has no effect if called once anywhere in the code # ! set handlers and format for the logger manually # Reset any existing handlers for handler in logger.handlers[:]: logger.removeHandler(handler) # Set up the new handlers and format formatter = logging.Formatter("[%(asctime)s] %(name)s: %(levelname)s - %(message)s") for handler in handlers: handler.setFormatter(formatter) logger.addHandler(handler) logging.getLogger("matplotlib.font_manager").disabled = True return logger
[docs] def get_logger( log_suffix, folder="logs", display=True, logger_id="vuegen" ) -> tuple[logging.Logger, str]: """ Initialize the logger with a log file name that includes an optional suffix. Parameters ---------- log_suffix : str A string to append to the log file name. Returns ------- tuple[logging.Logger, str] A tuple containing the logger instance and the log file path. """ # Generate log file name log_file = generate_log_filename(folder=folder, suffix=log_suffix) # Initialize logger logger = init_log(log_file, display=display, logger_id=logger_id) # Log the path to the log file logger.info("Path to log file: %s", log_file) return logger, log_file
[docs] def get_completion_message(report_type: str, config_path: str) -> str: """ Generate a formatted completion message after report generation. Parameters ---------- report_type : str The type of report generated (e.g., "streamlit", "html"). config_path : str The path to the configuration file used for generating the report. Returns ------- str A formatted string containing the completion message. """ border = "─" * 65 # Creates a separator line if report_type == "streamlit": message = textwrap.dedent(f""" 🚀 Streamlit Report Generated! 📂 All scripts to build the Streamlit app are available at: streamlit_report/sections ▶️ To run the Streamlit app, use the following command: streamlit run streamlit_report/sections/report_manager.py ✨ You can extend the report by adding new files to the input directory or updating the config file. 🛠️ Advanced users can modify the Python scripts directly in: streamlit_report/sections ⚙️ Configuration file used: {config_path} """) else: message = textwrap.dedent(f""" 🚀 {report_type.capitalize()} Report Generated! 📂 Your {report_type} report is available at: quarto_report ✨ You can extend the report by adding new files to the input directory or updating the config file. 🛠️ Advanced users can modify the report template directly in: quarto_report/quarto_report.qmd ⚙️ Configuration file used: {config_path} """) return f"{message}\n{border}"
# REPORT FORMATTING # ? move as only used in streamlit_report
[docs] def sort_imports(imp: Iterable[str]) -> tuple[list[str], list[str]]: """Separte 'from' and 'import' statements from setup code. Parameters ---------- imp : Iterable[str] A list of import statements and setup statements. Returns ------- Tuple[List[str], List[str]] A tuple of two lists: one for import statements and one for setup statements. Examples -------- >>> imp = [ ... 'import logging', ... 'import shutil', ... 'logging.basicConfig(level=logging.INFO)', ... 'import pandas as pd', ... 'import numpy as np', ... ] >>> sort_imports(imp) (['import logging', 'import numpy as np', 'import pandas as pd', 'import shutil ], ['logging.basicConfig(level=logging.INFO)']) """ imports_statements, setup_statements = [], [] for line in imp: line = line.strip() # just for safety if line.startswith("from ") or line.startswith("import "): imports_statements.append(line) else: setup_statements.append(line) imports_statements.sort() setup_statements.sort() return imports_statements, setup_statements