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/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
+ )