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
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()
|