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:

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=true sets debug. MYAPP_DATABASE__HOST=db sets database.host: __ separates nested fields.

  • db_password comes from the file /run/secrets/myapp_db_password when no MYAPP_DB_PASSWORD variable is set. Secret files carry the same prefix as the variables.

  • SecretStr keeps the password out of repr() and logs; call get_secret_value() to use it.

  • A missing or invalid value stops the program at start-up with a message that names the field. config_resolver returned 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/xdg by default), then ~/.config/bird-feeder/config.toml, then the file named in BIRD_FEEDER_CONFIG or 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-config option 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, .env files, environment variables and secret stores, with per-environment sections.

environs

Parses and validates environment variables into Python types; reads .env files.

python-decouple

Reads settings from environment variables with fallbacks to .env or settings.ini files, with simple type casting.