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.
- utilityhub_config-0.1.0/PKG-INFO +301 -0
- utilityhub_config-0.1.0/README.md +289 -0
- utilityhub_config-0.1.0/pyproject.toml +15 -0
- utilityhub_config-0.1.0/src/utilityhub_config/__init__.py +12 -0
- utilityhub_config-0.1.0/src/utilityhub_config/api.py +59 -0
- utilityhub_config-0.1.0/src/utilityhub_config/errors.py +37 -0
- utilityhub_config-0.1.0/src/utilityhub_config/metadata.py +19 -0
- utilityhub_config-0.1.0/src/utilityhub_config/readers/__init__.py +5 -0
- utilityhub_config-0.1.0/src/utilityhub_config/readers/dotenv_reader.py +35 -0
- utilityhub_config-0.1.0/src/utilityhub_config/readers/toml_reader.py +17 -0
- utilityhub_config-0.1.0/src/utilityhub_config/readers/yaml_reader.py +20 -0
- utilityhub_config-0.1.0/src/utilityhub_config/resolver.py +172 -0
|
@@ -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,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)
|