Source code for pisces.utilities.config
"""Pisces global configuration system.
This module provides a hierarchical, YAML-backed configuration manager for
Pisces. Configurations can be accessed and updated via dot-separated keys
(e.g., ``pisces_config['system.appearance.disable_progress_bars'] = False``) and
changes are persisted to disk if autosave is enabled.
Configuration files are located using the following precedence:
1. Environment variable ``$PISCES_CONFIG`` (if set)
2. Local project file ``.piscesrc`` in the current working directory
3. User-specific config at ``~/.config/pisces/config.yaml``
4. Package default config distributed with Pisces
Use :attr:`pisces_config` to access the active configuration. It behaves like a
nested dictionary with automatic loading and saving.
"""
import os
from collections.abc import MutableMapping
from pathlib import Path
from platformdirs import user_config_dir
from .io_tools import unyt_yaml
# --------------------------------- #
# Configuration Manager #
# --------------------------------- #
# Functions for configuring the environment at the MPI
# and XSPEC levels.
[docs]
class ConfigManager(MutableMapping):
"""Hierarchical configuration manager with dot-separated keys and optional autosave.
Stores configuration data as nested dictionaries, backed by a YAML file.
Allows dot-separated key access for nested structures.
Parameters
----------
path: str or `Path`
Path to the YAML configuration file.
autosave: bool
If True, automatically save changes to disk. Defaults to True.
"""
__YAML__ = unyt_yaml
""" The YAML manager."""
[docs]
def __init__(self, path: str | Path, autosave: bool = True):
self._path = Path(path).expanduser().resolve()
self._autosave = autosave
self._data = self._load()
def _load(self) -> dict:
"""Load configuration data from the YAML file."""
if not self._path.exists():
return {}
with open(self._path) as f:
return self.__YAML__.load(f) or {}
def _save(self) -> None:
"""Save configuration data to the YAML file."""
with open(self._path, "w") as f:
self.__YAML__.dump(self._data, f)
def _traverse(self, key: str, create_missing: bool = False):
"""Navigate nested dictionaries using dot-separated keys.
Parameters
----------
key : str
Dot-separated key (e.g., "database.host").
create_missing : bool, optional
If True, create intermediate dictionaries as needed.
Returns
-------
tuple
A tuple (parent dictionary, final key).
Raises
------
KeyError
If a key is missing and `create_missing` is False.
"""
keys = key.split(".")
node = self._data
for k in keys[:-1]:
if k not in node:
if create_missing:
node[k] = {}
else:
raise KeyError(f"'{k}' not found in config.")
node = node[k]
return node, keys[-1]
def __getitem__(self, key: str):
"""Retrieve a value from the configuration using a dot-separated key.
Parameters
----------
key : str
The dot-separated key identifying the configuration value.
Returns
-------
Any
The corresponding value from the configuration.
Raises
------
KeyError
If the specified key does not exist.
"""
node, final_key = self._traverse(key)
return node[final_key]
def __setitem__(self, key: str, value):
"""Set a configuration value using a dot-separated key.
Parameters
----------
key : str
The dot-separated key identifying the configuration value.
value : Any
The value to assign.
Notes
-----
If `autosave` is enabled, the configuration will be written to disk after setting.
"""
node, final_key = self._traverse(key, create_missing=True)
node[final_key] = value
if self._autosave:
self._save()
def __delitem__(self, key: str):
"""Delete a configuration value using a dot-separated key.
Parameters
----------
key : str
The dot-separated key identifying the configuration value to delete.
Raises
------
KeyError
If the specified key does not exist.
Notes
-----
If `autosave` is enabled, the change is written to disk immediately.
"""
node, final_key = self._traverse(key)
del node[final_key]
if self._autosave:
self._save()
def __iter__(self):
"""Return an iterator over the top-level keys in the configuration.
Returns
-------
Iterator[str]
An iterator over the top-level keys in the root configuration dictionary.
"""
return iter(self._data)
def __len__(self) -> int:
"""Return the number of top-level keys in the configuration.
Returns
-------
int
Number of top-level entries in the configuration.
"""
return len(self._data)
def __repr__(self) -> str:
"""Return a string representation of the configuration manager.
Returns
-------
str
A string showing the path to the config file and current in-memory state.
"""
return f"<ConfigManager path={self._path} data={self._data}>"
[docs]
def to_dict(self) -> dict:
"""Return the full configuration data as a dictionary."""
return self._data
[docs]
def update(self, mapping, **kwargs) -> None:
"""Update the configuration with another dictionary."""
self._data.update(mapping, **kwargs)
if self._autosave:
self._save()
# Cache to avoid reloading
__PCONFIG__ = None
[docs]
def get_config() -> ConfigManager:
"""Return global Pisces configuration following precedence."""
# Seek out a global configuration in the
# name space.
global __PCONFIG__
if __PCONFIG__ is not None:
return __PCONFIG__
# We've failed to identify an existing __PCONFIG__
# configuration. We'll need to see out candidates.
candidates = []
# 1. Environment override
# 2. Project-local file
# 3. User-global config
# 4. Package defaults
env_path = os.environ.get("PISCES_CONFIG")
if env_path:
candidates.append(Path(env_path).expanduser())
candidates.append(Path.cwd() / ".piscesrc")
user_path = Path(user_config_dir("pisces")) / "config.yaml"
candidates.append(user_path)
default_path = Path(__file__).parents[1] / "bin" / "config.yaml"
candidates.append(default_path)
# Find the first existing config
for path in candidates:
if path.exists():
__PCONFIG__ = ConfigManager(path)
break
else:
raise OSError(f"Missing default configuration file at {default_path}.\nWasPisces install corrupted?")
return __PCONFIG__
pisces_config = get_config()