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
pyproject.toml([project]table + optional[tool.<name>]section) - Read and merge multiple
.envfiles with explicit priority ordering - Nested configuration via
__separator in.envkeys (e.g.,DATABASE__HOST=localhost) - Automatic coercion of
.envlist and dict values from Pydantic model hints .envvalues overridepyproject.tomlvalues- 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,
tool_section="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):
pyproject.tomlroot section fields (default:[project])pyproject.toml[tool.<name>]section.envfiles (in the order provided in theenv_fileslist)- OS environment variables (if your model inherits from
pydantic_settings.BaseSettings)
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 likelist[str] | int | Nonealso 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 childlist/dictfields inside the model are parsed the same way as top-level fields. - Custom separator: pass
list_separator=";"toload_settingsto split on a different character. - Opt out: pass
coerce_env=Falseto 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)
Custom Root Section
By default, pydsettingsforge reads from the [project] section (filtering to known metadata keys). You can specify a custom root section to read all keys from any TOML table:
[settings]
host = "localhost"
port = 8080
debug = true
settings = load_settings(
ServerSettings,
root_section="settings",
)
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,
tool_section: str | None = None,
root_section: str = "project",
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) |
tool_section |
str | None |
None |
[tool.<name>] section to read |
root_section |
str |
"project" |
Root TOML section to read (custom sections include all keys) |
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 |
Root section is missing |
ToolSectionNotFoundError |
[tool.<name>] section 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pydsettingsforge-1.0.0.tar.gz.
File metadata
- Download URL: pydsettingsforge-1.0.0.tar.gz
- Upload date:
- Size: 8.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a5b7bf41dd6ac4807afd694f32c9550426dfe2b5ad32966499526f2e678f3201
|
|
| MD5 |
58dcda1b56b85a2b2f1f81b6941b2220
|
|
| BLAKE2b-256 |
0c4688887fca459c5f8b877c3041638a1e2aade4e50e1f0edbe6dc8e9588ee34
|
File details
Details for the file pydsettingsforge-1.0.0-py3-none-any.whl.
File metadata
- Download URL: pydsettingsforge-1.0.0-py3-none-any.whl
- Upload date:
- Size: 12.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c43d58fdb95beed3f44df9b0942ab7f8879874ba8175432393f6689391c1ec1
|
|
| MD5 |
c5cb9eabf1c9fc252f99218fb5305e90
|
|
| BLAKE2b-256 |
3fecdb5bab1c84e6bbe4d422689ddfd4e9a2ff2b495bb9ce29e8519a6d760dd4
|