Source code for ferrosoft.config

"""
YAML-based Django settings loader with environment merging and variable substitution.

This module is the core of Ferrosoft's configuration system.  Rather than
keeping settings in a Python file, settings are stored in one or more YAML
files whose structure mirrors Django's settings namespace.  The loading
pipeline has three stages:

1. **Merge files** — one or more YAML files are read and merged with the
   ``|`` operator (right-hand file wins on key collision).
2. **Select environment** — the merged YAML is expected to have a
   ``"default"`` top-level key and one key per environment (e.g.
   ``"development"``, ``"production"``).  The active environment's block is
   merged on top of ``"default"``.
3. **Substitute variables** — string values that start with ``"@ "`` are
   treated as :meth:`str.format` templates and rendered with two namespaces:

   * ``env`` — :data:`os.environ`, so ``"@ {env[SECRET_KEY]}"`` reads the
     ``SECRET_KEY`` environment variable.
   * ``settings`` — the fully merged (but not yet substituted) settings dict,
     so ``"@ {settings[BASE_DIR]}/media"`` derives a path from another
     setting.

   If a referenced key is missing, a :class:`MissingOption` sentinel is
   stored instead of raising immediately.  The error is deferred until the
   setting value is actually used (e.g. converted to ``str``), so that
   optional settings that are never accessed do not cause startup failures.

Environment variables
---------------------
``FERROSOFT_ENV``
    Selects the environment block merged on top of ``default``.
    Defaults to ``"development"``.
``FERROSOFT_CONFIG``
    Colon-separated list of YAML file paths to load and merge.
    Defaults to ``"settings.yml"``.

Typical usage
-------------
In a Django settings module::

    from ferrosoft.config import load_django
    load_django(globals())

This writes every resolved key from the YAML configuration directly into the
calling module's global namespace, making them available to Django as settings.
"""

import logging
import os
from typing import Iterable

import yaml


#: Name of the environment variable that selects the active config environment.
ENV_ENV = "FERROSOFT_ENV"
#: Default value for :data:`ENV_ENV` when the variable is not set.
ENV_DEFAULT = "development"
#: Top-level YAML key whose block is used as the base for all environments.
DEFAULT_ENV_NAME = "default"
#: Name of the environment variable that lists YAML config file paths.
CONFIG_FILE_ENV = "FERROSOFT_CONFIG"
#: Default config file path used when :data:`CONFIG_FILE_ENV` is not set.
CONFIG_FILE_DEFAULT = "settings.yml"


[docs] class ConfigError(RuntimeError): """Raised for unrecoverable configuration problems. Examples include a YAML file that does not contain a mapping, or a required Django setting that is absent from the resolved configuration. """
[docs] class MissingOption: """Deferred error sentinel for unresolvable variable references. When a ``"@ {env[VAR]}"`` or ``"@ {settings[KEY]}"`` substitution fails because the referenced name is missing, a :class:`MissingOption` instance is stored in its place rather than raising immediately. The :exc:`ConfigError` is only raised when the sentinel's ``__str__`` method is called — i.e. when the setting value is actually used as a string. This design allows the full configuration to be loaded without aborting on optional or environment-specific settings that happen not to be set in the current environment. Args: option_keys: The sequence of YAML key names that form the path to the setting that could not be resolved (used in the error message). originator: The :exc:`KeyError` raised during :meth:`str.format` that triggered the deferred error. """ def __init__(self, option_keys: Iterable[str], originator: Exception): self.option_keys = option_keys self.originator = originator def __str__(self): raise ConfigError( "Config value is incomplete: %s" % "/".join(self.option_keys) ) from self.originator
[docs] def require_settings(*keys: str) -> dict: """Fetch named settings from the active Django settings module. Validates that each requested key is present and returns all values in a single dictionary. Intended for use by services that need a small, named subset of Django settings without importing the full settings module. Args: *keys: One or more Django setting names to retrieve. Returns: A dictionary mapping each requested key to its current value. Raises: ConfigError: If any of the requested keys is absent from Django settings. Example: :: from ferrosoft.config import require_settings cfg = require_settings("VICTORIA_BASE_URL", "MEDIA_ROOT") base_url = cfg["VICTORIA_BASE_URL"] """ from django.conf import settings result = {} for key in keys: if not hasattr(settings, key): raise ConfigError(f"Missing setting: {key}") result[key] = getattr(settings, key) return result
[docs] def load_django(settings_module: dict) -> None: """Populate a Django settings module from the resolved YAML configuration. Reads all YAML configuration files, selects and merges the active environment block, performs variable substitution, and writes every resulting key directly into ``settings_module``. Intended to be called from a Django settings file with ``globals()`` as the argument:: from ferrosoft.config import load_django load_django(globals()) Args: settings_module: The ``globals()`` dict of the calling Django settings module. Each resolved configuration key is written as a top-level entry. """ for key, value in read_config_files().items(): settings_module[key] = value
[docs] def read_config_files() -> dict: """Execute the full configuration pipeline and return resolved settings. Combines :func:`read_config_files_merged`, :func:`merge_config_environment`, and :func:`substitute` into a single call. Returns: A flat dictionary of Django settings with all variable references resolved. Values that could not be resolved are represented by :class:`MissingOption` sentinels. """ settings = merge_config_environment(read_config_files_merged()) return substitute(settings, settings)
[docs] def read_config_file(file_name: str) -> dict: """Read and parse a single YAML configuration file. Args: file_name: Path to the YAML file to read. Returns: The parsed YAML content as a Python dictionary, or an empty dict if the file does not exist. Raises: ConfigError: If the file exists but its content is not a YAML mapping. """ logging.debug("Loading config file %s", file_name) try: with open(file_name, "rb") as config_file: return yaml.safe_load(config_file) except FileNotFoundError: logging.warning("Config file not read: %s", file_name) return {}
[docs] def read_config_files_merged() -> dict: """Read and merge all YAML configuration files listed in ``FERROSOFT_CONFIG``. Splits the ``FERROSOFT_CONFIG`` environment variable on ``":"`` and loads each path in order. Files are merged with the ``|`` operator so that keys from later files override keys from earlier files. The resulting dict still contains the top-level environment keys (``"default"``, ``"development"``, etc.) — environment selection happens in :func:`merge_config_environment`. Returns: A merged dictionary of all YAML files, keyed by environment name at the top level. Raises: ConfigError: If any loaded file's top-level value is not a mapping. """ file_names = os.getenv(CONFIG_FILE_ENV, CONFIG_FILE_DEFAULT) merged = {} for name in file_names.split(":"): name = name.strip() config = read_config_file(name) if not isinstance(config, dict): raise ConfigError("Configuration must be a dictionary") merged = merged | config return merged
[docs] def merge_config_environment(unmerged_config: dict) -> dict: """Select and merge the active environment block from the raw config dict. Looks for a ``"default"`` top-level key and merges the block identified by the ``FERROSOFT_ENV`` environment variable on top of it (right-wins). If ``FERROSOFT_ENV`` is not set, falls back to ``"development"``. Args: unmerged_config: The raw configuration dictionary as returned by :func:`read_config_files_merged`, containing one key per environment plus ``"default"``. Returns: A flat settings dictionary with environment-specific overrides applied. If ``"default"`` is absent the entire ``unmerged_config`` is returned as-is with a warning. If the active environment key is absent, the ``"default"`` block is returned with a warning. """ if DEFAULT_ENV_NAME not in unmerged_config: logging.warning("Default environment not found in configuration.") logging.warning("Using whole configuration unmerged.") return unmerged_config config = unmerged_config[DEFAULT_ENV_NAME] env = os.getenv(ENV_ENV, ENV_DEFAULT) if env not in unmerged_config: logging.warning("Environment is not configured: %s", env) return config return config | unmerged_config[env]
[docs] def substitute( settings: dict, root_settings: dict, key_trail: list[str] | None = None ) -> dict: """Recursively resolve variable references in a settings dictionary. Iterates over every key in ``settings`` and calls :func:`substitute_value` on each value. Nested dicts are recursed; the ``key_trail`` is extended at each level so that error messages can report the full dotted path to the problematic setting. Args: settings: The dictionary of settings to process. root_settings: The top-level (unsubstituted) settings dictionary, passed down unchanged so that ``{settings[KEY]}`` references can look up any top-level key. key_trail: Accumulated list of key names representing the path from the root to the current ``settings`` dict. Used in :class:`MissingOption` error messages. Pass ``None`` (or omit) at the top-level call. Returns: A new dictionary with the same keys as ``settings`` and all ``"@ ..."`` string values replaced by their resolved counterparts. """ key_trail = key_trail or [] return { key: substitute_value(value, root_settings, key_trail + [key]) for key, value in settings.items() }
[docs] def substitute_value(value, root_settings: dict, key_trail: list[str]): """Resolve a single configuration value, handling the substitution DSL. String values that begin with ``"@ "`` are treated as :meth:`str.format` templates rendered with two keyword namespaces: * ``env`` — :data:`os.environ` * ``settings`` — the top-level ``root_settings`` dictionary Examples of valid substitution strings:: "@ {env[DATABASE_URL]}" "@ {settings[BASE_DIR]}/media" "@ {env[SECRET_KEY]}" Dictionaries are recursed via :func:`substitute`. List elements are substituted individually. All other types (``int``, ``bool``, ``None``, etc.) are returned unchanged. If a ``{env[...]}`` or ``{settings[...]}`` reference cannot be resolved because the key is absent, a :class:`MissingOption` sentinel is returned instead of raising immediately. Args: value: The raw configuration value to process. root_settings: The top-level settings dictionary used to resolve ``{settings[KEY]}`` references. key_trail: Path of keys from the root to the current value, used to construct informative :class:`MissingOption` error messages. Returns: The resolved value, a :class:`MissingOption` sentinel, or the original value unchanged if no substitution applies. """ if isinstance(value, str) and len(value) > 1 and value[:2] == "@ ": try: # Variable substitution return value[2:].format(env=os.environ, settings=root_settings) except KeyError as e: # Defer raising error until the option is actually used. return MissingOption(key_trail, e) elif isinstance(value, dict): return substitute(value, root_settings, key_trail) elif isinstance(value, list): return [ substitute_value(inner, root_settings, key_trail + [str(i)]) for i, inner in enumerate(value) ] else: return value