Source code for gsplot.config.config

from __future__ import annotations

import json
import os
from datetime import datetime
from threading import Lock
from typing import Any, cast

import matplotlib as mpl
import yaml
from matplotlib import rcParams
from rich.traceback import install

from ..path.path import PathToMain
from ..version import __commit__, __version__

rcParams["pdf.fonttype"] = 42
rcParams["ps.fonttype"] = 42

# Legend with normal box (as V1)
rcParams["legend.fancybox"] = False
rcParams["legend.framealpha"] = None
rcParams["legend.edgecolor"] = "inherit"
rcParams["legend.frameon"] = False

# Nice round numbers on axis and 'tight' axis limits to data (as V1)
rcParams["axes.autolimit_mode"] = "round_numbers"
rcParams["axes.xmargin"] = 0
rcParams["axes.ymargin"] = 0

# Ticks as in mpl V1 (everywhere and inside)
rcParams["xtick.direction"] = "in"
rcParams["ytick.direction"] = "in"
rcParams["xtick.top"] = True
rcParams["ytick.right"] = True
rcParams["legend.labelspacing"] = 0.3

rcParams["font.family"] = "sans-serif"
rcParams["font.sans-serif"] = ["DejaVu Sans"]

rcParams["xtick.major.pad"] = 6
rcParams["ytick.major.pad"] = 6

__all__: list[str] = ["config_load", "config_dict", "config_entry_option"]


class Config:
    """
    A thread-safe singleton class for managing configuration data.

    This class provides a centralized mechanism to load, retrieve, and manage
    configuration settings. It ensures thread safety through a locking mechanism.

    Attributes
    --------------------
    _instance : Config or None
        The singleton instance of the `Config` class.
    _lock : threading.Lock
        A lock to ensure thread safety during singleton initialization.
    _config_dict : dict of str, Any
        The loaded configuration data.

    Methods
    --------------------
    load(config_path=None)
        Loads configuration data from a specified path or reloads the default configuration.
    get_config_entry_option(key)
        Retrieves a specific entry from the configuration dictionary based on the provided key.

    Examples
    --------------------
    >>> config = Config()
    >>> config_data = config.load("path/to/config.json")
    >>> print(config_data)
    {'setting1': 'value1', 'setting2': 'value2', 'setting3': {'setting4': 'value4'}}

    >>> entry_option = config.get_config_entry_option("setting1")
    >>> print(entry_option)
    {'setting3': 'value3'}
    """

    _instance: Config | None = None
    _lock: Lock = Lock()

    def __new__(cls) -> "Config":
        """
        Ensures a single instance of the Config class (singleton pattern).

        Returns
        --------------------
        Config
            The singleton instance of the Config class.
        """
        with cls._lock:
            if cls._instance is None:
                cls._instance = super(Config, cls).__new__(cls)
                cls._instance._initialize_config_dict()
        return cls._instance

    def _initialize_config_dict(self) -> None:
        """
        Initializes the configuration dictionary by loading default configuration data.
        """
        self._config_dict: dict[str, Any] = ConfigLoad().init_load()

    @property
    def config_dict(self) -> dict[str, Any]:
        """
        The configuration dictionary containing all loaded settings.

        Returns
        --------------------
        dict of str, Any
            The current configuration dictionary.
        """
        return self._config_dict

    @config_dict.setter
    def config_dict(self, config_dict: dict[str, Any]) -> None:
        """
        Sets a new configuration dictionary.

        Parameters
        --------------------
        config_dict : dict of str, Any
            The new configuration dictionary to set.
        """
        self._config_dict = config_dict

    def load(self, config_path: str | None = None) -> dict[str, Any]:
        """
        Loads configuration data from a file or reloads the current configuration.

        Parameters
        --------------------
        config_path : str or None, optional
            The path to the configuration file. If `None`, reloads the existing configuration (default is None).

        Returns
        --------------------
        dict of str, Any
            The loaded configuration dictionary.

        Examples
        --------------------
        >>> config = Config()
        >>> config_data = config.load("path/to/config.json")
        >>> print(config_data)
        {'setting1': 'value1', 'setting2': 'value2'}
        """
        loader: ConfigLoad = ConfigLoad(config_path)
        config_dict: dict[str, Any] = (
            loader.init_load() if config_path else loader.get_config()
        )
        self.config_dict = config_dict

        # Save metadata
        metadata_store = MetadataStore()
        metadata_store.create_metadata()

        return config_dict

    def get_config_entry_option(self, key: str) -> Any | dict[str, Any]:
        """
        Retrieves a specific entry from the configuration dictionary.

        Parameters
        --------------------
        key : str
            The key for the configuration entry to retrieve.

        Returns
        --------------------
        Any and dict of str, Any
            The configuration entry corresponding to the provided key.

        Examples
        --------------------
        >>> config = Config()
        >>> entry_option = config.get_config_entry_option("setting3")
        >>> print(entry_option)
        {'setting4': 'value4'}
        """
        entry_option: dict[str, Any] = self.config_dict.get(key, {})
        return entry_option


class ConfigLoad:
    """
    A utility class for loading and applying configuration files.

    This class handles the discovery of configuration file paths, loading configuration
    data, and applying specific settings such as Matplotlib parameters (`rcParams`) and
    rich traceback settings.

    Attributes
    --------------------
    DEFAULT_CONFIG_NAME : str
        The default name of the configuration file ("gsplot.json").
    config_path : str or None
        The resolved path to the configuration file, if found.

    Parameters
    --------------------
    config_path : str or None, optional
        The explicit path to the configuration file. If not provided, default
        locations will be searched (default is None).

    Methods
    --------------------
    find_config_path(config_path)
        Resolves the configuration file path based on the provided path or default locations.
    init_load()
        Loads the configuration file and applies specific settings if present.
    apply_rc_params(rc_params)
        Applies Matplotlib `rcParams` settings from the configuration file.
    get_config()
        Reads and returns the configuration file as a dictionary.

    Examples
    --------------------
    >>> loader = ConfigLoad()
    >>> config = loader.init_load()
    >>> print(config)
    {'rcParams': {'figure.dpi': 100}, 'rich': {'traceback': {}}}
    """

    DEFAULT_CONFIG_NAME: str = "gsplot.json"

    def __init__(self, config_path: str | None = None) -> None:
        self.config_path: str | None = self.find_config_path(config_path)

    def find_config_path(self, config_path: str | None) -> str | None:
        """
        Determines the configuration file path.

        If a path is provided, it checks its existence. If no path is provided,
        searches default locations for the configuration file.

        Parameters
        --------------------
        config_path : str or None
            The explicit path to the configuration file.

        Returns
        --------------------
        str or None
            The resolved configuration file path, or None if no file is found.

        Raises
        --------------------
        FileNotFoundError
            If the provided path does not exist.

        Examples
        --------------------
        >>> loader = ConfigLoad(config_path="path/to/config.json")
        >>> print(loader.config_path)
        'path/to/config.json'
        """
        if config_path:
            if not os.path.exists(config_path):
                raise FileNotFoundError(f"Configuration file not found: {config_path}")
            return config_path

        # Search in default locations
        search_paths = [
            os.getcwd(),  # Current directory
            os.path.join(
                os.path.expanduser("~"), ".config", "gsplot"
            ),  # User config directory
            os.path.expanduser("~"),  # Home directory
        ]

        for path in search_paths:
            potential_path = os.path.join(path, ConfigLoad.DEFAULT_CONFIG_NAME)
            if os.path.exists(potential_path):
                return potential_path
        return None

    def init_load(self) -> dict[str, Any]:
        """
        Loads the configuration file and applies specific settings if present.

        This method reads the configuration file and applies Matplotlib `rcParams`
        and rich traceback settings if they are defined in the configuration.

        Returns
        --------------------
        dict of str, Any
            The loaded configuration dictionary.

        Examples
        --------------------
        >>> loader = ConfigLoad()
        >>> config = loader.init_load()
        >>> print(config)
        {'rcParams': {'figure.dpi': 100}, 'rich': {'traceback': {}}}
        """
        config_dict: dict[str, Any] = self.get_config()
        if "rcParams" in config_dict:
            rc_params = config_dict["rcParams"]
            self.apply_rc_params(rc_params)
        if "rich" in config_dict:
            if "traceback" in config_dict["rich"]:
                traceback_params = config_dict["rich"]["traceback"]
                install(**traceback_params)
        return config_dict

    @staticmethod
    def apply_rc_params(rc_params: dict[str, Any]) -> None:
        """
        Applies Matplotlib `rcParams` settings from the configuration file.

        Parameters
        --------------------
        rc_params : dict of str, Any
            A dictionary of Matplotlib `rcParams` settings.

        Examples
        --------------------
        >>> rc_params = {"figure.dpi": 100, "backend": "TkAgg"}
        >>> ConfigLoad.apply_rc_params(rc_params)
        """
        backend = rc_params.pop("backends", None)
        if backend:
            mpl.use(backend)
        rcParams.update(rc_params)

    def get_config(self) -> dict[str, Any]:
        """
        Reads and returns the configuration file as a dictionary.

        Returns
        --------------------
        dict of str, Any
            The loaded configuration dictionary. Returns an empty dictionary if
            no configuration file is found.

        Examples
        --------------------
        >>> loader = ConfigLoad("path/to/config.json")
        >>> config = loader.get_config()
        >>> print(config)
        {'rcParams': {'figure.dpi': 100}, 'rich': {'traceback': {}}}
        """
        if not self.config_path:
            return {}
        with open(self.config_path, "r") as f:
            return cast(dict[str, Any], json.load(f))


[docs] def config_load(config_path: str | None = None) -> dict[str, Any]: """ Loads the configuration data from a specified file or reloads the existing configuration. This function initializes the `Config` singleton, loads the configuration file, and returns the loaded configuration dictionary. Parameters -------------------- config_path : str or None, optional The path to the configuration file. If `None`, the existing configuration is reloaded (default is None). Returns -------------------- dict of str, Any The loaded configuration dictionary. Examples -------------------- >>> import gsplot as gs >>> config_data = gs.config_load("path/to/config.json") >>> print(config_data) {'rcParams': {'figure.dpi': 100}, 'rich': {'traceback': {}}} """ _config: Config = Config() config_dict: dict[str, Any] = _config.load(config_path) return config_dict
[docs] def config_dict() -> dict[str, Any]: """ Retrieves the current configuration dictionary. This function accesses the `Config` singleton and returns the configuration dictionary currently in memory. Returns -------------------- dict of str, Any The current configuration dictionary. Examples -------------------- >>> import gsplot as gs >>> config_data = gs.config_dict() >>> print(config_data) {'rcParams': {'figure.dpi': 100}, 'rich': {'traceback': {}}} """ _config: Config = Config() config_dict: dict[str, Any] = _config.config_dict return config_dict
[docs] def config_entry_option(key: str) -> dict[str, Any]: """ Retrieves a specific entry from the configuration dictionary based on the provided key. This function accesses the `Config` singleton and retrieves the configuration entry associated with the given key. Parameters -------------------- key : str The key for the configuration entry to retrieve. Returns -------------------- dict of str, Any The configuration entry corresponding to the provided key. Examples -------------------- >>> import gsplot as gs >>> entry_option = gs.config_entry_option("rcParams") >>> print(entry_option) {'figure.dpi': 100, 'backend': 'TkAgg'} """ _config: Config = Config() entry_option: dict[str, Any] = _config.get_config_entry_option(key) return entry_option
class MetadataHistory: def __init__( self, new_metadata: Any, new_config: Any, metadata_dir: str, ) -> None: self.new_metadata = new_metadata.copy() self.new_config = new_config.copy() self.metadata_dir = metadata_dir self.needs_update = False self.history: Any = {} self.new_entry: Any = {} self.history_dir = os.path.join(self.metadata_dir, "history") def _get_old_metadata(self) -> None | Any: if not os.path.exists(os.path.join(self.metadata_dir, "metadata.yml")): return None with open(os.path.join(self.metadata_dir, "metadata.yml"), "r") as file: return yaml.safe_load(file) def _get_old_config(self) -> None | Any: if not os.path.exists(os.path.join(self.metadata_dir, "config.json")): return None with open(os.path.join(self.metadata_dir, "config.json"), "r") as file: return json.load(file) def _is_identical(self) -> None: old_metadata = self._get_old_metadata() old_config = self._get_old_config() if not old_metadata or not old_config: self.needs_update = True return None exclude_keys = ["date"] def remove_keys(data: dict, keys: list) -> dict: return {k: v for k, v in data.items() if k not in keys} filtered_old_metadata = remove_keys(old_metadata, exclude_keys) filtered_new_metadata = remove_keys(self.new_metadata, exclude_keys) if filtered_old_metadata != filtered_new_metadata: self.needs_update = True return None if old_config != self.new_config: self.needs_update = True return None def _create_history_dir(self) -> None: metadata_history_dir = os.path.join(self.history_dir) if not os.path.exists(metadata_history_dir): os.makedirs(metadata_history_dir) def _read_history(self) -> Any: self._create_history_dir() history_file = os.path.join(self.history_dir, "history.txt") if not os.path.exists(history_file): return [] try: with open(history_file, "r") as file: return [json.loads(line) for line in file if line.strip()] except Exception as e: print(f"Error reading history file: {e}") return [] def _create_new_history(self) -> None: if not self.needs_update: return None self.history = self._read_history() self.new_entry = self.new_metadata self.new_entry["config"] = self.new_config def _write_history(self) -> None: history_file = os.path.join(self.history_dir, "history.txt") with open(history_file, "a") as file: json.dump(self.new_entry, file) file.write("\n") def create_history(self) -> None: self._is_identical() self._create_new_history() if self.new_entry: self._write_history() class MetadataStore: def __init__( self, ) -> None: path_to_main = PathToMain() self.main_dir = path_to_main.get_executed_file_dir() self.meta_data_dir_name = ".gsplot" self.meta_data_dir = os.path.join( self.main_dir, self.meta_data_dir_name, ) self.date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.version = __version__ self.commit = __commit__ self.new_metadata = { "date": self.date, "version": self.version, "commit": self.commit, } # get config dictionary config = Config() self.new_config_dict = config.config_dict self.is_stored = config.get_config_entry_option("metadata") def _create_metadata_dir(self) -> None: if not os.path.exists(self.meta_data_dir): os.makedirs(self.meta_data_dir) def _create_new_metadata(self) -> None: with open(os.path.join(self.meta_data_dir, "metadata.yml"), "w") as file: yaml.dump( self.new_metadata, file, default_flow_style=False, sort_keys=False, indent=2, ) def _create_new_config(self) -> None: with open(os.path.join(self.meta_data_dir, "config.json"), "w") as file: # write config dictionary to file as json json.dump(self.new_config_dict, file, indent=2) def create_metadata(self) -> None: if not self.is_stored: return None self._create_metadata_dir() metadata_history = MetadataHistory( new_metadata=self.new_metadata, new_config=self.new_config_dict, metadata_dir=self.meta_data_dir, ) metadata_history.create_history() self._create_new_metadata() self._create_new_config() def save_metadata() -> None: _metadata = MetadataStore() _metadata.create_metadata()