utilityhub_config 0.1.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,301 @@
1
+ Metadata-Version: 2.3
2
+ Name: utilityhub_config
3
+ Version: 0.1.0
4
+ Summary: A deterministic, typed configuration engine for serious automation systems
5
+ Author: Rajesh Das
6
+ Author-email: Rajesh Das <rajesh@hyperoot.dev>
7
+ Requires-Dist: pydantic>=2.12.5
8
+ Requires-Dist: pyyaml>=6.0.3
9
+ Requires-Dist: python-dotenv>=1.0.0
10
+ Requires-Python: >=3.14
11
+ Description-Content-Type: text/markdown
12
+
13
+ # utilityhub_config
14
+
15
+ A **deterministic, typed configuration loader** for modern Python applications. Load settings from multiple sources with clear precedence, comprehensive metadata tracking, and detailed validation errors.
16
+
17
+ ## Features ✨
18
+
19
+ - **Multi-source configuration loading** with explicit precedence order
20
+ - **Strongly typed** with Pydantic v2+ (full type safety)
21
+ - **Metadata tracking** — see which source provided each field
22
+ - **Multiple formats** — TOML, YAML, .env, environment variables
23
+ - **Rich error reporting** — Validation failures show sources, checked files, and precedence
24
+ - **Zero magic** — Deterministic, transparent resolution order
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install utilityhub_config
30
+ ```
31
+
32
+ ## Quick Start
33
+
34
+ ```python
35
+ from pydantic import BaseModel
36
+ from utilityhub_config import load_settings
37
+
38
+ class Config(BaseModel):
39
+ database_url: str = "sqlite:///default.db"
40
+ debug: bool = False
41
+
42
+ # Load settings and metadata
43
+ settings, metadata = load_settings(Config)
44
+
45
+ # Type-safe access (no casting needed)
46
+ print(settings.database_url)
47
+
48
+ # Track which source provided a field
49
+ source = metadata.get_source("database_url")
50
+ print(f"database_url came from: {source.source}")
51
+ ```
52
+
53
+ ## How It Works
54
+
55
+ Settings are resolved in **strict precedence order** (lowest to highest):
56
+
57
+ 1. **Defaults** — Field defaults from your Pydantic model
58
+ 2. **Global config** — `~/.config/{app_name}/{app_name}.{toml,yaml}`
59
+ 3. **Project config** — `{cwd}/{app_name}.{toml,yaml}` or `{cwd}/config/*.{toml,yaml}`
60
+ 4. **Dotenv** — `.env` file in current directory
61
+ 5. **Environment variables** — `{APP_NAME}_{FIELD_NAME}` or `{FIELD_NAME}`
62
+ 6. **Runtime overrides** — Passed via `overrides` parameter (highest priority)
63
+
64
+ Each level overrides the previous one. Only sources that exist are consulted.
65
+
66
+ ## Examples
67
+
68
+ ### Basic usage with model defaults
69
+
70
+ ```python
71
+ from pydantic import BaseModel
72
+ from utilityhub_config import load_settings
73
+
74
+ class PizzaShopConfig(BaseModel):
75
+ shop_name: str = "lazy_pepperoni_palace"
76
+ delivery_radius_km: int = 5
77
+ accepts_orders: bool = False # closed by default
78
+
79
+ settings, metadata = load_settings(PizzaShopConfig)
80
+ print(f"🍕 {settings.shop_name} is {'open' if settings.accepts_orders else 'closed'}")
81
+ ```
82
+
83
+ ### Override with environment variables
84
+
85
+ ```python
86
+ import os
87
+
88
+ # Friday night rush: OPEN ALL THE STORES!
89
+ os.environ["ACCEPTS_ORDERS"] = "true"
90
+ os.environ["DELIVERY_RADIUS_KM"] = "15"
91
+
92
+ settings, metadata = load_settings(PizzaShopConfig)
93
+ print(f"🚗 Delivering pizza up to {settings.delivery_radius_km}km away!")
94
+ ```
95
+
96
+ ### Runtime overrides (highest priority)
97
+
98
+ ```python
99
+ # Emergency: meteor incoming, expand radius and accept everything!
100
+ settings, metadata = load_settings(
101
+ PizzaShopConfig,
102
+ overrides={
103
+ "accepts_orders": True,
104
+ "delivery_radius_km": 100,
105
+ "shop_name": "doomsday_pizza_bunker"
106
+ }
107
+ )
108
+ print(f"🚀 {settings.shop_name} now delivers {settings.delivery_radius_km}km!")
109
+ ```
110
+
111
+ ### Custom app name and config directory
112
+
113
+ ```python
114
+ settings, metadata = load_settings(
115
+ PizzaShopConfig,
116
+ app_name="pizza_empire",
117
+ cwd="/etc/pizza_shops/"
118
+ )
119
+ # Looks for: /etc/pizza_shops/pizza_empire.toml or .yaml
120
+ ```
121
+
122
+ ### Environment variable prefix
123
+
124
+ ```python
125
+ os.environ["PIZZASHOP_ACCEPTS_ORDERS"] = "true"
126
+ os.environ["PIZZASHOP_DELIVERY_RADIUS_KM"] = "42"
127
+
128
+ settings, metadata = load_settings(
129
+ PizzaShopConfig,
130
+ env_prefix="PIZZASHOP"
131
+ )
132
+ # Will check: PIZZASHOP_ACCEPTS_ORDERS, then ACCEPTS_ORDERS (in that order)
133
+ print(f"🍕 Accepting orders: {settings.accepts_orders}")
134
+ ```
135
+
136
+ ### Inspect metadata (detective mode 🕵️)
137
+
138
+ ```python
139
+ settings, metadata = load_settings(PizzaShopConfig)
140
+
141
+ # Which source provided this field?
142
+ source = metadata.get_source("delivery_radius_km")
143
+ print(f"Delivery radius came from: {source.source}")
144
+ print(f"Location: {source.source_path or 'model defaults'}")
145
+ print(f"Raw value: {source.raw_value}")
146
+
147
+ # Track all field origins
148
+ for field, source_info in metadata.per_field.items():
149
+ print(f" {field}: from {source_info.source}")
150
+ ```
151
+
152
+ ## Configuration Files
153
+
154
+ ### TOML example (`pizza_empire.toml`)
155
+
156
+ ```toml
157
+ # 🍕 Pizza Empire Global Settings
158
+ shop_name = "the_great_carb_dispensary"
159
+ delivery_radius_km = 5
160
+ accepts_orders = false
161
+
162
+ # The business secret sauce 🔥
163
+ [quality]
164
+ cheese_ratio = 0.42 # more cheese = more problems (and happiness)
165
+ crust_crispiness = "perfect"
166
+ pineapple_tolerance = 0.0 # this is not a debate
167
+
168
+ [timings]
169
+ avg_prep_time_minutes = 15
170
+ delivery_timeout_minutes = 45
171
+ ```
172
+
173
+ ### YAML example (`pizza_empire.yaml`)
174
+
175
+ ```yaml
176
+ # 🍕 Pizza Empire Configuration
177
+ shop_name: the_great_carb_dispensary
178
+ delivery_radius_km: 5
179
+ accepts_orders: false
180
+
181
+ # The art of pizza
182
+ quality:
183
+ cheese_ratio: 0.42
184
+ crust_crispiness: "perfect"
185
+ pineapple_tolerance: 0.0
186
+
187
+ timings:
188
+ avg_prep_time_minutes: 15
189
+ delivery_timeout_minutes: 45
190
+ ```
191
+
192
+ ### Dotenv example (`.env`)
193
+
194
+ ```bash
195
+ # 🍕 Quick overrides for this deployment
196
+ SHOP_NAME=emergency_pizza_hut
197
+ DELIVERY_RADIUS_KM=100
198
+ ACCEPTS_ORDERS=true
199
+ CHEESE_RATIO=0.99
200
+ PINEAPPLE_TOLERANCE=0.0 # NEVER SURRENDER
201
+ ```
202
+
203
+ ## API Reference
204
+
205
+ ### `load_settings(model, *, app_name=None, cwd=None, env_prefix=None, overrides=None)`
206
+
207
+ Load and validate settings from all sources.
208
+
209
+ **Parameters:**
210
+
211
+ - `model` (type[T]): A Pydantic BaseModel subclass to validate and populate.
212
+ - `app_name` (str | None): Application name for config file lookup. Defaults to lowercased model class name.
213
+ - `cwd` (Path | None): Working directory for config file search. Defaults to current directory.
214
+ - `env_prefix` (str | None): Optional prefix for environment variables (e.g., `"MYAPP"`).
215
+ - `overrides` (dict[str, Any] | None): Runtime overrides (highest precedence).
216
+
217
+ **Returns:**
218
+
219
+ A tuple `(settings, metadata)` where:
220
+ - `settings` is an instance of your model type (fully type-safe, no casting needed).
221
+ - `metadata` is a `SettingsMetadata` object tracking field sources.
222
+
223
+ **Raises:**
224
+
225
+ - `ConfigValidationError` — If validation fails, includes detailed context:
226
+ - Validation errors from Pydantic
227
+ - Files that were checked
228
+ - Precedence order
229
+ - Which source provided each field
230
+
231
+ ### `SettingsMetadata`
232
+
233
+ Tracks where each field value came from.
234
+
235
+ - `per_field: dict[str, FieldSource]` — Field name to source mapping.
236
+ - `get_source(field: str) -> FieldSource | None` — Look up a single field's source.
237
+
238
+ ### `FieldSource`
239
+
240
+ - `source: str` — Source name (`"defaults"`, `"env"`, `"project"`, etc.).
241
+ - `source_path: str | None` — File path or env var name.
242
+ - `raw_value: Any` — The raw value before type coercion.
243
+
244
+ ## Known Limitations
245
+
246
+ - **Nested types**: Complex nested Pydantic models in TOML/YAML are supported (Pydantic handles validation), but the loader doesn't do special merging. Flat dictionaries are recommended.
247
+ - **Case sensitivity**: Dotenv keys are normalized to lowercase; model field names are case-sensitive.
248
+ - **Variable expansion**: Dotenv values don't expand environment variables (e.g., `$HOME` won't expand). Use `python-dotenv` directly if needed.
249
+
250
+ ## Error Handling
251
+
252
+ When validation fails, you get a detailed error with full context (perfect for debugging at 3 AM):
253
+
254
+ ```python
255
+ from utilityhub_config import load_settings
256
+ from utilityhub_config.errors import ConfigValidationError
257
+
258
+ class PizzaShopConfig(BaseModel):
259
+ delivery_radius_km: int # REQUIRED (no default, no pizza!)
260
+
261
+ try:
262
+ settings, metadata = load_settings(PizzaShopConfig)
263
+ except ConfigValidationError as e:
264
+ # Shows:
265
+ # - What validation failed
266
+ # - Which files were checked
267
+ # - The precedence order
268
+ # - Which source provided each field
269
+ print(e) # Complete context for debugging!
270
+ ```
271
+
272
+ Output example:
273
+ ```
274
+ Validation failed
275
+
276
+ Validation errors:
277
+ input should be a valid integer [type=int_parsing, input_value=None, input_type=NoneType]
278
+
279
+ Files checked:
280
+ - ~/.config/pizzashop/pizzashop.toml
281
+ - ~/.config/pizzashop/pizzashop.yaml
282
+ - /home/user/.env
283
+
284
+ Precedence (low -> high):
285
+ defaults -> global -> project -> dotenv -> env -> overrides
286
+
287
+ Field sources:
288
+ - delivery_radius_km: defaults (None)
289
+ ```
290
+
291
+ ## Contributing
292
+
293
+ For issues, improvements, or questions, please open an issue or pull request.
294
+
295
+ ## Documentation
296
+
297
+ Full documentation and examples will be available at: **[docs link TBD]**
298
+
299
+ ---
300
+
301
+ **License**: See project LICENSE file.
@@ -0,0 +1,289 @@
1
+ # utilityhub_config
2
+
3
+ A **deterministic, typed configuration loader** for modern Python applications. Load settings from multiple sources with clear precedence, comprehensive metadata tracking, and detailed validation errors.
4
+
5
+ ## Features ✨
6
+
7
+ - **Multi-source configuration loading** with explicit precedence order
8
+ - **Strongly typed** with Pydantic v2+ (full type safety)
9
+ - **Metadata tracking** — see which source provided each field
10
+ - **Multiple formats** — TOML, YAML, .env, environment variables
11
+ - **Rich error reporting** — Validation failures show sources, checked files, and precedence
12
+ - **Zero magic** — Deterministic, transparent resolution order
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install utilityhub_config
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```python
23
+ from pydantic import BaseModel
24
+ from utilityhub_config import load_settings
25
+
26
+ class Config(BaseModel):
27
+ database_url: str = "sqlite:///default.db"
28
+ debug: bool = False
29
+
30
+ # Load settings and metadata
31
+ settings, metadata = load_settings(Config)
32
+
33
+ # Type-safe access (no casting needed)
34
+ print(settings.database_url)
35
+
36
+ # Track which source provided a field
37
+ source = metadata.get_source("database_url")
38
+ print(f"database_url came from: {source.source}")
39
+ ```
40
+
41
+ ## How It Works
42
+
43
+ Settings are resolved in **strict precedence order** (lowest to highest):
44
+
45
+ 1. **Defaults** — Field defaults from your Pydantic model
46
+ 2. **Global config** — `~/.config/{app_name}/{app_name}.{toml,yaml}`
47
+ 3. **Project config** — `{cwd}/{app_name}.{toml,yaml}` or `{cwd}/config/*.{toml,yaml}`
48
+ 4. **Dotenv** — `.env` file in current directory
49
+ 5. **Environment variables** — `{APP_NAME}_{FIELD_NAME}` or `{FIELD_NAME}`
50
+ 6. **Runtime overrides** — Passed via `overrides` parameter (highest priority)
51
+
52
+ Each level overrides the previous one. Only sources that exist are consulted.
53
+
54
+ ## Examples
55
+
56
+ ### Basic usage with model defaults
57
+
58
+ ```python
59
+ from pydantic import BaseModel
60
+ from utilityhub_config import load_settings
61
+
62
+ class PizzaShopConfig(BaseModel):
63
+ shop_name: str = "lazy_pepperoni_palace"
64
+ delivery_radius_km: int = 5
65
+ accepts_orders: bool = False # closed by default
66
+
67
+ settings, metadata = load_settings(PizzaShopConfig)
68
+ print(f"🍕 {settings.shop_name} is {'open' if settings.accepts_orders else 'closed'}")
69
+ ```
70
+
71
+ ### Override with environment variables
72
+
73
+ ```python
74
+ import os
75
+
76
+ # Friday night rush: OPEN ALL THE STORES!
77
+ os.environ["ACCEPTS_ORDERS"] = "true"
78
+ os.environ["DELIVERY_RADIUS_KM"] = "15"
79
+
80
+ settings, metadata = load_settings(PizzaShopConfig)
81
+ print(f"🚗 Delivering pizza up to {settings.delivery_radius_km}km away!")
82
+ ```
83
+
84
+ ### Runtime overrides (highest priority)
85
+
86
+ ```python
87
+ # Emergency: meteor incoming, expand radius and accept everything!
88
+ settings, metadata = load_settings(
89
+ PizzaShopConfig,
90
+ overrides={
91
+ "accepts_orders": True,
92
+ "delivery_radius_km": 100,
93
+ "shop_name": "doomsday_pizza_bunker"
94
+ }
95
+ )
96
+ print(f"🚀 {settings.shop_name} now delivers {settings.delivery_radius_km}km!")
97
+ ```
98
+
99
+ ### Custom app name and config directory
100
+
101
+ ```python
102
+ settings, metadata = load_settings(
103
+ PizzaShopConfig,
104
+ app_name="pizza_empire",
105
+ cwd="/etc/pizza_shops/"
106
+ )
107
+ # Looks for: /etc/pizza_shops/pizza_empire.toml or .yaml
108
+ ```
109
+
110
+ ### Environment variable prefix
111
+
112
+ ```python
113
+ os.environ["PIZZASHOP_ACCEPTS_ORDERS"] = "true"
114
+ os.environ["PIZZASHOP_DELIVERY_RADIUS_KM"] = "42"
115
+
116
+ settings, metadata = load_settings(
117
+ PizzaShopConfig,
118
+ env_prefix="PIZZASHOP"
119
+ )
120
+ # Will check: PIZZASHOP_ACCEPTS_ORDERS, then ACCEPTS_ORDERS (in that order)
121
+ print(f"🍕 Accepting orders: {settings.accepts_orders}")
122
+ ```
123
+
124
+ ### Inspect metadata (detective mode 🕵️)
125
+
126
+ ```python
127
+ settings, metadata = load_settings(PizzaShopConfig)
128
+
129
+ # Which source provided this field?
130
+ source = metadata.get_source("delivery_radius_km")
131
+ print(f"Delivery radius came from: {source.source}")
132
+ print(f"Location: {source.source_path or 'model defaults'}")
133
+ print(f"Raw value: {source.raw_value}")
134
+
135
+ # Track all field origins
136
+ for field, source_info in metadata.per_field.items():
137
+ print(f" {field}: from {source_info.source}")
138
+ ```
139
+
140
+ ## Configuration Files
141
+
142
+ ### TOML example (`pizza_empire.toml`)
143
+
144
+ ```toml
145
+ # 🍕 Pizza Empire Global Settings
146
+ shop_name = "the_great_carb_dispensary"
147
+ delivery_radius_km = 5
148
+ accepts_orders = false
149
+
150
+ # The business secret sauce 🔥
151
+ [quality]
152
+ cheese_ratio = 0.42 # more cheese = more problems (and happiness)
153
+ crust_crispiness = "perfect"
154
+ pineapple_tolerance = 0.0 # this is not a debate
155
+
156
+ [timings]
157
+ avg_prep_time_minutes = 15
158
+ delivery_timeout_minutes = 45
159
+ ```
160
+
161
+ ### YAML example (`pizza_empire.yaml`)
162
+
163
+ ```yaml
164
+ # 🍕 Pizza Empire Configuration
165
+ shop_name: the_great_carb_dispensary
166
+ delivery_radius_km: 5
167
+ accepts_orders: false
168
+
169
+ # The art of pizza
170
+ quality:
171
+ cheese_ratio: 0.42
172
+ crust_crispiness: "perfect"
173
+ pineapple_tolerance: 0.0
174
+
175
+ timings:
176
+ avg_prep_time_minutes: 15
177
+ delivery_timeout_minutes: 45
178
+ ```
179
+
180
+ ### Dotenv example (`.env`)
181
+
182
+ ```bash
183
+ # 🍕 Quick overrides for this deployment
184
+ SHOP_NAME=emergency_pizza_hut
185
+ DELIVERY_RADIUS_KM=100
186
+ ACCEPTS_ORDERS=true
187
+ CHEESE_RATIO=0.99
188
+ PINEAPPLE_TOLERANCE=0.0 # NEVER SURRENDER
189
+ ```
190
+
191
+ ## API Reference
192
+
193
+ ### `load_settings(model, *, app_name=None, cwd=None, env_prefix=None, overrides=None)`
194
+
195
+ Load and validate settings from all sources.
196
+
197
+ **Parameters:**
198
+
199
+ - `model` (type[T]): A Pydantic BaseModel subclass to validate and populate.
200
+ - `app_name` (str | None): Application name for config file lookup. Defaults to lowercased model class name.
201
+ - `cwd` (Path | None): Working directory for config file search. Defaults to current directory.
202
+ - `env_prefix` (str | None): Optional prefix for environment variables (e.g., `"MYAPP"`).
203
+ - `overrides` (dict[str, Any] | None): Runtime overrides (highest precedence).
204
+
205
+ **Returns:**
206
+
207
+ A tuple `(settings, metadata)` where:
208
+ - `settings` is an instance of your model type (fully type-safe, no casting needed).
209
+ - `metadata` is a `SettingsMetadata` object tracking field sources.
210
+
211
+ **Raises:**
212
+
213
+ - `ConfigValidationError` — If validation fails, includes detailed context:
214
+ - Validation errors from Pydantic
215
+ - Files that were checked
216
+ - Precedence order
217
+ - Which source provided each field
218
+
219
+ ### `SettingsMetadata`
220
+
221
+ Tracks where each field value came from.
222
+
223
+ - `per_field: dict[str, FieldSource]` — Field name to source mapping.
224
+ - `get_source(field: str) -> FieldSource | None` — Look up a single field's source.
225
+
226
+ ### `FieldSource`
227
+
228
+ - `source: str` — Source name (`"defaults"`, `"env"`, `"project"`, etc.).
229
+ - `source_path: str | None` — File path or env var name.
230
+ - `raw_value: Any` — The raw value before type coercion.
231
+
232
+ ## Known Limitations
233
+
234
+ - **Nested types**: Complex nested Pydantic models in TOML/YAML are supported (Pydantic handles validation), but the loader doesn't do special merging. Flat dictionaries are recommended.
235
+ - **Case sensitivity**: Dotenv keys are normalized to lowercase; model field names are case-sensitive.
236
+ - **Variable expansion**: Dotenv values don't expand environment variables (e.g., `$HOME` won't expand). Use `python-dotenv` directly if needed.
237
+
238
+ ## Error Handling
239
+
240
+ When validation fails, you get a detailed error with full context (perfect for debugging at 3 AM):
241
+
242
+ ```python
243
+ from utilityhub_config import load_settings
244
+ from utilityhub_config.errors import ConfigValidationError
245
+
246
+ class PizzaShopConfig(BaseModel):
247
+ delivery_radius_km: int # REQUIRED (no default, no pizza!)
248
+
249
+ try:
250
+ settings, metadata = load_settings(PizzaShopConfig)
251
+ except ConfigValidationError as e:
252
+ # Shows:
253
+ # - What validation failed
254
+ # - Which files were checked
255
+ # - The precedence order
256
+ # - Which source provided each field
257
+ print(e) # Complete context for debugging!
258
+ ```
259
+
260
+ Output example:
261
+ ```
262
+ Validation failed
263
+
264
+ Validation errors:
265
+ input should be a valid integer [type=int_parsing, input_value=None, input_type=NoneType]
266
+
267
+ Files checked:
268
+ - ~/.config/pizzashop/pizzashop.toml
269
+ - ~/.config/pizzashop/pizzashop.yaml
270
+ - /home/user/.env
271
+
272
+ Precedence (low -> high):
273
+ defaults -> global -> project -> dotenv -> env -> overrides
274
+
275
+ Field sources:
276
+ - delivery_radius_km: defaults (None)
277
+ ```
278
+
279
+ ## Contributing
280
+
281
+ For issues, improvements, or questions, please open an issue or pull request.
282
+
283
+ ## Documentation
284
+
285
+ Full documentation and examples will be available at: **[docs link TBD]**
286
+
287
+ ---
288
+
289
+ **License**: See project LICENSE file.
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "utilityhub_config"
3
+ version = "0.1.0"
4
+ description = "A deterministic, typed configuration engine for serious automation systems"
5
+ readme = "README.md"
6
+ authors = [{ name = "Rajesh Das", email = "rajesh@hyperoot.dev" }]
7
+ requires-python = ">=3.14"
8
+ dependencies = ["pydantic>=2.12.5", "PyYAML>=6.0.3", "python-dotenv>=1.0.0"]
9
+
10
+ [project.scripts]
11
+ utilityhub_config = "utilityhub_config:main"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.9.18,<0.10.0"]
15
+ build-backend = "uv_build"
@@ -0,0 +1,12 @@
1
+ """utilityhub_config
2
+
3
+ Small, deterministic configuration loader for automation tools.
4
+ """
5
+
6
+ from utilityhub_config.api import load_settings
7
+
8
+ __all__: list[str] = ["load_settings"]
9
+
10
+
11
+ def main() -> None:
12
+ print("utilityhub-config: use `load_settings()` in your code")
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, TypeVar
5
+
6
+ from pydantic import BaseModel, ValidationError
7
+
8
+ from utilityhub_config.errors import ConfigValidationError
9
+ from utilityhub_config.metadata import SettingsMetadata
10
+ from utilityhub_config.resolver import PrecedenceResolver
11
+
12
+ T = TypeVar("T", bound=BaseModel)
13
+
14
+
15
+ def load_settings[T: BaseModel](
16
+ model: type[T],
17
+ *,
18
+ app_name: str | None = None,
19
+ cwd: Path | None = None,
20
+ env_prefix: str | None = None,
21
+ overrides: dict[str, Any] | None = None,
22
+ ) -> tuple[T, SettingsMetadata]:
23
+ """Load and validate settings for the given Pydantic model.
24
+
25
+ Resolves configuration from multiple sources in precedence order:
26
+ defaults → global config → project config → dotenv → environment variables → runtime overrides.
27
+
28
+ Args:
29
+ model: A Pydantic BaseModel subclass to validate and populate.
30
+ app_name: Application name for config file lookup (defaults to model class name).
31
+ cwd: Working directory for config file search (defaults to current directory).
32
+ env_prefix: Optional prefix for environment variable lookup (e.g., 'MYAPP_').
33
+ overrides: Runtime overrides as a dictionary (highest precedence).
34
+
35
+ Returns:
36
+ A tuple of (settings_instance, metadata) where settings_instance is an instance
37
+ of the provided model type, and metadata tracks which source provided each field.
38
+
39
+ Raises:
40
+ ConfigValidationError: If validation fails, with detailed context about sources and files checked.
41
+ """
42
+ cwd = Path.cwd() if cwd is None else cwd
43
+
44
+ resolver = PrecedenceResolver(app_name=app_name, cwd=cwd, env_prefix=env_prefix)
45
+
46
+ merged, metadata, checked_files = resolver.resolve(model=model, overrides=overrides or {})
47
+
48
+ try:
49
+ instance = model.model_validate(merged)
50
+ except ValidationError as exc: # pydantic v2
51
+ raise ConfigValidationError(
52
+ "Validation failed",
53
+ errors=exc,
54
+ metadata=metadata,
55
+ checked_files=checked_files,
56
+ precedence=resolver.precedence_order,
57
+ ) from exc
58
+
59
+ return instance, metadata
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from utilityhub_config.metadata import SettingsMetadata
9
+
10
+
11
+ class ConfigError(Exception):
12
+ """Base configuration error."""
13
+
14
+
15
+ @dataclass
16
+ class ConfigValidationError(ConfigError):
17
+ message: str
18
+ errors: ValidationError
19
+ metadata: SettingsMetadata
20
+ checked_files: Iterable[str]
21
+ precedence: list[str]
22
+
23
+ def __str__(self) -> str: # human-friendly
24
+ case_lines = [self.message, ""]
25
+ case_lines.append("Validation errors:")
26
+ case_lines.extend([str(self.errors)])
27
+ case_lines.append("")
28
+ case_lines.append("Files checked:")
29
+ case_lines.extend([f" - {p}" for p in self.checked_files])
30
+ case_lines.append("")
31
+ case_lines.append("Precedence (low -> high):")
32
+ case_lines.append(" -> ".join(self.precedence))
33
+ case_lines.append("")
34
+ case_lines.append("Field sources:")
35
+ for k, v in self.metadata.per_field.items():
36
+ case_lines.append(f" - {k}: {v.source} ({v.source_path})")
37
+ return "\n".join(case_lines)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class FieldSource:
9
+ source: str
10
+ source_path: str | None
11
+ raw_value: Any
12
+
13
+
14
+ @dataclass
15
+ class SettingsMetadata:
16
+ per_field: dict[str, FieldSource]
17
+
18
+ def get_source(self, field: str) -> FieldSource | None:
19
+ return self.per_field.get(field)
@@ -0,0 +1,5 @@
1
+ from utilityhub_config.readers.dotenv_reader import parse_dotenv
2
+ from utilityhub_config.readers.toml_reader import read_toml
3
+ from utilityhub_config.readers.yaml_reader import read_yaml
4
+
5
+ __all__ = ["read_toml", "read_yaml", "parse_dotenv"]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def parse_dotenv(path: Path) -> dict[str, str]:
7
+ """Parse a .env file into a dictionary.
8
+
9
+ Uses python-dotenv for robust parsing with support for:
10
+ - Comments (lines starting with #)
11
+ - Quoted values (single and double quotes)
12
+ - Escape sequences
13
+ - Variable expansion (if expand_vars is True)
14
+
15
+ Args:
16
+ path: Path to the .env file.
17
+
18
+ Returns:
19
+ A dictionary of environment variables from the file, or empty dict if file doesn't exist.
20
+ """
21
+ if not path.exists():
22
+ return {}
23
+ try:
24
+ from dotenv import dotenv_values
25
+
26
+ values = dotenv_values(path) or {}
27
+ # Ensure all values are strings (python-dotenv can return None)
28
+ return {k: (v or "") for k, v in values.items()}
29
+ except ModuleNotFoundError as exc:
30
+ raise RuntimeError(
31
+ f"dotenv file found at {path}, but python-dotenv is not installed. "
32
+ "Install 'python-dotenv' to enable .env file support."
33
+ ) from exc
34
+ except Exception as exc:
35
+ raise RuntimeError(f"Failed to parse .env file at {path}: {exc}") from exc
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+
7
+ def read_toml(path: Path) -> dict[str, Any]:
8
+ """Read a TOML file and return mapping, or empty dict if not found."""
9
+ if not path.exists():
10
+ return {}
11
+ try:
12
+ import tomllib
13
+
14
+ with path.open("rb") as fh:
15
+ return tomllib.load(fh) or {}
16
+ except Exception as exc: # be explicit in errors upstream
17
+ raise RuntimeError(f"Failed to parse TOML at {path}: {exc}") from exc
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+
7
+ def read_yaml(path: Path) -> dict[str, Any]:
8
+ if not path.exists():
9
+ return {}
10
+ try:
11
+ import yaml
12
+
13
+ with path.open("r", encoding="utf8") as fh:
14
+ return yaml.safe_load(fh) or {}
15
+ except ModuleNotFoundError as exc:
16
+ raise RuntimeError(
17
+ f"YAML file found at {path}, but PyYAML is not installed. Install 'PyYAML' to enable YAML support."
18
+ ) from exc
19
+ except Exception as exc:
20
+ raise RuntimeError(f"Failed to parse YAML at {path}: {exc}") from exc
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from utilityhub_config.metadata import FieldSource, SettingsMetadata
11
+ from utilityhub_config.readers import parse_dotenv, read_toml, read_yaml
12
+
13
+
14
+ @dataclass
15
+ class PrecedenceResolver:
16
+ """Resolves configuration from multiple sources in precedence order.
17
+
18
+ Attributes:
19
+ app_name: Application name for config file lookup. If None, derived from model class name.
20
+ cwd: Working directory for config file search (defaults: current working directory).
21
+ env_prefix: Optional prefix for environment variable lookup (e.g., 'MYAPP_').
22
+ """
23
+
24
+ app_name: str | None = None
25
+ cwd: Path = field(default_factory=Path.cwd)
26
+ env_prefix: str | None = None
27
+
28
+ def __post_init__(self) -> None:
29
+ if self.cwd is None:
30
+ self.cwd = Path.cwd()
31
+ self.precedence_order = [
32
+ "defaults",
33
+ "global",
34
+ "project",
35
+ "dotenv",
36
+ "env",
37
+ "overrides",
38
+ ]
39
+
40
+ def resolve(
41
+ self, *, model: type[BaseModel], overrides: dict[str, Any]
42
+ ) -> tuple[dict[str, Any], SettingsMetadata, list[str]]:
43
+ app = self._determine_app_name(model)
44
+
45
+ checked_files: list[str] = []
46
+ per_field: dict[str, FieldSource] = {}
47
+
48
+ # 1. defaults from model
49
+ merged: dict[str, Any] = {}
50
+ defaults = self._model_defaults(model)
51
+ merged.update(defaults)
52
+ for k, v in defaults.items():
53
+ per_field[k] = FieldSource("defaults", None, v)
54
+
55
+ # 2. global config
56
+ global_paths = self._global_config_paths(app)
57
+ for p in global_paths:
58
+ checked_files.append(str(p))
59
+ if p.exists():
60
+ data = read_toml(p) if p.suffix.lower() == ".toml" else read_yaml(p)
61
+ self._merge_into(merged, per_field, data, source_name="global", source_path=str(p))
62
+
63
+ # 3. project config
64
+ project_files = self._project_config_paths(app)
65
+ for p in project_files:
66
+ checked_files.append(str(p))
67
+ if p.exists():
68
+ data = read_toml(p) if p.suffix.lower() == ".toml" else read_yaml(p)
69
+ self._merge_into(merged, per_field, data, source_name="project", source_path=str(p))
70
+
71
+ # 4. dotenv
72
+ dotenv_path = self.cwd / ".env"
73
+ checked_files.append(str(dotenv_path))
74
+ dotenv_data = parse_dotenv(dotenv_path)
75
+ # dotenv keys are usually uppercase; normalize to field names
76
+ normalized_dotenv = {self._normalize(k): v for k, v in dotenv_data.items()}
77
+ self._merge_into(merged, per_field, normalized_dotenv, source_name="dotenv", source_path=str(dotenv_path))
78
+
79
+ # 5. environment variables
80
+ env_map: dict[str, Any] = {}
81
+ for field_name in self._field_names(model):
82
+ candidates: list[str] = []
83
+ if self.env_prefix:
84
+ candidates.append(f"{self.env_prefix}_{field_name.upper()}")
85
+ candidates.append(field_name.upper())
86
+ for name in candidates:
87
+ if name in os.environ:
88
+ env_map[field_name] = os.environ[name]
89
+ per_field[field_name] = FieldSource("env", f"ENV:{name}", os.environ[name])
90
+ break
91
+
92
+ merged.update(env_map)
93
+
94
+ # 6. overrides
95
+ if overrides:
96
+ self._merge_into(merged, per_field, overrides, source_name="overrides", source_path="runtime")
97
+
98
+ metadata = SettingsMetadata(per_field=per_field)
99
+ return merged, metadata, checked_files
100
+
101
+ def _model_defaults(self, model: type[BaseModel]) -> dict[str, Any]:
102
+ out: dict[str, Any] = {}
103
+ # pydantic v2
104
+ fields = getattr(model, "model_fields", None)
105
+ if fields is not None:
106
+ for k, info in fields.items():
107
+ if getattr(info, "default", None) not in (None, ...):
108
+ out[k] = info.default
109
+ else:
110
+ # pydantic v1 style
111
+ fields = getattr(model, "__fields__", {})
112
+ for k, f in fields.items():
113
+ if f.default is not None:
114
+ out[k] = f.default
115
+ return out
116
+
117
+ def _field_names(self, model: type[BaseModel]) -> list[str]:
118
+ fields = getattr(model, "model_fields", None)
119
+ if fields is not None:
120
+ return list(fields.keys())
121
+ return list(getattr(model, "__fields__", {}).keys())
122
+
123
+ def _determine_app_name(self, model: type[BaseModel]) -> str:
124
+ """Determine the app name from explicit arg, model default, or class name.
125
+
126
+ Precedence: explicit app_name > model field default > model class name (lowercased).
127
+ """
128
+ if self.app_name:
129
+ return self.app_name
130
+ # try to pull default from model field 'app_name' if present
131
+ fields = getattr(model, "model_fields", None)
132
+ if fields and "app_name" in fields:
133
+ default = fields["app_name"].default
134
+ if default not in (None, ...):
135
+ return str(default)
136
+ # fallback to model class name
137
+ return model.__name__.lower()
138
+
139
+ def _global_config_paths(self, app: str) -> list[Path]:
140
+ home = Path.home()
141
+ cfg_dir = home / ".config" / app
142
+ return [cfg_dir / f"{app}.toml", cfg_dir / f"{app}.yaml"]
143
+
144
+ def _project_config_paths(self, app: str) -> list[Path]:
145
+ out: list[Path] = []
146
+ root_toml = self.cwd / f"{app}.toml"
147
+ root_yaml = self.cwd / f"{app}.yaml"
148
+ out.extend([root_toml, root_yaml])
149
+ config_dir = self.cwd / "config"
150
+ if config_dir.exists() and config_dir.is_dir():
151
+ for ext in ("*.toml", "*.yaml", "*.yml"):
152
+ out.extend(sorted(config_dir.glob(ext)))
153
+ return out
154
+
155
+ def _normalize(self, key: str) -> str:
156
+ return key.strip().lower().replace("-", "_")
157
+
158
+ def _merge_into(
159
+ self,
160
+ target: dict[str, Any],
161
+ per_field: dict[str, FieldSource],
162
+ source: dict[str, Any],
163
+ *,
164
+ source_name: str,
165
+ source_path: str | None = None,
166
+ ) -> None:
167
+ # source keys may be in various forms; normalize and map to fields
168
+ for k, v in source.items():
169
+ nk = self._normalize(str(k))
170
+ # if key is nested mapping matching field exactly, allow
171
+ target[nk] = v
172
+ per_field[nk] = FieldSource(source_name, source_path, v)