dotenvmodel 0.3.2__tar.gz → 0.5.0__tar.gz
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-0.5.0/.release-please-manifest.json +3 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/CHANGELOG.md +29 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/PKG-INFO +145 -2
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/README.md +144 -1
- dotenvmodel-0.5.0/SECURITY.md +30 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/fields.md +47 -1
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/loading.md +4 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/validation.md +131 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/index.md +3 -1
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/__init__.py +6 -2
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/coercion.py +69 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/config.py +338 -9
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/describe/formatters.py +47 -7
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/exceptions.py +3 -2
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/fields.py +152 -2
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/metaclass.py +27 -8
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/types.py +43 -7
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/validation.py +20 -3
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/mkdocs.yml +4 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/pyproject.toml +4 -4
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_basic.py +1 -1
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_coercion.py +11 -2
- dotenvmodel-0.5.0/tests/test_defaults_coercion.py +245 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_describe.py +269 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_adoption.py +3 -1
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_path_options.py +4 -5
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_field_constraints.py +1 -1
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_future_annotations.py +3 -7
- dotenvmodel-0.5.0/tests/test_post_load.py +541 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_secret_redaction.py +10 -9
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_secretstr_security.py +6 -6
- dotenvmodel-0.5.0/tests/test_string_affixes.py +310 -0
- dotenvmodel-0.5.0/tests/test_strip.py +558 -0
- dotenvmodel-0.5.0/tests/test_strip_integration.py +124 -0
- dotenvmodel-0.5.0/tests/test_validator.py +765 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/uv.lock +120 -120
- dotenvmodel-0.3.2/.release-please-manifest.json +0 -3
- dotenvmodel-0.3.2/SECURITY.md +0 -23
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/workflows/ci.yml +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/workflows/publish.yml +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.gitignore +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/.python-version +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/LICENSE +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/Makefile +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/changelog.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/docs/guides/types.md +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/_redaction.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/examples/logging_example.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/release-please-config.json +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/__init__.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/conftest.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_enum.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_errors.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_json.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_loading.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_nested_config.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_reload.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_types.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.3.2 → dotenvmodel-0.5.0}/tests/test_urls.py +0 -0
|
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.4.0...v0.5.0) (2026-07-21)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add post_load hook for cross-field validation and normalization ([78bcef1](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/78bcef18b90354ea29050f30c8548a193e78c0e3))
|
|
14
|
+
* add post_load hook for cross-field validation and normalization ([#39](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/39)) ([39241ed](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/39241ed09ca416c38869076cfe6e22a7c94b2d95))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* mask hook-authored constraint in sensitive validator errors ([6f99371](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/6f99371c5038d60020c68524c05f766d1bfc0ddd))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Documentation
|
|
23
|
+
|
|
24
|
+
* correct post_load raise-propagation boundary, document partial reload ([9ec9a00](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/9ec9a00fdfb0e2d455830690489d4c7b5215a92c))
|
|
25
|
+
* document post_load cross-field validation hook ([536c981](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/536c981b201d169a219c49af16e45cdd92202443))
|
|
26
|
+
* post_load discoverability and secrets-warning scope ([895566e](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/895566e365c1d32952b06d59e4063cf7cf2ace8c))
|
|
27
|
+
* qualify post_load claims for validate=False and reload paths ([0786c8d](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/0786c8da115e2d033cad278a97557425ffad4c63))
|
|
28
|
+
* qualify post_load raise propagation for nested ValidationError ([40416b6](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/40416b69c15e33b3234fd8ee6531a743589f4e27))
|
|
29
|
+
|
|
30
|
+
## [0.4.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.2...v0.4.0) (2026-07-20)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Features
|
|
34
|
+
|
|
35
|
+
* add strip, starts_with/ends_with, and custom validator hook to Field ([#36](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/36)) ([8e4210e](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/8e4210e80e5888ef0b769bbf966f486c7b4b526e))
|
|
36
|
+
|
|
8
37
|
## [0.3.2](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.1...v0.3.2) (2026-07-15)
|
|
9
38
|
|
|
10
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotenvmodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Type-safe environment configuration with automatic .env file loading
|
|
5
5
|
Project-URL: Homepage, https://github.com/AZX-PBC-OSS/dotenvmodel
|
|
6
6
|
Project-URL: Repository, https://github.com/AZX-PBC-OSS/dotenvmodel
|
|
@@ -44,7 +44,8 @@ Description-Content-Type: text/markdown
|
|
|
44
44
|
- **.env.example Generation**: Automatically generate `.env.example` files with type hints, constraints, and examples
|
|
45
45
|
- **File Export**: Save documentation directly to files for integration with build tools and wikis
|
|
46
46
|
- **Environment Prefixes**: Class-level `env_prefix` to namespace environment variables
|
|
47
|
-
- **Validation**: Numeric constraints (ge, le, gt, lt), string constraints (min_length, max_length, regex), choice validation, and collection size constraints (min_items, max_items)
|
|
47
|
+
- **Validation**: Numeric constraints (ge, le, gt, lt), string constraints (min_length, max_length, regex, starts_with, ends_with), choice validation, custom validator hooks, a model-level `post_load` hook for cross-field validation, and collection size constraints (min_items, max_items)
|
|
48
|
+
- **String Stripping**: Per-field and class-level whitespace/char-set/regex stripping of raw values before coercion
|
|
48
49
|
- **Clear Error Messages**: Helpful validation errors that guide you to fixes
|
|
49
50
|
- **Optional Logging**: Built-in logging support to debug configuration loading
|
|
50
51
|
- **Zero Runtime Overhead**: All validation happens at startup/load time
|
|
@@ -362,6 +363,8 @@ class Config(DotEnvConfig):
|
|
|
362
363
|
tags: dict[str, str] = Field(default_factory=dict)
|
|
363
364
|
```
|
|
364
365
|
|
|
366
|
+
A `str` default for a non-`str` field type is **coerced to the declared type and validated at load** — e.g. a `bool` default of `'false'` becomes `False`, a `SecretStr` default becomes a masked `SecretStr`, and a `PostgresDsn` default is validated (a bad scheme raises at load) with its password redacted in `repr`. Non-`str` defaults (e.g. `int 8000`, `default_factory=list`) and `str` defaults for `str`-typed fields are left untouched. Constraints fire on the coerced default just as they do on env-provided values.
|
|
367
|
+
|
|
365
368
|
### Field Aliases
|
|
366
369
|
|
|
367
370
|
Use a different environment variable name than the field name:
|
|
@@ -389,8 +392,44 @@ class Config(DotEnvConfig):
|
|
|
389
392
|
)
|
|
390
393
|
```
|
|
391
394
|
|
|
395
|
+
### String Stripping
|
|
396
|
+
|
|
397
|
+
Raw environment values often come with stray whitespace or wrapping quotes. Use `strip` to clean string-like values (`str`, `SecretStr`, their `Optional` forms, `str` subclasses like `HttpUrl`, and `Literal["a", "b"]` fields whose every member is `str`) **before** coercion and validation:
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
import re
|
|
401
|
+
|
|
402
|
+
class Config(DotEnvConfig):
|
|
403
|
+
# Whitespace strip: " hello " -> "hello"
|
|
404
|
+
name: str = Field(strip=True)
|
|
405
|
+
|
|
406
|
+
# Char-set strip (str.strip(chars) semantics): ",'hello'," -> "hello"
|
|
407
|
+
tag: str = Field(strip=",'\"")
|
|
408
|
+
|
|
409
|
+
# Regex strip: removes every match, anywhere in the string
|
|
410
|
+
key: SecretStr = Field(strip=re.compile(r"^['\"]+|['\"]+$"))
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Set `strip_strings = True` on the class to strip every string-like field by default, and opt out per field with `strip=False`:
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
class Config(DotEnvConfig):
|
|
417
|
+
strip_strings: bool = True
|
|
418
|
+
|
|
419
|
+
name: str = Field() # stripped (inherits class setting)
|
|
420
|
+
literal: str = Field(strip=False) # per-field override wins
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Stripping is value processing, not validation: it runs even with `validate=False`, and constraints see the stripped value (e.g. `min_length` checks the final length, and a whitespace-only value for an `Optional[str]` field strips to `""`, which maps to `None`).
|
|
424
|
+
|
|
425
|
+
For `SecretStr` fields, `strip` runs **before** `url_unquote`, so percent-encoded whitespace (e.g. `%20`) survives stripping — it is removed while still percent-encoded, then unquoted. Use a `re.Pattern` strip if you need to strip decoded whitespace.
|
|
426
|
+
|
|
427
|
+
> **Warning — use linear-time regex patterns.** Both the `regex` constraint and `strip` with an `re.Pattern` run developer-supplied patterns against env values, which can be operator-controlled. Avoid patterns with nested quantifiers (e.g. `(a+)+`, `(a*)*`) that can cause catastrophic backtracking (ReDoS). Prefer anchored, linear-time patterns.
|
|
428
|
+
|
|
392
429
|
## Validation
|
|
393
430
|
|
|
431
|
+
> **Note — empty vs missing:** `Optional[T]` fields map missing **and** empty values to `None` and skip constraints, while plain `str` preserves empty strings as real values. So "validate only if present" is spelled `str | None = Field(default=None, min_length=...)`.
|
|
432
|
+
|
|
394
433
|
### Numeric Validation
|
|
395
434
|
|
|
396
435
|
```python
|
|
@@ -425,6 +464,10 @@ class Config(DotEnvConfig):
|
|
|
425
464
|
# Regex pattern
|
|
426
465
|
email: str = Field(regex=r'^[\w\.-]+@[\w\.-]+\.\w+$')
|
|
427
466
|
|
|
467
|
+
# Required prefix / suffix
|
|
468
|
+
client_key: str = Field(starts_with="sk-")
|
|
469
|
+
signed_token: str = Field(ends_with=".sig")
|
|
470
|
+
|
|
428
471
|
# Combined constraints
|
|
429
472
|
password: str = Field(
|
|
430
473
|
min_length=8,
|
|
@@ -449,6 +492,106 @@ class Config(DotEnvConfig):
|
|
|
449
492
|
)
|
|
450
493
|
```
|
|
451
494
|
|
|
495
|
+
### Custom Validators
|
|
496
|
+
|
|
497
|
+
For logic that built-in constraints can't express, attach a `validator` hook. It receives the **coerced, constraint-validated** value plus a `ValidatorContext` (field name and env var name), and its return value becomes the final field value — so it can also transform:
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
from dotenvmodel import DotEnvConfig, Field, SecretStr, ValidatorContext
|
|
501
|
+
|
|
502
|
+
def check_api_key(value: SecretStr, ctx: ValidatorContext) -> SecretStr:
|
|
503
|
+
# The hook receives the coerced value: a SecretStr stays wrapped,
|
|
504
|
+
# so call get_secret_value() to inspect the plaintext.
|
|
505
|
+
if not value.get_secret_value().startswith("sk-"):
|
|
506
|
+
# ValueError/TypeError are wrapped in ConstraintViolationError
|
|
507
|
+
# and aggregate into MultipleValidationErrors like any other failure
|
|
508
|
+
raise ValueError(f"{ctx.env_var_name} must start with 'sk-'")
|
|
509
|
+
return value
|
|
510
|
+
|
|
511
|
+
class Config(DotEnvConfig):
|
|
512
|
+
api_key: SecretStr = Field(validator=check_api_key)
|
|
513
|
+
|
|
514
|
+
# Transform example: normalize to lowercase
|
|
515
|
+
region: str = Field(default="us-east-1", validator=lambda v, ctx: v.lower())
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Semantics to know:
|
|
519
|
+
|
|
520
|
+
- The hook receives the **coerced** value. A `SecretStr` stays wrapped — use `get_secret_value()` to inspect the plaintext.
|
|
521
|
+
- Runs **after** built-in constraints, on non-`None` values only, and even with `validate=False` (transformation is part of loading).
|
|
522
|
+
- Built-in constraints are **not** re-run on a transformed value.
|
|
523
|
+
- For sensitive fields (`SecretStr`, DSN types), returning a plain `str` re-wraps it in the declared type so the secret stays masked in `repr`.
|
|
524
|
+
- Returning `None` on a non-`Optional` field raises `TypeCoercionError`.
|
|
525
|
+
- For sensitive fields, **any** exception from the hook is masked to a generic `ConstraintViolationError` with no exception chaining — the hook's message is never embedded, so a carelessly written hook can't leak the secret. For non-sensitive fields, `str(e)` is embedded in the error message and the original exception is chained.
|
|
526
|
+
- Raise `ConstraintViolationError` directly for a fully custom message on non-sensitive fields (it passes through untouched).
|
|
527
|
+
|
|
528
|
+
### Cross-Field Validation with `post_load`
|
|
529
|
+
|
|
530
|
+
Per-field constraints and `validator` hooks see one value at a time. For invariants that span several fields (`lock_lease >= 4 * heartbeat_interval`) or derived values built from multiple inputs (a replica DSN falling back to the primary), override the model-level `post_load()` hook. It runs **once after all fields load cleanly**, on every load path — `load()`, `load_from_dict()`, `reload()`, and nested config loading — and always runs, even with `validate=False` (transformation is part of loading, same as the per-field `validator` hook). The default implementation is a no-op.
|
|
531
|
+
|
|
532
|
+
```python
|
|
533
|
+
from dotenvmodel import DotEnvConfig, Field, ValidationError
|
|
534
|
+
|
|
535
|
+
class WorkerConfig(DotEnvConfig):
|
|
536
|
+
primary_dsn: str = Field(default="postgresql://localhost/primary")
|
|
537
|
+
replica_dsn: str | None = Field(default=None)
|
|
538
|
+
heartbeat_interval: int = Field(default=5, ge=1) # seconds
|
|
539
|
+
lock_lease: int = Field(default=30, ge=1) # seconds
|
|
540
|
+
|
|
541
|
+
def post_load(self) -> list[ValidationError] | None:
|
|
542
|
+
# Fix/transform: derived value — fall back to the primary DSN.
|
|
543
|
+
if self.replica_dsn is None:
|
|
544
|
+
self.replica_dsn = self.primary_dsn
|
|
545
|
+
|
|
546
|
+
# Cross-validate: invariant spanning two fields.
|
|
547
|
+
if self.lock_lease < 4 * self.heartbeat_interval:
|
|
548
|
+
return [
|
|
549
|
+
ValidationError(
|
|
550
|
+
field_name="lock_lease", # tag the primary field
|
|
551
|
+
value=self.lock_lease,
|
|
552
|
+
error_msg="lock_lease must be >= 4 * heartbeat_interval",
|
|
553
|
+
)
|
|
554
|
+
]
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
config = WorkerConfig.load_from_dict({})
|
|
558
|
+
print(config.replica_dsn) # postgresql://localhost/primary (fallback applied)
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
WorkerConfig.load_from_dict({"LOCK_LEASE": "10"}) # 10 < 4 * 5
|
|
562
|
+
except ValidationError as e:
|
|
563
|
+
print(f"{e.field_name}: {e.error_msg}")
|
|
564
|
+
# lock_lease: lock_lease must be >= 4 * heartbeat_interval
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Usage modes (combinable in one body):
|
|
568
|
+
|
|
569
|
+
- **Fix / transform** — mutate `self`, return `None` (the DSN fallback above).
|
|
570
|
+
- **Cross-validate** — collect violations into a `list[ValidationError]` and return them (the DSN-invariant style above); the library raises them as described below.
|
|
571
|
+
- **Continue** — log or swallow issues internally, return `None`.
|
|
572
|
+
- **Fatal** — `raise`; an exception that is neither `ValidationError` nor `MultipleValidationErrors` propagates unchanged (never wrapped or aggregated), even when raised from a nested config's hook. Use `raise` for unexpected/programming errors and `return` for expected violations.
|
|
573
|
+
|
|
574
|
+
Semantics to know:
|
|
575
|
+
|
|
576
|
+
- Return `None` or `[]` → success. Return **one** `ValidationError` → raised directly, its exact type preserved (e.g. `ConstraintViolationError`). Return **several** → raised as `MultipleValidationErrors`.
|
|
577
|
+
- Returned errors are the same `ValidationError` objects field validation produces, so introspection is uniform whether the failure came from a constraint, a per-field `validator`, or `post_load`:
|
|
578
|
+
|
|
579
|
+
```python
|
|
580
|
+
except MultipleValidationErrors as e:
|
|
581
|
+
for err in e.errors:
|
|
582
|
+
print(f"{err.field_name} ({err.env_var_name}): {err.error_msg}")
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
- **Hook-author contract:** tag each returned error with the *primary* field name (`lock_lease` above) and reference the other participating fields in `error_msg`. `env_var_name` defaults to the uppercased field name if not passed.
|
|
586
|
+
- The hook runs **only when every field loaded cleanly** — cross-field checks always see **coerced** values, constraint-validated too unless you loaded with `validate=False` (which skips constraints but still runs the hook). A nested config's hook fires when the nested instance finishes loading, before the parent's hook; its returned errors flatten into the parent's collection. A `ValidationError` or `MultipleValidationErrors` **raised** (rather than returned) by a nested hook is treated the same way — its errors join the parent's collection like nested field failures: a single collected error is re-raised as-is (a solo raised `MultipleValidationErrors` surfaces as its bare member), several aggregate into `MultipleValidationErrors`.
|
|
587
|
+
- `reload()` re-runs the hook against the freshly reloaded state. If the hook (or field validation) fails during `reload()`, the instance may be **partially reloaded** — the same caveat as field errors during reload.
|
|
588
|
+
- The hook does **not** run on bare `Cls()` construction — no load path is involved.
|
|
589
|
+
- If the hook both mutates and returns errors, the load still fails — on `load()`/`load_from_dict()` the half-built instance is discarded and the mutations vanish with it, but on `reload()` the caller already holds the instance, so hook mutations persist even though the reload raised (see the partial-reload caveat above).
|
|
590
|
+
|
|
591
|
+
> **Warning — keep secrets out of `error_msg` and raised exceptions.** The library redacts the `value` attribute for `SecretStr`/DSN fields when formatting raised errors, but it cannot mask prose you write. Never embed secret values in `error_msg` — and don't interpolate them into exceptions raised from the hook either, since those propagate unmasked.
|
|
592
|
+
|
|
593
|
+
The pattern follows pydantic's `model_validator(mode="after")` (run after all fields validate; mutate and return `self`) and the zod/t3-env final-schema `.transform()` pattern (one post-hook that can both transform values and accumulate multiple issues).
|
|
594
|
+
|
|
452
595
|
## Loading Configuration
|
|
453
596
|
|
|
454
597
|
### From Environment Variables
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
- **.env.example Generation**: Automatically generate `.env.example` files with type hints, constraints, and examples
|
|
17
17
|
- **File Export**: Save documentation directly to files for integration with build tools and wikis
|
|
18
18
|
- **Environment Prefixes**: Class-level `env_prefix` to namespace environment variables
|
|
19
|
-
- **Validation**: Numeric constraints (ge, le, gt, lt), string constraints (min_length, max_length, regex), choice validation, and collection size constraints (min_items, max_items)
|
|
19
|
+
- **Validation**: Numeric constraints (ge, le, gt, lt), string constraints (min_length, max_length, regex, starts_with, ends_with), choice validation, custom validator hooks, a model-level `post_load` hook for cross-field validation, and collection size constraints (min_items, max_items)
|
|
20
|
+
- **String Stripping**: Per-field and class-level whitespace/char-set/regex stripping of raw values before coercion
|
|
20
21
|
- **Clear Error Messages**: Helpful validation errors that guide you to fixes
|
|
21
22
|
- **Optional Logging**: Built-in logging support to debug configuration loading
|
|
22
23
|
- **Zero Runtime Overhead**: All validation happens at startup/load time
|
|
@@ -334,6 +335,8 @@ class Config(DotEnvConfig):
|
|
|
334
335
|
tags: dict[str, str] = Field(default_factory=dict)
|
|
335
336
|
```
|
|
336
337
|
|
|
338
|
+
A `str` default for a non-`str` field type is **coerced to the declared type and validated at load** — e.g. a `bool` default of `'false'` becomes `False`, a `SecretStr` default becomes a masked `SecretStr`, and a `PostgresDsn` default is validated (a bad scheme raises at load) with its password redacted in `repr`. Non-`str` defaults (e.g. `int 8000`, `default_factory=list`) and `str` defaults for `str`-typed fields are left untouched. Constraints fire on the coerced default just as they do on env-provided values.
|
|
339
|
+
|
|
337
340
|
### Field Aliases
|
|
338
341
|
|
|
339
342
|
Use a different environment variable name than the field name:
|
|
@@ -361,8 +364,44 @@ class Config(DotEnvConfig):
|
|
|
361
364
|
)
|
|
362
365
|
```
|
|
363
366
|
|
|
367
|
+
### String Stripping
|
|
368
|
+
|
|
369
|
+
Raw environment values often come with stray whitespace or wrapping quotes. Use `strip` to clean string-like values (`str`, `SecretStr`, their `Optional` forms, `str` subclasses like `HttpUrl`, and `Literal["a", "b"]` fields whose every member is `str`) **before** coercion and validation:
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
import re
|
|
373
|
+
|
|
374
|
+
class Config(DotEnvConfig):
|
|
375
|
+
# Whitespace strip: " hello " -> "hello"
|
|
376
|
+
name: str = Field(strip=True)
|
|
377
|
+
|
|
378
|
+
# Char-set strip (str.strip(chars) semantics): ",'hello'," -> "hello"
|
|
379
|
+
tag: str = Field(strip=",'\"")
|
|
380
|
+
|
|
381
|
+
# Regex strip: removes every match, anywhere in the string
|
|
382
|
+
key: SecretStr = Field(strip=re.compile(r"^['\"]+|['\"]+$"))
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Set `strip_strings = True` on the class to strip every string-like field by default, and opt out per field with `strip=False`:
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
class Config(DotEnvConfig):
|
|
389
|
+
strip_strings: bool = True
|
|
390
|
+
|
|
391
|
+
name: str = Field() # stripped (inherits class setting)
|
|
392
|
+
literal: str = Field(strip=False) # per-field override wins
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Stripping is value processing, not validation: it runs even with `validate=False`, and constraints see the stripped value (e.g. `min_length` checks the final length, and a whitespace-only value for an `Optional[str]` field strips to `""`, which maps to `None`).
|
|
396
|
+
|
|
397
|
+
For `SecretStr` fields, `strip` runs **before** `url_unquote`, so percent-encoded whitespace (e.g. `%20`) survives stripping — it is removed while still percent-encoded, then unquoted. Use a `re.Pattern` strip if you need to strip decoded whitespace.
|
|
398
|
+
|
|
399
|
+
> **Warning — use linear-time regex patterns.** Both the `regex` constraint and `strip` with an `re.Pattern` run developer-supplied patterns against env values, which can be operator-controlled. Avoid patterns with nested quantifiers (e.g. `(a+)+`, `(a*)*`) that can cause catastrophic backtracking (ReDoS). Prefer anchored, linear-time patterns.
|
|
400
|
+
|
|
364
401
|
## Validation
|
|
365
402
|
|
|
403
|
+
> **Note — empty vs missing:** `Optional[T]` fields map missing **and** empty values to `None` and skip constraints, while plain `str` preserves empty strings as real values. So "validate only if present" is spelled `str | None = Field(default=None, min_length=...)`.
|
|
404
|
+
|
|
366
405
|
### Numeric Validation
|
|
367
406
|
|
|
368
407
|
```python
|
|
@@ -397,6 +436,10 @@ class Config(DotEnvConfig):
|
|
|
397
436
|
# Regex pattern
|
|
398
437
|
email: str = Field(regex=r'^[\w\.-]+@[\w\.-]+\.\w+$')
|
|
399
438
|
|
|
439
|
+
# Required prefix / suffix
|
|
440
|
+
client_key: str = Field(starts_with="sk-")
|
|
441
|
+
signed_token: str = Field(ends_with=".sig")
|
|
442
|
+
|
|
400
443
|
# Combined constraints
|
|
401
444
|
password: str = Field(
|
|
402
445
|
min_length=8,
|
|
@@ -421,6 +464,106 @@ class Config(DotEnvConfig):
|
|
|
421
464
|
)
|
|
422
465
|
```
|
|
423
466
|
|
|
467
|
+
### Custom Validators
|
|
468
|
+
|
|
469
|
+
For logic that built-in constraints can't express, attach a `validator` hook. It receives the **coerced, constraint-validated** value plus a `ValidatorContext` (field name and env var name), and its return value becomes the final field value — so it can also transform:
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
from dotenvmodel import DotEnvConfig, Field, SecretStr, ValidatorContext
|
|
473
|
+
|
|
474
|
+
def check_api_key(value: SecretStr, ctx: ValidatorContext) -> SecretStr:
|
|
475
|
+
# The hook receives the coerced value: a SecretStr stays wrapped,
|
|
476
|
+
# so call get_secret_value() to inspect the plaintext.
|
|
477
|
+
if not value.get_secret_value().startswith("sk-"):
|
|
478
|
+
# ValueError/TypeError are wrapped in ConstraintViolationError
|
|
479
|
+
# and aggregate into MultipleValidationErrors like any other failure
|
|
480
|
+
raise ValueError(f"{ctx.env_var_name} must start with 'sk-'")
|
|
481
|
+
return value
|
|
482
|
+
|
|
483
|
+
class Config(DotEnvConfig):
|
|
484
|
+
api_key: SecretStr = Field(validator=check_api_key)
|
|
485
|
+
|
|
486
|
+
# Transform example: normalize to lowercase
|
|
487
|
+
region: str = Field(default="us-east-1", validator=lambda v, ctx: v.lower())
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Semantics to know:
|
|
491
|
+
|
|
492
|
+
- The hook receives the **coerced** value. A `SecretStr` stays wrapped — use `get_secret_value()` to inspect the plaintext.
|
|
493
|
+
- Runs **after** built-in constraints, on non-`None` values only, and even with `validate=False` (transformation is part of loading).
|
|
494
|
+
- Built-in constraints are **not** re-run on a transformed value.
|
|
495
|
+
- For sensitive fields (`SecretStr`, DSN types), returning a plain `str` re-wraps it in the declared type so the secret stays masked in `repr`.
|
|
496
|
+
- Returning `None` on a non-`Optional` field raises `TypeCoercionError`.
|
|
497
|
+
- For sensitive fields, **any** exception from the hook is masked to a generic `ConstraintViolationError` with no exception chaining — the hook's message is never embedded, so a carelessly written hook can't leak the secret. For non-sensitive fields, `str(e)` is embedded in the error message and the original exception is chained.
|
|
498
|
+
- Raise `ConstraintViolationError` directly for a fully custom message on non-sensitive fields (it passes through untouched).
|
|
499
|
+
|
|
500
|
+
### Cross-Field Validation with `post_load`
|
|
501
|
+
|
|
502
|
+
Per-field constraints and `validator` hooks see one value at a time. For invariants that span several fields (`lock_lease >= 4 * heartbeat_interval`) or derived values built from multiple inputs (a replica DSN falling back to the primary), override the model-level `post_load()` hook. It runs **once after all fields load cleanly**, on every load path — `load()`, `load_from_dict()`, `reload()`, and nested config loading — and always runs, even with `validate=False` (transformation is part of loading, same as the per-field `validator` hook). The default implementation is a no-op.
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
from dotenvmodel import DotEnvConfig, Field, ValidationError
|
|
506
|
+
|
|
507
|
+
class WorkerConfig(DotEnvConfig):
|
|
508
|
+
primary_dsn: str = Field(default="postgresql://localhost/primary")
|
|
509
|
+
replica_dsn: str | None = Field(default=None)
|
|
510
|
+
heartbeat_interval: int = Field(default=5, ge=1) # seconds
|
|
511
|
+
lock_lease: int = Field(default=30, ge=1) # seconds
|
|
512
|
+
|
|
513
|
+
def post_load(self) -> list[ValidationError] | None:
|
|
514
|
+
# Fix/transform: derived value — fall back to the primary DSN.
|
|
515
|
+
if self.replica_dsn is None:
|
|
516
|
+
self.replica_dsn = self.primary_dsn
|
|
517
|
+
|
|
518
|
+
# Cross-validate: invariant spanning two fields.
|
|
519
|
+
if self.lock_lease < 4 * self.heartbeat_interval:
|
|
520
|
+
return [
|
|
521
|
+
ValidationError(
|
|
522
|
+
field_name="lock_lease", # tag the primary field
|
|
523
|
+
value=self.lock_lease,
|
|
524
|
+
error_msg="lock_lease must be >= 4 * heartbeat_interval",
|
|
525
|
+
)
|
|
526
|
+
]
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
config = WorkerConfig.load_from_dict({})
|
|
530
|
+
print(config.replica_dsn) # postgresql://localhost/primary (fallback applied)
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
WorkerConfig.load_from_dict({"LOCK_LEASE": "10"}) # 10 < 4 * 5
|
|
534
|
+
except ValidationError as e:
|
|
535
|
+
print(f"{e.field_name}: {e.error_msg}")
|
|
536
|
+
# lock_lease: lock_lease must be >= 4 * heartbeat_interval
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Usage modes (combinable in one body):
|
|
540
|
+
|
|
541
|
+
- **Fix / transform** — mutate `self`, return `None` (the DSN fallback above).
|
|
542
|
+
- **Cross-validate** — collect violations into a `list[ValidationError]` and return them (the DSN-invariant style above); the library raises them as described below.
|
|
543
|
+
- **Continue** — log or swallow issues internally, return `None`.
|
|
544
|
+
- **Fatal** — `raise`; an exception that is neither `ValidationError` nor `MultipleValidationErrors` propagates unchanged (never wrapped or aggregated), even when raised from a nested config's hook. Use `raise` for unexpected/programming errors and `return` for expected violations.
|
|
545
|
+
|
|
546
|
+
Semantics to know:
|
|
547
|
+
|
|
548
|
+
- Return `None` or `[]` → success. Return **one** `ValidationError` → raised directly, its exact type preserved (e.g. `ConstraintViolationError`). Return **several** → raised as `MultipleValidationErrors`.
|
|
549
|
+
- Returned errors are the same `ValidationError` objects field validation produces, so introspection is uniform whether the failure came from a constraint, a per-field `validator`, or `post_load`:
|
|
550
|
+
|
|
551
|
+
```python
|
|
552
|
+
except MultipleValidationErrors as e:
|
|
553
|
+
for err in e.errors:
|
|
554
|
+
print(f"{err.field_name} ({err.env_var_name}): {err.error_msg}")
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
- **Hook-author contract:** tag each returned error with the *primary* field name (`lock_lease` above) and reference the other participating fields in `error_msg`. `env_var_name` defaults to the uppercased field name if not passed.
|
|
558
|
+
- The hook runs **only when every field loaded cleanly** — cross-field checks always see **coerced** values, constraint-validated too unless you loaded with `validate=False` (which skips constraints but still runs the hook). A nested config's hook fires when the nested instance finishes loading, before the parent's hook; its returned errors flatten into the parent's collection. A `ValidationError` or `MultipleValidationErrors` **raised** (rather than returned) by a nested hook is treated the same way — its errors join the parent's collection like nested field failures: a single collected error is re-raised as-is (a solo raised `MultipleValidationErrors` surfaces as its bare member), several aggregate into `MultipleValidationErrors`.
|
|
559
|
+
- `reload()` re-runs the hook against the freshly reloaded state. If the hook (or field validation) fails during `reload()`, the instance may be **partially reloaded** — the same caveat as field errors during reload.
|
|
560
|
+
- The hook does **not** run on bare `Cls()` construction — no load path is involved.
|
|
561
|
+
- If the hook both mutates and returns errors, the load still fails — on `load()`/`load_from_dict()` the half-built instance is discarded and the mutations vanish with it, but on `reload()` the caller already holds the instance, so hook mutations persist even though the reload raised (see the partial-reload caveat above).
|
|
562
|
+
|
|
563
|
+
> **Warning — keep secrets out of `error_msg` and raised exceptions.** The library redacts the `value` attribute for `SecretStr`/DSN fields when formatting raised errors, but it cannot mask prose you write. Never embed secret values in `error_msg` — and don't interpolate them into exceptions raised from the hook either, since those propagate unmasked.
|
|
564
|
+
|
|
565
|
+
The pattern follows pydantic's `model_validator(mode="after")` (run after all fields validate; mutate and return `self`) and the zod/t3-env final-schema `.transform()` pattern (one post-hook that can both transform values and accumulate multiple issues).
|
|
566
|
+
|
|
424
567
|
## Loading Configuration
|
|
425
568
|
|
|
426
569
|
### From Environment Variables
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
We take security vulnerabilities seriously. If you discover a security vulnerability in dotenvmodel, please report it responsibly.
|
|
6
|
+
|
|
7
|
+
**Do NOT open a public GitHub issue for security vulnerabilities.**
|
|
8
|
+
|
|
9
|
+
Instead, please use [GitHub Security Advisories](https://github.com/AZX-PBC-OSS/dotenvmodel/security/advisories/new) to report vulnerabilities privately.
|
|
10
|
+
|
|
11
|
+
## Response Timeline
|
|
12
|
+
|
|
13
|
+
- **Acknowledgment:** Within 48 hours
|
|
14
|
+
- **Initial Assessment:** Within 5 business days
|
|
15
|
+
- **Fix or Mitigation:** Depends on severity, typically within 30 days for high-severity issues
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
This policy covers the dotenvmodel Python package and its CI/CD pipeline. Vulnerabilities in third-party dependencies should be reported to their respective maintainers.
|
|
20
|
+
|
|
21
|
+
## Disclosure
|
|
22
|
+
|
|
23
|
+
We follow coordinated disclosure. Once a fix is released, we will publish a GitHub Security Advisory with credit to the reporter (unless they prefer to remain anonymous).
|
|
24
|
+
|
|
25
|
+
## Residual Secret-Exposure Channels
|
|
26
|
+
|
|
27
|
+
dotenvmodel masks secrets in the common display paths (`repr`, error messages, and exception chains for `SecretStr` and DSN-typed fields). Two residual channels remain by design and are the caller's responsibility to mitigate:
|
|
28
|
+
|
|
29
|
+
- **Traceback frame locals.** When a load-time error is raised, library internals hold the raw env value in local variables (e.g. `raw_value` in `_process_field`); validator-hook errors additionally hold the coerced value in `_run_field_validator`/`_run_sensitive_validator` frame locals. Error-reporting tools that capture locals will record these — Sentry with `include_locals=True`, `pytest --showlocals`, or rich tracebacks. Do not enable local capture for processes that load configs containing secrets.
|
|
30
|
+
- **`FieldInfo.__repr__`.** `FieldInfo.__repr__` prints `default` values verbatim (the raw default stored on the descriptor, before load-time coercion). A `str` default supplied for a `SecretStr` field is visible in `repr(field_info)` and in `describe()`/`generate_env_example()` introspection. Do not log `FieldInfo` reprs, or pass secret defaults as `SecretStr(...)` instances (which mask themselves) rather than plain strings.
|
|
@@ -39,7 +39,11 @@ class Config(DotEnvConfig):
|
|
|
39
39
|
| `min_length` | `int \| None` | Minimum string length (inclusive). For `str` and `SecretStr`. |
|
|
40
40
|
| `max_length` | `int \| None` | Maximum string length (inclusive). For `str` and `SecretStr`. |
|
|
41
41
|
| `regex` | `str \| None` | Regular expression pattern the string must match. |
|
|
42
|
+
| `starts_with` | `str \| None` | Required string prefix. For `str` and `SecretStr`. |
|
|
43
|
+
| `ends_with` | `str \| None` | Required string suffix. For `str` and `SecretStr`. |
|
|
44
|
+
| `strip` | `bool \| str \| re.Pattern \| None` | Strip mode applied to the raw string before coercion. `None` inherits the class-level `strip_strings`; `True` strips whitespace; `False` disables; a non-empty `str` is a char set (`value.strip(chars)`); a compiled pattern removes every match. |
|
|
42
45
|
| `choices` | `list[Any] \| None` | List of allowed values (validated after type coercion). |
|
|
46
|
+
| `validator` | `Callable[[Any, ValidatorContext], Any] \| None` | Custom hook receiving the coerced, validated value plus context; its return value becomes the final value. |
|
|
43
47
|
| `min_items` | `int \| None` | Minimum items in a collection (`list`, `set`, `tuple`, `dict`). |
|
|
44
48
|
| `max_items` | `int \| None` | Maximum items in a collection (`list`, `set`, `tuple`, `dict`). |
|
|
45
49
|
| `uuid_version` | `int \| None` | Required UUID version (`1`, `3`, `4`, or `5`). |
|
|
@@ -49,7 +53,7 @@ class Config(DotEnvConfig):
|
|
|
49
53
|
| `require_exists` | `bool` | Whether a `Path` field must point to an existing path. Default: `False`. |
|
|
50
54
|
|
|
51
55
|
!!! info "Validation parameters"
|
|
52
|
-
The validation parameters (`ge`, `le`, `gt`, `lt`, `min_length`, `max_length`, `regex`, `choices`, `min_items`, `max_items`, `uuid_version`) are documented in detail in the [Validation guide](validation.md).
|
|
56
|
+
The validation parameters (`ge`, `le`, `gt`, `lt`, `min_length`, `max_length`, `regex`, `starts_with`, `ends_with`, `choices`, `validator`, `min_items`, `max_items`, `uuid_version`) are documented in detail in the [Validation guide](validation.md).
|
|
53
57
|
|
|
54
58
|
---
|
|
55
59
|
|
|
@@ -130,6 +134,9 @@ There are three equivalent ways to mark a field as required. All produce identic
|
|
|
130
134
|
!!! warning "Never use mutable defaults"
|
|
131
135
|
Using `default=[]` or `default={}` shares the same object across all instances. Always use `default_factory=list` or `default_factory=dict` instead. `Field()` raises a `ValueError` if you specify both `default` and `default_factory`.
|
|
132
136
|
|
|
137
|
+
!!! note "`str` defaults are coerced and validated for non-`str` field types"
|
|
138
|
+
A `str` default for a non-`str` field type is coerced to the declared type and run through validation at load — e.g. a `bool` default of `'false'` becomes `False`, a `SecretStr` default becomes a masked `SecretStr`, and a `PostgresDsn` default is validated (a bad scheme raises at load) with its password redacted in `repr`. Non-`str` defaults (e.g. `int 8000`, `default_factory=list`) and `str` defaults for `str`-typed fields are left untouched.
|
|
139
|
+
|
|
133
140
|
---
|
|
134
141
|
|
|
135
142
|
## Aliases
|
|
@@ -186,6 +193,45 @@ See the [Configuration Documentation guide](configuration-docs.md) for examples
|
|
|
186
193
|
|
|
187
194
|
---
|
|
188
195
|
|
|
196
|
+
## String Stripping
|
|
197
|
+
|
|
198
|
+
The `strip` parameter cleans raw string values **before** coercion and validation. It applies to string-like fields: `str`, `SecretStr`, their `Optional` forms, `str` subclasses (`HttpUrl`, `PostgresDsn`, `RedisDsn`), and `Literal["a", "b"]` fields whose every member is `str`.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
import re
|
|
202
|
+
|
|
203
|
+
class Config(DotEnvConfig):
|
|
204
|
+
# Whitespace strip: " hello " -> "hello"
|
|
205
|
+
name: str = Field(strip=True)
|
|
206
|
+
|
|
207
|
+
# Char-set strip (str.strip(chars) semantics): ",'hello'," -> "hello"
|
|
208
|
+
tag: str = Field(strip=",'\"")
|
|
209
|
+
|
|
210
|
+
# Regex strip: removes every match, anywhere in the string
|
|
211
|
+
key: SecretStr = Field(strip=re.compile(r"^['\"]+|['\"]+$"))
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Set the `strip_strings` class attribute to strip every string-like field by default; per-field `strip` overrides it:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
class Config(DotEnvConfig):
|
|
218
|
+
strip_strings: bool = True
|
|
219
|
+
|
|
220
|
+
name: str = Field() # stripped (inherits class setting)
|
|
221
|
+
literal: str = Field(strip=False) # per-field override wins
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
!!! note "Stripping is processing, not validation"
|
|
225
|
+
`strip` runs even with `validate=False`, and constraints see the stripped value — `min_length` checks the final length, and a whitespace-only value for an `Optional[str]` field strips to `""`, which maps to `None`.
|
|
226
|
+
|
|
227
|
+
!!! note "Strip runs before URL-unquoting on `SecretStr`"
|
|
228
|
+
For `SecretStr` fields, `strip` is applied to the raw value **before** `url_unquote`, so percent-encoded whitespace (e.g. `%20`) survives stripping — it is removed while still percent-encoded, then unquoted. Use a `re.Pattern` strip if you need to strip decoded whitespace.
|
|
229
|
+
|
|
230
|
+
!!! warning "Use linear-time regex patterns"
|
|
231
|
+
Both the `regex` constraint and `strip` with an `re.Pattern` run developer-supplied patterns against env values, which can be operator-controlled. Avoid patterns with nested quantifiers (e.g. `(a+)+`, `(a*)*`) that can cause catastrophic backtracking (ReDoS). Prefer anchored, linear-time patterns.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
189
235
|
## Path Options
|
|
190
236
|
|
|
191
237
|
Two parameters control `Path` field behavior:
|
|
@@ -185,6 +185,10 @@ config.reload(env="prod")
|
|
|
185
185
|
config.reload(override=False)
|
|
186
186
|
```
|
|
187
187
|
|
|
188
|
+
!!! warning "A failed reload can leave the instance partially reloaded"
|
|
189
|
+
|
|
190
|
+
Fields are reloaded onto the same instance one at a time, so if validation (or the `post_load` hook) fails mid-reload, fields already reloaded keep their new values while the rest keep the old. If you catch reload errors, treat the instance as suspect — or build a fresh one with `load()` instead.
|
|
191
|
+
|
|
188
192
|
!!! info "reload() returns the same instance"
|
|
189
193
|
|
|
190
194
|
`reload()` returns `self`, making it useful for method chaining:
|