# 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)