Skip to main content

Load and merge settings from pyproject.toml and .env files into validated Pydantic models

Project description

pydsettingsforge

Load and merge application settings from pyproject.toml and .env files into validated Pydantic models.

Features

  • Read settings from any number of pyproject.toml sections via dot-separated paths (e.g. "project", "tool.myapp", "autotests.settings")
  • Sections are deep-merged left-to-right, later sections override earlier ones
  • Read and merge multiple .env files with explicit priority ordering
  • Nested configuration via __ separator in .env keys (e.g., DATABASE__HOST=localhost)
  • Automatic coercion of .env list and dict values from Pydantic model hints
  • .env values override all pyproject.toml values
  • Validate merged settings against a user-provided Pydantic model
  • Clear, specific error messages for missing files, sections, and validation failures

Installation

uv add pydsettingsforge

Quick Start

1. Define your settings model

from pydantic import BaseModel

class DatabaseConfig(BaseModel):
    host: str
    port: int

class AppSettings(BaseModel):
    name: str
    version: str
    debug: bool = False
    log_level: str = "info"
    database: DatabaseConfig | None = None

2. Add settings to pyproject.toml

[project]
name = "myapp"
version = "1.0.0"

[tool.myapp]
debug = false
log_level = "info"

[tool.myapp.database]
host = "localhost"
port = 5432

3. Create .env files (optional)

# .env
DEBUG=true
LOG_LEVEL=debug

# .env.local (overrides .env)
DATABASE__HOST=db.production.com
DATABASE__PORT=3306

4. Load settings

from pydsettingsforge import load_settings
from myapp.config import AppSettings

settings = load_settings(
    AppSettings,
    toml_sections=["project", "tool.myapp"],
    env_files=[".env", ".env.local"],
)

print(settings.name)            # "myapp"
print(settings.debug)           # True (overridden by .env)
print(settings.database.host)   # "db.production.com" (overridden by .env.local)

Override Priority

Settings are merged in this order (lowest to highest priority):

  1. pyproject.toml sections (in the order provided in the toml_sections list)
  2. .env files (in the order provided in the env_files list)
  3. OS environment variables (if your model inherits from pydantic_settings.BaseSettings)

TOML Sections

The toml_sections parameter accepts dot-separated paths that walk into the parsed TOML structure:

[tool.myapp]
debug = false

[tool.myapp.database]
host = "localhost"
port = 5432

[autotests.settings]
browser = "chromium"
workers = 4

[autotests.settings.retry]
max_attempts = 3
backoff = "exponential"
from pydsettingsforge import load_settings

class RetryConfig(BaseModel):
    max_attempts: int
    backoff: str

class AutotestSettings(BaseModel):
    browser: str
    workers: int
    retry: RetryConfig

class AppSettings(BaseModel):
    name: str
    debug: bool = False
    database: DatabaseConfig | None = None
    autotests: AutotestSettings | None = None

settings = load_settings(
    AppSettings,
    toml_sections=[
        "project",                # [project] — filtered to PEP 621 keys
        "tool.myapp",             # [tool.myapp] — all keys unfiltered
        "tool.myapp.database",    # [tool.myapp.database] — nested sub-table
        "autotests.settings",     # [autotests.settings] — custom nested section
    ],
    env_files=[".env"],
)

Sections are deep-merged left-to-right. When the path is exactly "project", only known PEP 621 metadata keys (name, version, description, requires-python, readme, authors) are included. All other sections include every key unfiltered.

Lists and Dicts in .env

.env values are always strings, but your Pydantic model knows the target type. pydsettingsforge uses those hints to parse list-like and dict fields automatically:

class AppSettings(BaseModel):
    allowed_hosts: list[str]
    ports: list[int]
    features: dict[str, int]
# .env
ALLOWED_HOSTS=api.example.com,web.example.com
PORTS=80,443,5432
FEATURES={"timeout": 30, "retries": 3}
settings = load_settings(AppSettings, env_files=[".env"])
settings.allowed_hosts  # ["api.example.com", "web.example.com"]
settings.ports          # [80, 443, 5432]  (Pydantic coerces each element)
settings.features       # {"timeout": 30, "retries": 3}

Rules:

  • List-like fields (list, set, tuple, frozenset): split on , by default, whitespace stripped, empty parts dropped. If the value starts with [, it is parsed as JSON instead; on invalid JSON the value is split as a fallback.
  • Dict fields: parsed as JSON.
  • Per-element types (e.g. list[int], list[bool]): the list is split into strings, then Pydantic coerces each element during model validation.
  • Optional list/dict fields (list[str] | None) are detected. Multi-member unions like list[str] | int | None also detect the list member.
  • Nested model lists (list[BaseModel], set[BaseModel], tuple[BaseModel, ...]): the value must be a JSON list; each element is recursively coerced, so child list / dict fields inside the model are parsed the same way as top-level fields.
  • Custom separator: pass list_separator=";" to load_settings to split on a different character.
  • Opt out: pass coerce_env=False to keep raw string passthrough (the prior behavior).

If a value cannot be parsed (e.g. malformed JSON for a dict field or a list[BaseModel] field), a SettingsValidationError is raised with the offending field name.

class Server(BaseModel):
    host: str
    tags: list[str]

class AppSettings(BaseModel):
    servers: list[Server]
SERVERS=[{"host": "a.example.com", "tags": "primary,public"}, {"host": "b.example.com", "tags": "backup"}]
settings.servers[0].host  # "a.example.com"
settings.servers[0].tags  # ["primary", "public"]  (child list coerced too)

Extra Fields

By default, Pydantic ignores any fields in your configuration that aren't defined in your model:

class AppSettings(BaseModel):
    debug: bool

# If pyproject.toml has extra fields like "name" or "version",
# they are silently ignored

You can control this behavior using Pydantic's model_config:

Forbid Extra Fields (Strict Mode)

Raise an error if unexpected fields are present:

from pydantic import BaseModel, ConfigDict

class StrictSettings(BaseModel):
    model_config = ConfigDict(extra="forbid")
    debug: bool
    log_level: str

# Raises SettingsValidationError if pyproject.toml contains
# fields not defined in the model

Allow Extra Fields

Accept and store extra fields dynamically:

from pydantic import BaseModel, ConfigDict

class FlexibleSettings(BaseModel):
    model_config = ConfigDict(extra="allow")
    debug: bool

# Extra fields are accessible via settings.model_extra

Note: This behavior is controlled by your Pydantic model configuration, not by pydsettingsforge.

API Reference

load_settings()

def load_settings[T: BaseModel](
    model_class: type[T],
    *,
    pyproject_path: Path | str | None = None,
    env_files: list[Path | str] | None = None,
    toml_sections: list[str] | None = None,
    env_nesting_separator: str = "__",
    coerce_env: bool = True,
    list_separator: str = ",",
) -> T
Parameter Type Default Description
model_class type[BaseModel] required Pydantic model to validate against
pyproject_path Path | str | None ./pyproject.toml Path to pyproject.toml
env_files list[Path | str] | None None Ordered list of .env files (later wins)
toml_sections list[str] | None ["project"] Ordered list of dot-separated TOML section paths (later wins). When "project" is listed, only PEP 621 metadata keys are included. All other sections include every key unfiltered.
env_nesting_separator str "__" Separator for nested .env keys
coerce_env bool True Parse list/dict string values via the model hints before validation
list_separator str "," Separator for list-like fields when coerce_env is enabled

coerce_env_values()

def coerce_env_values(
    model_class: type[BaseModel],
    data: dict[str, Any],
    *,
    list_separator: str = ",",
    coerce_env: bool = True,
) -> dict[str, Any]

The same list/dict coercion that load_settings runs after merging, exposed as a standalone helper. Use it when you build the settings dict yourself (e.g. from a custom config source) and want the same string-to-typed-value behavior before handing the dict to Pydantic.

Parameter Type Default Description
model_class type[BaseModel] required Pydantic model used to interpret each leaf
data dict[str, Any] required The dict to coerce (not mutated)
list_separator str "," Separator for list-like fields
coerce_env bool True Set to False to return a shallow copy with no coercion

Exceptions

Exception When
PyprojectNotFoundError pyproject.toml not found
EnvFileNotFoundError A specified .env file doesn't exist
RootSectionNotFoundError A requested TOML section path is missing
SettingsValidationError Merged data fails Pydantic validation

Development

Prerequisites

  • Python 3.13+
  • uv

Setup

git clone <repo-url>
cd pydsettingsforge
uv sync --all-groups

Commands

# Run tests
uv run pytest

# Run tests with coverage
uv run pytest --cov=pydsettingsforge

# Lint
uv run ruff check src/ tests/

# Format
uv run ruff format src/ tests/

# Type check
uv run ty check src/

# All checks
uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/ && uv run pytest

Project Structure

pydsettingsforge/
├── pyproject.toml
├── uv.lock
├── README.md
├── .gitignore
├── src/
│   └── pydsettingsforge/
│       ├── __init__.py          # Public API: load_settings(), coerce_env_values()
│       ├── constants.py         # Default constants
│       ├── coercer.py           # List/dict coercion from Pydantic hints
│       ├── env_reader.py        # .env file parsing and nesting
│       ├── exceptions.py        # Custom exceptions
│       ├── merger.py            # Deep-merge dictionaries
│       ├── toml_reader.py       # pyproject.toml parsing
│       └── validator.py         # Pydantic validation
└── tests/
    ├── conftest.py              # Shared fixtures
    ├── test_coercer.py
    ├── test_env_reader.py
    ├── test_load_settings.py    # Integration tests
    ├── test_merger.py
    ├── test_toml_reader.py
    └── test_validator.py

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pydsettingsforge-2.0.1.tar.gz (9.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pydsettingsforge-2.0.1-py3-none-any.whl (12.8 kB view details)

Uploaded Python 3

File details

Details for the file pydsettingsforge-2.0.1.tar.gz.

File metadata

  • Download URL: pydsettingsforge-2.0.1.tar.gz
  • Upload date:
  • Size: 9.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pydsettingsforge-2.0.1.tar.gz
Algorithm Hash digest
SHA256 973688d1de8c5a19aeb13a05d27927ba3afb5b435dc0f084b6ce1e5c5a389942
MD5 ffb5124e33e5af864ca5f3771fd460bd
BLAKE2b-256 f8074e653eaec90073355e48a169346e6d561a1d271b269bd64e2f52a8de0d33

See more details on using hashes here.

File details

Details for the file pydsettingsforge-2.0.1-py3-none-any.whl.

File metadata

File hashes

Hashes for pydsettingsforge-2.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 12e4b3a627ad1512a857e43fd63d87a3ada8749d09c8fbf7bcf9b005b82988a7
MD5 cd3c8d994871be77dfcfd2642ac4576c
BLAKE2b-256 919ba538f3959b10472cb88f9f51141687f73d403c00ca329fbe9cb037266a8d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page