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}.envfile loading - load env files vialoaden_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_pathloaden_includeloaden_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:
- 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). - 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):
- Main config file
- Later includes override earlier includes
- 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 filerequired_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 inconfig_path,loaden_include, andloaden_envpaths (default: False)
Returns: dict[str, Any] - The configuration dictionary
Raises:
FileNotFoundError: Config file doesn't existyaml.YAMLError: Invalid YAML syntaxValueError: 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 dictionarykey_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 dictionaryoverlay(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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9982ce5574cfa5255a23a295adcaf96002ddfb28329587e04ab3db38b63a7b46
|
|
| MD5 |
ffed74a2ceb9bafae8257a771e75d078
|
|
| BLAKE2b-256 |
a80724cc5bd61df6637944e6c8a6b0a61006e1832ca62c4a63976212921cf426
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3d95c6ed319c4f51052f889890c981e1159e36fe5384215ae3b875b0407fabf0
|
|
| MD5 |
cabdb44c3c9f08832b49e9bbcdf94a1b
|
|
| BLAKE2b-256 |
614f4eb9d278624010712c73ec8824f0c1cd41be5df47cc41747868d957a7573
|