simple_config turns YAML files into ready-to-use Python config objects. Define shared settings once in base.yaml, override them per environment (development.yaml, production.yaml, …), and switch environments with a single environment variable.
At a glance:
- Layered configs —
base.yamlplus one file per mode, deep-merged - One-variable mode switching —
<PROJECT_NAME>_MODE=production - Natural access —
config.databases.replica.host,config.databases['replica'], or a mix - String interpolation —
'{config.project_dir}/logs'inside YAML values - Smart values — automatic path normalization (
*_dirkeys), datetime parsing (*_datetimekeys), and AWS Secrets Manager lookups (.SECRETkeys) - Per-user local overrides in
~/.simple_config/— personal settings that never touch the repository - Bundled logging setup (
init_logger) and singleton helpers
- Installation
- Quick start
- Project structure
- Modes
- How files are merged
- Accessing values
- Value transforms
- Per-user local overrides
- Validating every mode
- Logging helper
- API quick reference
- Example projects
- License
From a clone of this repository:
pip install .Requires Python 3.9+. Dependencies (installed automatically): PyYAML, python-dateutil, boto3.
Lay out your project like this (see Project structure for the rules and an alternate layout):
my_project/ <- repository root
├─ app/
│ ├─ config/
│ │ ├─ base.yaml <- shared defaults (optional, recommended)
│ │ ├─ development.yaml <- one file per mode
│ │ ├─ production.yaml
│ │ ├─ test.yaml
│ ├─ my_project/
│ │ ├─ __init__.py
│ │ ├─ environment.py <- creates and loads the config (below)
│ │ ├─ main.py
1. Write your config files. base.yaml holds what every mode shares; each mode file overrides or adds what's different:
# app/config/base.yaml
logging:
logger_type: 'stdout'
logger_options:
name: 'my_project'
base_level: 'INFO'
line_format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
databases:
replica:
host: 'localhost'
port: 3306# app/config/production.yaml
logging:
logger_type: 'local_file'
logger_options:
directory: '{config.project_dir}/shared/log'
file_name: '{config.process_name}.log'
databases:
replica:
host: 'replica.internal.example.com'Mode files can be empty to start — an empty development.yaml simply means "use base.yaml as-is".
2. Create the config once, in an environment.py:
# app/my_project/environment.py
import pathlib
from simple_config import YamlConfig
from simple_config.singleton import Singleton
from simple_config.logging_tools import init_logger
MODULE_DIR = pathlib.Path(__file__).parent # app/my_project/
APP_ROOT = MODULE_DIR.parent # app/
PROJECT_ROOT = APP_ROOT.parent # repository root
class Env(metaclass=Singleton):
PROJECT_NAME = "my_project"
def __init__(self):
self.config = YamlConfig(self.PROJECT_NAME)
self.config.app_dir = APP_ROOT.as_posix() # parent of config/ — required
self.config.project_dir = PROJECT_ROOT.as_posix() # optional, for interpolation
self.config.load()
self.logger = init_logger(
self.config.logging.logger_type,
self.config.logging.logger_options,
)3. Use it anywhere. Thanks to the Singleton metaclass, Env() builds the config on first use and returns that same loaded instance everywhere after:
# app/my_project/main.py
from my_project.environment import Env
env = Env()
db = env.config.databases.replica
env.logger.info(f"Connecting to {db.host}:{db.port}")4. Pick the mode at launch with the MY_PROJECT_MODE environment variable:
python -m my_project.main # development (the default)
MY_PROJECT_MODE=production python -m my_project.mainThat's the whole loop: YAML files in config/, one environment variable, one loaded object.
simple_config reads *.yaml files from <app_dir>/config/. You tell it where app_dir is, so any layout works — including both of these (working copies of each are in the project files under test/example_projects/):
| Layout | config/ location |
Directory settings |
|---|---|---|
| Containerized / k8s-style (recommended) | <root>/app/config/ |
app_dir = <root>/app, project_dir = <root> |
| Legacy / flat | <root>/config/ |
app_dir = project_dir = <root> |
Three directory attributes exist on the config object:
| Attribute | Required? | Meaning |
|---|---|---|
config.app_dir |
Yes, before load() |
The directory that contains config/ |
config.project_dir |
Optional | Repository root — handy in {config.project_dir} interpolation |
config.workspace_dir |
Optional | Any scratch/workspace path you want available in interpolation |
Rules for the config/ folder:
- Files must use the
.yamlextension — anything else in the folder is ignored. - File names must be lowercase (
Test.yamlraisesConfigError). - At least one non-
basefile must exist;base.yamlitself is optional. - Empty files are fine (they contribute nothing); a non-empty file must be a YAML mapping at the top level.
YamlConfig(project_name, default_mode="development", process_name=None)| Parameter | Meaning |
|---|---|
project_name |
Your project's name (conventionally the repository name). Lowercased internally; also names the per-user override folder. |
default_mode |
Mode used when the environment variable isn't set. Default: development. |
process_name |
Name of the running process, available as {config.process_name}. Default: the current script's filename stem. |
The mode-selecting environment variable is the project name, uppercased with - replaced by _, plus _MODE — for my-project that's MY_PROJECT_MODE (also available as config.env_var).
- The valid modes are exactly the config file stems (minus
base): createstaging.yamlandstagingbecomes a valid mode. - Values are case-insensitive:
MY_PROJECT_MODE=TESTselectstest. - An invalid value raises
EnvironmentErrornaming the valid choices. - The mode is read once, on first use, and stays fixed for the life of the config object.
config.load() reads, in order — later files win:
base.yaml(if present)<mode>.yaml~/.simple_config/<project_name>/<mode>.yaml— only withload(include_user_overrides=True)
Nested mappings are deep-merged key by key; every other value — strings, numbers, lists — is replaced wholesale:
# base.yaml # production.yaml # loaded result
retries: 3 retries: 5 retries: 5
database: database: database:
host: 'localhost' host: 'db.internal' host: 'db.internal'
port: 5432 port: 5432 <- kept from base
tags: ['a', 'b'] tags: ['prod'] tags: ['prod'] <- lists replace, never mergeMode files may also introduce keys that base.yaml doesn't have.
After load(), the values live on the config object itself:
config.databases.replica.host # attribute style
config.databases['replica']['host'] # dict style
config.databases['replica'].host # mix and match
section = config.databases.replica
section.get('passwd') # None if missing
section.get('passwd', 'fallback') # with a default
'host' in section # membership test
section.keys(); section.values(); section.items()The top level is attribute-only (config.databases, not config['databases']); from there down, every nested section supports both styles plus get/in/keys/values/items.
Treat the loaded config as read-only — assigning through dict syntax (config.databases['replica']['host'] = ...) raises TypeError.
Values are transformed as they load:
| You write | You get |
|---|---|
'...{config.mode}...' in any string |
The placeholder interpolated from the config object |
A key ending in _dir |
The path with ~ expanded, made absolute, and normalized |
A key ending in _datetime |
A datetime object (parsed by dateutil) |
A key suffixed .SECRET |
The value fetched from AWS Secrets Manager |
Every string value — at any nesting depth, including inside lists — is formatted with config bound, so {config.<field>} placeholders resolve to:
| Placeholder | Value |
|---|---|
{config.mode} |
The active mode, e.g. production |
{config.project_name} |
The (lowercased) project name |
{config.process_name} |
The script name, or the process_name you passed |
{config.project_dir}, {config.app_dir}, {config.workspace_dir} |
The directories you set |
{config.<anything>} |
Any attribute you set on the config object before load() |
paths:
log_dir: '{config.project_dir}/logs/{config.mode}'To include a literal brace in a string, double it: '{{' and '}}'.
cache_dir: '~/caches/{config.project_name}' # -> /home/you/caches/my_project
launch_datetime: '2026-07-04 12:00' # -> datetime.datetime(2026, 7, 4, 12, 0)Relative _dir paths resolve against the current working directory.
Use secrets without writing them down. The value is fetched at load time and only ever held in memory:
jwt:
jwt_key.SECRET: 'my-app-staging-secrets/jwt'The value format is <secret_name>/<json_key>: the named secret's string is parsed as JSON and the key is extracted. The .SECRET suffix is stripped, so the result above is a plain config.jwt.jwt_key.
- Uses your standard boto3/AWS credentials; secrets are read from the
us-east-1region. - Each secret is fetched once per process, then cached.
- Any other
.SUFFIXon a key prints a warning and yieldsNone.
For values personal to one developer or machine, simple_config can merge one extra file from outside the repository, last in the chain: ~/.simple_config/<project_name>/<mode>.yaml. It's opt-in per load:
config.load(include_user_overrides=True)If the folder or file doesn't exist, loading proceeds normally. A typical use — point development at your own database without editing project files:
# ~/.simple_config/my_project/development.yaml
databases:
replica:
host: 'my-sandbox-db.local'For a personal scratch mode, commit an empty ad_hoc.yaml to config/ (making ad_hoc a valid mode), keep the real values in ~/.simple_config/my_project/ad_hoc.yaml, and run:
MY_PROJECT_MODE=ad_hoc python -m my_project.mainCatch a broken production.yaml in your test suite, not at deploy time. These load every mode on throwaway copies, without touching your loaded config:
config.load_all_configs() # raises the first failure it finds
errors = config.try_loading_all_configs() # or: {mode: exception}, empty if all goodinit_logger(logger_type, logger_options) builds a standard logging logger from config — pass it the config section, as in the quick start:
logger = init_logger(config.logging.logger_type, config.logging.logger_options)logger_type |
Writes to | Required logger_options |
|---|---|---|
'stdout' |
Console | name, base_level, line_format |
'local_file' |
Log file + console | the above, plus directory, file_name |
With local_file, the log directory is created if missing, everything at base_level and up goes to the file, DEBUG/INFO echo to stdout, and WARNING and up echo to stderr.
from simple_config import YamlConfig # the main class
from simple_config.logging_tools import init_logger # config-driven logger setup
from simple_config.singleton import Singleton # metaclass for a shared Env
from simple_config.error_types import ConfigError # raised for config-file problemsYamlConfig member |
What it does |
|---|---|
YamlConfig(project_name, default_mode='development', process_name=None) |
Create an unloaded config |
.app_dir |
Set before loading — the directory that contains config/ |
.project_dir, .workspace_dir |
Optional directories, available to interpolation |
.load(include_user_overrides=False) |
Read, merge, and transform the active mode's files onto the object |
.load_all_configs() |
Load every mode on copies; raise the first failure |
.try_loading_all_configs() |
As above, but return {mode: exception} instead of raising |
.mode |
The active mode (fixed after first access) |
.valid_modes |
Set of modes that have a config file |
.env_var |
Name of the mode-selecting environment variable |
.config_dir |
<app_dir>/config |
.user_override_config_dir |
~/.simple_config/<project_name> |
.project_name, .process_name, .default_mode |
As given at construction (normalized) |
Errors you may meet: ConfigError (folder/file rules violated), EnvironmentError (invalid mode selected), and PyYAML's ParserError (malformed YAML).
Complete working layouts live in the project files: test/example_projects/ has one containerized-style and one legacy-style project, and test/example_user_home_dirs/ shows the per-user override folder structure.
Licensed under the Apache License 2.0.