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/types.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Special types for dotenvmodel."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
from urllib.parse import ParseResult, unquote, urlparse
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from dotenvmodel.exceptions import TypeCoercionError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SecretStr:
|
|
14
|
+
"""
|
|
15
|
+
A string type that hides its value in logs and repr output.
|
|
16
|
+
|
|
17
|
+
Use this for sensitive data like API keys, passwords, and tokens to prevent
|
|
18
|
+
them from appearing in logs, error messages, or debugging output.
|
|
19
|
+
|
|
20
|
+
Security features:
|
|
21
|
+
- Hidden in str/repr output
|
|
22
|
+
- Name-mangled attribute to prevent accidental access
|
|
23
|
+
- Prevents pickling to avoid serialization leaks
|
|
24
|
+
- Immutable to prevent modification
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
class Config(DotEnvConfig):
|
|
29
|
+
api_key: SecretStr = Field()
|
|
30
|
+
password: SecretStr = Field(min_length=8)
|
|
31
|
+
|
|
32
|
+
config = Config.load()
|
|
33
|
+
print(config.api_key) # SecretStr('**********')
|
|
34
|
+
print(config.api_key.get_secret_value()) # 'actual-secret-key'
|
|
35
|
+
```
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("__secret",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, value: str) -> None:
|
|
41
|
+
object.__setattr__(self, "_SecretStr__secret", value)
|
|
42
|
+
|
|
43
|
+
def get_secret_value(self) -> str:
|
|
44
|
+
"""Get the actual secret value."""
|
|
45
|
+
return self.__secret # type: ignore[attr-defined]
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return "**********"
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return "SecretStr('**********')"
|
|
52
|
+
|
|
53
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
54
|
+
"""Prevent attribute modification."""
|
|
55
|
+
raise AttributeError("SecretStr is immutable")
|
|
56
|
+
|
|
57
|
+
def __delattr__(self, name: str) -> None:
|
|
58
|
+
"""Prevent attribute deletion."""
|
|
59
|
+
raise AttributeError("SecretStr is immutable")
|
|
60
|
+
|
|
61
|
+
def __reduce__(self) -> tuple:
|
|
62
|
+
"""Prevent pickling by raising an error."""
|
|
63
|
+
raise TypeError(
|
|
64
|
+
"SecretStr cannot be pickled for security reasons. "
|
|
65
|
+
"Extract the secret value with get_secret_value() before pickling if needed."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def __eq__(self, other: object) -> bool:
|
|
69
|
+
if isinstance(other, SecretStr):
|
|
70
|
+
return self.__secret == other.__secret # type: ignore[attr-defined]
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def __hash__(self) -> int:
|
|
74
|
+
return hash(self.__secret) # type: ignore[attr-defined]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BaseDsn(str):
|
|
78
|
+
"""
|
|
79
|
+
Base class for DSN (Data Source Name) types.
|
|
80
|
+
|
|
81
|
+
This base class provides common URL validation and parsing functionality
|
|
82
|
+
that can be extended by specific DSN types.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
allowed_schemes: Tuple of allowed URL schemes (e.g., ("http", "https"))
|
|
86
|
+
require_host: Whether the URL must have a host/netloc component
|
|
87
|
+
default_port: Default port number for this DSN type
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Class attributes that subclasses should override
|
|
91
|
+
allowed_schemes: tuple[str, ...] = ()
|
|
92
|
+
require_host: bool = True
|
|
93
|
+
default_port: int | None = None
|
|
94
|
+
|
|
95
|
+
def __new__(cls, value: str) -> "BaseDsn":
|
|
96
|
+
"""Validate and create DSN instance."""
|
|
97
|
+
parsed = urlparse(value)
|
|
98
|
+
|
|
99
|
+
# Check for scheme
|
|
100
|
+
if not parsed.scheme:
|
|
101
|
+
schemes_str = " or ".join(cls.allowed_schemes)
|
|
102
|
+
raise ValueError(f"URL must have a scheme ({schemes_str})")
|
|
103
|
+
|
|
104
|
+
# Validate scheme
|
|
105
|
+
if cls.allowed_schemes and parsed.scheme not in cls.allowed_schemes:
|
|
106
|
+
schemes_str = " or ".join(cls.allowed_schemes)
|
|
107
|
+
raise ValueError(f"URL scheme must be {schemes_str}, got: {parsed.scheme}")
|
|
108
|
+
|
|
109
|
+
# Check for host if required
|
|
110
|
+
if cls.require_host and not parsed.netloc:
|
|
111
|
+
raise ValueError("URL must have a host")
|
|
112
|
+
|
|
113
|
+
return str.__new__(cls, value)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def parsed(self) -> ParseResult:
|
|
117
|
+
"""Get the parsed URL components."""
|
|
118
|
+
return urlparse(self)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def scheme(self) -> str:
|
|
122
|
+
"""Get the URL scheme."""
|
|
123
|
+
return self.parsed.scheme
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def host(self) -> str:
|
|
127
|
+
"""Get the URL host."""
|
|
128
|
+
return self.parsed.hostname or ""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def port(self) -> int | None:
|
|
132
|
+
"""Get the URL port or default port."""
|
|
133
|
+
return self.parsed.port or self.default_port
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def path(self) -> str:
|
|
137
|
+
"""Get the URL path."""
|
|
138
|
+
return self.parsed.path
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def query(self) -> str:
|
|
142
|
+
"""Get the URL query string."""
|
|
143
|
+
return self.parsed.query
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def username(self) -> str | None:
|
|
147
|
+
"""Get the URL username."""
|
|
148
|
+
return self.parsed.username
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def password(self) -> str | None:
|
|
152
|
+
"""Get the URL password (decoded from percent-encoding)."""
|
|
153
|
+
if self.parsed.password:
|
|
154
|
+
return unquote(self.parsed.password)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class HttpUrl(BaseDsn):
|
|
159
|
+
"""
|
|
160
|
+
A URL type that validates HTTP/HTTPS URLs.
|
|
161
|
+
|
|
162
|
+
Validates that the URL has a valid format and uses http or https scheme.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
```python
|
|
166
|
+
class Config(DotEnvConfig):
|
|
167
|
+
api_url: HttpUrl = Field()
|
|
168
|
+
# Environment: API_URL=https://api.example.com/v1
|
|
169
|
+
```
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
allowed_schemes = ("http", "https")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PostgresDsn(BaseDsn):
|
|
176
|
+
"""
|
|
177
|
+
A DSN type for PostgreSQL database URLs.
|
|
178
|
+
|
|
179
|
+
Validates that the URL follows PostgreSQL connection string format.
|
|
180
|
+
Accepts both postgresql:// and postgres:// schemes.
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
```python
|
|
184
|
+
class Config(DotEnvConfig):
|
|
185
|
+
database_url: PostgresDsn = Field()
|
|
186
|
+
# Environment: DATABASE_URL=postgresql://user:pass@localhost:5432/db
|
|
187
|
+
```
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
allowed_schemes = ("postgresql", "postgres")
|
|
191
|
+
default_port = 5432
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def database(self) -> str:
|
|
195
|
+
"""Get the database name from the path."""
|
|
196
|
+
return self.path.lstrip("/") if self.path else ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class RedisDsn(BaseDsn):
|
|
200
|
+
"""
|
|
201
|
+
A DSN type for Redis URLs.
|
|
202
|
+
|
|
203
|
+
Validates that the URL follows Redis connection string format.
|
|
204
|
+
Accepts both redis:// and rediss:// (SSL) schemes.
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
```python
|
|
208
|
+
class Config(DotEnvConfig):
|
|
209
|
+
redis_url: RedisDsn = Field()
|
|
210
|
+
# Environment: REDIS_URL=redis://localhost:6379/0
|
|
211
|
+
```
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
allowed_schemes = ("redis", "rediss")
|
|
215
|
+
default_port = 6379
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def database(self) -> int:
|
|
219
|
+
"""Get the Redis database number from the path."""
|
|
220
|
+
if self.path and self.path != "/":
|
|
221
|
+
try:
|
|
222
|
+
return int(self.path.lstrip("/"))
|
|
223
|
+
except ValueError:
|
|
224
|
+
return 0
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class Json[T]:
|
|
229
|
+
"""
|
|
230
|
+
A type for parsing JSON strings into Python objects.
|
|
231
|
+
|
|
232
|
+
Use this for complex configuration that needs to be passed as JSON.
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
```python
|
|
236
|
+
class Config(DotEnvConfig):
|
|
237
|
+
feature_flags: Json[dict[str, bool]] = Field()
|
|
238
|
+
# Environment: FEATURE_FLAGS={"new_ui": true, "beta_api": false}
|
|
239
|
+
|
|
240
|
+
allowed_roles: Json[list[str]] = Field()
|
|
241
|
+
# Environment: ALLOWED_ROLES=["admin", "user", "guest"]
|
|
242
|
+
```
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __class_getitem__(cls, item: type[T]) -> type["Json[T]"]:
|
|
246
|
+
"""Support generic type syntax Json[T]."""
|
|
247
|
+
# Return a new class that remembers the inner type
|
|
248
|
+
return type(f"Json[{item}]", (Json,), {"__inner_type__": item})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def parse_timedelta(value: str) -> timedelta:
|
|
252
|
+
"""
|
|
253
|
+
Parse a human-readable duration string into a timedelta.
|
|
254
|
+
|
|
255
|
+
Supports formats like:
|
|
256
|
+
- Plain integers: "90" (seconds)
|
|
257
|
+
- With units: "1h30m", "90s", "1.5h", "2d"
|
|
258
|
+
|
|
259
|
+
Units:
|
|
260
|
+
- ms: milliseconds
|
|
261
|
+
- s: seconds
|
|
262
|
+
- m: minutes
|
|
263
|
+
- h: hours
|
|
264
|
+
- d: days
|
|
265
|
+
- w: weeks
|
|
266
|
+
|
|
267
|
+
Example:
|
|
268
|
+
>>> parse_timedelta("90")
|
|
269
|
+
timedelta(seconds=90)
|
|
270
|
+
>>> parse_timedelta("1h30m")
|
|
271
|
+
timedelta(seconds=5400)
|
|
272
|
+
>>> parse_timedelta("2d")
|
|
273
|
+
timedelta(days=2)
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
value: Duration string
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
timedelta object
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: If the format is invalid
|
|
283
|
+
"""
|
|
284
|
+
# Try parsing as plain number (seconds)
|
|
285
|
+
try:
|
|
286
|
+
return timedelta(seconds=float(value))
|
|
287
|
+
except ValueError:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
# Parse format like "1h30m", "90s", etc.
|
|
291
|
+
pattern = r"([\d.]+)(ms|s|m|h|d|w)"
|
|
292
|
+
matches = re.findall(pattern, value.lower())
|
|
293
|
+
|
|
294
|
+
if not matches:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"Invalid timedelta format: {value}. "
|
|
297
|
+
"Expected format like '90' (seconds), '1h30m', '2d', etc."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
total_seconds = 0.0
|
|
301
|
+
for amount_str, unit in matches:
|
|
302
|
+
amount = float(amount_str)
|
|
303
|
+
|
|
304
|
+
if unit == "ms":
|
|
305
|
+
total_seconds += amount / 1000
|
|
306
|
+
elif unit == "s":
|
|
307
|
+
total_seconds += amount
|
|
308
|
+
elif unit == "m":
|
|
309
|
+
total_seconds += amount * 60
|
|
310
|
+
elif unit == "h":
|
|
311
|
+
total_seconds += amount * 3600
|
|
312
|
+
elif unit == "d":
|
|
313
|
+
total_seconds += amount * 86400
|
|
314
|
+
elif unit == "w":
|
|
315
|
+
total_seconds += amount * 604800
|
|
316
|
+
|
|
317
|
+
return timedelta(seconds=total_seconds)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def coerce_datetime(value: str, field_name: str, env_var_name: str) -> datetime:
|
|
321
|
+
"""
|
|
322
|
+
Coerce a string to datetime using ISO 8601 format.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
value: ISO 8601 datetime string
|
|
326
|
+
field_name: Field name for error messages
|
|
327
|
+
env_var_name: Environment variable name for error messages
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
datetime object
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
TypeCoercionError: If parsing fails
|
|
334
|
+
"""
|
|
335
|
+
try:
|
|
336
|
+
return datetime.fromisoformat(value)
|
|
337
|
+
except ValueError as e:
|
|
338
|
+
raise TypeCoercionError(
|
|
339
|
+
field_name=field_name,
|
|
340
|
+
value=value,
|
|
341
|
+
error_msg=f"Invalid datetime format. Expected ISO 8601 format (e.g., '2025-01-15T10:30:00'). Error: {e}",
|
|
342
|
+
field_type=datetime,
|
|
343
|
+
env_var_name=env_var_name,
|
|
344
|
+
) from e
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def coerce_timedelta(value: str, field_name: str, env_var_name: str) -> timedelta:
|
|
348
|
+
"""
|
|
349
|
+
Coerce a string to timedelta.
|
|
350
|
+
|
|
351
|
+
Supports:
|
|
352
|
+
- Plain integers: "90" (seconds)
|
|
353
|
+
- Human-readable: "1h30m", "90s", "2d"
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
value: Duration string
|
|
357
|
+
field_name: Field name for error messages
|
|
358
|
+
env_var_name: Environment variable name for error messages
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
timedelta object
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
TypeCoercionError: If parsing fails
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
return parse_timedelta(value)
|
|
368
|
+
except ValueError as e:
|
|
369
|
+
raise TypeCoercionError(
|
|
370
|
+
field_name=field_name,
|
|
371
|
+
value=value,
|
|
372
|
+
error_msg=str(e),
|
|
373
|
+
field_type=timedelta,
|
|
374
|
+
env_var_name=env_var_name,
|
|
375
|
+
) from e
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def coerce_uuid(value: str, field_name: str, env_var_name: str) -> UUID:
|
|
379
|
+
"""
|
|
380
|
+
Coerce a string to UUID.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
value: UUID string (with or without hyphens)
|
|
384
|
+
field_name: Field name for error messages
|
|
385
|
+
env_var_name: Environment variable name for error messages
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
UUID object
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
TypeCoercionError: If parsing fails
|
|
392
|
+
"""
|
|
393
|
+
try:
|
|
394
|
+
return UUID(value)
|
|
395
|
+
except ValueError as e:
|
|
396
|
+
raise TypeCoercionError(
|
|
397
|
+
field_name=field_name,
|
|
398
|
+
value=value,
|
|
399
|
+
error_msg=f"Invalid UUID format. Error: {e}",
|
|
400
|
+
field_type=UUID,
|
|
401
|
+
env_var_name=env_var_name,
|
|
402
|
+
) from e
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def coerce_decimal(value: str, field_name: str, env_var_name: str) -> Decimal:
|
|
406
|
+
"""
|
|
407
|
+
Coerce a string to Decimal.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
value: Numeric string
|
|
411
|
+
field_name: Field name for error messages
|
|
412
|
+
env_var_name: Environment variable name for error messages
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Decimal object
|
|
416
|
+
|
|
417
|
+
Raises:
|
|
418
|
+
TypeCoercionError: If parsing fails
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
return Decimal(value)
|
|
422
|
+
except (ValueError, InvalidOperation) as e:
|
|
423
|
+
raise TypeCoercionError(
|
|
424
|
+
field_name=field_name,
|
|
425
|
+
value=value,
|
|
426
|
+
error_msg=f"Invalid decimal format. Error: {e}",
|
|
427
|
+
field_type=Decimal,
|
|
428
|
+
env_var_name=env_var_name,
|
|
429
|
+
) from e
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def coerce_json[T](
|
|
433
|
+
value: str, field_name: str, env_var_name: str, expected_type: type[T] | None = None
|
|
434
|
+
) -> T:
|
|
435
|
+
"""
|
|
436
|
+
Parse JSON string and optionally validate against expected type.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
value: JSON string
|
|
440
|
+
field_name: Field name for error messages
|
|
441
|
+
env_var_name: Environment variable name for error messages
|
|
442
|
+
expected_type: Optional type to validate against
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
Parsed JSON object
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
TypeCoercionError: If parsing fails or type doesn't match
|
|
449
|
+
"""
|
|
450
|
+
try:
|
|
451
|
+
parsed = json.loads(value)
|
|
452
|
+
except json.JSONDecodeError as e:
|
|
453
|
+
raise TypeCoercionError(
|
|
454
|
+
field_name=field_name,
|
|
455
|
+
value=value,
|
|
456
|
+
error_msg=f"Invalid JSON format. Error: {e}",
|
|
457
|
+
field_type=expected_type or dict,
|
|
458
|
+
env_var_name=env_var_name,
|
|
459
|
+
) from e
|
|
460
|
+
|
|
461
|
+
# Basic type validation if expected_type provided
|
|
462
|
+
if expected_type is not None:
|
|
463
|
+
if expected_type is dict and not isinstance(parsed, dict):
|
|
464
|
+
raise TypeCoercionError(
|
|
465
|
+
field_name=field_name,
|
|
466
|
+
value=value,
|
|
467
|
+
error_msg=f"Expected JSON object (dict), got {type(parsed).__name__}",
|
|
468
|
+
field_type=dict,
|
|
469
|
+
env_var_name=env_var_name,
|
|
470
|
+
)
|
|
471
|
+
elif expected_type is list and not isinstance(parsed, list):
|
|
472
|
+
raise TypeCoercionError(
|
|
473
|
+
field_name=field_name,
|
|
474
|
+
value=value,
|
|
475
|
+
error_msg=f"Expected JSON array (list), got {type(parsed).__name__}",
|
|
476
|
+
field_type=list,
|
|
477
|
+
env_var_name=env_var_name,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return parsed
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Field validation logic for dotenvmodel."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from dotenvmodel.exceptions import ConstraintViolationError
|
|
8
|
+
from dotenvmodel.fields import FieldInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_field(field_name: str, value: Any, field_info: FieldInfo, env_var_name: str) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Validate a field value against its constraints.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
field_name: Name of the field being validated
|
|
17
|
+
value: Value to validate
|
|
18
|
+
field_info: Field metadata containing validation constraints
|
|
19
|
+
env_var_name: Name of the environment variable
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ConstraintViolationError: If validation fails
|
|
23
|
+
"""
|
|
24
|
+
# Skip validation for None values (handled by type coercion)
|
|
25
|
+
if value is None:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
# Numeric validation (int, float, Decimal)
|
|
29
|
+
if isinstance(value, (int, float, Decimal)):
|
|
30
|
+
_validate_numeric(field_name, value, field_info, env_var_name)
|
|
31
|
+
|
|
32
|
+
# String validation (including SecretStr)
|
|
33
|
+
from dotenvmodel.types import SecretStr
|
|
34
|
+
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
_validate_string(field_name, value, field_info, env_var_name)
|
|
37
|
+
elif isinstance(value, SecretStr):
|
|
38
|
+
# Validate the secret value as a string
|
|
39
|
+
_validate_string(field_name, value.get_secret_value(), field_info, env_var_name)
|
|
40
|
+
|
|
41
|
+
# Choice validation (works for any type)
|
|
42
|
+
if field_info.choices is not None:
|
|
43
|
+
_validate_choices(field_name, value, field_info, env_var_name)
|
|
44
|
+
|
|
45
|
+
# Collection size validation (for list, set, tuple, dict)
|
|
46
|
+
if isinstance(value, (list, set, tuple, dict)):
|
|
47
|
+
_validate_collection_size(field_name, value, field_info, env_var_name)
|
|
48
|
+
|
|
49
|
+
# UUID version validation
|
|
50
|
+
if isinstance(value, UUID):
|
|
51
|
+
_validate_uuid_version(field_name, value, field_info, env_var_name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_numeric(
|
|
55
|
+
field_name: str, value: int | float | Decimal, field_info: FieldInfo, env_var_name: str
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Validate numeric constraints (ge, le, gt, lt) for int, float, and Decimal."""
|
|
58
|
+
if field_info.ge is not None and value < field_info.ge:
|
|
59
|
+
raise ConstraintViolationError(
|
|
60
|
+
field_name=field_name,
|
|
61
|
+
value=value,
|
|
62
|
+
constraint=f"ge={field_info.ge}",
|
|
63
|
+
error_msg=f"Value must be greater than or equal to {field_info.ge}",
|
|
64
|
+
env_var_name=env_var_name,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if field_info.le is not None and value > field_info.le:
|
|
68
|
+
raise ConstraintViolationError(
|
|
69
|
+
field_name=field_name,
|
|
70
|
+
value=value,
|
|
71
|
+
constraint=f"le={field_info.le}",
|
|
72
|
+
error_msg=f"Value must be less than or equal to {field_info.le}",
|
|
73
|
+
env_var_name=env_var_name,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if field_info.gt is not None and value <= field_info.gt:
|
|
77
|
+
raise ConstraintViolationError(
|
|
78
|
+
field_name=field_name,
|
|
79
|
+
value=value,
|
|
80
|
+
constraint=f"gt={field_info.gt}",
|
|
81
|
+
error_msg=f"Value must be greater than {field_info.gt}",
|
|
82
|
+
env_var_name=env_var_name,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if field_info.lt is not None and value >= field_info.lt:
|
|
86
|
+
raise ConstraintViolationError(
|
|
87
|
+
field_name=field_name,
|
|
88
|
+
value=value,
|
|
89
|
+
constraint=f"lt={field_info.lt}",
|
|
90
|
+
error_msg=f"Value must be less than {field_info.lt}",
|
|
91
|
+
env_var_name=env_var_name,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _validate_string(field_name: str, value: str, field_info: FieldInfo, env_var_name: str) -> None:
|
|
96
|
+
"""Validate string constraints (min_length, max_length, regex)."""
|
|
97
|
+
if field_info.min_length is not None and len(value) < field_info.min_length:
|
|
98
|
+
raise ConstraintViolationError(
|
|
99
|
+
field_name=field_name,
|
|
100
|
+
value=value,
|
|
101
|
+
constraint=f"min_length={field_info.min_length}",
|
|
102
|
+
error_msg=f"String must be at least {field_info.min_length} characters long",
|
|
103
|
+
env_var_name=env_var_name,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if field_info.max_length is not None and len(value) > field_info.max_length:
|
|
107
|
+
raise ConstraintViolationError(
|
|
108
|
+
field_name=field_name,
|
|
109
|
+
value=value,
|
|
110
|
+
constraint=f"max_length={field_info.max_length}",
|
|
111
|
+
error_msg=f"String must be at most {field_info.max_length} characters long",
|
|
112
|
+
env_var_name=env_var_name,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
field_info.regex is not None
|
|
117
|
+
and field_info._compiled_regex is not None
|
|
118
|
+
and not field_info._compiled_regex.match(value)
|
|
119
|
+
):
|
|
120
|
+
raise ConstraintViolationError(
|
|
121
|
+
field_name=field_name,
|
|
122
|
+
value=value,
|
|
123
|
+
constraint=f"regex={field_info.regex!r}",
|
|
124
|
+
error_msg=f"String must match pattern: {field_info.regex}",
|
|
125
|
+
env_var_name=env_var_name,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _validate_choices(
|
|
130
|
+
field_name: str, value: Any, field_info: FieldInfo, env_var_name: str
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Validate that value is in allowed choices."""
|
|
133
|
+
if field_info.choices is not None and value not in field_info.choices:
|
|
134
|
+
raise ConstraintViolationError(
|
|
135
|
+
field_name=field_name,
|
|
136
|
+
value=value,
|
|
137
|
+
constraint=f"choices={field_info.choices!r}",
|
|
138
|
+
error_msg=f"Value must be one of: {field_info.choices}",
|
|
139
|
+
env_var_name=env_var_name,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_collection_size(
|
|
144
|
+
field_name: str,
|
|
145
|
+
value: list[Any] | set[Any] | tuple[Any, ...] | dict[Any, Any],
|
|
146
|
+
field_info: FieldInfo,
|
|
147
|
+
env_var_name: str,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Validate collection size constraints (min_items, max_items)."""
|
|
150
|
+
collection_size = len(value)
|
|
151
|
+
|
|
152
|
+
if field_info.min_items is not None and collection_size < field_info.min_items:
|
|
153
|
+
raise ConstraintViolationError(
|
|
154
|
+
field_name=field_name,
|
|
155
|
+
value=value,
|
|
156
|
+
constraint=f"min_items={field_info.min_items}",
|
|
157
|
+
error_msg=f"Collection must have at least {field_info.min_items} items (got {collection_size})",
|
|
158
|
+
env_var_name=env_var_name,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if field_info.max_items is not None and collection_size > field_info.max_items:
|
|
162
|
+
raise ConstraintViolationError(
|
|
163
|
+
field_name=field_name,
|
|
164
|
+
value=value,
|
|
165
|
+
constraint=f"max_items={field_info.max_items}",
|
|
166
|
+
error_msg=f"Collection must have at most {field_info.max_items} items (got {collection_size})",
|
|
167
|
+
env_var_name=env_var_name,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _validate_uuid_version(
|
|
172
|
+
field_name: str, value: UUID, field_info: FieldInfo, env_var_name: str
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Validate UUID version constraint."""
|
|
175
|
+
if field_info.uuid_version is not None and value.version != field_info.uuid_version:
|
|
176
|
+
raise ConstraintViolationError(
|
|
177
|
+
field_name=field_name,
|
|
178
|
+
value=value,
|
|
179
|
+
constraint=f"uuid_version={field_info.uuid_version}",
|
|
180
|
+
error_msg=f"UUID must be version {field_info.uuid_version} (got version {value.version})",
|
|
181
|
+
env_var_name=env_var_name,
|
|
182
|
+
)
|