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.
@@ -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,9 @@
1
+ """Default constants for pydsettingsforge."""
2
+
3
+ DEFAULT_PYPROJECT_FILENAME: str = "pyproject.toml"
4
+
5
+ ENV_NESTING_SEPARATOR: str = "__"
6
+
7
+ TOOL_SECTION_PREFIX: str = "tool"
8
+
9
+ DEFAULT_ROOT_SECTION: str = "project"
@@ -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