"""
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