Source code for gitws.appconfig

# Copyright 2022-2023 c0fec0de
#
# This file is part of Git Workspace.
#
# Git Workspace is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Git Workspace is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with Git Workspace. If not, see <https://www.gnu.org/licenses/>.

"""
Application Configuration Handling.

This module implements the needed classes to handle persistent application settings. The core part of
the configuration system is the :class:`AppConfig` class, which allows easy access to options spread
across several configuration files. In addition, that class also allows to read and write dedicated
config files.
"""

import os
from contextlib import contextmanager
from enum import Enum
from os import environ
from pathlib import Path
from typing import Generator, Optional

import tomlkit
import tomlkit.exceptions
from appdirs import site_config_dir, user_config_dir
from pydantic import ValidationError

from gitws.exceptions import InvalidConfigurationFileError, InvalidConfigurationLocationError, UninitializedError

from .const import (
    APP_AUTHOR,
    APP_NAME,
    BLOCK_APP_CONFIG_FROM_ENV_ENV_NAME,
    CONFIG_FILE_NAME,
    GIT_WS_PATH,
    SYSTEM_CONFIG_PATH_ENV_NAME,
    USER_CONFIG_PATH_ENV_NAME,
    WORKSPACE_CONFIG_PATH_ENV_NAME,
)
from .datamodel import AppConfigData
from .workspacefinder import find_workspace

_SYSTEM_CONFIG_DIR = site_config_dir(APP_NAME, appauthor=APP_AUTHOR)
"""The default location where to look for system wide configuration files."""

_USER_CONFIG_DIR = user_config_dir(APP_NAME, appauthor=APP_AUTHOR)
"""The default location where to look for user configuration files."""


class _EnvAppConfigData(AppConfigData, env_prefix="git_ws_", case_sensitive=False):
    """
    Configuration data read from the environment.

    This class is used to read the configuration values from the user's environment variables.
    """


[docs]class AppConfigLocation(str, Enum): """ The location where configuration options are stored. This enum encodes the different locations where the application stores information. """ SYSTEM = "system" """ System wide configuration. Use this to refer to configuration options for the entire system. """ USER = "user" """ User configuration. Use this to refer to configuration specific to the current user. """ WORKSPACE = "workspace" """ Workspace configuration. Use this to refer to configuration specific to the current workspace (if any). By default, the workspace configuration is looked for in the :any:`GIT_WS_PATH` path within the workspace we are located in. The workspace location is retrieved by using :any:`Workspace.find_path`. """
[docs]class AppConfig: """ Application wide configuration. This class holds the application wide configuration and also provides means to modify it. In the simplest case, default construct this class to gain access to the options defined in the various configuration files: .. code-block:: python config = AppConfig() options = config.options # Now we can use the values defined in the options Alternatively, the two methods :any:`load` and :any:`save` can be used to load and save configuration files explicitly. By default, three configuration files will be loaded and merged: - A system wide configuration file. - A configuration file specific for the current user. - And a configuration file for the current workspace. In case the current directory is not within a workspace (and no path to a workspace has been explicitly set in the constructor), the workspace configuration file is skipped. In any case, the configurations are merged in that order, i.e. options from the system configuration file have the lowest priority, while the ones from the workspace have highest priority. On top, the class will also evaluate environment variables named `GIT_WS_*` and allow overriding configuration values that way. For example, the ``manifest_path`` option can be explicitly overridden by setting the ``GIT_WS_MANIFEST_PATH`` environment variable. Keyword Args: system_config_dir: The path to where the system configuration file is stored. If not set, a platform specific system configuration path will be used. user_config_dir: The path to where the user configuration file is stored. If not set, a platform specific user configuration path will be used. workspace_config_dir: The path to where the workspace configuration file is stored. If not set, the path will be looked up by searching from the current directory upwards for a workspace configuration folder. If none is found, no workspace configuration will be used. use_config_from_env: If set to False, reading of environment variables to override configuration values will be skipped. """ def __init__( self, system_config_dir: Optional[str] = None, user_config_dir: Optional[str] = None, workspace_config_dir: Optional[str] = None, use_config_from_env: bool = True, ) -> None: self._use_config_from_env = use_config_from_env if system_config_dir is None: sysconf_dir_from_env = environ.get(SYSTEM_CONFIG_PATH_ENV_NAME) if sysconf_dir_from_env is not None: system_config_dir = sysconf_dir_from_env else: system_config_dir = _SYSTEM_CONFIG_DIR if user_config_dir is None: userconf_dir_from_env = environ.get(USER_CONFIG_PATH_ENV_NAME) if userconf_dir_from_env is not None: user_config_dir = userconf_dir_from_env else: user_config_dir = _USER_CONFIG_DIR if workspace_config_dir is None: workspaceconf_from_env = environ.get(WORKSPACE_CONFIG_PATH_ENV_NAME) if workspaceconf_from_env is not None: workspace_config_dir = workspaceconf_from_env else: workspace_dir = find_workspace() if workspace_dir: workspace_config_dir = str(workspace_dir / GIT_WS_PATH) self._system_config_dir = system_config_dir self._user_config_dir = user_config_dir self._workspace_config_dir = workspace_config_dir self._merged_config: Optional[AppConfigData] = None @property def options(self) -> AppConfigData: """ Access the merged application configuration. This property holds the merged configuration options of the application. It is computed by loading the system, user and - if we are within a workspace - workspace configuration as well as - if enabled - the configurations read from environment variables and merging them together in that order. .. code-block:: python # Create a configuration object: config = AppConfig() # Now, we can use the options to access the configured values: if config.options.color_ui: # Print using color else: # Print without additional styling Note that the value is computed on first access, meaning that accessing this property can potentially raise an exception. See the documentation of the :any:`load` method to learn which exceptions are expected. Also note that the value is cached between accesses. Once the AppConfig object is created and this property is read once, its value will be reused between calls. An exception is when modifying configuration values using :any:`save`. In this case, the currently merged configurations are discarded and re-read on the next access of this property. """ if self._merged_config is None: sys_config = self.load(AppConfigLocation.SYSTEM) user_config = self.load(AppConfigLocation.USER) workspace_config = self.load(AppConfigLocation.WORKSPACE) merged_config_data: dict = {} merged_config_data.update(sys_config.model_dump(exclude_none=True)) merged_config_data.update(user_config.model_dump(exclude_none=True)) merged_config_data.update(workspace_config.model_dump(exclude_none=True)) if self._use_config_from_env and os.environ.get(BLOCK_APP_CONFIG_FROM_ENV_ENV_NAME) is None: env_config = _EnvAppConfigData() merged_config_data.update(env_config.model_dump(exclude_none=True)) self._merged_config = AppConfigData(**merged_config_data) self._fill_in_defaults(self._merged_config) return self._merged_config
[docs] def load(self, location: AppConfigLocation) -> AppConfigData: """ Load the configuration from the specified location. This method will load the configuration values from the given location. Note that only that location's config file values will be included. For *productive* use, rather read the merged values via the :any:`options` property. The main purpose of this method is to obtain a copy of the current configuration values from a specific configuration file, modify them and then write them back via :any:`save`. Args: location: The location to load the configuration from. Raises: InvalidConfigurationFileError: The configuration file is invalid and cannot be used. InvalidConfigurationLocationError: The location is invalid. """ try: doc = self._load(location) except UninitializedError: return AppConfigData() try: return AppConfigData(**doc) except ValidationError as validation_error: raise InvalidConfigurationFileError( self.get_config_file_path(location), str(validation_error) ) from validation_error
[docs] def save(self, config: AppConfigData, location: AppConfigLocation): """ Save the configuration back to disk. This saves the given configuration back to a file on disk. Use it together with the :any:`load` method to modify configuration values on disk: .. code-block:: python config = AppConfig() # Load configuration values from disk: values = config.load(AppConfigLocation.WORKSPACE) # Request not to color output values.color_ui = False # Request using the default manifest by unsetting whatever is in workspace config values.manifest_path = None # Save values back: config.save(AppConfigLocation.WORKSPACE) Args: config: The configuration options to be saved to disk. location: The location where to store. Raises: InvalidConfigurationLocationError: The location is not valid. InvalidConfigurationFileError: The target configuration file exists but is broken and cannot be updated. UninitializedError: Tried to save to a workspace configuration but we are not within a valid workspace. """ doc = self._load(location) values = config.model_dump() # Modify the document "in-place" to keep comments etc for key, value in values.items(): if value is not None: doc[key] = value elif key in doc: del doc[key] doc_path = self.get_config_file_path(location) doc_path.parent.mkdir(parents=True, exist_ok=True) doc_path.write_text(doc.as_string(), encoding="utf-8") # Clear the cached merged config so we'll reload it on next access self._merged_config = None
[docs] @contextmanager def edit(self, location: AppConfigLocation) -> Generator[AppConfigData, None, None]: """ Edit a configuration file. This method can be used to conveniently edit a configuration file: .. code-block:: python with app_config.edit(AppConfigLocation.WORKSPACE) as cfg: cfg.color_ui = False It basically combines :any:`load` and :any:`save` into a single method call which can be used with a `with` statement. """ config = self.load(location) yield config self.save(config, location)
@staticmethod def _load_config_from_path(file_path: Path) -> tomlkit.TOMLDocument: """ Load the configuration file at the given path. Args: file_path: The path to the config file to load. Raises: InvalidConfigurationFileError: The configuration file cannot be parsed. """ try: config_data = file_path.read_text() except FileNotFoundError: # If the file does not exist, construct a default settings object return tomlkit.TOMLDocument() try: return tomlkit.loads(config_data) except tomlkit.exceptions.ParseError as parse_error: # If we cannot parse the config file, raise an error here as we cannot # safely continue raise InvalidConfigurationFileError(file_path, str(parse_error)) from parse_error def _load(self, location: AppConfigLocation) -> tomlkit.TOMLDocument: """ Load a configuration file from a location. This loads a configuration file from a specific location. Args: location: The location to load the configuration from. Raises: UninitializedError: When trying to load the workspace configuration while being outside a workspace. InvalidConfigurationLocationError: When an invalid location has been specified. """ config_file_path = self.get_config_file_path(location) return AppConfig._load_config_from_path(config_file_path)
[docs] def get_config_file_path(self, location: AppConfigLocation) -> Path: """ Given a storage location, return the path to the config file. Raises: InvalidConfigurationLocationError: The location given is invalid. """ if location == AppConfigLocation.SYSTEM: return Path(self._system_config_dir) / CONFIG_FILE_NAME if location == AppConfigLocation.USER: return Path(self._user_config_dir) / CONFIG_FILE_NAME if location == AppConfigLocation.WORKSPACE: workspace_config_dir = self._workspace_config_dir if workspace_config_dir is not None: return Path(workspace_config_dir) / CONFIG_FILE_NAME raise UninitializedError() raise InvalidConfigurationLocationError(str(location))
@staticmethod def _fill_in_defaults(config: AppConfigData): """Fill in some sensible defaults in the given config object.""" for name, default in AppConfigData.defaults().items(): if getattr(config, name) is None: setattr(config, name, default)