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/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Type-safe environment configuration with automatic .env file loading."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.1"
|
|
4
|
+
|
|
5
|
+
from dotenvmodel.config import DotEnvConfig
|
|
6
|
+
from dotenvmodel.exceptions import (
|
|
7
|
+
ConstraintViolationError,
|
|
8
|
+
DotEnvModelError,
|
|
9
|
+
MissingFieldError,
|
|
10
|
+
MultipleValidationErrors,
|
|
11
|
+
TypeCoercionError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from dotenvmodel.fields import Field, Required
|
|
15
|
+
from dotenvmodel.logging_config import configure_logging, disable_logging
|
|
16
|
+
from dotenvmodel.types import (
|
|
17
|
+
HttpUrl,
|
|
18
|
+
Json,
|
|
19
|
+
PostgresDsn,
|
|
20
|
+
RedisDsn,
|
|
21
|
+
SecretStr,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ConstraintViolationError",
|
|
26
|
+
"DotEnvConfig",
|
|
27
|
+
"DotEnvModelError",
|
|
28
|
+
"Field",
|
|
29
|
+
"HttpUrl",
|
|
30
|
+
"Json",
|
|
31
|
+
"MissingFieldError",
|
|
32
|
+
"MultipleValidationErrors",
|
|
33
|
+
"PostgresDsn",
|
|
34
|
+
"RedisDsn",
|
|
35
|
+
"Required",
|
|
36
|
+
"SecretStr",
|
|
37
|
+
"TypeCoercionError",
|
|
38
|
+
"ValidationError",
|
|
39
|
+
"__version__",
|
|
40
|
+
"configure_logging",
|
|
41
|
+
"disable_logging",
|
|
42
|
+
]
|
dotenvmodel/coercion.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Type coercion logic for environment variable strings."""
|
|
2
|
+
|
|
3
|
+
import types
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from dotenvmodel.exceptions import TypeCoercionError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from dotenvmodel.fields import FieldInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def coerce_value(
|
|
17
|
+
field_name: str,
|
|
18
|
+
value: str | None,
|
|
19
|
+
field_type: type,
|
|
20
|
+
env_var_name: str,
|
|
21
|
+
field_info: "FieldInfo | None" = None,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""
|
|
24
|
+
Coerce a string value from environment variable to the target type.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
field_name: Name of the field being coerced
|
|
28
|
+
value: String value from environment variable (or None)
|
|
29
|
+
field_type: Target type to coerce to
|
|
30
|
+
env_var_name: Name of the environment variable
|
|
31
|
+
field_info: Optional field metadata (for separator, etc.)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Coerced value of the target type
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
TypeCoercionError: If coercion fails
|
|
38
|
+
"""
|
|
39
|
+
# Handle None/Optional types
|
|
40
|
+
origin = get_origin(field_type)
|
|
41
|
+
|
|
42
|
+
# Handle Literal types first
|
|
43
|
+
if origin is Literal:
|
|
44
|
+
return _coerce_literal(field_name, value, field_type, env_var_name)
|
|
45
|
+
|
|
46
|
+
# Handle Union types (including Optional[T] and str | None)
|
|
47
|
+
# Check for UnionType (str | None) or typing.Union
|
|
48
|
+
if origin is types.UnionType or (origin is not None and type(None) in get_args(field_type)):
|
|
49
|
+
args = get_args(field_type)
|
|
50
|
+
if type(None) in args:
|
|
51
|
+
# This is Optional[T] or T | None
|
|
52
|
+
if value is None or value == "":
|
|
53
|
+
return None
|
|
54
|
+
# Get the non-None type
|
|
55
|
+
actual_type = args[0] if args[1] is type(None) else args[1]
|
|
56
|
+
return coerce_value(field_name, value, actual_type, env_var_name, field_info)
|
|
57
|
+
else:
|
|
58
|
+
# Non-Optional Union types (e.g., Union[str, int] or str | int) are not supported
|
|
59
|
+
type_names = ", ".join(
|
|
60
|
+
str(arg.__name__ if hasattr(arg, "__name__") else arg) for arg in args
|
|
61
|
+
)
|
|
62
|
+
raise TypeCoercionError(
|
|
63
|
+
field_name=field_name,
|
|
64
|
+
value=value,
|
|
65
|
+
error_msg=f"Union types with multiple non-None types are not supported. "
|
|
66
|
+
f"Use Optional[T] or T | None for nullable fields. Got: Union[{type_names}]",
|
|
67
|
+
field_type=field_type,
|
|
68
|
+
env_var_name=env_var_name,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Handle other generic types (list, dict, set, tuple)
|
|
72
|
+
if origin is not None and origin not in (Literal,):
|
|
73
|
+
# This is a generic type like list[str], dict[str, str], etc.
|
|
74
|
+
separator = field_info.separator if field_info else ","
|
|
75
|
+
return _coerce_generic(field_name, value, field_type, origin, env_var_name, separator)
|
|
76
|
+
|
|
77
|
+
# If value is None, return None (empty string handling depends on type)
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
# Handle bool type (empty string is falsy for bool)
|
|
82
|
+
if field_type is bool:
|
|
83
|
+
return _coerce_bool(field_name, value, env_var_name)
|
|
84
|
+
|
|
85
|
+
# Handle str type explicitly - preserve empty strings
|
|
86
|
+
if field_type is str:
|
|
87
|
+
return value # Allow empty strings for str fields
|
|
88
|
+
|
|
89
|
+
# For other non-collection types (not str, not bool), empty string is treated as None
|
|
90
|
+
# This will cause required fields to fail validation
|
|
91
|
+
if value == "":
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Import types module here to avoid circular imports
|
|
95
|
+
from dotenvmodel import types as dotenv_types
|
|
96
|
+
|
|
97
|
+
# Handle basic and special types using match/case
|
|
98
|
+
match field_type:
|
|
99
|
+
case type() if field_type is str:
|
|
100
|
+
# Already handled above, but keep for completeness
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
case type() if field_type is int:
|
|
104
|
+
try:
|
|
105
|
+
return int(value)
|
|
106
|
+
except (ValueError, TypeError) as e:
|
|
107
|
+
raise TypeCoercionError(
|
|
108
|
+
field_name=field_name,
|
|
109
|
+
value=value,
|
|
110
|
+
error_msg=str(e),
|
|
111
|
+
field_type=int,
|
|
112
|
+
env_var_name=env_var_name,
|
|
113
|
+
) from e
|
|
114
|
+
|
|
115
|
+
case type() if field_type is float:
|
|
116
|
+
try:
|
|
117
|
+
return float(value)
|
|
118
|
+
except (ValueError, TypeError) as e:
|
|
119
|
+
raise TypeCoercionError(
|
|
120
|
+
field_name=field_name,
|
|
121
|
+
value=value,
|
|
122
|
+
error_msg=str(e),
|
|
123
|
+
field_type=float,
|
|
124
|
+
env_var_name=env_var_name,
|
|
125
|
+
) from e
|
|
126
|
+
|
|
127
|
+
case type() if field_type is Path:
|
|
128
|
+
return Path(value)
|
|
129
|
+
|
|
130
|
+
case type() if field_type is UUID:
|
|
131
|
+
return dotenv_types.coerce_uuid(value, field_name, env_var_name)
|
|
132
|
+
|
|
133
|
+
case type() if field_type is Decimal:
|
|
134
|
+
return dotenv_types.coerce_decimal(value, field_name, env_var_name)
|
|
135
|
+
|
|
136
|
+
case type() if field_type is datetime:
|
|
137
|
+
return dotenv_types.coerce_datetime(value, field_name, env_var_name)
|
|
138
|
+
|
|
139
|
+
case type() if field_type is timedelta:
|
|
140
|
+
return dotenv_types.coerce_timedelta(value, field_name, env_var_name)
|
|
141
|
+
|
|
142
|
+
case type() if field_type is dotenv_types.SecretStr:
|
|
143
|
+
# Apply URL unquoting if requested (default: True)
|
|
144
|
+
if field_info and field_info.url_unquote:
|
|
145
|
+
from urllib.parse import unquote
|
|
146
|
+
|
|
147
|
+
value = unquote(value)
|
|
148
|
+
return dotenv_types.SecretStr(value)
|
|
149
|
+
|
|
150
|
+
case type() if field_type in (
|
|
151
|
+
dotenv_types.HttpUrl,
|
|
152
|
+
dotenv_types.PostgresDsn,
|
|
153
|
+
dotenv_types.RedisDsn,
|
|
154
|
+
):
|
|
155
|
+
try:
|
|
156
|
+
return field_type(value)
|
|
157
|
+
except ValueError as e:
|
|
158
|
+
raise TypeCoercionError(
|
|
159
|
+
field_name=field_name,
|
|
160
|
+
value=value,
|
|
161
|
+
error_msg=str(e),
|
|
162
|
+
field_type=field_type,
|
|
163
|
+
env_var_name=env_var_name,
|
|
164
|
+
) from e
|
|
165
|
+
|
|
166
|
+
case type() if hasattr(field_type, "__name__") and field_type.__name__.startswith("Json["):
|
|
167
|
+
# Handle Json[T] type
|
|
168
|
+
inner_type = getattr(field_type, "__inner_type__", None)
|
|
169
|
+
return dotenv_types.coerce_json(value, field_name, env_var_name, inner_type)
|
|
170
|
+
|
|
171
|
+
case _:
|
|
172
|
+
# If we get here, the type is not supported
|
|
173
|
+
raise TypeCoercionError(
|
|
174
|
+
field_name=field_name,
|
|
175
|
+
value=value,
|
|
176
|
+
error_msg=f"Unsupported type: {field_type}",
|
|
177
|
+
field_type=field_type,
|
|
178
|
+
env_var_name=env_var_name,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _coerce_bool(field_name: str, value: str, env_var_name: str) -> bool:
|
|
183
|
+
"""Coerce a string to a boolean."""
|
|
184
|
+
lower_value = value.lower().strip()
|
|
185
|
+
|
|
186
|
+
# True values
|
|
187
|
+
if lower_value in ("true", "1", "yes", "on", "t", "y"):
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
# False values
|
|
191
|
+
if lower_value in ("false", "0", "no", "off", "f", "n", ""):
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
# Invalid value
|
|
195
|
+
raise TypeCoercionError(
|
|
196
|
+
field_name=field_name,
|
|
197
|
+
value=value,
|
|
198
|
+
error_msg="Invalid boolean value. Expected one of: true, false, 1, 0, yes, no, on, off, t, f, y, n (case-insensitive)",
|
|
199
|
+
field_type=bool,
|
|
200
|
+
env_var_name=env_var_name,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _coerce_literal(field_name: str, value: str | None, field_type: type, env_var_name: str) -> Any:
|
|
205
|
+
"""Coerce a value to a Literal type."""
|
|
206
|
+
if value is None:
|
|
207
|
+
raise TypeCoercionError(
|
|
208
|
+
field_name=field_name,
|
|
209
|
+
value=value,
|
|
210
|
+
error_msg="Value cannot be None for Literal type",
|
|
211
|
+
field_type=field_type,
|
|
212
|
+
env_var_name=env_var_name,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
allowed_values = get_args(field_type)
|
|
216
|
+
if value in allowed_values:
|
|
217
|
+
return value
|
|
218
|
+
|
|
219
|
+
raise TypeCoercionError(
|
|
220
|
+
field_name=field_name,
|
|
221
|
+
value=value,
|
|
222
|
+
error_msg=f"Value must be one of: {allowed_values}",
|
|
223
|
+
field_type=field_type,
|
|
224
|
+
env_var_name=env_var_name,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _coerce_generic(
|
|
229
|
+
field_name: str,
|
|
230
|
+
value: str | None,
|
|
231
|
+
field_type: type,
|
|
232
|
+
origin: Any,
|
|
233
|
+
env_var_name: str,
|
|
234
|
+
separator: str = ",",
|
|
235
|
+
) -> Any:
|
|
236
|
+
"""Coerce a value to a generic type (list, dict, set, tuple)."""
|
|
237
|
+
if value is None or value == "":
|
|
238
|
+
# Return empty collection for generic types
|
|
239
|
+
if origin is list:
|
|
240
|
+
return []
|
|
241
|
+
if origin is set:
|
|
242
|
+
return set()
|
|
243
|
+
if origin is tuple:
|
|
244
|
+
return ()
|
|
245
|
+
if origin is dict:
|
|
246
|
+
return {}
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
# Get the type arguments
|
|
250
|
+
args = get_args(field_type)
|
|
251
|
+
|
|
252
|
+
if origin is list:
|
|
253
|
+
return _coerce_list(field_name, value, args, env_var_name, separator)
|
|
254
|
+
|
|
255
|
+
if origin is set:
|
|
256
|
+
return _coerce_set(field_name, value, args, env_var_name, separator)
|
|
257
|
+
|
|
258
|
+
if origin is tuple:
|
|
259
|
+
return _coerce_tuple(field_name, value, args, env_var_name, separator)
|
|
260
|
+
|
|
261
|
+
if origin is dict:
|
|
262
|
+
return _coerce_dict(field_name, value, args, env_var_name, separator)
|
|
263
|
+
|
|
264
|
+
raise TypeCoercionError(
|
|
265
|
+
field_name=field_name,
|
|
266
|
+
value=value,
|
|
267
|
+
error_msg=f"Unsupported generic type: {origin}",
|
|
268
|
+
field_type=field_type,
|
|
269
|
+
env_var_name=env_var_name,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _coerce_list(
|
|
274
|
+
field_name: str,
|
|
275
|
+
value: str,
|
|
276
|
+
args: tuple[type, ...],
|
|
277
|
+
env_var_name: str,
|
|
278
|
+
separator: str = ",",
|
|
279
|
+
) -> list[Any]:
|
|
280
|
+
"""Coerce a separated string to a list."""
|
|
281
|
+
if not value:
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
items = [item.strip() for item in value.split(separator)]
|
|
285
|
+
|
|
286
|
+
# If no type argument, return list of strings
|
|
287
|
+
if not args:
|
|
288
|
+
return items
|
|
289
|
+
|
|
290
|
+
# Coerce each item to the element type
|
|
291
|
+
element_type = args[0]
|
|
292
|
+
result = []
|
|
293
|
+
for item in items:
|
|
294
|
+
try:
|
|
295
|
+
coerced = coerce_value(field_name, item, element_type, env_var_name)
|
|
296
|
+
# Skip None values for non-optional types (empty items in non-str lists)
|
|
297
|
+
# For list[str], empty items are preserved as ""
|
|
298
|
+
# For list[int], empty items are skipped entirely
|
|
299
|
+
if coerced is None and element_type is not str:
|
|
300
|
+
# Check if element type is Optional
|
|
301
|
+
from typing import get_args, get_origin
|
|
302
|
+
|
|
303
|
+
origin = get_origin(element_type)
|
|
304
|
+
if origin is None or type(None) not in get_args(element_type):
|
|
305
|
+
# Not Optional, skip None values
|
|
306
|
+
continue
|
|
307
|
+
result.append(coerced)
|
|
308
|
+
except TypeCoercionError as e:
|
|
309
|
+
raise TypeCoercionError(
|
|
310
|
+
field_name=field_name,
|
|
311
|
+
value=value,
|
|
312
|
+
error_msg=f"Failed to coerce list element '{item}': {e.error_msg}",
|
|
313
|
+
field_type=list,
|
|
314
|
+
env_var_name=env_var_name,
|
|
315
|
+
) from e
|
|
316
|
+
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _coerce_set(
|
|
321
|
+
field_name: str,
|
|
322
|
+
value: str,
|
|
323
|
+
args: tuple[type, ...],
|
|
324
|
+
env_var_name: str,
|
|
325
|
+
separator: str = ",",
|
|
326
|
+
) -> set[Any]:
|
|
327
|
+
"""Coerce a separated string to a set."""
|
|
328
|
+
list_result = _coerce_list(field_name, value, args, env_var_name, separator)
|
|
329
|
+
return set(list_result)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _coerce_tuple(
|
|
333
|
+
field_name: str,
|
|
334
|
+
value: str,
|
|
335
|
+
args: tuple[type, ...],
|
|
336
|
+
env_var_name: str,
|
|
337
|
+
separator: str = ",",
|
|
338
|
+
) -> tuple[Any, ...]:
|
|
339
|
+
"""Coerce a separated string to a tuple."""
|
|
340
|
+
list_result = _coerce_list(field_name, value, args, env_var_name, separator)
|
|
341
|
+
return tuple(list_result)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _coerce_dict(
|
|
345
|
+
field_name: str,
|
|
346
|
+
value: str,
|
|
347
|
+
args: tuple[type, ...],
|
|
348
|
+
env_var_name: str,
|
|
349
|
+
separator: str = ",",
|
|
350
|
+
) -> dict[Any, Any]:
|
|
351
|
+
"""Coerce a separated string of key=value pairs to a dict."""
|
|
352
|
+
if not value:
|
|
353
|
+
return {}
|
|
354
|
+
|
|
355
|
+
result = {}
|
|
356
|
+
pairs = [pair.strip() for pair in value.split(separator)]
|
|
357
|
+
|
|
358
|
+
key_type = args[0] if len(args) >= 1 else str
|
|
359
|
+
value_type = args[1] if len(args) >= 2 else str
|
|
360
|
+
|
|
361
|
+
for pair in pairs:
|
|
362
|
+
if "=" not in pair:
|
|
363
|
+
raise TypeCoercionError(
|
|
364
|
+
field_name=field_name,
|
|
365
|
+
value=value,
|
|
366
|
+
error_msg=f"Invalid dict format. Expected 'key=value', got: {pair}",
|
|
367
|
+
field_type=dict,
|
|
368
|
+
env_var_name=env_var_name,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
key_str, val_str = pair.split("=", 1)
|
|
372
|
+
key_str = key_str.strip()
|
|
373
|
+
val_str = val_str.strip()
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
coerced_key = coerce_value(field_name, key_str, key_type, env_var_name)
|
|
377
|
+
coerced_val = coerce_value(field_name, val_str, value_type, env_var_name)
|
|
378
|
+
|
|
379
|
+
# Skip None keys (empty keys for non-str types) - this would be invalid
|
|
380
|
+
if coerced_key is None and key_type is not str:
|
|
381
|
+
from typing import get_args, get_origin
|
|
382
|
+
|
|
383
|
+
origin = get_origin(key_type)
|
|
384
|
+
if origin is None or type(None) not in get_args(key_type):
|
|
385
|
+
continue # Skip this pair
|
|
386
|
+
|
|
387
|
+
# For values, we allow None if value_type is Optional
|
|
388
|
+
result[coerced_key] = coerced_val
|
|
389
|
+
except TypeCoercionError as e:
|
|
390
|
+
raise TypeCoercionError(
|
|
391
|
+
field_name=field_name,
|
|
392
|
+
value=value,
|
|
393
|
+
error_msg=f"Failed to coerce dict pair '{pair}': {e.error_msg}",
|
|
394
|
+
field_type=dict,
|
|
395
|
+
env_var_name=env_var_name,
|
|
396
|
+
) from e
|
|
397
|
+
|
|
398
|
+
return result
|