pydsettingsforge 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydsettingsforge-1.0.0/PKG-INFO +352 -0
- pydsettingsforge-1.0.0/README.md +340 -0
- pydsettingsforge-1.0.0/pyproject.toml +55 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/__init__.py +109 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/coercer.py +171 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/constants.py +9 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/env_reader.py +58 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/exceptions.py +40 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/merger.py +23 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/py.typed +0 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/toml_reader.py +78 -0
- pydsettingsforge-1.0.0/src/pydsettingsforge/validator.py +22 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pydsettingsforge
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Load and merge settings from pyproject.toml and .env files into validated Pydantic models
|
|
5
|
+
Author: Ivan Shcherbenko
|
|
6
|
+
Author-email: Ivan Shcherbenko <ivan_shcherbenko@outlook.com>
|
|
7
|
+
Requires-Dist: pydantic>=2.13.4
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.14.1
|
|
9
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# pydsettingsforge
|
|
14
|
+
|
|
15
|
+
Load and merge application settings from `pyproject.toml` and `.env` files into validated Pydantic models.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Read settings from `pyproject.toml` (`[project]` table + optional `[tool.<name>]` section)
|
|
20
|
+
- Read and merge multiple `.env` files with explicit priority ordering
|
|
21
|
+
- Nested configuration via `__` separator in `.env` keys (e.g., `DATABASE__HOST=localhost`)
|
|
22
|
+
- Automatic coercion of `.env` list and dict values from Pydantic model hints
|
|
23
|
+
- `.env` values override `pyproject.toml` values
|
|
24
|
+
- Validate merged settings against a user-provided Pydantic model
|
|
25
|
+
- Clear, specific error messages for missing files, sections, and validation failures
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv add pydsettingsforge
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### 1. Define your settings model
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from pydantic import BaseModel
|
|
39
|
+
|
|
40
|
+
class DatabaseConfig(BaseModel):
|
|
41
|
+
host: str
|
|
42
|
+
port: int
|
|
43
|
+
|
|
44
|
+
class AppSettings(BaseModel):
|
|
45
|
+
name: str
|
|
46
|
+
version: str
|
|
47
|
+
debug: bool = False
|
|
48
|
+
log_level: str = "info"
|
|
49
|
+
database: DatabaseConfig | None = None
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Add settings to `pyproject.toml`
|
|
53
|
+
|
|
54
|
+
```toml
|
|
55
|
+
[project]
|
|
56
|
+
name = "myapp"
|
|
57
|
+
version = "1.0.0"
|
|
58
|
+
|
|
59
|
+
[tool.myapp]
|
|
60
|
+
debug = false
|
|
61
|
+
log_level = "info"
|
|
62
|
+
|
|
63
|
+
[tool.myapp.database]
|
|
64
|
+
host = "localhost"
|
|
65
|
+
port = 5432
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 3. Create `.env` files (optional)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# .env
|
|
72
|
+
DEBUG=true
|
|
73
|
+
LOG_LEVEL=debug
|
|
74
|
+
|
|
75
|
+
# .env.local (overrides .env)
|
|
76
|
+
DATABASE__HOST=db.production.com
|
|
77
|
+
DATABASE__PORT=3306
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 4. Load settings
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from pydsettingsforge import load_settings
|
|
84
|
+
from myapp.config import AppSettings
|
|
85
|
+
|
|
86
|
+
settings = load_settings(
|
|
87
|
+
AppSettings,
|
|
88
|
+
tool_section="myapp",
|
|
89
|
+
env_files=[".env", ".env.local"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
print(settings.name) # "myapp"
|
|
93
|
+
print(settings.debug) # True (overridden by .env)
|
|
94
|
+
print(settings.database.host) # "db.production.com" (overridden by .env.local)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Override Priority
|
|
98
|
+
|
|
99
|
+
Settings are merged in this order (lowest to highest priority):
|
|
100
|
+
|
|
101
|
+
1. `pyproject.toml` root section fields (default: `[project]`)
|
|
102
|
+
2. `pyproject.toml` `[tool.<name>]` section
|
|
103
|
+
3. `.env` files (in the order provided in the `env_files` list)
|
|
104
|
+
4. OS environment variables (if your model inherits from `pydantic_settings.BaseSettings`)
|
|
105
|
+
|
|
106
|
+
## Lists and Dicts in .env
|
|
107
|
+
|
|
108
|
+
`.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:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
class AppSettings(BaseModel):
|
|
112
|
+
allowed_hosts: list[str]
|
|
113
|
+
ports: list[int]
|
|
114
|
+
features: dict[str, int]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# .env
|
|
119
|
+
ALLOWED_HOSTS=api.example.com,web.example.com
|
|
120
|
+
PORTS=80,443,5432
|
|
121
|
+
FEATURES={"timeout": 30, "retries": 3}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
settings = load_settings(AppSettings, env_files=[".env"])
|
|
126
|
+
settings.allowed_hosts # ["api.example.com", "web.example.com"]
|
|
127
|
+
settings.ports # [80, 443, 5432] (Pydantic coerces each element)
|
|
128
|
+
settings.features # {"timeout": 30, "retries": 3}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Rules:**
|
|
132
|
+
|
|
133
|
+
- **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.
|
|
134
|
+
- **Dict fields**: parsed as JSON.
|
|
135
|
+
- **Per-element types** (e.g. `list[int]`, `list[bool]`): the list is split into strings, then Pydantic coerces each element during model validation.
|
|
136
|
+
- **Optional list/dict fields** (`list[str] | None`) are detected. Multi-member unions like `list[str] | int | None` also detect the list member.
|
|
137
|
+
- **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.
|
|
138
|
+
- **Custom separator**: pass `list_separator=";"` to `load_settings` to split on a different character.
|
|
139
|
+
- **Opt out**: pass `coerce_env=False` to keep raw string passthrough (the prior behavior).
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
class Server(BaseModel):
|
|
145
|
+
host: str
|
|
146
|
+
tags: list[str]
|
|
147
|
+
|
|
148
|
+
class AppSettings(BaseModel):
|
|
149
|
+
servers: list[Server]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
SERVERS=[{"host": "a.example.com", "tags": "primary,public"}, {"host": "b.example.com", "tags": "backup"}]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
settings.servers[0].host # "a.example.com"
|
|
158
|
+
settings.servers[0].tags # ["primary", "public"] (child list coerced too)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Custom Root Section
|
|
162
|
+
|
|
163
|
+
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:
|
|
164
|
+
|
|
165
|
+
```toml
|
|
166
|
+
[settings]
|
|
167
|
+
host = "localhost"
|
|
168
|
+
port = 8080
|
|
169
|
+
debug = true
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
settings = load_settings(
|
|
174
|
+
ServerSettings,
|
|
175
|
+
root_section="settings",
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Extra Fields
|
|
180
|
+
|
|
181
|
+
By default, Pydantic ignores any fields in your configuration that aren't defined in your model:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
class AppSettings(BaseModel):
|
|
185
|
+
debug: bool
|
|
186
|
+
|
|
187
|
+
# If pyproject.toml has extra fields like "name" or "version",
|
|
188
|
+
# they are silently ignored
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
You can control this behavior using Pydantic's `model_config`:
|
|
192
|
+
|
|
193
|
+
### Forbid Extra Fields (Strict Mode)
|
|
194
|
+
|
|
195
|
+
Raise an error if unexpected fields are present:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from pydantic import BaseModel, ConfigDict
|
|
199
|
+
|
|
200
|
+
class StrictSettings(BaseModel):
|
|
201
|
+
model_config = ConfigDict(extra="forbid")
|
|
202
|
+
debug: bool
|
|
203
|
+
log_level: str
|
|
204
|
+
|
|
205
|
+
# Raises SettingsValidationError if pyproject.toml contains
|
|
206
|
+
# fields not defined in the model
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Allow Extra Fields
|
|
210
|
+
|
|
211
|
+
Accept and store extra fields dynamically:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from pydantic import BaseModel, ConfigDict
|
|
215
|
+
|
|
216
|
+
class FlexibleSettings(BaseModel):
|
|
217
|
+
model_config = ConfigDict(extra="allow")
|
|
218
|
+
debug: bool
|
|
219
|
+
|
|
220
|
+
# Extra fields are accessible via settings.model_extra
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Note**: This behavior is controlled by your Pydantic model configuration, not by pydsettingsforge.
|
|
224
|
+
|
|
225
|
+
## API Reference
|
|
226
|
+
|
|
227
|
+
### `load_settings()`
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
def load_settings[T: BaseModel](
|
|
231
|
+
model_class: type[T],
|
|
232
|
+
*,
|
|
233
|
+
pyproject_path: Path | str | None = None,
|
|
234
|
+
env_files: list[Path | str] | None = None,
|
|
235
|
+
tool_section: str | None = None,
|
|
236
|
+
root_section: str = "project",
|
|
237
|
+
env_nesting_separator: str = "__",
|
|
238
|
+
coerce_env: bool = True,
|
|
239
|
+
list_separator: str = ",",
|
|
240
|
+
) -> T
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
| Parameter | Type | Default | Description |
|
|
244
|
+
|-----------|------|---------|-------------|
|
|
245
|
+
| `model_class` | `type[BaseModel]` | required | Pydantic model to validate against |
|
|
246
|
+
| `pyproject_path` | `Path \| str \| None` | `./pyproject.toml` | Path to `pyproject.toml` |
|
|
247
|
+
| `env_files` | `list[Path \| str] \| None` | `None` | Ordered list of `.env` files (later wins) |
|
|
248
|
+
| `tool_section` | `str \| None` | `None` | `[tool.<name>]` section to read |
|
|
249
|
+
| `root_section` | `str` | `"project"` | Root TOML section to read (custom sections include all keys) |
|
|
250
|
+
| `env_nesting_separator` | `str` | `"__"` | Separator for nested `.env` keys |
|
|
251
|
+
| `coerce_env` | `bool` | `True` | Parse list/dict string values via the model hints before validation |
|
|
252
|
+
| `list_separator` | `str` | `","` | Separator for list-like fields when `coerce_env` is enabled |
|
|
253
|
+
|
|
254
|
+
### `coerce_env_values()`
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
def coerce_env_values(
|
|
258
|
+
model_class: type[BaseModel],
|
|
259
|
+
data: dict[str, Any],
|
|
260
|
+
*,
|
|
261
|
+
list_separator: str = ",",
|
|
262
|
+
coerce_env: bool = True,
|
|
263
|
+
) -> dict[str, Any]
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
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.
|
|
267
|
+
|
|
268
|
+
| Parameter | Type | Default | Description |
|
|
269
|
+
|-----------|------|---------|-------------|
|
|
270
|
+
| `model_class` | `type[BaseModel]` | required | Pydantic model used to interpret each leaf |
|
|
271
|
+
| `data` | `dict[str, Any]` | required | The dict to coerce (not mutated) |
|
|
272
|
+
| `list_separator` | `str` | `","` | Separator for list-like fields |
|
|
273
|
+
| `coerce_env` | `bool` | `True` | Set to `False` to return a shallow copy with no coercion |
|
|
274
|
+
|
|
275
|
+
### Exceptions
|
|
276
|
+
|
|
277
|
+
| Exception | When |
|
|
278
|
+
|-----------|------|
|
|
279
|
+
| `PyprojectNotFoundError` | `pyproject.toml` not found |
|
|
280
|
+
| `EnvFileNotFoundError` | A specified `.env` file doesn't exist |
|
|
281
|
+
| `RootSectionNotFoundError` | Root section is missing |
|
|
282
|
+
| `ToolSectionNotFoundError` | `[tool.<name>]` section is missing |
|
|
283
|
+
| `SettingsValidationError` | Merged data fails Pydantic validation |
|
|
284
|
+
|
|
285
|
+
## Development
|
|
286
|
+
|
|
287
|
+
### Prerequisites
|
|
288
|
+
|
|
289
|
+
- Python 3.13+
|
|
290
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
291
|
+
|
|
292
|
+
### Setup
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
git clone <repo-url>
|
|
296
|
+
cd pydsettingsforge
|
|
297
|
+
uv sync --all-groups
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Commands
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
# Run tests
|
|
304
|
+
uv run pytest
|
|
305
|
+
|
|
306
|
+
# Run tests with coverage
|
|
307
|
+
uv run pytest --cov=pydsettingsforge
|
|
308
|
+
|
|
309
|
+
# Lint
|
|
310
|
+
uv run ruff check src/ tests/
|
|
311
|
+
|
|
312
|
+
# Format
|
|
313
|
+
uv run ruff format src/ tests/
|
|
314
|
+
|
|
315
|
+
# Type check
|
|
316
|
+
uv run ty check src/
|
|
317
|
+
|
|
318
|
+
# All checks
|
|
319
|
+
uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/ && uv run pytest
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Project Structure
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
pydsettingsforge/
|
|
326
|
+
├── pyproject.toml
|
|
327
|
+
├── uv.lock
|
|
328
|
+
├── README.md
|
|
329
|
+
├── .gitignore
|
|
330
|
+
├── src/
|
|
331
|
+
│ └── pydsettingsforge/
|
|
332
|
+
│ ├── __init__.py # Public API: load_settings(), coerce_env_values()
|
|
333
|
+
│ ├── constants.py # Default constants
|
|
334
|
+
│ ├── coercer.py # List/dict coercion from Pydantic hints
|
|
335
|
+
│ ├── env_reader.py # .env file parsing and nesting
|
|
336
|
+
│ ├── exceptions.py # Custom exceptions
|
|
337
|
+
│ ├── merger.py # Deep-merge dictionaries
|
|
338
|
+
│ ├── toml_reader.py # pyproject.toml parsing
|
|
339
|
+
│ └── validator.py # Pydantic validation
|
|
340
|
+
└── tests/
|
|
341
|
+
├── conftest.py # Shared fixtures
|
|
342
|
+
├── test_coercer.py
|
|
343
|
+
├── test_env_reader.py
|
|
344
|
+
├── test_load_settings.py # Integration tests
|
|
345
|
+
├── test_merger.py
|
|
346
|
+
├── test_toml_reader.py
|
|
347
|
+
└── test_validator.py
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## License
|
|
351
|
+
|
|
352
|
+
MIT
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# pydsettingsforge
|
|
2
|
+
|
|
3
|
+
Load and merge application settings from `pyproject.toml` and `.env` files into validated Pydantic models.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Read settings from `pyproject.toml` (`[project]` table + optional `[tool.<name>]` section)
|
|
8
|
+
- Read and merge multiple `.env` files with explicit priority ordering
|
|
9
|
+
- Nested configuration via `__` separator in `.env` keys (e.g., `DATABASE__HOST=localhost`)
|
|
10
|
+
- Automatic coercion of `.env` list and dict values from Pydantic model hints
|
|
11
|
+
- `.env` values override `pyproject.toml` values
|
|
12
|
+
- Validate merged settings against a user-provided Pydantic model
|
|
13
|
+
- Clear, specific error messages for missing files, sections, and validation failures
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add pydsettingsforge
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Define your settings model
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
class DatabaseConfig(BaseModel):
|
|
29
|
+
host: str
|
|
30
|
+
port: int
|
|
31
|
+
|
|
32
|
+
class AppSettings(BaseModel):
|
|
33
|
+
name: str
|
|
34
|
+
version: str
|
|
35
|
+
debug: bool = False
|
|
36
|
+
log_level: str = "info"
|
|
37
|
+
database: DatabaseConfig | None = None
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. Add settings to `pyproject.toml`
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
[project]
|
|
44
|
+
name = "myapp"
|
|
45
|
+
version = "1.0.0"
|
|
46
|
+
|
|
47
|
+
[tool.myapp]
|
|
48
|
+
debug = false
|
|
49
|
+
log_level = "info"
|
|
50
|
+
|
|
51
|
+
[tool.myapp.database]
|
|
52
|
+
host = "localhost"
|
|
53
|
+
port = 5432
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 3. Create `.env` files (optional)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# .env
|
|
60
|
+
DEBUG=true
|
|
61
|
+
LOG_LEVEL=debug
|
|
62
|
+
|
|
63
|
+
# .env.local (overrides .env)
|
|
64
|
+
DATABASE__HOST=db.production.com
|
|
65
|
+
DATABASE__PORT=3306
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 4. Load settings
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pydsettingsforge import load_settings
|
|
72
|
+
from myapp.config import AppSettings
|
|
73
|
+
|
|
74
|
+
settings = load_settings(
|
|
75
|
+
AppSettings,
|
|
76
|
+
tool_section="myapp",
|
|
77
|
+
env_files=[".env", ".env.local"],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
print(settings.name) # "myapp"
|
|
81
|
+
print(settings.debug) # True (overridden by .env)
|
|
82
|
+
print(settings.database.host) # "db.production.com" (overridden by .env.local)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Override Priority
|
|
86
|
+
|
|
87
|
+
Settings are merged in this order (lowest to highest priority):
|
|
88
|
+
|
|
89
|
+
1. `pyproject.toml` root section fields (default: `[project]`)
|
|
90
|
+
2. `pyproject.toml` `[tool.<name>]` section
|
|
91
|
+
3. `.env` files (in the order provided in the `env_files` list)
|
|
92
|
+
4. OS environment variables (if your model inherits from `pydantic_settings.BaseSettings`)
|
|
93
|
+
|
|
94
|
+
## Lists and Dicts in .env
|
|
95
|
+
|
|
96
|
+
`.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:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
class AppSettings(BaseModel):
|
|
100
|
+
allowed_hosts: list[str]
|
|
101
|
+
ports: list[int]
|
|
102
|
+
features: dict[str, int]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# .env
|
|
107
|
+
ALLOWED_HOSTS=api.example.com,web.example.com
|
|
108
|
+
PORTS=80,443,5432
|
|
109
|
+
FEATURES={"timeout": 30, "retries": 3}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
settings = load_settings(AppSettings, env_files=[".env"])
|
|
114
|
+
settings.allowed_hosts # ["api.example.com", "web.example.com"]
|
|
115
|
+
settings.ports # [80, 443, 5432] (Pydantic coerces each element)
|
|
116
|
+
settings.features # {"timeout": 30, "retries": 3}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Rules:**
|
|
120
|
+
|
|
121
|
+
- **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.
|
|
122
|
+
- **Dict fields**: parsed as JSON.
|
|
123
|
+
- **Per-element types** (e.g. `list[int]`, `list[bool]`): the list is split into strings, then Pydantic coerces each element during model validation.
|
|
124
|
+
- **Optional list/dict fields** (`list[str] | None`) are detected. Multi-member unions like `list[str] | int | None` also detect the list member.
|
|
125
|
+
- **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.
|
|
126
|
+
- **Custom separator**: pass `list_separator=";"` to `load_settings` to split on a different character.
|
|
127
|
+
- **Opt out**: pass `coerce_env=False` to keep raw string passthrough (the prior behavior).
|
|
128
|
+
|
|
129
|
+
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.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
class Server(BaseModel):
|
|
133
|
+
host: str
|
|
134
|
+
tags: list[str]
|
|
135
|
+
|
|
136
|
+
class AppSettings(BaseModel):
|
|
137
|
+
servers: list[Server]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
SERVERS=[{"host": "a.example.com", "tags": "primary,public"}, {"host": "b.example.com", "tags": "backup"}]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
settings.servers[0].host # "a.example.com"
|
|
146
|
+
settings.servers[0].tags # ["primary", "public"] (child list coerced too)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Custom Root Section
|
|
150
|
+
|
|
151
|
+
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:
|
|
152
|
+
|
|
153
|
+
```toml
|
|
154
|
+
[settings]
|
|
155
|
+
host = "localhost"
|
|
156
|
+
port = 8080
|
|
157
|
+
debug = true
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
settings = load_settings(
|
|
162
|
+
ServerSettings,
|
|
163
|
+
root_section="settings",
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Extra Fields
|
|
168
|
+
|
|
169
|
+
By default, Pydantic ignores any fields in your configuration that aren't defined in your model:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
class AppSettings(BaseModel):
|
|
173
|
+
debug: bool
|
|
174
|
+
|
|
175
|
+
# If pyproject.toml has extra fields like "name" or "version",
|
|
176
|
+
# they are silently ignored
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
You can control this behavior using Pydantic's `model_config`:
|
|
180
|
+
|
|
181
|
+
### Forbid Extra Fields (Strict Mode)
|
|
182
|
+
|
|
183
|
+
Raise an error if unexpected fields are present:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from pydantic import BaseModel, ConfigDict
|
|
187
|
+
|
|
188
|
+
class StrictSettings(BaseModel):
|
|
189
|
+
model_config = ConfigDict(extra="forbid")
|
|
190
|
+
debug: bool
|
|
191
|
+
log_level: str
|
|
192
|
+
|
|
193
|
+
# Raises SettingsValidationError if pyproject.toml contains
|
|
194
|
+
# fields not defined in the model
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Allow Extra Fields
|
|
198
|
+
|
|
199
|
+
Accept and store extra fields dynamically:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from pydantic import BaseModel, ConfigDict
|
|
203
|
+
|
|
204
|
+
class FlexibleSettings(BaseModel):
|
|
205
|
+
model_config = ConfigDict(extra="allow")
|
|
206
|
+
debug: bool
|
|
207
|
+
|
|
208
|
+
# Extra fields are accessible via settings.model_extra
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Note**: This behavior is controlled by your Pydantic model configuration, not by pydsettingsforge.
|
|
212
|
+
|
|
213
|
+
## API Reference
|
|
214
|
+
|
|
215
|
+
### `load_settings()`
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
def load_settings[T: BaseModel](
|
|
219
|
+
model_class: type[T],
|
|
220
|
+
*,
|
|
221
|
+
pyproject_path: Path | str | None = None,
|
|
222
|
+
env_files: list[Path | str] | None = None,
|
|
223
|
+
tool_section: str | None = None,
|
|
224
|
+
root_section: str = "project",
|
|
225
|
+
env_nesting_separator: str = "__",
|
|
226
|
+
coerce_env: bool = True,
|
|
227
|
+
list_separator: str = ",",
|
|
228
|
+
) -> T
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
| Parameter | Type | Default | Description |
|
|
232
|
+
|-----------|------|---------|-------------|
|
|
233
|
+
| `model_class` | `type[BaseModel]` | required | Pydantic model to validate against |
|
|
234
|
+
| `pyproject_path` | `Path \| str \| None` | `./pyproject.toml` | Path to `pyproject.toml` |
|
|
235
|
+
| `env_files` | `list[Path \| str] \| None` | `None` | Ordered list of `.env` files (later wins) |
|
|
236
|
+
| `tool_section` | `str \| None` | `None` | `[tool.<name>]` section to read |
|
|
237
|
+
| `root_section` | `str` | `"project"` | Root TOML section to read (custom sections include all keys) |
|
|
238
|
+
| `env_nesting_separator` | `str` | `"__"` | Separator for nested `.env` keys |
|
|
239
|
+
| `coerce_env` | `bool` | `True` | Parse list/dict string values via the model hints before validation |
|
|
240
|
+
| `list_separator` | `str` | `","` | Separator for list-like fields when `coerce_env` is enabled |
|
|
241
|
+
|
|
242
|
+
### `coerce_env_values()`
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
def coerce_env_values(
|
|
246
|
+
model_class: type[BaseModel],
|
|
247
|
+
data: dict[str, Any],
|
|
248
|
+
*,
|
|
249
|
+
list_separator: str = ",",
|
|
250
|
+
coerce_env: bool = True,
|
|
251
|
+
) -> dict[str, Any]
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
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.
|
|
255
|
+
|
|
256
|
+
| Parameter | Type | Default | Description |
|
|
257
|
+
|-----------|------|---------|-------------|
|
|
258
|
+
| `model_class` | `type[BaseModel]` | required | Pydantic model used to interpret each leaf |
|
|
259
|
+
| `data` | `dict[str, Any]` | required | The dict to coerce (not mutated) |
|
|
260
|
+
| `list_separator` | `str` | `","` | Separator for list-like fields |
|
|
261
|
+
| `coerce_env` | `bool` | `True` | Set to `False` to return a shallow copy with no coercion |
|
|
262
|
+
|
|
263
|
+
### Exceptions
|
|
264
|
+
|
|
265
|
+
| Exception | When |
|
|
266
|
+
|-----------|------|
|
|
267
|
+
| `PyprojectNotFoundError` | `pyproject.toml` not found |
|
|
268
|
+
| `EnvFileNotFoundError` | A specified `.env` file doesn't exist |
|
|
269
|
+
| `RootSectionNotFoundError` | Root section is missing |
|
|
270
|
+
| `ToolSectionNotFoundError` | `[tool.<name>]` section is missing |
|
|
271
|
+
| `SettingsValidationError` | Merged data fails Pydantic validation |
|
|
272
|
+
|
|
273
|
+
## Development
|
|
274
|
+
|
|
275
|
+
### Prerequisites
|
|
276
|
+
|
|
277
|
+
- Python 3.13+
|
|
278
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
279
|
+
|
|
280
|
+
### Setup
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
git clone <repo-url>
|
|
284
|
+
cd pydsettingsforge
|
|
285
|
+
uv sync --all-groups
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Commands
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
# Run tests
|
|
292
|
+
uv run pytest
|
|
293
|
+
|
|
294
|
+
# Run tests with coverage
|
|
295
|
+
uv run pytest --cov=pydsettingsforge
|
|
296
|
+
|
|
297
|
+
# Lint
|
|
298
|
+
uv run ruff check src/ tests/
|
|
299
|
+
|
|
300
|
+
# Format
|
|
301
|
+
uv run ruff format src/ tests/
|
|
302
|
+
|
|
303
|
+
# Type check
|
|
304
|
+
uv run ty check src/
|
|
305
|
+
|
|
306
|
+
# All checks
|
|
307
|
+
uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/ && uv run pytest
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Project Structure
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
pydsettingsforge/
|
|
314
|
+
├── pyproject.toml
|
|
315
|
+
├── uv.lock
|
|
316
|
+
├── README.md
|
|
317
|
+
├── .gitignore
|
|
318
|
+
├── src/
|
|
319
|
+
│ └── pydsettingsforge/
|
|
320
|
+
│ ├── __init__.py # Public API: load_settings(), coerce_env_values()
|
|
321
|
+
│ ├── constants.py # Default constants
|
|
322
|
+
│ ├── coercer.py # List/dict coercion from Pydantic hints
|
|
323
|
+
│ ├── env_reader.py # .env file parsing and nesting
|
|
324
|
+
│ ├── exceptions.py # Custom exceptions
|
|
325
|
+
│ ├── merger.py # Deep-merge dictionaries
|
|
326
|
+
│ ├── toml_reader.py # pyproject.toml parsing
|
|
327
|
+
│ └── validator.py # Pydantic validation
|
|
328
|
+
└── tests/
|
|
329
|
+
├── conftest.py # Shared fixtures
|
|
330
|
+
├── test_coercer.py
|
|
331
|
+
├── test_env_reader.py
|
|
332
|
+
├── test_load_settings.py # Integration tests
|
|
333
|
+
├── test_merger.py
|
|
334
|
+
├── test_toml_reader.py
|
|
335
|
+
└── test_validator.py
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydsettingsforge"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Load and merge settings from pyproject.toml and .env files into validated Pydantic models"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Ivan Shcherbenko", email = "ivan_shcherbenko@outlook.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydantic>=2.13.4",
|
|
12
|
+
"pydantic-settings>=2.14.1",
|
|
13
|
+
"python-dotenv>=1.2.2",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=9.0.3",
|
|
23
|
+
"pytest-cov>=7.1.0",
|
|
24
|
+
"ruff>=0.15.15",
|
|
25
|
+
"ty>=0.0.42",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
target-version = "py313"
|
|
30
|
+
line-length = 88
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
|
|
34
|
+
|
|
35
|
+
[tool.ty]
|
|
36
|
+
|
|
37
|
+
[tool.ty.environment]
|
|
38
|
+
python-version = "3.13"
|
|
39
|
+
python = ".venv/bin/python3"
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
addopts = "-v --tb=short"
|
|
44
|
+
|
|
45
|
+
[tool.semantic_release]
|
|
46
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
47
|
+
version_variables = ["src/pydsettingsforge/__init__.py:__version__"]
|
|
48
|
+
changelog_file = "CHANGELOG.md"
|
|
49
|
+
upload_to_pypi = false
|
|
50
|
+
upload_to_release = true
|
|
51
|
+
commit_message = "chore(release): {version}"
|
|
52
|
+
branch = "main"
|
|
53
|
+
commit_parser = "conventional"
|
|
54
|
+
major_on_zero = true
|
|
55
|
+
tag_format = "v{version}"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""pydsettingsforge — Load and merge settings from pyproject.toml and .env files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
|
|
10
|
+
from pydsettingsforge.coercer import coerce_env_values
|
|
11
|
+
from pydsettingsforge.constants import DEFAULT_ROOT_SECTION, ENV_NESTING_SEPARATOR
|
|
12
|
+
from pydsettingsforge.env_reader import expand_nested_keys, read_env_files
|
|
13
|
+
from pydsettingsforge.exceptions import (
|
|
14
|
+
EnvFileNotFoundError,
|
|
15
|
+
PyprojectNotFoundError,
|
|
16
|
+
RootSectionNotFoundError,
|
|
17
|
+
SettingsForgeError,
|
|
18
|
+
SettingsValidationError,
|
|
19
|
+
ToolSectionNotFoundError,
|
|
20
|
+
)
|
|
21
|
+
from pydsettingsforge.merger import deep_merge
|
|
22
|
+
from pydsettingsforge.toml_reader import (
|
|
23
|
+
extract_settings,
|
|
24
|
+
read_pyproject,
|
|
25
|
+
resolve_pyproject_path,
|
|
26
|
+
)
|
|
27
|
+
from pydsettingsforge.validator import validate_settings
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"EnvFileNotFoundError",
|
|
31
|
+
"PyprojectNotFoundError",
|
|
32
|
+
"RootSectionNotFoundError",
|
|
33
|
+
"SettingsForgeError",
|
|
34
|
+
"SettingsValidationError",
|
|
35
|
+
"ToolSectionNotFoundError",
|
|
36
|
+
"coerce_env_values",
|
|
37
|
+
"load_settings",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_settings[T: BaseModel](
|
|
42
|
+
model_class: type[T],
|
|
43
|
+
*,
|
|
44
|
+
pyproject_path: Path | str | None = None,
|
|
45
|
+
env_files: list[Path | str] | None = None,
|
|
46
|
+
tool_section: str | None = None,
|
|
47
|
+
root_section: str = DEFAULT_ROOT_SECTION,
|
|
48
|
+
env_nesting_separator: str = ENV_NESTING_SEPARATOR,
|
|
49
|
+
coerce_env: bool = True,
|
|
50
|
+
list_separator: str = ",",
|
|
51
|
+
) -> T:
|
|
52
|
+
"""Load, merge, and validate application settings.
|
|
53
|
+
|
|
54
|
+
Reads settings from pyproject.toml (root section + optional [tool.<name>] section),
|
|
55
|
+
merges with .env files (later files override earlier ones), and validates
|
|
56
|
+
the result against a user-provided Pydantic model.
|
|
57
|
+
|
|
58
|
+
Override priority (lowest to highest):
|
|
59
|
+
1. pyproject.toml root section fields
|
|
60
|
+
2. pyproject.toml [tool.<name>] section
|
|
61
|
+
3. .env files (in list order)
|
|
62
|
+
4. OS environment variables (handled by pydantic-settings if the model
|
|
63
|
+
inherits from BaseSettings)
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
model_class: A Pydantic BaseModel subclass defining the expected settings.
|
|
67
|
+
pyproject_path: Path to pyproject.toml. Defaults to ./pyproject.toml.
|
|
68
|
+
env_files: Ordered list of .env file paths. Later files override earlier.
|
|
69
|
+
tool_section: Name of the [tool.<name>] section to read from pyproject.toml.
|
|
70
|
+
root_section: Root TOML section to read (default: "project").
|
|
71
|
+
When "project", only known metadata keys are included.
|
|
72
|
+
Custom sections include all keys unfiltered.
|
|
73
|
+
env_nesting_separator: Separator for nested keys in .env files (default: "__").
|
|
74
|
+
coerce_env: When True (default), string values for list, set, tuple, and
|
|
75
|
+
dict fields are parsed before Pydantic validation: list-like fields
|
|
76
|
+
are split on ``list_separator`` (or parsed as JSON if the value starts
|
|
77
|
+
with ``[`` or ``{``), and dict fields are parsed as JSON. Set to False
|
|
78
|
+
to keep raw string passthrough.
|
|
79
|
+
list_separator: Separator used to split string values for list-like
|
|
80
|
+
fields when ``coerce_env`` is True (default: ``,``).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A validated instance of model_class.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
PyprojectNotFoundError: If pyproject.toml is not found.
|
|
87
|
+
EnvFileNotFoundError: If a specified .env file does not exist.
|
|
88
|
+
RootSectionNotFoundError: If the root section is missing.
|
|
89
|
+
ToolSectionNotFoundError: If the [tool.<name>] section is missing.
|
|
90
|
+
SettingsValidationError: If the merged data fails Pydantic validation.
|
|
91
|
+
"""
|
|
92
|
+
resolved_pyproject = resolve_pyproject_path(
|
|
93
|
+
Path(pyproject_path) if pyproject_path else None
|
|
94
|
+
)
|
|
95
|
+
pyproject_data = read_pyproject(resolved_pyproject)
|
|
96
|
+
toml_settings = extract_settings(pyproject_data, tool_section, root_section)
|
|
97
|
+
|
|
98
|
+
env_settings: dict[str, Any] = {}
|
|
99
|
+
if env_files:
|
|
100
|
+
resolved_env_paths = [Path(p) for p in env_files]
|
|
101
|
+
flat_env = read_env_files(resolved_env_paths)
|
|
102
|
+
env_settings = expand_nested_keys(flat_env, env_nesting_separator)
|
|
103
|
+
|
|
104
|
+
merged = deep_merge(toml_settings, env_settings)
|
|
105
|
+
|
|
106
|
+
if coerce_env:
|
|
107
|
+
merged = coerce_env_values(model_class, merged, list_separator=list_separator)
|
|
108
|
+
|
|
109
|
+
return validate_settings(model_class, merged)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Coerce env-string values into typed Python objects using a Pydantic model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from pydsettingsforge.exceptions import SettingsValidationError
|
|
11
|
+
|
|
12
|
+
_CONTAINER_ORIGINS = (list, set, frozenset, tuple)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _unwrap_optional(annotation: Any) -> Any:
|
|
16
|
+
args = get_args(annotation)
|
|
17
|
+
if not args or type(None) not in args:
|
|
18
|
+
return annotation
|
|
19
|
+
for a in args:
|
|
20
|
+
if a is not type(None) and get_origin(a) in _CONTAINER_ORIGINS + (dict,):
|
|
21
|
+
return a
|
|
22
|
+
non_none = [a for a in args if a is not type(None)]
|
|
23
|
+
if len(non_none) == 1:
|
|
24
|
+
return non_none[0]
|
|
25
|
+
return annotation
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _split_list(value: str, separator: str) -> list[str]:
|
|
29
|
+
return [item.strip() for item in value.split(separator) if item.strip()]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_list_like(annotation: Any) -> bool:
|
|
33
|
+
return get_origin(_unwrap_optional(annotation)) in _CONTAINER_ORIGINS
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_dict(annotation: Any) -> bool:
|
|
37
|
+
return get_origin(_unwrap_optional(annotation)) is dict
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_list_value(
|
|
41
|
+
value: str,
|
|
42
|
+
list_separator: str,
|
|
43
|
+
field_name: str,
|
|
44
|
+
) -> Any:
|
|
45
|
+
stripped = value.strip()
|
|
46
|
+
if stripped.startswith("["):
|
|
47
|
+
try:
|
|
48
|
+
parsed = json.loads(stripped)
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
return _split_list(stripped, list_separator)
|
|
51
|
+
if isinstance(parsed, list):
|
|
52
|
+
return parsed
|
|
53
|
+
return _split_list(stripped, list_separator)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce_dict_value(
|
|
57
|
+
value: str,
|
|
58
|
+
field_name: str,
|
|
59
|
+
) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(value.strip())
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise SettingsValidationError(
|
|
64
|
+
f"Failed to coerce env value for field '{field_name}' "
|
|
65
|
+
f"to dict: invalid JSON: {exc}"
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _coerce_list_of_models(
|
|
70
|
+
value: str,
|
|
71
|
+
inner_model: type[BaseModel],
|
|
72
|
+
list_separator: str,
|
|
73
|
+
field_name: str,
|
|
74
|
+
) -> list[dict[str, Any]]:
|
|
75
|
+
try:
|
|
76
|
+
parsed = json.loads(value.strip())
|
|
77
|
+
except json.JSONDecodeError as exc:
|
|
78
|
+
raise SettingsValidationError(
|
|
79
|
+
f"Failed to coerce env value for field '{field_name}' "
|
|
80
|
+
f"to list[{inner_model.__name__}]: invalid JSON: {exc}"
|
|
81
|
+
) from exc
|
|
82
|
+
if not isinstance(parsed, list):
|
|
83
|
+
raise SettingsValidationError(
|
|
84
|
+
f"Failed to coerce env value for field '{field_name}': expected JSON list"
|
|
85
|
+
)
|
|
86
|
+
return [
|
|
87
|
+
coerce_env_values(inner_model, item, list_separator=list_separator)
|
|
88
|
+
for item in parsed
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _coerce_field(
|
|
93
|
+
value: Any,
|
|
94
|
+
annotation: Any,
|
|
95
|
+
list_separator: str,
|
|
96
|
+
field_name: str,
|
|
97
|
+
) -> Any:
|
|
98
|
+
inner = _unwrap_optional(annotation)
|
|
99
|
+
origin = get_origin(inner)
|
|
100
|
+
args = get_args(inner)
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
isinstance(inner, type)
|
|
104
|
+
and issubclass(inner, BaseModel)
|
|
105
|
+
and isinstance(value, dict)
|
|
106
|
+
):
|
|
107
|
+
return coerce_env_values(inner, value, list_separator=list_separator)
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
origin in _CONTAINER_ORIGINS
|
|
111
|
+
and args
|
|
112
|
+
and isinstance(args[0], type)
|
|
113
|
+
and issubclass(args[0], BaseModel)
|
|
114
|
+
):
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
return _coerce_list_of_models(value, args[0], list_separator, field_name)
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
if _is_list_like(annotation):
|
|
120
|
+
if isinstance(value, str):
|
|
121
|
+
return _coerce_list_value(value, list_separator, field_name)
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
if _is_dict(annotation):
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
return _coerce_dict_value(value, field_name)
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def coerce_env_values(
|
|
133
|
+
model_class: type[BaseModel],
|
|
134
|
+
data: dict[str, Any],
|
|
135
|
+
*,
|
|
136
|
+
list_separator: str = ",",
|
|
137
|
+
coerce_env: bool = True,
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
"""Pre-process a merged settings dict so list/dict leaf strings become typed values.
|
|
140
|
+
|
|
141
|
+
Uses ``model_class.model_fields`` to decide how to interpret each string leaf.
|
|
142
|
+
String values whose target field is a list/set/tuple are split on
|
|
143
|
+
``list_separator`` (whitespace stripped, empties dropped), or parsed as JSON if
|
|
144
|
+
the value starts with ``[`` (falls back to split on invalid JSON). String
|
|
145
|
+
values whose target field is a ``dict`` are parsed as JSON. String values for
|
|
146
|
+
``list[BaseModel]`` (or ``set[BaseModel]`` / ``tuple[BaseModel, ...]``) fields
|
|
147
|
+
are parsed as JSON and each element is recursively coerced. Primitive types
|
|
148
|
+
(``int``/``bool``/``float``/``str``) are left untouched — Pydantic handles
|
|
149
|
+
their coercion in ``model_validate``. Optional list/dict fields
|
|
150
|
+
(``list[str] | None``) are detected, including multi-member unions like
|
|
151
|
+
``list[str] | int | None``. When ``coerce_env`` is False, ``data`` is returned
|
|
152
|
+
as a shallow copy with no coercion applied.
|
|
153
|
+
|
|
154
|
+
Extra fields not declared on the model are passed through unchanged.
|
|
155
|
+
"""
|
|
156
|
+
if not coerce_env:
|
|
157
|
+
return dict(data)
|
|
158
|
+
|
|
159
|
+
result: dict[str, Any] = {}
|
|
160
|
+
known_fields = model_class.model_fields
|
|
161
|
+
|
|
162
|
+
for name, field in known_fields.items():
|
|
163
|
+
if name not in data:
|
|
164
|
+
continue
|
|
165
|
+
result[name] = _coerce_field(data[name], field.annotation, list_separator, name)
|
|
166
|
+
|
|
167
|
+
for name, value in data.items():
|
|
168
|
+
if name not in known_fields:
|
|
169
|
+
result[name] = value
|
|
170
|
+
|
|
171
|
+
return result
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Read and parse .env files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dotenv import dotenv_values
|
|
7
|
+
|
|
8
|
+
from pydsettingsforge.constants import ENV_NESTING_SEPARATOR
|
|
9
|
+
from pydsettingsforge.exceptions import EnvFileNotFoundError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_env_file(path: Path) -> dict[str, str]:
|
|
13
|
+
"""Parse a single .env file and return key-value pairs.
|
|
14
|
+
|
|
15
|
+
Raises EnvFileNotFoundError if the file does not exist.
|
|
16
|
+
"""
|
|
17
|
+
if not path.is_file():
|
|
18
|
+
raise EnvFileNotFoundError(str(path))
|
|
19
|
+
|
|
20
|
+
values = dotenv_values(path)
|
|
21
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_env_files(paths: list[Path]) -> dict[str, str]:
|
|
25
|
+
"""Read multiple .env files and merge them.
|
|
26
|
+
|
|
27
|
+
Files are processed in order; later files override earlier ones.
|
|
28
|
+
"""
|
|
29
|
+
merged: dict[str, str] = {}
|
|
30
|
+
for path in paths:
|
|
31
|
+
merged.update(read_env_file(path))
|
|
32
|
+
return merged
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def expand_nested_keys(
|
|
36
|
+
flat: dict[str, str],
|
|
37
|
+
separator: str = ENV_NESTING_SEPARATOR,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Convert flat keys with a nesting separator into a nested dictionary.
|
|
40
|
+
|
|
41
|
+
Example: {"DB__HOST": "localhost"} -> {"db": {"host": "localhost"}}
|
|
42
|
+
|
|
43
|
+
Keys are lowercased to match typical Pydantic field naming conventions.
|
|
44
|
+
"""
|
|
45
|
+
result: dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
for key, value in flat.items():
|
|
48
|
+
parts = key.lower().split(separator)
|
|
49
|
+
current = result
|
|
50
|
+
|
|
51
|
+
for part in parts[:-1]:
|
|
52
|
+
if part not in current or not isinstance(current[part], dict):
|
|
53
|
+
current[part] = {}
|
|
54
|
+
current = current[part]
|
|
55
|
+
|
|
56
|
+
current[parts[-1]] = value
|
|
57
|
+
|
|
58
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Custom exceptions for pydsettingsforge."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SettingsForgeError(Exception):
|
|
5
|
+
"""Base exception for all pydsettingsforge errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PyprojectNotFoundError(SettingsForgeError):
|
|
9
|
+
"""Raised when pyproject.toml cannot be found at the specified path."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, path: str) -> None:
|
|
12
|
+
super().__init__(f"pyproject.toml not found at: {path}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EnvFileNotFoundError(SettingsForgeError):
|
|
16
|
+
"""Raised when a specified .env file does not exist."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: str) -> None:
|
|
19
|
+
super().__init__(f".env file not found: {path}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolSectionNotFoundError(SettingsForgeError):
|
|
23
|
+
"""Raised when [tool.<name>] section is missing from pyproject.toml."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, section: str) -> None:
|
|
26
|
+
super().__init__(f"[tool.{section}] section not found in pyproject.toml")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RootSectionNotFoundError(SettingsForgeError):
|
|
30
|
+
"""Raised when the specified root section is missing from pyproject.toml."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, section: str) -> None:
|
|
33
|
+
super().__init__(f"[{section}] section not found in pyproject.toml")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SettingsValidationError(SettingsForgeError):
|
|
37
|
+
"""Raised when the merged settings dictionary fails Pydantic validation."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str) -> None:
|
|
40
|
+
super().__init__(f"Settings validation failed: {message}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Deep-merge dictionaries with override semantics."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
7
|
+
"""Recursively merge two dictionaries. Values from `override` win on conflict.
|
|
8
|
+
|
|
9
|
+
Nested dicts are merged recursively; all other types (including lists)
|
|
10
|
+
are replaced entirely by the override value.
|
|
11
|
+
Returns a new dictionary without mutating inputs.
|
|
12
|
+
"""
|
|
13
|
+
result: dict[str, Any] = {**base}
|
|
14
|
+
|
|
15
|
+
for key, override_value in override.items():
|
|
16
|
+
base_value = result.get(key)
|
|
17
|
+
|
|
18
|
+
if isinstance(base_value, dict) and isinstance(override_value, dict):
|
|
19
|
+
result[key] = deep_merge(base_value, override_value)
|
|
20
|
+
else:
|
|
21
|
+
result[key] = override_value
|
|
22
|
+
|
|
23
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Read and extract settings from pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydsettingsforge.constants import (
|
|
8
|
+
DEFAULT_PYPROJECT_FILENAME,
|
|
9
|
+
DEFAULT_ROOT_SECTION,
|
|
10
|
+
TOOL_SECTION_PREFIX,
|
|
11
|
+
)
|
|
12
|
+
from pydsettingsforge.exceptions import (
|
|
13
|
+
PyprojectNotFoundError,
|
|
14
|
+
RootSectionNotFoundError,
|
|
15
|
+
ToolSectionNotFoundError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
TOP_LEVEL_KEYS: frozenset[str] = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"name",
|
|
21
|
+
"version",
|
|
22
|
+
"description",
|
|
23
|
+
"requires-python",
|
|
24
|
+
"readme",
|
|
25
|
+
"authors",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_pyproject_path(path: Path | None = None) -> Path:
|
|
31
|
+
"""Resolve the path to pyproject.toml.
|
|
32
|
+
|
|
33
|
+
If no path is given, looks in the current working directory.
|
|
34
|
+
"""
|
|
35
|
+
if path is None:
|
|
36
|
+
return Path.cwd() / DEFAULT_PYPROJECT_FILENAME
|
|
37
|
+
return path.resolve()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_pyproject(path: Path) -> dict[str, Any]:
|
|
41
|
+
"""Parse pyproject.toml and return its contents as a dictionary."""
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
raise PyprojectNotFoundError(str(path))
|
|
44
|
+
|
|
45
|
+
with path.open("rb") as f:
|
|
46
|
+
return tomllib.load(f)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extract_settings(
|
|
50
|
+
pyproject_data: dict[str, Any],
|
|
51
|
+
tool_section: str | None = None,
|
|
52
|
+
root_section: str = DEFAULT_ROOT_SECTION,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Extract settings from parsed pyproject.toml data.
|
|
55
|
+
|
|
56
|
+
Returns fields from the root section merged with an optional [tool.<name>] section.
|
|
57
|
+
When root_section is "project" (default), only known metadata keys are included.
|
|
58
|
+
For custom root sections, all keys are included without filtering.
|
|
59
|
+
"""
|
|
60
|
+
if root_section not in pyproject_data:
|
|
61
|
+
raise RootSectionNotFoundError(root_section)
|
|
62
|
+
|
|
63
|
+
root_table: dict[str, Any] = pyproject_data[root_section]
|
|
64
|
+
|
|
65
|
+
if root_section == DEFAULT_ROOT_SECTION:
|
|
66
|
+
top_level = {k: v for k, v in root_table.items() if k in TOP_LEVEL_KEYS}
|
|
67
|
+
else:
|
|
68
|
+
top_level = dict(root_table)
|
|
69
|
+
|
|
70
|
+
if tool_section is None:
|
|
71
|
+
return top_level
|
|
72
|
+
|
|
73
|
+
tool_table = pyproject_data.get(TOOL_SECTION_PREFIX, {})
|
|
74
|
+
if tool_section not in tool_table:
|
|
75
|
+
raise ToolSectionNotFoundError(tool_section)
|
|
76
|
+
|
|
77
|
+
section_data = tool_table[tool_section]
|
|
78
|
+
return {**top_level, **section_data}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Validate merged settings against a user-provided Pydantic model."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ValidationError
|
|
6
|
+
|
|
7
|
+
from pydsettingsforge.exceptions import SettingsValidationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_settings[T: BaseModel](
|
|
11
|
+
model_class: type[T],
|
|
12
|
+
data: dict[str, Any],
|
|
13
|
+
) -> T:
|
|
14
|
+
"""Validate a settings dictionary against a Pydantic model.
|
|
15
|
+
|
|
16
|
+
Returns an instance of the model if validation passes.
|
|
17
|
+
Raises SettingsValidationError with details on failure.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
return model_class.model_validate(data)
|
|
21
|
+
except ValidationError as exc:
|
|
22
|
+
raise SettingsValidationError(str(exc)) from exc
|