dotenvmodel 0.1.1__py3-none-any.whl
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.
- dotenvmodel/__init__.py +42 -0
- dotenvmodel/coercion.py +398 -0
- dotenvmodel/config.py +483 -0
- dotenvmodel/exceptions.py +131 -0
- dotenvmodel/fields.py +298 -0
- dotenvmodel/loading.py +145 -0
- dotenvmodel/logging_config.py +92 -0
- dotenvmodel/py.typed +0 -0
- dotenvmodel/types.py +480 -0
- dotenvmodel/validation.py +182 -0
- dotenvmodel-0.1.1.dist-info/METADATA +950 -0
- dotenvmodel-0.1.1.dist-info/RECORD +14 -0
- dotenvmodel-0.1.1.dist-info/WHEEL +4 -0
- dotenvmodel-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotenvmodel
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Type-safe environment configuration with automatic .env file loading
|
|
5
|
+
Project-URL: Homepage, https://github.com/AZX-PBC/dotenvmodel
|
|
6
|
+
Project-URL: Repository, https://github.com/AZX-PBC/dotenvmodel
|
|
7
|
+
Project-URL: Issues, https://github.com/AZX-PBC/dotenvmodel/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/AZX-PBC/dotenvmodel/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Rich Evans <re@azx.io>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: config,configuration,dotenv,env,environment,settings
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: python-dotenv>=0.19.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# dotenvmodel
|
|
32
|
+
|
|
33
|
+
> Type-safe environment configuration with automatic .env file loading
|
|
34
|
+
|
|
35
|
+
**dotenvmodel** is a Python library that provides type-safe environment configuration with automatic `.env` file loading. It combines the familiar developer experience of Pydantic-style field definitions with intelligent `.env` file cascading inspired by Node.js dotenv patterns.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **Minimal Dependencies**: Only requires `python-dotenv`
|
|
40
|
+
- **Type Safety**: Full type hint support with automatic type coercion
|
|
41
|
+
- **Rich Type Support**: UUID, Decimal, datetime, timedelta, SecretStr, HttpUrl, PostgresDsn, RedisDsn, Json[T], and more
|
|
42
|
+
- **Developer Experience**: Intuitive Pydantic-style API
|
|
43
|
+
- **Smart .env Loading**: Automatic cascading of `.env`, `.env.{env}`, `.env.{env}.local` files
|
|
44
|
+
- **Configuration Reload**: Reload configuration at runtime without creating new instances
|
|
45
|
+
- **Environment Prefixes**: Class-level `env_prefix` to namespace environment variables
|
|
46
|
+
- **Validation**: Numeric constraints (ge, le, gt, lt), string constraints (min_length, max_length, regex), choice validation, and collection size constraints (min_items, max_items)
|
|
47
|
+
- **Clear Error Messages**: Helpful validation errors that guide you to fixes
|
|
48
|
+
- **Optional Logging**: Built-in logging support to debug configuration loading
|
|
49
|
+
- **Zero Runtime Overhead**: All validation happens at startup/load time
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install dotenvmodel
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or with uv:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add dotenvmodel
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from dotenvmodel import DotEnvConfig, Field
|
|
67
|
+
|
|
68
|
+
class AppConfig(DotEnvConfig):
|
|
69
|
+
# Required fields (Pydantic-style)
|
|
70
|
+
database_url: str = Field(...)
|
|
71
|
+
api_key: str = Field(...)
|
|
72
|
+
|
|
73
|
+
# Optional with defaults
|
|
74
|
+
debug: bool = Field(default=False)
|
|
75
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
76
|
+
workers: int = Field(default=4, ge=1, le=16)
|
|
77
|
+
|
|
78
|
+
# Collection types
|
|
79
|
+
allowed_hosts: list[str] = Field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
# Load configuration from .env files
|
|
82
|
+
config = AppConfig.load(env="dev")
|
|
83
|
+
|
|
84
|
+
# Access configuration with full type safety and IntelliSense
|
|
85
|
+
print(f"Connecting to {config.database_url}") # config.database_url: str
|
|
86
|
+
print(f"Running on port {config.port}") # config.port: int
|
|
87
|
+
print(f"Debug mode: {config.debug}") # config.debug: bool
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Type Safety and IntelliSense
|
|
91
|
+
|
|
92
|
+
**dotenvmodel provides full type safety** - your IDE and type checkers (mypy, pyright) understand the types of your configuration fields:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
class AppConfig(DotEnvConfig):
|
|
96
|
+
database_url: str = Field()
|
|
97
|
+
port: int = Field(default=8000)
|
|
98
|
+
debug: bool = Field(default=False)
|
|
99
|
+
|
|
100
|
+
config = AppConfig.load()
|
|
101
|
+
|
|
102
|
+
# ✅ Type checkers know these types:
|
|
103
|
+
db_url: str = config.database_url # ✅ Correct: str = str
|
|
104
|
+
port_num: int = config.port # ✅ Correct: int = int
|
|
105
|
+
is_debug: bool = config.debug # ✅ Correct: bool = bool
|
|
106
|
+
|
|
107
|
+
# ❌ Type checker errors:
|
|
108
|
+
wrong: int = config.database_url # ❌ Error: str is not compatible with int
|
|
109
|
+
wrong: str = config.debug # ❌ Error: bool is not compatible with str
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Your IDE will provide:
|
|
113
|
+
|
|
114
|
+
- **Autocomplete** for all config fields
|
|
115
|
+
- **Type hints** showing field types
|
|
116
|
+
- **Error detection** for type mismatches
|
|
117
|
+
- **Go to definition** support
|
|
118
|
+
|
|
119
|
+
## Supported Types
|
|
120
|
+
|
|
121
|
+
### Basic Types
|
|
122
|
+
|
|
123
|
+
**String**
|
|
124
|
+
```python
|
|
125
|
+
class Config(DotEnvConfig):
|
|
126
|
+
name: str = Field()
|
|
127
|
+
# Environment: NAME=myapp
|
|
128
|
+
# Result: config.name == "myapp"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Integer**
|
|
132
|
+
```python
|
|
133
|
+
class Config(DotEnvConfig):
|
|
134
|
+
port: int = Field(default=8000)
|
|
135
|
+
# Environment: PORT=3000
|
|
136
|
+
# Result: config.port == 3000 (int)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Float**
|
|
140
|
+
```python
|
|
141
|
+
class Config(DotEnvConfig):
|
|
142
|
+
timeout: float = Field(default=30.0)
|
|
143
|
+
# Environment: TIMEOUT=60.5
|
|
144
|
+
# Result: config.timeout == 60.5 (float)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Boolean**
|
|
148
|
+
|
|
149
|
+
Supports multiple formats for true/false values:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
class Config(DotEnvConfig):
|
|
153
|
+
debug: bool = Field(default=False)
|
|
154
|
+
|
|
155
|
+
# True values: "true", "1", "yes", "on", "t", "y" (case-insensitive)
|
|
156
|
+
# False values: "false", "0", "no", "off", "f", "n", "" (case-insensitive)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Path**
|
|
160
|
+
```python
|
|
161
|
+
from pathlib import Path
|
|
162
|
+
|
|
163
|
+
class Config(DotEnvConfig):
|
|
164
|
+
config_path: Path = Field(default=Path("/etc/app"))
|
|
165
|
+
# Environment: CONFIG_PATH=/opt/myapp/config
|
|
166
|
+
# Result: config.config_path == Path("/opt/myapp/config")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Collection Types
|
|
170
|
+
|
|
171
|
+
**List**
|
|
172
|
+
```python
|
|
173
|
+
class Config(DotEnvConfig):
|
|
174
|
+
# List of strings
|
|
175
|
+
hosts: list[str] = Field(default_factory=list)
|
|
176
|
+
# Environment: HOSTS=localhost,example.com,*.example.com
|
|
177
|
+
# Result: config.hosts == ["localhost", "example.com", "*.example.com"]
|
|
178
|
+
|
|
179
|
+
# List of integers
|
|
180
|
+
ports: list[int] = Field(default_factory=list)
|
|
181
|
+
# Environment: PORTS=8000,8001,8002
|
|
182
|
+
# Result: config.ports == [8000, 8001, 8002]
|
|
183
|
+
|
|
184
|
+
# Custom separator
|
|
185
|
+
tags: list[str] = Field(default_factory=list, separator=";")
|
|
186
|
+
# Environment: TAGS=web;api;backend
|
|
187
|
+
# Result: config.tags == ["web", "api", "backend"]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Set**
|
|
191
|
+
```python
|
|
192
|
+
class Config(DotEnvConfig):
|
|
193
|
+
roles: set[str] = Field(default_factory=set)
|
|
194
|
+
# Environment: ROLES=admin,user,admin
|
|
195
|
+
# Result: config.roles == {"admin", "user"}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Tuple**
|
|
199
|
+
```python
|
|
200
|
+
class Config(DotEnvConfig):
|
|
201
|
+
coordinates: tuple[str, ...] = Field()
|
|
202
|
+
# Environment: COORDINATES=x,y,z
|
|
203
|
+
# Result: config.coordinates == ("x", "y", "z")
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Dictionary**
|
|
207
|
+
```python
|
|
208
|
+
class Config(DotEnvConfig):
|
|
209
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
210
|
+
# Environment: HEADERS=Content-Type=application/json,Accept=*/*
|
|
211
|
+
# Result: config.headers == {"Content-Type": "application/json", "Accept": "*/*"}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Advanced Types
|
|
215
|
+
|
|
216
|
+
**UUID**
|
|
217
|
+
```python
|
|
218
|
+
from uuid import UUID
|
|
219
|
+
|
|
220
|
+
class Config(DotEnvConfig):
|
|
221
|
+
tenant_id: UUID = Field()
|
|
222
|
+
# Environment: TENANT_ID=550e8400-e29b-41d4-a716-446655440000
|
|
223
|
+
# Result: config.tenant_id == UUID('550e8400-e29b-41d4-a716-446655440000')
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Decimal (for precise arithmetic)**
|
|
227
|
+
```python
|
|
228
|
+
from decimal import Decimal
|
|
229
|
+
|
|
230
|
+
class Config(DotEnvConfig):
|
|
231
|
+
price: Decimal = Field()
|
|
232
|
+
tax_rate: Decimal = Field(ge=Decimal('0'), le=Decimal('1'))
|
|
233
|
+
# Environment: PRICE=19.99, TAX_RATE=0.0825
|
|
234
|
+
# Result: config.price == Decimal('19.99')
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Datetime and Timedelta**
|
|
238
|
+
```python
|
|
239
|
+
from datetime import datetime, timedelta
|
|
240
|
+
|
|
241
|
+
class Config(DotEnvConfig):
|
|
242
|
+
created_at: datetime = Field()
|
|
243
|
+
# Environment: CREATED_AT=2025-01-15T10:30:00
|
|
244
|
+
# Result: config.created_at == datetime(2025, 1, 15, 10, 30, 0)
|
|
245
|
+
|
|
246
|
+
cache_ttl: timedelta = Field()
|
|
247
|
+
# Environment: CACHE_TTL=1h30m (or: 5400 for seconds)
|
|
248
|
+
# Result: config.cache_ttl == timedelta(hours=1, minutes=30)
|
|
249
|
+
# Supports: ms, s, m, h, d, w
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**SecretStr (hide sensitive data)**
|
|
253
|
+
```python
|
|
254
|
+
from dotenvmodel.types import SecretStr
|
|
255
|
+
|
|
256
|
+
class Config(DotEnvConfig):
|
|
257
|
+
api_key: SecretStr = Field(min_length=32)
|
|
258
|
+
password: SecretStr = Field()
|
|
259
|
+
# Hides value in logs and repr
|
|
260
|
+
|
|
261
|
+
config = Config.load()
|
|
262
|
+
print(config.api_key) # SecretStr('**********')
|
|
263
|
+
print(config.api_key.get_secret_value()) # 'actual-secret-key'
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**URL and DSN Types**
|
|
267
|
+
```python
|
|
268
|
+
from dotenvmodel.types import HttpUrl, PostgresDsn, RedisDsn
|
|
269
|
+
|
|
270
|
+
class Config(DotEnvConfig):
|
|
271
|
+
api_url: HttpUrl = Field()
|
|
272
|
+
# Environment: API_URL=https://api.example.com/v1
|
|
273
|
+
# Validates scheme, provides parsed components
|
|
274
|
+
|
|
275
|
+
database_url: PostgresDsn = Field()
|
|
276
|
+
# Environment: DATABASE_URL=postgresql://user:pass@localhost:5432/db
|
|
277
|
+
|
|
278
|
+
redis_url: RedisDsn = Field()
|
|
279
|
+
# Environment: REDIS_URL=redis://localhost:6379/0
|
|
280
|
+
|
|
281
|
+
# URL types work like strings but provide properties:
|
|
282
|
+
config = Config.load()
|
|
283
|
+
print(config.api_url.host) # 'api.example.com'
|
|
284
|
+
print(config.api_url.port) # 443
|
|
285
|
+
print(config.database_url.database) # 'db'
|
|
286
|
+
print(config.redis_url.database) # 0
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**JSON Parsing**
|
|
290
|
+
```python
|
|
291
|
+
from dotenvmodel.types import Json
|
|
292
|
+
|
|
293
|
+
class Config(DotEnvConfig):
|
|
294
|
+
feature_flags: Json[dict[str, bool]] = Field()
|
|
295
|
+
# Environment: FEATURE_FLAGS={"new_ui": true, "beta_api": false}
|
|
296
|
+
|
|
297
|
+
allowed_roles: Json[list[str]] = Field()
|
|
298
|
+
# Environment: ALLOWED_ROLES=["admin", "user", "guest"]
|
|
299
|
+
|
|
300
|
+
config = Config.load()
|
|
301
|
+
assert config.feature_flags == {"new_ui": True, "beta_api": False}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Optional Types
|
|
305
|
+
|
|
306
|
+
Optional types automatically default to `None` if no explicit default is provided:
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
from typing import Optional
|
|
310
|
+
|
|
311
|
+
class Config(DotEnvConfig):
|
|
312
|
+
# These automatically default to None (no need for explicit default=None)
|
|
313
|
+
optional_value: str | None = Field()
|
|
314
|
+
optional_port: int | None = Field()
|
|
315
|
+
|
|
316
|
+
# Using Optional from typing also works
|
|
317
|
+
optional_name: Optional[str] = Field()
|
|
318
|
+
|
|
319
|
+
# You can still provide explicit defaults if needed
|
|
320
|
+
optional_with_default: str | None = Field(default="custom")
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Field Definitions
|
|
324
|
+
|
|
325
|
+
### Defining Required Fields
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
from dotenvmodel import DotEnvConfig, Field, Required
|
|
329
|
+
|
|
330
|
+
class Config(DotEnvConfig):
|
|
331
|
+
# Method 1: Pydantic-style Field(...) - Recommended
|
|
332
|
+
api_key: str = Field(...)
|
|
333
|
+
|
|
334
|
+
# Method 2: Field() with no default - Also works
|
|
335
|
+
database_url: str = Field()
|
|
336
|
+
|
|
337
|
+
# Method 3: Required sentinel - Alternative
|
|
338
|
+
secret: str = Required
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
All three methods work identically at runtime and have no type checker issues. We recommend **`Field(...)`** as it's consistent with Pydantic's API and makes it explicit that you're defining a field.
|
|
342
|
+
|
|
343
|
+
### Optional Fields with Defaults
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
class Config(DotEnvConfig):
|
|
347
|
+
# Simple default
|
|
348
|
+
port: int = Field(default=8000)
|
|
349
|
+
|
|
350
|
+
# Default factory for mutable defaults
|
|
351
|
+
hosts: list[str] = Field(default_factory=list)
|
|
352
|
+
tags: dict[str, str] = Field(default_factory=dict)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Field Aliases
|
|
356
|
+
|
|
357
|
+
Use a different environment variable name than the field name:
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
class Config(DotEnvConfig):
|
|
361
|
+
# Field name: postgres_dsn
|
|
362
|
+
# Environment variable: DATABASE_URL
|
|
363
|
+
postgres_dsn: str = Field(alias="DATABASE_URL")
|
|
364
|
+
|
|
365
|
+
# Field name: api_token
|
|
366
|
+
# Environment variable: SECRET_TOKEN
|
|
367
|
+
api_token: str = Field(alias="SECRET_TOKEN")
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Field Descriptions
|
|
371
|
+
|
|
372
|
+
Document your fields for better maintainability:
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
class Config(DotEnvConfig):
|
|
376
|
+
timeout: float = Field(
|
|
377
|
+
default=30.0,
|
|
378
|
+
description="Request timeout in seconds"
|
|
379
|
+
)
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Validation
|
|
383
|
+
|
|
384
|
+
### Numeric Validation
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
class Config(DotEnvConfig):
|
|
388
|
+
# Greater than or equal (>=)
|
|
389
|
+
min_connections: int = Field(ge=1)
|
|
390
|
+
|
|
391
|
+
# Less than or equal (<=)
|
|
392
|
+
max_connections: int = Field(le=100)
|
|
393
|
+
|
|
394
|
+
# Greater than (>)
|
|
395
|
+
timeout: float = Field(gt=0)
|
|
396
|
+
|
|
397
|
+
# Less than (<)
|
|
398
|
+
percentage: float = Field(lt=100.0)
|
|
399
|
+
|
|
400
|
+
# Combined constraints
|
|
401
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
402
|
+
pool_size: int = Field(default=10, ge=1, le=100)
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### String Validation
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
class Config(DotEnvConfig):
|
|
409
|
+
# Minimum length
|
|
410
|
+
api_key: str = Field(min_length=32)
|
|
411
|
+
|
|
412
|
+
# Maximum length
|
|
413
|
+
username: str = Field(max_length=20)
|
|
414
|
+
|
|
415
|
+
# Regex pattern
|
|
416
|
+
email: str = Field(regex=r'^[\w\.-]+@[\w\.-]+\.\w+$')
|
|
417
|
+
|
|
418
|
+
# Combined constraints
|
|
419
|
+
password: str = Field(
|
|
420
|
+
min_length=8,
|
|
421
|
+
max_length=128,
|
|
422
|
+
regex=r'^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).+$'
|
|
423
|
+
)
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Choice Validation
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
class Config(DotEnvConfig):
|
|
430
|
+
# Must be one of the specified values
|
|
431
|
+
environment: str = Field(
|
|
432
|
+
default="dev",
|
|
433
|
+
choices=["dev", "test", "staging", "prod"]
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
log_level: str = Field(
|
|
437
|
+
default="INFO",
|
|
438
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
439
|
+
)
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## Loading Configuration
|
|
443
|
+
|
|
444
|
+
### From Environment Variables
|
|
445
|
+
|
|
446
|
+
```python
|
|
447
|
+
# Load from environment and .env files
|
|
448
|
+
config = AppConfig.load()
|
|
449
|
+
|
|
450
|
+
# Specify environment explicitly
|
|
451
|
+
config = AppConfig.load(env="prod")
|
|
452
|
+
|
|
453
|
+
# Override behavior
|
|
454
|
+
config = AppConfig.load(override=True) # .env files override env vars (default)
|
|
455
|
+
config = AppConfig.load(override=False) # Env vars take precedence
|
|
456
|
+
|
|
457
|
+
# Custom .env file directory
|
|
458
|
+
from pathlib import Path
|
|
459
|
+
config = AppConfig.load(env_dir=Path("/app/config"))
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### .env File Cascading
|
|
463
|
+
|
|
464
|
+
Files are loaded in order (later files override earlier ones):
|
|
465
|
+
|
|
466
|
+
1. `.env` - Base configuration (usually gitignored)
|
|
467
|
+
2. `.env.local` - Local base overrides (gitignored, never committed)
|
|
468
|
+
3. `.env.{env}` - Environment-specific (committed to repo)
|
|
469
|
+
4. `.env.{env}.local` - Local environment overrides (gitignored, never committed)
|
|
470
|
+
|
|
471
|
+
**Example:**
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
# .env (base - usually gitignored)
|
|
475
|
+
DATABASE_URL=postgresql://localhost/myapp
|
|
476
|
+
REDIS_URL=redis://localhost:6379
|
|
477
|
+
DEBUG=false
|
|
478
|
+
|
|
479
|
+
# .env.local (local base overrides - gitignored)
|
|
480
|
+
DATABASE_URL=postgresql://localhost/myapp_local
|
|
481
|
+
|
|
482
|
+
# .env.dev (development - committed to repo)
|
|
483
|
+
DEBUG=true
|
|
484
|
+
LOG_LEVEL=DEBUG
|
|
485
|
+
|
|
486
|
+
# .env.dev.local (local dev overrides - gitignored)
|
|
487
|
+
ENABLE_PROFILING=true
|
|
488
|
+
API_KEY=dev-key-local-override
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
When you load with `env="dev"`:
|
|
492
|
+
```python
|
|
493
|
+
config = AppConfig.load(env="dev")
|
|
494
|
+
# Loads in order: .env → .env.local → .env.dev → .env.dev.local
|
|
495
|
+
# Final DATABASE_URL: postgresql://localhost/myapp_local (from .env.local)
|
|
496
|
+
# Final DEBUG: true (from .env.dev)
|
|
497
|
+
# Final ENABLE_PROFILING: true (from .env.dev.local)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### From Dictionary (Testing)
|
|
501
|
+
|
|
502
|
+
```python
|
|
503
|
+
# Load from dictionary for testing
|
|
504
|
+
config = AppConfig.load_from_dict({
|
|
505
|
+
"DATABASE_URL": "postgresql://localhost/test",
|
|
506
|
+
"API_KEY": "test-key",
|
|
507
|
+
"DEBUG": "true",
|
|
508
|
+
"PORT": "8000",
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
# Skip validation if needed
|
|
512
|
+
config = AppConfig.load_from_dict(data, validate=False)
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
## Logging
|
|
516
|
+
|
|
517
|
+
dotenvmodel includes optional logging to help debug configuration issues. Logging is disabled by default but can be easily enabled.
|
|
518
|
+
|
|
519
|
+
### Enable Logging
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
from dotenvmodel import configure_logging, DotEnvConfig, Field
|
|
523
|
+
|
|
524
|
+
# Enable INFO level logging
|
|
525
|
+
configure_logging("INFO")
|
|
526
|
+
|
|
527
|
+
class Config(DotEnvConfig):
|
|
528
|
+
database_url: str = Field()
|
|
529
|
+
|
|
530
|
+
config = Config.load()
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Logging Output Example
|
|
534
|
+
|
|
535
|
+
```
|
|
536
|
+
2025-12-05 00:33:40 - dotenvmodel - INFO - Loading Config configuration
|
|
537
|
+
2025-12-05 00:33:40 - dotenvmodel - INFO - Loading configuration for environment: dev
|
|
538
|
+
2025-12-05 00:33:40 - dotenvmodel - INFO - Loading environment variables from .env
|
|
539
|
+
2025-12-05 00:33:40 - dotenvmodel - INFO - Loading environment variables from .env.dev
|
|
540
|
+
2025-12-05 00:33:40 - dotenvmodel - INFO - Successfully loaded 2 file(s): .env, .env.dev
|
|
541
|
+
2025-12-05 00:33:40 - dotenvmodel - INFO - Config configuration loaded successfully
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### Log Levels
|
|
545
|
+
|
|
546
|
+
```python
|
|
547
|
+
# DEBUG - Most verbose, shows all operations
|
|
548
|
+
configure_logging("DEBUG")
|
|
549
|
+
|
|
550
|
+
# INFO - Shows file loading and configuration status
|
|
551
|
+
configure_logging("INFO")
|
|
552
|
+
|
|
553
|
+
# WARNING - Only shows warnings (e.g., missing files)
|
|
554
|
+
configure_logging("WARNING")
|
|
555
|
+
|
|
556
|
+
# ERROR - Only shows errors
|
|
557
|
+
configure_logging("ERROR")
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### Using Environment Variable
|
|
561
|
+
|
|
562
|
+
```bash
|
|
563
|
+
# Set via environment variable
|
|
564
|
+
export DOTENVMODEL_LOG_LEVEL=DEBUG
|
|
565
|
+
python your_app.py
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Disable Logging
|
|
569
|
+
|
|
570
|
+
```python
|
|
571
|
+
from dotenvmodel import disable_logging
|
|
572
|
+
|
|
573
|
+
disable_logging()
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Custom Logging Configuration
|
|
577
|
+
|
|
578
|
+
```python
|
|
579
|
+
import logging
|
|
580
|
+
from dotenvmodel import configure_logging
|
|
581
|
+
|
|
582
|
+
# Use custom format
|
|
583
|
+
configure_logging(
|
|
584
|
+
"INFO",
|
|
585
|
+
format_string="[%(levelname)s] %(message)s"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# Or configure directly with standard logging
|
|
589
|
+
logger = logging.getLogger("dotenvmodel")
|
|
590
|
+
logger.setLevel(logging.DEBUG)
|
|
591
|
+
handler = logging.StreamHandler()
|
|
592
|
+
handler.setFormatter(logging.Formatter('%(message)s'))
|
|
593
|
+
logger.addHandler(handler)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## Configuration Methods
|
|
597
|
+
|
|
598
|
+
### Access as Dictionary
|
|
599
|
+
|
|
600
|
+
```python
|
|
601
|
+
config = AppConfig.load()
|
|
602
|
+
config_dict = config.dict()
|
|
603
|
+
# {'database_url': 'postgresql://...', 'debug': True, 'port': 8000}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### Get Method with Default
|
|
607
|
+
|
|
608
|
+
```python
|
|
609
|
+
config = AppConfig.load()
|
|
610
|
+
timeout = config.get('timeout', 30) # Returns 30 if timeout not set
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
### String Representation
|
|
614
|
+
|
|
615
|
+
```python
|
|
616
|
+
config = AppConfig.load()
|
|
617
|
+
print(repr(config))
|
|
618
|
+
# AppConfig(database_url='postgresql://...', debug=True, port=8000)
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
### Reload Configuration
|
|
622
|
+
|
|
623
|
+
Reload configuration from environment variables without creating a new instance:
|
|
624
|
+
|
|
625
|
+
```python
|
|
626
|
+
# Load initial configuration
|
|
627
|
+
config = AppConfig.load(env="dev")
|
|
628
|
+
print(config.port) # 8000
|
|
629
|
+
|
|
630
|
+
# Later, when environment variables change...
|
|
631
|
+
os.environ["PORT"] = "9000"
|
|
632
|
+
|
|
633
|
+
# Reload the configuration
|
|
634
|
+
config.reload()
|
|
635
|
+
print(config.port) # 9000
|
|
636
|
+
|
|
637
|
+
# Reload reuses the original parameters by default
|
|
638
|
+
config = AppConfig.load(env="dev", override=True)
|
|
639
|
+
config.reload() # Uses env="dev", override=True
|
|
640
|
+
|
|
641
|
+
# Override any parameter during reload
|
|
642
|
+
config.reload(env="prod") # Switch to production environment
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
The `reload()` method:
|
|
646
|
+
|
|
647
|
+
- Reloads all fields from environment variables and .env files
|
|
648
|
+
- By default, reuses the same `env`, `override`, and `env_dir` parameters from the original `load()` call
|
|
649
|
+
- Allows overriding any parameter by passing new values
|
|
650
|
+
- Validates all fields and raises errors if validation fails
|
|
651
|
+
- Returns the same instance (useful for method chaining)
|
|
652
|
+
|
|
653
|
+
## Environment Variable Prefixes
|
|
654
|
+
|
|
655
|
+
Use class-level prefixes to namespace environment variables:
|
|
656
|
+
|
|
657
|
+
```python
|
|
658
|
+
class DatabaseConfig(DotEnvConfig):
|
|
659
|
+
env_prefix = "DB_" # All fields will be prefixed with DB_
|
|
660
|
+
host: str = Field()
|
|
661
|
+
port: int = Field(default=5432)
|
|
662
|
+
name: str = Field()
|
|
663
|
+
|
|
664
|
+
# Reads DB_HOST, DB_PORT, DB_NAME from environment
|
|
665
|
+
config = DatabaseConfig.load_from_dict({
|
|
666
|
+
"DB_HOST": "localhost",
|
|
667
|
+
"DB_PORT": "5433",
|
|
668
|
+
"DB_NAME": "myapp"
|
|
669
|
+
})
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Prefix Behavior
|
|
673
|
+
|
|
674
|
+
- **Automatic Uppercasing**: Field names are automatically uppercased and prefixed
|
|
675
|
+
- `host` → `DB_HOST`
|
|
676
|
+
- `port` → `DB_PORT`
|
|
677
|
+
|
|
678
|
+
- **Aliases Override Prefix**: When using `alias`, the prefix is NOT applied (aliases are absolute)
|
|
679
|
+
|
|
680
|
+
```python
|
|
681
|
+
class Config(DotEnvConfig):
|
|
682
|
+
env_prefix = "APP_"
|
|
683
|
+
db_url: str = Field(alias="DATABASE_URL") # Reads DATABASE_URL (no prefix)
|
|
684
|
+
api_key: str = Field() # Reads APP_API_KEY (with prefix)
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
- **No Prefix by Default**: If `env_prefix` is not set, no prefix is applied
|
|
688
|
+
|
|
689
|
+
```python
|
|
690
|
+
class Config(DotEnvConfig):
|
|
691
|
+
# No env_prefix defined
|
|
692
|
+
host: str = Field() # Reads HOST
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### Multiple Config Classes with Different Prefixes
|
|
696
|
+
|
|
697
|
+
```python
|
|
698
|
+
class DatabaseConfig(DotEnvConfig):
|
|
699
|
+
env_prefix = "DB_"
|
|
700
|
+
host: str = Field()
|
|
701
|
+
port: int = Field(default=5432)
|
|
702
|
+
|
|
703
|
+
class RedisConfig(DotEnvConfig):
|
|
704
|
+
env_prefix = "REDIS_"
|
|
705
|
+
host: str = Field()
|
|
706
|
+
port: int = Field(default=6379)
|
|
707
|
+
|
|
708
|
+
class AppConfig(DotEnvConfig):
|
|
709
|
+
env_prefix = "APP_"
|
|
710
|
+
name: str = Field()
|
|
711
|
+
version: str = Field()
|
|
712
|
+
|
|
713
|
+
# Each config reads its own prefixed variables
|
|
714
|
+
db = DatabaseConfig.load() # Reads DB_HOST, DB_PORT
|
|
715
|
+
redis = RedisConfig.load() # Reads REDIS_HOST, REDIS_PORT
|
|
716
|
+
app = AppConfig.load() # Reads APP_NAME, APP_VERSION
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
## Error Handling
|
|
720
|
+
|
|
721
|
+
### Missing Required Field
|
|
722
|
+
|
|
723
|
+
```python
|
|
724
|
+
try:
|
|
725
|
+
config = AppConfig.load()
|
|
726
|
+
except MissingFieldError as e:
|
|
727
|
+
print(e)
|
|
728
|
+
# MissingFieldError: Required field 'api_key' is not set.
|
|
729
|
+
#
|
|
730
|
+
# Environment variable name: API_KEY
|
|
731
|
+
# Field type: str
|
|
732
|
+
# Hint: Set API_KEY in your environment or .env file
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
### Type Coercion Error
|
|
736
|
+
|
|
737
|
+
```python
|
|
738
|
+
try:
|
|
739
|
+
config = AppConfig.load_from_dict({"PORT": "abc"})
|
|
740
|
+
except TypeCoercionError as e:
|
|
741
|
+
print(e)
|
|
742
|
+
# TypeCoercionError: Failed to coerce field 'port' to type int.
|
|
743
|
+
#
|
|
744
|
+
# Value: "abc"
|
|
745
|
+
# Environment variable: PORT
|
|
746
|
+
# Error: invalid literal for int() with base 10: 'abc'
|
|
747
|
+
# Hint: Ensure PORT contains a valid int
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### Validation Constraint Error
|
|
751
|
+
|
|
752
|
+
```python
|
|
753
|
+
try:
|
|
754
|
+
config = AppConfig.load_from_dict({"PORT": "99999"})
|
|
755
|
+
except ConstraintViolationError as e:
|
|
756
|
+
print(e)
|
|
757
|
+
# ConstraintViolationError: Field 'port' violates constraint.
|
|
758
|
+
#
|
|
759
|
+
# Value: 99999
|
|
760
|
+
# Constraint: le=65535
|
|
761
|
+
# Error: Value must be less than or equal to 65535
|
|
762
|
+
# Hint: Set PORT to a value that satisfies the constraint
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
## Advanced Examples
|
|
766
|
+
|
|
767
|
+
### Complete Application Configuration
|
|
768
|
+
|
|
769
|
+
```python
|
|
770
|
+
from pathlib import Path
|
|
771
|
+
from dotenvmodel import DotEnvConfig, Field, Required
|
|
772
|
+
|
|
773
|
+
class DatabaseConfig(DotEnvConfig):
|
|
774
|
+
env_prefix = "DB_" # Namespace with DB_ prefix
|
|
775
|
+
host: str = Field()
|
|
776
|
+
port: int = Field(default=5432)
|
|
777
|
+
name: str = Field()
|
|
778
|
+
pool_size: int = Field(default=10, ge=1, le=100)
|
|
779
|
+
pool_timeout: float = Field(default=30.0, gt=0)
|
|
780
|
+
echo: bool = Field(default=False)
|
|
781
|
+
|
|
782
|
+
class RedisConfig(DotEnvConfig):
|
|
783
|
+
env_prefix = "REDIS_" # Namespace with REDIS_ prefix
|
|
784
|
+
host: str = Field()
|
|
785
|
+
port: int = Field(default=6379)
|
|
786
|
+
password: str | None = Field(default=None)
|
|
787
|
+
db: int = Field(default=0, ge=0, le=15)
|
|
788
|
+
socket_keepalive: bool = Field(default=True)
|
|
789
|
+
|
|
790
|
+
class AppConfig(DotEnvConfig):
|
|
791
|
+
env_prefix = "APP_" # Namespace with APP_ prefix
|
|
792
|
+
|
|
793
|
+
# App settings
|
|
794
|
+
environment: str = Field(
|
|
795
|
+
default="dev",
|
|
796
|
+
choices=["dev", "test", "staging", "prod"]
|
|
797
|
+
)
|
|
798
|
+
debug: bool = Field(default=False)
|
|
799
|
+
secret_key: str = Field(min_length=32)
|
|
800
|
+
|
|
801
|
+
# Server settings
|
|
802
|
+
host: str = Field(default="0.0.0.0")
|
|
803
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
804
|
+
workers: int = Field(default=4, ge=1)
|
|
805
|
+
|
|
806
|
+
# External services (using alias to override prefix)
|
|
807
|
+
api_base_url: str = Field(alias="API_BASE_URL")
|
|
808
|
+
api_timeout: float = Field(default=30.0, ge=0.1, le=300.0)
|
|
809
|
+
|
|
810
|
+
# Feature flags
|
|
811
|
+
enable_caching: bool = Field(default=True)
|
|
812
|
+
enable_metrics: bool = Field(default=False)
|
|
813
|
+
|
|
814
|
+
# Lists and paths
|
|
815
|
+
allowed_origins: list[str] = Field(default_factory=list)
|
|
816
|
+
upload_dir: Path = Field(default=Path("/tmp/uploads"))
|
|
817
|
+
|
|
818
|
+
# Load all configs with prefixes
|
|
819
|
+
# DatabaseConfig reads: DB_HOST, DB_PORT, DB_NAME, etc.
|
|
820
|
+
# RedisConfig reads: REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, etc.
|
|
821
|
+
# AppConfig reads: APP_ENVIRONMENT, APP_DEBUG, APP_HOST, API_BASE_URL (alias), etc.
|
|
822
|
+
db_config = DatabaseConfig.load(env="prod")
|
|
823
|
+
redis_config = RedisConfig.load(env="prod")
|
|
824
|
+
app_config = AppConfig.load(env="prod")
|
|
825
|
+
|
|
826
|
+
# Reload configuration when environment changes
|
|
827
|
+
# (e.g., after receiving SIGHUP signal or config update)
|
|
828
|
+
db_config.reload() # Reloads with same env="prod"
|
|
829
|
+
redis_config.reload()
|
|
830
|
+
app_config.reload()
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### Testing Configuration
|
|
834
|
+
|
|
835
|
+
```python
|
|
836
|
+
import pytest
|
|
837
|
+
from dotenvmodel import DotEnvConfig, Field, Required, MissingFieldError
|
|
838
|
+
|
|
839
|
+
class TestConfig(DotEnvConfig):
|
|
840
|
+
database_url: str = Required
|
|
841
|
+
api_key: str = Required
|
|
842
|
+
debug: bool = Field(default=False)
|
|
843
|
+
|
|
844
|
+
def test_load_from_dict():
|
|
845
|
+
config = TestConfig.load_from_dict({
|
|
846
|
+
"database_url": "sqlite:///:memory:",
|
|
847
|
+
"api_key": "test-key-123",
|
|
848
|
+
"debug": "true",
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
assert config.database_url == "sqlite:///:memory:"
|
|
852
|
+
assert config.api_key == "test-key-123"
|
|
853
|
+
assert config.debug is True
|
|
854
|
+
|
|
855
|
+
def test_missing_required_field():
|
|
856
|
+
with pytest.raises(MissingFieldError) as exc_info:
|
|
857
|
+
TestConfig.load_from_dict({"api_key": "test"})
|
|
858
|
+
|
|
859
|
+
assert "database_url" in str(exc_info.value)
|
|
860
|
+
|
|
861
|
+
@pytest.fixture
|
|
862
|
+
def test_config():
|
|
863
|
+
"""Fixture providing test configuration."""
|
|
864
|
+
return TestConfig.load_from_dict({
|
|
865
|
+
"database_url": "sqlite:///:memory:",
|
|
866
|
+
"api_key": "test-key",
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
def test_with_fixture(test_config):
|
|
870
|
+
assert test_config.database_url == "sqlite:///:memory:"
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
## Best Practices
|
|
874
|
+
|
|
875
|
+
1. **Use Type Hints**: Always specify type hints for proper validation
|
|
876
|
+
```python
|
|
877
|
+
port: int = Field(default=8000) # ✓ Good
|
|
878
|
+
port = Field(default=8000) # ✗ Bad - no type hint
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
2. **Use Validation**: Add constraints to catch configuration errors early
|
|
882
|
+
```python
|
|
883
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
3. **Use Aliases**: Keep environment variable names consistent with conventions
|
|
887
|
+
```python
|
|
888
|
+
postgres_dsn: str = Field(alias="DATABASE_URL")
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
4. **Use Default Factories**: For mutable defaults like lists and dicts
|
|
892
|
+
```python
|
|
893
|
+
hosts: list[str] = Field(default_factory=list) # ✓ Good
|
|
894
|
+
hosts: list[str] = Field(default=[]) # ✗ Bad - mutable default
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
5. **Document Fields**: Use descriptions for complex configurations
|
|
898
|
+
```python
|
|
899
|
+
timeout: float = Field(
|
|
900
|
+
default=30.0,
|
|
901
|
+
ge=0.1,
|
|
902
|
+
description="API request timeout in seconds"
|
|
903
|
+
)
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
## Requirements
|
|
907
|
+
|
|
908
|
+
- Python 3.12+
|
|
909
|
+
- python-dotenv
|
|
910
|
+
|
|
911
|
+
## Known Limitations
|
|
912
|
+
|
|
913
|
+
### Union Types (Non-Optional)
|
|
914
|
+
|
|
915
|
+
Non-optional Union types like `str | int` or `Union[str, int]` are not currently supported. Only Optional unions (types with `None`) work:
|
|
916
|
+
|
|
917
|
+
```python
|
|
918
|
+
# ✅ Supported - Optional unions
|
|
919
|
+
class Config(DotEnvConfig):
|
|
920
|
+
value: str | None = Field() # Works
|
|
921
|
+
other: int | None = Field() # Works
|
|
922
|
+
|
|
923
|
+
# ❌ Not supported - Non-optional unions
|
|
924
|
+
class Config(DotEnvConfig):
|
|
925
|
+
value: str | int = Field() # Not supported
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
**Workaround**: Use a single type (typically `str`) and handle conversion in your application code:
|
|
929
|
+
|
|
930
|
+
```python
|
|
931
|
+
class Config(DotEnvConfig):
|
|
932
|
+
value: str = Field()
|
|
933
|
+
|
|
934
|
+
config = Config.load()
|
|
935
|
+
# Convert to int if needed in your code
|
|
936
|
+
value_as_int = int(config.value) if config.value.isdigit() else config.value
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
## License
|
|
940
|
+
|
|
941
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
942
|
+
|
|
943
|
+
## Contributing
|
|
944
|
+
|
|
945
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
946
|
+
|
|
947
|
+
## Links
|
|
948
|
+
|
|
949
|
+
- [GitHub Repository](https://github.com/azxio/dotenvmodel)
|
|
950
|
+
- [Full Specification](libraryspec.md)
|