Skip to main content

YAML config loader with file includes, ${VAR} substitution, .env support, and deep merging

Project description

Loaden

YAML config loader with file includes, ${VAR} substitution, .env support, and deep merging.

# config.yaml
loaden_include: base.yaml       # compose from multiple files
loaden_env: .env                # load environment files

database:
  host: ${DB_HOST:-localhost}   # env var with default
  password: ${DB_PASSWORD}      # from .env file
from loaden import load_config, get

config = load_config("config.yaml")
host = get(config, "database.host", "127.0.0.1")  # safe nested access

Features

  • File includes - compose configs via loaden_include: [base.yaml, local.yaml]
  • Deep merging - nested dicts merge recursively, later values win
  • ${VAR} substitution - expand env vars with optional defaults ${VAR:-default}
  • .env file loading - load env files via loaden_env: .env
  • Loader path expansion - optionally expand ~ and env vars in config, include, and env-file paths
  • Safe nested access - get(config, "db.host", default) helper
  • Required key validation - fail fast on missing config
  • CLI tool - validate, show, combine, and extract configs

Installation

pip install loaden

Quick Start

from loaden import load_config

config = load_config("config.yaml")
print(config["database"]["host"])

Usage

Basic Configuration

Create a config.yaml:

database:
  host: localhost
  port: 5432
  name: myapp

logging:
  level: INFO

Load it:

from loaden import load_config

config = load_config("config.yaml")
# config = {"database": {"host": "localhost", "port": 5432, ...}, ...}

Include Files

Split configuration across multiple files using loaden_include:

base.yaml:

database:
  host: localhost
  port: 5432

logging:
  level: INFO

config.yaml:

loaden_include: base.yaml

database:
  name: production_db

api:
  key: secret123

Result after loading config.yaml:

{
    "database": {"host": "localhost", "port": 5432, "name": "production_db"},
    "logging": {"level": "INFO"},
    "api": {"key": "secret123"}
}

Multiple Includes

Include multiple files - they merge in order, with later files overriding earlier ones:

loaden_include:
  - defaults.yaml
  - database.yaml
  - local.yaml

app:
  name: myapp

Nested Includes

Included files can include other files:

common/logging.yaml:

logging:
  format: "%(levelname)s - %(message)s"

base.yaml:

loaden_include: common/logging.yaml

database:
  pool_size: 5

config.yaml:

loaden_include: base.yaml

database:
  host: prod.example.com

Environment Variables

Loaden provides three ways to work with environment variables:

1. Variable Substitution in Values

Use ${VAR} or ${VAR:-default} syntax in any string value:

database:
  host: ${DB_HOST:-localhost}
  password: ${DB_PASSWORD}
  url: postgres://${DB_HOST:-localhost}:5432/myapp
import os
os.environ["DB_HOST"] = "prod.example.com"

config = load_config("config.yaml")
print(config["database"]["host"])  # "prod.example.com"
print(config["database"]["url"])   # "postgres://prod.example.com:5432/myapp"

If a variable is not set and has no default, it remains as ${VAR} in the output.

Substituted values are always strings. Substitution runs on the parsed config tree, not on the raw YAML text, so port: ${PORT:-5432} yields the string "5432", not the integer 5432. Cast at the call site if you need a non-string type.

${VAR:-default} diverges from shell on empty values. A variable set to the empty string (VAR="") is treated as set, so the default is not applied — the substitution returns "". Shell's ${VAR:-default} falls back to the default for empty values too; loaden does not. If you need the shell-style behaviour, post-process the resolved value (value or default).

2. Load Env Files with loaden_env

Load environment variables from .env or YAML files:

loaden_env: .env
# or multiple files:
loaden_env:
  - .env
  - secrets.env

database:
  password: ${DB_PASSWORD}

.env file format:

# Comments are ignored
DB_HOST=localhost
DB_PASSWORD="secret123"
API_KEY='my-api-key'

YAML env file format (secrets.yaml):

DB_PASSWORD: secret123
API_KEY: my-api-key

Loader Path Expansion

Enable loader path expansion when you want loaden to expand ~ and environment variables in paths it manages itself:

  • config_path
  • loaden_include
  • loaden_env

Relative include and env-file paths remain relative to the config file that declares them. Ordinary config values are not treated as paths.

# config.yaml
loaden_include:
  - ~/shared/loaden/base.yaml
  - ${LOADEN_CONFIG_DIR}/local.yaml

loaden_env: ${LOADEN_SECRETS_DIR}/app.env

app:
  name: demo
from loaden import load_config

config = load_config("config.yaml", expand_loader_paths=True)

The CLI exposes the same behavior with --expand-loader-paths:

loaden show --expand-loader-paths config.yaml

3. Set Env Vars with env Section

Set environment variables from config (useful for child processes):

env:
  DATABASE_URL: postgres://localhost/myapp
  API_TIMEOUT: 30
import os
from loaden import load_config

config = load_config("config.yaml")
print(os.environ["DATABASE_URL"])  # "postgres://localhost/myapp"

Shell environment always takes precedence - existing vars are not overwritten.

Side Effects

load_config is not a pure function. It writes to os.environ in two cases:

  1. A loaden_env: directive pointing at one or more env files — each variable the file defines is set in the process environment (existing values win).
  2. A top-level env: section in the resolved config — each key/value pair is set in the process environment (existing values win).

os.environ is not thread-safe for writes. Concurrent calls to load_config that touch either mechanism can race. If you load configuration from multiple threads, serialise the calls or restrict the env-mutating mechanisms to a single-threaded startup phase.

${VAR} substitution itself does not mutate os.environ — it only reads.

Required Keys Validation

Fail fast if required configuration is missing:

from loaden import load_config

config = load_config(
    "config.yaml",
    required_keys=["database.host", "database.port", "api.key"]
)

Raises ValueError with clear message if any key is missing:

ValueError: Invalid config: missing required keys in config.yaml: api.key

Safe Nested Access

Use get() to safely access nested keys without try/except:

from loaden import load_config, get

config = load_config("config.yaml")

# Safe access with default
host = get(config, "database.host", "localhost")
timeout = get(config, "api.timeout", 30)

# Returns None if not found (no default specified)
optional = get(config, "maybe.missing")

Deep Merge

Use deep_merge directly for custom merging:

from loaden import deep_merge

base = {"a": 1, "b": {"c": 2, "d": 3}}
overlay = {"b": {"d": 99, "e": 4}, "f": 5}

result = deep_merge(base, overlay)
# {"a": 1, "b": {"c": 2, "d": 99, "e": 4}, "f": 5}

CLI

Loaden includes a command-line tool for working with config files.

Validate

Check if a config file is valid:

loaden validate config.yaml
loaden validate config.yaml -v                    # verbose
loaden validate config.yaml -r "db.host,api.key"  # check required keys

Show

Display resolved config (with includes merged):

loaden show config.yaml           # full config
loaden show config.yaml -k database  # specific section

Combine

Merge multiple config files (later files override earlier):

loaden combine defaults.yaml local.yaml           # output to stdout
loaden combine defaults.yaml local.yaml -o out.yaml  # output to file

Extract

Extract a section to a new file:

loaden extract config.yaml database              # output to stdout
loaden extract config.yaml database -o db.yaml   # output to file

Merge Precedence

When using includes, values are merged with this precedence (highest wins):

  1. Main config file
  2. Later includes override earlier includes
  3. Included files (in order listed)

API Reference

load_config(config_path, required_keys=None, expand_vars=True, expand_loader_paths=False)

Load configuration from a YAML file.

Parameters:

  • config_path (str): Path to the YAML config file
  • required_keys (list[str] | None): Dot-separated keys that must exist (e.g., ["db.host", "api.key"])
  • expand_vars (bool): Whether to expand ${VAR} in values (default: True)
  • expand_loader_paths (bool): Whether to expand ~ and environment variables in config_path, loaden_include, and loaden_env paths (default: False)

Returns: dict[str, Any] - The configuration dictionary

Raises:

  • FileNotFoundError: Config file doesn't exist
  • yaml.YAMLError: Invalid YAML syntax
  • ValueError: Config is not a dict, circular include, or missing required keys

get(config, key_path, default=None)

Safely get a nested key using dot notation.

Parameters:

  • config (dict): Configuration dictionary
  • key_path (str): Dot-separated path (e.g., "database.host")
  • default (Any): Value to return if key not found (default: None)

Returns: Value at key path, or default if not found

deep_merge(base, overlay)

Recursively merge two dictionaries.

Parameters:

  • base (dict): Base dictionary
  • overlay (dict): Dictionary to merge on top (takes precedence)

Returns: dict - New merged dictionary (inputs not modified)

Development

# Create virtual environment
python3 -m venv ~/Environments/loaden
source ~/Environments/loaden/bin/activate

# Install with dev dependencies
pip install -e ".[dev]"

# Run tests
pytest tests/ -v

# Lint and format
ruff check .
ruff format .

License

MIT

Changelog

See CHANGELOG.md.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

loaden-0.1.2.tar.gz (31.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

loaden-0.1.2-py3-none-any.whl (23.6 kB view details)

Uploaded Python 3

File details

Details for the file loaden-0.1.2.tar.gz.

File metadata

  • Download URL: loaden-0.1.2.tar.gz
  • Upload date:
  • Size: 31.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for loaden-0.1.2.tar.gz
Algorithm Hash digest
SHA256 9982ce5574cfa5255a23a295adcaf96002ddfb28329587e04ab3db38b63a7b46
MD5 ffed74a2ceb9bafae8257a771e75d078
BLAKE2b-256 a80724cc5bd61df6637944e6c8a6b0a61006e1832ca62c4a63976212921cf426

See more details on using hashes here.

File details

Details for the file loaden-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: loaden-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 23.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for loaden-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 3d95c6ed319c4f51052f889890c981e1159e36fe5384215ae3b875b0407fabf0
MD5 cabdb44c3c9f08832b49e9bbcdf94a1b
BLAKE2b-256 614f4eb9d278624010712c73ec8824f0c1cd41be5df47cc41747868d957a7573

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page