Alternatives and migration¶
config_resolver is no longer maintained.
Version 5.2.0 is the final release.
This page shows how to cover its common use cases
with maintained libraries or the standard library.
Every snippet below runs in the project’s test suite
(tests/test_migration_snippets.py).
Pick the section that matches the application:
A service in a container: Containerised services.
Existing INI files, smallest change: Keep the INI files.
A tool installed on a host, with system and user files: Command-line tools with an XDG cascade.
The last two sections replace the version and secure lookup
options.
Silence the warning while migrating¶
Version 5.2.0 emits
ConfigResolverDeprecationWarning
on every call to get_config() and
from_string().
It is a DeprecationWarning, so Python hides it by default
outside __main__ and test runners.
Code that runs with -W error fails on these calls.
To silence only this warning until the migration is done:
import warnings
from config_resolver import (
ConfigResolverDeprecationWarning,
)
warnings.filterwarnings(
"ignore",
category=ConfigResolverDeprecationWarning,
)
In pytest, add this to the filterwarnings setting:
ignore::config_resolver.ConfigResolverDeprecationWarning
Containerised services¶
A service in a container takes its configuration from environment variables and mounted secret files (twelve-factor app, factor III). pydantic-settings does this and checks every value against a type:
"""
Settings for a containerised service.
"""
import sys
from pydantic import (
BaseModel,
SecretStr,
ValidationError,
)
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
)
class Database(BaseModel):
host: str
port: int = 5432
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MYAPP_",
env_nested_delimiter="__",
secrets_dir="/run/secrets",
)
debug: bool = False
database: Database
db_password: SecretStr
def load_settings() -> Settings:
try:
return Settings()
except ValidationError as exc:
sys.exit(f"Invalid configuration:\n{exc}")
MYAPP_DEBUG=truesetsdebug.MYAPP_DATABASE__HOST=dbsetsdatabase.host:__separates nested fields.db_passwordcomes from the file/run/secrets/myapp_db_passwordwhen noMYAPP_DB_PASSWORDvariable is set. Secret files carry the same prefix as the variables.SecretStrkeeps the password out ofrepr()and logs; callget_secret_value()to use it.A missing or invalid value stops the program at start-up with a message that names the field.
config_resolverreturned an empty config instead.
Keep the INI files¶
To keep the existing files and change as little as possible, use
configparser.ConfigParser from the standard library on the
same paths config_resolver searched:
"""
Read the INI files config_resolver used to read.
"""
import os
from configparser import ConfigParser
from pathlib import Path
def config_paths(group, app, filename="app.ini"):
home = os.environ.get(
"XDG_CONFIG_HOME", Path.home() / ".config"
)
xdg_dirs = os.environ.get(
"XDG_CONFIG_DIRS", "/etc/xdg"
).split(":")
# Lowest precedence first.
return [
Path("/etc", group, app, filename),
*(
Path(d, group, app, filename)
for d in reversed(xdg_dirs)
),
Path(home, group, app, filename),
Path.cwd() / f".{group}" / app / filename,
]
def load_config(group, app):
config = ConfigParser()
# read() skips missing files, applies the rest
# in order and returns the ones it loaded.
loaded_files = config.read(
config_paths(group, app)
)
if not loaded_files:
raise FileNotFoundError(
"no config file found"
)
return config, loaded_files
read() already does what
config_resolver did with its default INI handler:
it skips files that do not exist;
it applies the others in order, so later files override earlier ones;
it returns the list of files it read, the same list as
meta.loaded_files.
The empty-list check makes a missing config an error;
remove it to keep the old behaviour.
config_resolver also honoured <GROUP>_<APP>_PATH and
<GROUP>_<APP>_FILENAME; if deployments set them, read them with
os.environ in config_paths().
Command-line tools with an XDG cascade¶
A tool installed on a host often has system-wide defaults, a user file
that overrides them, and a --config option or an environment
variable that names one more file.
platformdirs finds the
directories on every platform, and tomllib (Python 3.11 and
later) reads TOML.
On Python 3.10 and earlier, install
tomli, which has the same API.
"""
Layered TOML configuration for a CLI tool.
"""
import os
import sys
from pathlib import Path
from platformdirs import (
site_config_dir,
user_config_path,
)
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
APP = "bird-feeder"
FILENAME = "config.toml"
def default_files():
"""Return files, lowest precedence first."""
site = site_config_dir(APP, multipath=True)
dirs = reversed(site.split(os.pathsep))
files = [Path(d, FILENAME) for d in dirs]
files.append(user_config_path(APP) / FILENAME)
return files
def merge(base, override):
"""Merge dicts; *override* wins."""
result = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(
result.get(key), dict
):
value = merge(result[key], value)
result[key] = value
return result
def load_config(explicit=None):
"""Return the config and its source files."""
explicit = explicit or os.environ.get(
"BIRD_FEEDER_CONFIG"
)
files = [
f for f in default_files() if f.is_file()
]
if explicit:
# Must exist: open() fails if it does not.
files.append(Path(explicit))
config = {}
for path in files:
with path.open("rb") as fp:
config = merge(
config, tomllib.load(fp)
)
return config, files
The files load in this order: each directory in
XDG_CONFIG_DIRS(/etc/xdgby default), then~/.config/bird-feeder/config.toml, then the file named inBIRD_FEEDER_CONFIGor passed as explicit.Nested tables merge key by key; later files win.
load_config()returns the files it loaded as the second value, for a--show-configoption or a log line.A file named explicitly must exist; the default locations are optional.
Replace the version check¶
The version lookup option compared a [meta] version entry in
each file with the version the application expected.
Instead, give the file a schema_version field and validate the
whole config with a pydantic.BaseModel.
A file with another version then fails validation like any other
wrong value:
"""
Check the schema version while validating.
"""
from typing import Literal
from pydantic import BaseModel
class Feeder(BaseModel):
capacity_g: int
refill_hour: int = 6
class Config(BaseModel):
# Bump on every incompatible change to the
# file format. Older files fail validation.
schema_version: Literal[2]
feeder: Feeder
def check_version(data: dict) -> None:
"""The same check without pydantic."""
version = data.get("schema_version")
if version != 2:
raise ValueError(
f"schema_version {version!r} != 2"
)
Config.model_validate(config) accepts the dictionary returned by
load_config() in the previous section.
pydantic-settings models from the first section validate the same
way.
Replace secure=True¶
secure=True skipped every file that the group or other users could
read.
It did not check who owned the file, so a file that another user could
replace still passed.
This check looks at both:
"""
Refuse config files others can read or change.
"""
import os
import stat
def check_private(path):
info = os.stat(path)
if info.st_uid not in (os.getuid(), 0):
raise PermissionError(
f"{path} must be owned by you or root"
)
if info.st_mode & (
stat.S_IRWXG | stat.S_IRWXO
):
mode = stat.filemode(info.st_mode)
raise PermissionError(
f"{path} is {mode}, expected 0600"
)
Call check_private(path) before reading a file that holds secrets.
os.getuid() exists on POSIX systems only.
Note
Do not run this check on secrets mounted into a container.
Docker and Kubernetes usually mount them as 0444 or 0644,
owned by root.
The mount protects them, not the file mode: only the container sees
the file.
Other options¶
- Dynaconf
Layered settings from TOML, YAML, JSON, INI,
.envfiles, environment variables and secret stores, with per-environment sections.- environs
Parses and validates environment variables into Python types; reads
.envfiles.- python-decouple
Reads settings from environment variables with fallbacks to
.envorsettings.inifiles, with simple type casting.