Layered, profile-composable configuration for Python applications.
Configuration arrives from several places at once — packaged defaults, a named
environment, a user file, a project file, environment variables, explicit
overrides. xcfg composes them in a documented order and validates the result
with a pydantic model your application owns.
The library owns the mechanism. The model, the YAML files, and every policy decision stay with you.
pip install xcfg # or: uv add xcfgfrom pathlib import Path
from pydantic import BaseModel
from xcfg import ConfigLoader, ConfigSpec
class Database(BaseModel):
driver: str = "sqlite"
pool_size: int = 5
class Settings(BaseModel):
database: Database = Database()
loader = ConfigLoader(
ConfigSpec(
config_root=Path(__file__).parent / "resources" / "config",
env_prefix="MYAPP_", # MYAPP_DATABASE__POOL_SIZE=20
app_name="myapp", # ~/.config/myapp/config.yml
project_dir=".myapp", # ./.myapp/config.yml, found by walking up
profiled_sections=("database",),
),
Settings,
)
settings = loader.load(env_name="staging")A base config is selected first:
- an explicit path — the
config_pathargument, orMYAPP_CONFIG - a named env — the
env_nameargument, orMYAPP_ENV, loadingconfig.<name>.yml - otherwise
config.default.yml
Then these layers merge over it, later winning:
- user config at
${XDG_CONFIG_HOME}/myapp/config.yml - project config at
.myapp/config.yml, nearest walking up from the CWD - environment variables,
MYAPP_<SECTION>__<KEY> - explicit dotted-path overrides,
{"database.pool_size": "20"}
Merging is deep, so a layer setting one key leaves its siblings intact. Scalars
from the environment and from overrides are parsed as YAML would parse them, so
"20" becomes an int and "true" becomes a bool.
Every layer is optional: an app with a single config file sets only
config_root.
For any section listed in profiled_sections, the value may be:
- a string — a profile name, resolving to
<config_root>/<section>/<name>.yml - a mapping — used inline, as-is
# resources/config/config.staging.yml
database: postgres # -> resources/config/database/postgres.yml
storage: {backend: s3} # inline; storage is not profiledProfiles stay independent, so the file count is additive — N database profiles plus M cache profiles, never N×M combined files.
resources/config/
config.default.yml
config.staging.yml
database/
sqlite.yml
postgres.yml
When a setting belongs to one input rather than a whole tree, declare a
source_rules_key and bind by glob:
sources:
"*measure-anything*": {chaptering: toc-depth1}
"*/decks/*.pptx": {naming: verbose}settings = loader.load(source=Path("measure-anything.mobi"))
loader.matching_source_rules(path) # -> ("*measure-anything*",)Patterns match the full path and the bare filename. Rules apply after the file layers and before explicit overrides — specific enough to beat a project default, never specific enough to beat an explicit flag. The key is loader machinery and never reaches your model.
loader.available_envs() # ("default", "staging")
loader.available_profiles("database") # ("postgres", "sqlite")
loader.load_raw() # the merged mapping, before validationEverything raises ConfigError with an actionable message — an unknown profile
names the alternatives, a validation failure names the field:
unknown database profile 'postgres9' (available: postgres, sqlite)
invalid configuration: database.pool_size: Input should be a valid integer
Use extra="forbid" on your models so a typo in a config file fails loudly
instead of vanishing.
resources/skills/use-xcfg/SKILL.md covers structuring configuration
correctly — in particular the N x M layout mistake, what belongs in a profile
versus inline, and how to adopt xcfg in a project with a hand-rolled loader.
tests/test_skill.py checks it against the real surface, so a renamed spec
field fails the build rather than misleading a reader.
MIT