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/config.py ADDED
@@ -0,0 +1,483 @@
1
+ """DotEnvConfig base class for configuration management."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any, Self, get_args, get_origin, get_type_hints
6
+
7
+ from dotenvmodel.coercion import coerce_value
8
+ from dotenvmodel.exceptions import (
9
+ MissingFieldError,
10
+ MultipleValidationErrors,
11
+ ValidationError,
12
+ )
13
+ from dotenvmodel.fields import _MISSING, FieldInfo, _RequiredSentinel
14
+ from dotenvmodel.loading import get_env_var, get_env_var_name, load_env_files
15
+ from dotenvmodel.validation import validate_field
16
+
17
+ # Module-level logger
18
+ logger = logging.getLogger("dotenvmodel")
19
+
20
+
21
+ def _is_optional_type(field_type: type) -> bool:
22
+ """Check if a type is Optional (Union with None)."""
23
+ origin = get_origin(field_type)
24
+ # For Union types (including str | None syntax which creates UnionType)
25
+ if origin is not None:
26
+ args = get_args(field_type)
27
+ # Check if None is one of the union members
28
+ return type(None) in args
29
+ return False
30
+
31
+
32
+ class ConfigMeta(type):
33
+ """Metaclass for DotEnvConfig that discovers field definitions."""
34
+
35
+ def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> type:
36
+ # Inherit fields from parent classes
37
+ fields: dict[str, tuple[type, FieldInfo]] = {}
38
+ for base in bases:
39
+ if hasattr(base, "_fields"):
40
+ # Copy parent fields
41
+ fields.update(base._fields) # type: ignore[arg-type]
42
+
43
+ # Get type hints for the class
44
+ hints = namespace.get("__annotations__", {})
45
+
46
+ # Discover fields from class attributes (can override parent fields)
47
+ for field_name, field_type in hints.items():
48
+ # Skip private attributes
49
+ if field_name.startswith("_"):
50
+ continue
51
+
52
+ # Skip class-level configuration attributes (not config fields)
53
+ if field_name == "env_prefix":
54
+ continue
55
+
56
+ # Get the field value from namespace
57
+ field_value = namespace.get(field_name, _MISSING)
58
+
59
+ # Handle different field value types
60
+ if isinstance(field_value, FieldInfo):
61
+ field_info = field_value
62
+ # If field is Optional and has no default, default to None
63
+ if (
64
+ field_info.default is _MISSING
65
+ and field_info.default_factory is None
66
+ and _is_optional_type(field_type)
67
+ ):
68
+ field_info.default = None
69
+ field_info.required = False # Update required flag
70
+ elif isinstance(field_value, _RequiredSentinel):
71
+ # User used Required sentinel
72
+ field_info = FieldInfo()
73
+ elif field_value is _MISSING:
74
+ # No default provided
75
+ if _is_optional_type(field_type):
76
+ # Optional types default to None
77
+ field_info = FieldInfo(default=None)
78
+ else:
79
+ # Non-optional types are required
80
+ field_info = FieldInfo()
81
+ elif field_value is ...:
82
+ # Ellipsis means required
83
+ field_info = FieldInfo()
84
+ else:
85
+ # Regular default value
86
+ field_info = FieldInfo(default=field_value)
87
+
88
+ fields[field_name] = (field_type, field_info)
89
+
90
+ # Remove field from namespace to avoid conflicts
91
+ namespace.pop(field_name, None)
92
+
93
+ # Store fields in the class
94
+ namespace["_fields"] = fields
95
+
96
+ return super().__new__(mcs, name, bases, namespace)
97
+
98
+
99
+ class DotEnvConfig(metaclass=ConfigMeta):
100
+ """
101
+ Base class for type-safe environment configuration.
102
+
103
+ Subclass this to define your configuration schema using type annotations
104
+ and Field descriptors.
105
+
106
+ Example:
107
+ ```python
108
+ class AppConfig(DotEnvConfig):
109
+ # Required fields
110
+ database_url: str = Field()
111
+ api_key: str = Required
112
+
113
+ # Optional with defaults
114
+ debug: bool = Field(default=False)
115
+ port: int = Field(default=8000, ge=1, le=65535)
116
+
117
+ # With validation
118
+ pool_size: int = Field(default=10, ge=1, le=100)
119
+
120
+ # Load configuration
121
+ config = AppConfig.load(env="dev")
122
+ print(config.database_url)
123
+ ```
124
+ """
125
+
126
+ _fields: dict[str, tuple[type, FieldInfo]]
127
+ _loaded: bool = False
128
+ _load_env: str | None = None # Store the env used during load
129
+ _load_override: bool = True # Store the override flag used during load
130
+ _load_env_dir: Path | None = None # Store the env_dir used during load
131
+ env_prefix: str = "" # Class-level prefix for environment variables (default: no prefix)
132
+
133
+ def _process_field(
134
+ self,
135
+ field_name: str,
136
+ field_type: type,
137
+ field_info: FieldInfo,
138
+ raw_value: str | None,
139
+ env_var_name: str,
140
+ *,
141
+ validate: bool = True,
142
+ ) -> Any:
143
+ """
144
+ Process a single field: handle missing values, coerce, and validate.
145
+
146
+ Args:
147
+ field_name: Name of the field
148
+ field_type: Type annotation for the field
149
+ field_info: Field metadata
150
+ raw_value: Raw string value from environment (or None)
151
+ env_var_name: Environment variable name for error messages
152
+ validate: Whether to perform validation (default: True)
153
+
154
+ Returns:
155
+ Processed and validated value
156
+
157
+ Raises:
158
+ MissingFieldError: If required field is missing
159
+ ValidationError: If validation fails
160
+ """
161
+ # Handle missing values
162
+ if raw_value is None:
163
+ if field_info.required:
164
+ raise MissingFieldError(
165
+ field_name=field_name,
166
+ field_type=field_type,
167
+ env_var_name=env_var_name,
168
+ )
169
+ else:
170
+ value = field_info.get_default()
171
+ else:
172
+ # Coerce the string value to the target type
173
+ value = coerce_value(field_name, raw_value, field_type, env_var_name, field_info)
174
+
175
+ # Check if coercion resulted in None for a required field
176
+ if value is None and field_info.required:
177
+ raise MissingFieldError(
178
+ field_name=field_name,
179
+ field_type=field_type,
180
+ env_var_name=env_var_name,
181
+ )
182
+
183
+ # Validate the value (whether from default or coerced)
184
+ if validate:
185
+ validate_field(field_name, value, field_info, env_var_name)
186
+
187
+ return value
188
+
189
+ @classmethod
190
+ def load(
191
+ cls,
192
+ env: str | None = None,
193
+ *,
194
+ override: bool = True,
195
+ env_dir: Path | None = None,
196
+ ) -> Self:
197
+ """
198
+ Load configuration from environment variables and .env files.
199
+
200
+ Args:
201
+ env: Environment name (e.g., "dev", "prod", "test"). If None, reads from
202
+ ENV environment variable, defaults to "dev"
203
+ override: If True, .env file values override existing environment variables.
204
+ If False, existing env vars take precedence
205
+ env_dir: Optional custom base directory for .env files
206
+
207
+ Returns:
208
+ Instance of the config class with all fields populated and validated
209
+
210
+ Raises:
211
+ ValidationError: If required fields are missing or validation fails
212
+ FileNotFoundError: If custom env_dir path doesn't exist
213
+
214
+ Example:
215
+ ```python
216
+ # Auto-detect environment from ENV variable
217
+ config = Config.load()
218
+
219
+ # Explicit environment
220
+ config = Config.load(env="prod")
221
+
222
+ # Don't override existing env vars
223
+ config = Config.load(override=False)
224
+
225
+ # Custom .env file location
226
+ config = Config.load(env_dir=Path("/app/config"))
227
+ ```
228
+ """
229
+ logger.info(f"Loading {cls.__name__} configuration")
230
+
231
+ # Load .env files first
232
+ load_env_files(env=env, override=override, env_dir=env_dir)
233
+
234
+ # Create instance and load fields
235
+ instance = cls()
236
+ errors: list[ValidationError] = []
237
+
238
+ # Get type hints for the class
239
+ get_type_hints(cls)
240
+
241
+ logger.debug(f"Processing {len(cls._fields)} field(s)")
242
+
243
+ # Get the class prefix (if any)
244
+ prefix = getattr(cls, "env_prefix", "")
245
+
246
+ # Process each field
247
+ for field_name, (field_type, field_info) in cls._fields.items():
248
+ env_var_name = get_env_var_name(field_name, field_info.alias, prefix)
249
+ raw_value = get_env_var(field_name, field_info.alias, prefix)
250
+
251
+ try:
252
+ value = instance._process_field(
253
+ field_name, field_type, field_info, raw_value, env_var_name
254
+ )
255
+ setattr(instance, field_name, value)
256
+ except ValidationError as e:
257
+ errors.append(e)
258
+
259
+ # If there were validation errors, raise them
260
+ if errors:
261
+ logger.error(f"Configuration loading failed with {len(errors)} error(s)")
262
+ if len(errors) == 1:
263
+ raise errors[0]
264
+ else:
265
+ raise MultipleValidationErrors(errors)
266
+
267
+ logger.info(f"{cls.__name__} configuration loaded successfully")
268
+ logger.debug(f"Loaded fields: {', '.join(cls._fields.keys())}")
269
+
270
+ instance._loaded = True
271
+ # Store load parameters for reload()
272
+ instance._load_env = env
273
+ instance._load_override = override
274
+ instance._load_env_dir = env_dir
275
+ return instance
276
+
277
+ def reload(
278
+ self,
279
+ env: str | None = None,
280
+ *,
281
+ override: bool | None = None,
282
+ env_dir: Path | None = None,
283
+ ) -> Self:
284
+ """
285
+ Reload configuration from environment variables and .env files.
286
+
287
+ This method reloads all fields from the environment, allowing you to
288
+ pick up changes to environment variables or .env files without creating
289
+ a new instance.
290
+
291
+ By default, this uses the same parameters (env, override, env_dir) that
292
+ were used during the original load() call. You can override any of these
293
+ by passing new values.
294
+
295
+ Args:
296
+ env: Environment name (e.g., "dev", "prod", "test"). If None, uses
297
+ the env from the original load() call
298
+ override: If True, .env file values override existing environment variables.
299
+ If False, existing env vars take precedence. If None, uses the
300
+ override value from the original load() call
301
+ env_dir: Optional custom base directory for .env files. If None, uses
302
+ the env_dir from the original load() call
303
+
304
+ Returns:
305
+ Self (the same instance with reloaded values)
306
+
307
+ Raises:
308
+ ValidationError: If required fields are missing or validation fails
309
+ FileNotFoundError: If custom env_dir path doesn't exist
310
+
311
+ Example:
312
+ ```python
313
+ config = AppConfig.load(env="dev", override=True)
314
+
315
+ # ... later, environment variables change ...
316
+
317
+ config.reload() # Reloads with env="dev", override=True
318
+
319
+ # Or reload with different parameters
320
+ config.reload(env="prod") # Switch to prod environment
321
+ ```
322
+ """
323
+ logger.info(f"Reloading {self.__class__.__name__} configuration")
324
+
325
+ # Use stored parameters if not explicitly provided
326
+ reload_env = env if env is not None else self._load_env
327
+ reload_override = override if override is not None else self._load_override
328
+ reload_env_dir = env_dir if env_dir is not None else self._load_env_dir
329
+
330
+ # Load .env files first
331
+ load_env_files(env=reload_env, override=reload_override, env_dir=reload_env_dir)
332
+
333
+ errors: list[ValidationError] = []
334
+
335
+ # Get type hints for the class
336
+ get_type_hints(self.__class__)
337
+
338
+ logger.debug(f"Reloading {len(self._fields)} field(s)")
339
+
340
+ # Get the class prefix (if any)
341
+ prefix = getattr(self.__class__, "env_prefix", "")
342
+
343
+ # Process each field
344
+ for field_name, (field_type, field_info) in self._fields.items():
345
+ env_var_name = get_env_var_name(field_name, field_info.alias, prefix)
346
+ raw_value = get_env_var(field_name, field_info.alias, prefix)
347
+
348
+ try:
349
+ value = self._process_field(
350
+ field_name, field_type, field_info, raw_value, env_var_name
351
+ )
352
+ setattr(self, field_name, value)
353
+ except ValidationError as e:
354
+ errors.append(e)
355
+
356
+ # If there were validation errors, raise them
357
+ if errors:
358
+ logger.error(f"Configuration reload failed with {len(errors)} error(s)")
359
+ if len(errors) == 1:
360
+ raise errors[0]
361
+ else:
362
+ raise MultipleValidationErrors(errors)
363
+
364
+ logger.info(f"{self.__class__.__name__} configuration reloaded successfully")
365
+ logger.debug(f"Reloaded fields: {', '.join(self._fields.keys())}")
366
+
367
+ return self
368
+
369
+ @classmethod
370
+ def load_from_dict(
371
+ cls,
372
+ data: dict[str, str],
373
+ *,
374
+ validate: bool = True,
375
+ ) -> Self:
376
+ """
377
+ Load configuration from a dictionary (useful for testing).
378
+
379
+ Args:
380
+ data: Dictionary mapping field names (or aliases) to string values
381
+ validate: Whether to perform validation (default True)
382
+
383
+ Returns:
384
+ Instance of the config class
385
+
386
+ Raises:
387
+ ValidationError: If validation fails
388
+
389
+ Example:
390
+ ```python
391
+ config = Config.load_from_dict({
392
+ "DATABASE_URL": "postgresql://localhost/db",
393
+ "DEBUG": "true",
394
+ "PORT": "8000",
395
+ })
396
+ ```
397
+ """
398
+ instance = cls()
399
+ errors: list[ValidationError] = []
400
+
401
+ # Get type hints for the class
402
+ get_type_hints(cls)
403
+
404
+ # Get the class prefix (if any)
405
+ prefix = getattr(cls, "env_prefix", "")
406
+
407
+ # Process each field
408
+ for field_name, (field_type, field_info) in cls._fields.items():
409
+ env_var_name = get_env_var_name(field_name, field_info.alias, prefix)
410
+
411
+ # Try to get value from dict using env var name or field name
412
+ raw_value = data.get(env_var_name)
413
+ if raw_value is None:
414
+ raw_value = data.get(field_name)
415
+
416
+ try:
417
+ value = instance._process_field(
418
+ field_name,
419
+ field_type,
420
+ field_info,
421
+ raw_value,
422
+ env_var_name,
423
+ validate=validate,
424
+ )
425
+ setattr(instance, field_name, value)
426
+ except ValidationError as e:
427
+ errors.append(e)
428
+
429
+ # If there were validation errors, raise them
430
+ if errors:
431
+ if len(errors) == 1:
432
+ raise errors[0]
433
+ else:
434
+ raise MultipleValidationErrors(errors)
435
+
436
+ instance._loaded = True
437
+ return instance
438
+
439
+ def dict(self) -> dict[str, Any]:
440
+ """
441
+ Return configuration as a dictionary with actual values.
442
+
443
+ Returns:
444
+ Dictionary mapping field names to their values
445
+
446
+ Example:
447
+ ```python
448
+ config = Config.load()
449
+ print(config.dict())
450
+ # {'database_url': 'postgresql://...', 'debug': True, 'port': 8000}
451
+ ```
452
+ """
453
+ result = {}
454
+ for field_name in self._fields:
455
+ if hasattr(self, field_name):
456
+ result[field_name] = getattr(self, field_name)
457
+ return result
458
+
459
+ def get(self, key: str, default: Any = None) -> Any:
460
+ """
461
+ Get a configuration value by key with optional default.
462
+
463
+ Args:
464
+ key: Field name
465
+ default: Default value if field not found
466
+
467
+ Returns:
468
+ Field value or default
469
+
470
+ Example:
471
+ ```python
472
+ timeout = config.get('timeout', 30)
473
+ ```
474
+ """
475
+ return getattr(self, key, default)
476
+
477
+ def __repr__(self) -> str:
478
+ field_strs = []
479
+ for field_name in self._fields:
480
+ if hasattr(self, field_name):
481
+ value = getattr(self, field_name)
482
+ field_strs.append(f"{field_name}={value!r}")
483
+ return f"{self.__class__.__name__}({', '.join(field_strs)})"
@@ -0,0 +1,131 @@
1
+ """Exception types for dotenvmodel."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class DotEnvModelError(Exception):
7
+ """Base exception for all dotenvmodel errors."""
8
+
9
+ pass
10
+
11
+
12
+ class ValidationError(DotEnvModelError):
13
+ """Raised when field validation fails."""
14
+
15
+ def __init__(
16
+ self,
17
+ field_name: str,
18
+ value: Any,
19
+ error_msg: str,
20
+ field_type: type | None = None,
21
+ env_var_name: str | None = None,
22
+ ) -> None:
23
+ self.field_name = field_name
24
+ self.value = value
25
+ self.error_msg = error_msg
26
+ self.field_type = field_type
27
+ self.env_var_name = env_var_name or field_name.upper()
28
+ super().__init__(self._format_message())
29
+
30
+ def _format_message(self) -> str:
31
+ """Format a detailed error message."""
32
+ msg = f"Field '{self.field_name}' validation failed:\n"
33
+ msg += f" Value: {self.value!r}\n"
34
+ if self.field_type:
35
+ msg += f" Expected type: {self.field_type.__name__}\n"
36
+ msg += f" Error: {self.error_msg}\n"
37
+ msg += f" Environment variable: {self.env_var_name}"
38
+ return msg
39
+
40
+
41
+ class MissingFieldError(ValidationError):
42
+ """Raised when a required field is missing."""
43
+
44
+ def __init__(
45
+ self, field_name: str, field_type: type | None = None, env_var_name: str | None = None
46
+ ) -> None:
47
+ env_name = env_var_name or field_name.upper()
48
+ super().__init__(
49
+ field_name=field_name,
50
+ value=None,
51
+ error_msg="Required field is not set",
52
+ field_type=field_type,
53
+ env_var_name=env_name,
54
+ )
55
+
56
+ def _format_message(self) -> str:
57
+ """Format a detailed error message for missing fields."""
58
+ msg = f"MissingFieldError: Required field '{self.field_name}' is not set.\n\n"
59
+ msg += f"Environment variable name: {self.env_var_name}\n"
60
+ if self.field_type:
61
+ type_name = getattr(self.field_type, "__name__", str(self.field_type))
62
+ msg += f"Field type: {type_name}\n"
63
+ msg += f"Hint: Set {self.env_var_name} in your environment or .env file"
64
+ return msg
65
+
66
+
67
+ class TypeCoercionError(ValidationError):
68
+ """Raised when type coercion fails."""
69
+
70
+ def _format_message(self) -> str:
71
+ """Format a detailed error message for type coercion failures."""
72
+ type_name = "unknown"
73
+ if self.field_type:
74
+ type_name = getattr(self.field_type, "__name__", str(self.field_type))
75
+ msg = f"TypeCoercionError: Failed to coerce field '{self.field_name}' to type {type_name}.\n\n"
76
+ msg += f"Value: {self.value!r}\n"
77
+ msg += f"Environment variable: {self.env_var_name}\n"
78
+ msg += f"Error: {self.error_msg}\n"
79
+ msg += f"Hint: Ensure {self.env_var_name} contains a valid {type_name}"
80
+ return msg
81
+
82
+
83
+ class ConstraintViolationError(ValidationError):
84
+ """Raised when a validation constraint is violated."""
85
+
86
+ def __init__(
87
+ self,
88
+ field_name: str,
89
+ value: Any,
90
+ constraint: str,
91
+ error_msg: str,
92
+ env_var_name: str | None = None,
93
+ ) -> None:
94
+ self.constraint = constraint
95
+ super().__init__(
96
+ field_name=field_name,
97
+ value=value,
98
+ error_msg=error_msg,
99
+ env_var_name=env_var_name,
100
+ )
101
+
102
+ def _format_message(self) -> str:
103
+ """Format a detailed error message for constraint violations."""
104
+ msg = f"ConstraintViolationError: Field '{self.field_name}' violates constraint.\n\n"
105
+ msg += f"Value: {self.value!r}\n"
106
+ msg += f"Constraint: {self.constraint}\n"
107
+ msg += f"Error: {self.error_msg}\n"
108
+ msg += f"Hint: Set {self.env_var_name} to a value that satisfies the constraint"
109
+ return msg
110
+
111
+
112
+ class MultipleValidationErrors(DotEnvModelError):
113
+ """Raised when multiple validation errors occur."""
114
+
115
+ def __init__(self, errors: list[ValidationError]) -> None:
116
+ self.errors = errors
117
+ super().__init__(self._format_message())
118
+
119
+ def _format_message(self) -> str:
120
+ """Format a detailed error message for multiple validation errors."""
121
+ msg = f"MultipleValidationErrors: Configuration validation failed with {len(self.errors)} error(s):\n\n"
122
+ for i, error in enumerate(self.errors, 1):
123
+ msg += f"{i}. {error.__class__.__name__}: {error.error_msg}\n"
124
+ msg += f" Field: {error.field_name}\n"
125
+ if error.value is not None:
126
+ msg += f" Value: {error.value!r}\n"
127
+ msg += f" Environment variable: {error.env_var_name}\n"
128
+ if hasattr(error, "constraint"):
129
+ msg += f" Constraint: {error.constraint}\n"
130
+ msg += "\n"
131
+ return msg.rstrip()