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.
@@ -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)