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,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
+ ]
@@ -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