dotenvmodel 0.4.0__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.4.0 → dotenvmodel-0.5.0}/CHANGELOG.md +22 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/PKG-INFO +69 -2
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/README.md +68 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/SECURITY.md +1 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/loading.md +4 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/validation.md +81 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/index.md +2 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/__init__.py +3 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/config.py +110 -30
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/describe/formatters.py +7 -4
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/exceptions.py +3 -2
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/metaclass.py +12 -7
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/types.py +9 -7
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/mkdocs.yml +4 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/pyproject.toml +1 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_basic.py +1 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_coercion.py +11 -2
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_adoption.py +3 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_path_options.py +4 -5
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_field_constraints.py +1 -1
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_future_annotations.py +3 -7
- dotenvmodel-0.5.0/tests/test_post_load.py +541 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_secret_redaction.py +10 -9
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_secretstr_security.py +6 -6
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_string_affixes.py +6 -6
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_strip.py +5 -5
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_validator.py +41 -6
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/uv.lock +1 -1
- dotenvmodel-0.4.0/.release-please-manifest.json +0 -3
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/ci.yml +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/publish.yml +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.gitignore +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.python-version +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/LICENSE +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/Makefile +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/changelog.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/fields.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/types.md +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/_redaction.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/coercion.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/fields.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/validation.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/logging_example.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/release-please-config.json +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/__init__.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/conftest.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_defaults_coercion.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_describe.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_enum.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_errors.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_json.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_loading.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_nested_config.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_reload.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_strip_integration.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_types.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_urls.py +0 -0
|
@@ -5,6 +5,28 @@ 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
|
+
|
|
8
30
|
## [0.4.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.2...v0.4.0) (2026-07-20)
|
|
9
31
|
|
|
10
32
|
|
|
@@ -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,7 @@ 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, starts_with, ends_with), choice validation, custom validator hooks, 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
48
|
- **String Stripping**: Per-field and class-level whitespace/char-set/regex stripping of raw values before coercion
|
|
49
49
|
- **Clear Error Messages**: Helpful validation errors that guide you to fixes
|
|
50
50
|
- **Optional Logging**: Built-in logging support to debug configuration loading
|
|
@@ -525,6 +525,73 @@ Semantics to know:
|
|
|
525
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
526
|
- Raise `ConstraintViolationError` directly for a fully custom message on non-sensitive fields (it passes through untouched).
|
|
527
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
|
+
|
|
528
595
|
## Loading Configuration
|
|
529
596
|
|
|
530
597
|
### From Environment Variables
|
|
@@ -16,7 +16,7 @@
|
|
|
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, starts_with, ends_with), choice validation, custom validator hooks, 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
20
|
- **String Stripping**: Per-field and class-level whitespace/char-set/regex stripping of raw values before coercion
|
|
21
21
|
- **Clear Error Messages**: Helpful validation errors that guide you to fixes
|
|
22
22
|
- **Optional Logging**: Built-in logging support to debug configuration loading
|
|
@@ -497,6 +497,73 @@ Semantics to know:
|
|
|
497
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
498
|
- Raise `ConstraintViolationError` directly for a fully custom message on non-sensitive fields (it passes through untouched).
|
|
499
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
|
+
|
|
500
567
|
## Loading Configuration
|
|
501
568
|
|
|
502
569
|
### From Environment Variables
|
|
@@ -26,5 +26,5 @@ We follow coordinated disclosure. Once a fix is released, we will publish a GitH
|
|
|
26
26
|
|
|
27
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
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`). 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.
|
|
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
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.
|
|
@@ -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:
|
|
@@ -264,6 +264,87 @@ Semantics:
|
|
|
264
264
|
|
|
265
265
|
---
|
|
266
266
|
|
|
267
|
+
## Cross-Field Validation with `post_load`
|
|
268
|
+
|
|
269
|
+
Constraints and per-field `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.
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
from dotenvmodel import DotEnvConfig, Field, ValidationError
|
|
273
|
+
|
|
274
|
+
class WorkerConfig(DotEnvConfig):
|
|
275
|
+
primary_dsn: str = Field(default="postgresql://localhost/primary")
|
|
276
|
+
replica_dsn: str | None = Field(default=None)
|
|
277
|
+
heartbeat_interval: int = Field(default=5, ge=1) # seconds
|
|
278
|
+
lock_lease: int = Field(default=30, ge=1) # seconds
|
|
279
|
+
|
|
280
|
+
def post_load(self) -> list[ValidationError] | None:
|
|
281
|
+
# Fix / transform: derived value — fall back to the primary DSN.
|
|
282
|
+
if self.replica_dsn is None:
|
|
283
|
+
self.replica_dsn = self.primary_dsn
|
|
284
|
+
|
|
285
|
+
# Cross-validate: invariant spanning two fields.
|
|
286
|
+
if self.lock_lease < 4 * self.heartbeat_interval:
|
|
287
|
+
return [
|
|
288
|
+
ValidationError(
|
|
289
|
+
field_name="lock_lease", # tag the primary field
|
|
290
|
+
value=self.lock_lease,
|
|
291
|
+
error_msg="lock_lease must be >= 4 * heartbeat_interval",
|
|
292
|
+
)
|
|
293
|
+
]
|
|
294
|
+
return None
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
!!! example "Valid vs Invalid"
|
|
298
|
+
=== "Valid"
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
config = WorkerConfig.load_from_dict({})
|
|
302
|
+
# Fallback applied even though no env vars were set:
|
|
303
|
+
assert config.replica_dsn == "postgresql://localhost/primary"
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
=== "Invalid"
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
# .env — 10 < 4 * 5
|
|
310
|
+
LOCK_LEASE=10
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
# Raises ValidationError:
|
|
315
|
+
# Field 'lock_lease' validation failed:
|
|
316
|
+
# Value: 10
|
|
317
|
+
# Error: lock_lease must be >= 4 * heartbeat_interval
|
|
318
|
+
# Environment variable: LOCK_LEASE
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Usage modes (combinable in one body):
|
|
322
|
+
|
|
323
|
+
| Mode | How | Use for |
|
|
324
|
+
|------|-----|---------|
|
|
325
|
+
| Fix / transform | Mutate `self`, return `None` | Derived values, fallbacks, normalization |
|
|
326
|
+
| Cross-validate | Return `list[ValidationError]` | Invariants spanning multiple fields |
|
|
327
|
+
| Continue | Log or swallow issues internally, return `None` | Non-fatal drift you only want to observe |
|
|
328
|
+
| Fatal | `raise` | Unexpected/programming errors — an exception that is neither `ValidationError` nor `MultipleValidationErrors` propagates unchanged (never wrapped or aggregated), even from a nested hook |
|
|
329
|
+
|
|
330
|
+
Semantics:
|
|
331
|
+
|
|
332
|
+
- Return `None` or `[]` → success. One returned error → raised directly, its exact type preserved (e.g. `ConstraintViolationError`). Several → raised as `MultipleValidationErrors`.
|
|
333
|
+
- Returned errors are the same `ValidationError` objects field validation produces, so introspection is uniform: iterating `MultipleValidationErrors.errors` yields `field_name`, `env_var_name`, `value`, and `error_msg` per error, whether the failure came from a constraint, a per-field `validator`, or `post_load`. See [Error Handling](error-handling.md).
|
|
334
|
+
- The hook runs **only when every field loaded cleanly** — cross-field checks always see **coerced** values; constraint validation has also been applied 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`.
|
|
335
|
+
- **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.
|
|
336
|
+
|
|
337
|
+
!!! warning "Keep secrets out of error messages"
|
|
338
|
+
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.
|
|
339
|
+
|
|
340
|
+
!!! note "When the hook does not run"
|
|
341
|
+
`post_load()` does not run on bare `Cls()` construction (no load path is involved), and it does not run if any field fails to load. On `reload()` the hook re-runs against the freshly reloaded state; if it (or field validation) fails mid-reload, the instance may be **partially reloaded** — the same caveat as field errors during reload.
|
|
342
|
+
|
|
343
|
+
!!! tip "Pattern lineage"
|
|
344
|
+
The hook 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).
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
267
348
|
## Collection Size Constraints
|
|
268
349
|
|
|
269
350
|
The `min_items` and `max_items` parameters constrain the number of items in `list`, `set`, `tuple`, and `dict` fields.
|
|
@@ -45,7 +45,8 @@ safety, validation, and automatic `.env` loading — with only one runtime depen
|
|
|
45
45
|
|
|
46
46
|
Numeric constraints (`ge`, `le`, `gt`, `lt`), string constraints (`min_length`,
|
|
47
47
|
`max_length`, `regex`, `starts_with`, `ends_with`), choice validation, custom
|
|
48
|
-
`validator` hooks,
|
|
48
|
+
`validator` hooks, a model-level `post_load` hook for cross-field validation,
|
|
49
|
+
string `strip` processing, and collection size constraints.
|
|
49
50
|
|
|
50
51
|
- :material-file-document-multiple:{ .lg .middle } **Configuration Documentation**
|
|
51
52
|
|
|
@@ -21,6 +21,8 @@ Public API:
|
|
|
21
21
|
- `Field`: Define fields with defaults, validation, and aliases
|
|
22
22
|
- `Required`: Sentinel for required fields (alternative to `Field()`)
|
|
23
23
|
- `ValidatorContext`: Context passed to `Field(validator=...)` hooks
|
|
24
|
+
- `DotEnvConfig.post_load`: Model-level hook for cross-field validation
|
|
25
|
+
and normalization after loading
|
|
24
26
|
- `SecretStr`: String type that hides values in logs
|
|
25
27
|
- `HttpUrl`, `PostgresDsn`, `RedisDsn`: URL/DSN types with validation
|
|
26
28
|
- `Json`: Type for parsing JSON strings
|
|
@@ -32,7 +34,7 @@ Public API:
|
|
|
32
34
|
Exception hierarchy
|
|
33
35
|
"""
|
|
34
36
|
|
|
35
|
-
__version__ = "0.
|
|
37
|
+
__version__ = "0.5.0"
|
|
36
38
|
__author__ = "AZX, PBC."
|
|
37
39
|
__email__ = "oss@azx.io"
|
|
38
40
|
__license__ = "MIT"
|
|
@@ -53,15 +53,20 @@ def _run_sensitive_validator(
|
|
|
53
53
|
"""Run a validator hook for a sensitive-typed field.
|
|
54
54
|
|
|
55
55
|
Any hook failure (``ConstraintViolationError`` or any other ``Exception``)
|
|
56
|
-
is masked: the hook
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
is masked: nothing from the hook exception — message, ``constraint``, or
|
|
57
|
+
any other text, any of which may embed the plaintext secret or a URL
|
|
58
|
+
password — is carried into the raised error, which uses a generic
|
|
59
|
+
``validator=<name>`` constraint and message. The masked error is raised
|
|
60
|
+
outside the ``except`` block with ``__cause__``/``__context__`` cleared
|
|
61
|
+
(an empty chain). A plain-``str`` return value is re-wrapped in the
|
|
62
|
+
declared type so the secret stays masked in ``repr``.
|
|
63
|
+
|
|
64
|
+
Note:
|
|
65
|
+
Traceback frame locals across the load path still reference the live
|
|
66
|
+
value (this frame's ``value``, caller frames' ``raw_value`` and
|
|
67
|
+
``value``), so locals-capturing error reporting must not be enabled
|
|
68
|
+
for processes loading secrets — see SECURITY.md.
|
|
62
69
|
"""
|
|
63
|
-
constraint = f"validator={name}"
|
|
64
|
-
cve_constraint: str | None = None
|
|
65
70
|
failed = False
|
|
66
71
|
result: Any = None
|
|
67
72
|
try:
|
|
@@ -73,30 +78,18 @@ def _run_sensitive_validator(
|
|
|
73
78
|
# construction is caught below and masked.
|
|
74
79
|
if isinstance(result, str) and not isinstance(result, unwrapped_type):
|
|
75
80
|
result = unwrapped_type(result)
|
|
76
|
-
except ConstraintViolationError as e:
|
|
77
|
-
cve_constraint = e.constraint
|
|
78
|
-
failed = True
|
|
79
|
-
del e
|
|
80
81
|
except Exception:
|
|
81
|
-
#
|
|
82
|
-
#
|
|
82
|
+
# Carry nothing over from the hook exception — message, constraint, or
|
|
83
|
+
# any other text may embed the plaintext secret. Raising the masked
|
|
84
|
+
# error outside the except keeps __context__ None.
|
|
83
85
|
failed = True
|
|
84
86
|
|
|
85
87
|
if failed:
|
|
86
|
-
report_value = _masked_report_value(value)
|
|
87
|
-
if cve_constraint is not None:
|
|
88
|
-
raise ConstraintViolationError(
|
|
89
|
-
field_name=field_name,
|
|
90
|
-
value=report_value,
|
|
91
|
-
constraint=cve_constraint,
|
|
92
|
-
error_msg=f"validator={name} rejected the value",
|
|
93
|
-
env_var_name=env_var_name,
|
|
94
|
-
) from None
|
|
95
88
|
raise ConstraintViolationError(
|
|
96
89
|
field_name=field_name,
|
|
97
|
-
value=
|
|
98
|
-
constraint=
|
|
99
|
-
error_msg="
|
|
90
|
+
value=_masked_report_value(value),
|
|
91
|
+
constraint=f"validator={name}",
|
|
92
|
+
error_msg=f"validator={name} rejected the value",
|
|
100
93
|
env_var_name=env_var_name,
|
|
101
94
|
) from None
|
|
102
95
|
|
|
@@ -218,6 +211,29 @@ def _run_field_validator(
|
|
|
218
211
|
)
|
|
219
212
|
|
|
220
213
|
|
|
214
|
+
def _raise_collected(errors: list[ValidationError] | None) -> None:
|
|
215
|
+
"""Raise collected validation errors, preserving single-error types.
|
|
216
|
+
|
|
217
|
+
Shared by the field-error loop and the `post_load` hook in
|
|
218
|
+
`DotEnvConfig._load_fields`. A single error is raised unchanged so its
|
|
219
|
+
specific type (e.g. `MissingFieldError`, `ConstraintViolationError`)
|
|
220
|
+
reaches the caller; several are aggregated into
|
|
221
|
+
`MultipleValidationErrors`. `None` and an empty list both mean success.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
errors: Collected errors, or `None` when the source reports success.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ValidationError: The single collected error, raised unchanged.
|
|
228
|
+
MultipleValidationErrors: If two or more errors were collected.
|
|
229
|
+
"""
|
|
230
|
+
if not errors:
|
|
231
|
+
return
|
|
232
|
+
if len(errors) == 1:
|
|
233
|
+
raise errors[0]
|
|
234
|
+
raise MultipleValidationErrors(errors)
|
|
235
|
+
|
|
236
|
+
|
|
221
237
|
class DotEnvConfig(metaclass=ConfigMeta):
|
|
222
238
|
"""Base class for type-safe environment configuration.
|
|
223
239
|
|
|
@@ -458,10 +474,8 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
458
474
|
# it escape uncaught past the aggregation loop.
|
|
459
475
|
errors.extend(e.errors)
|
|
460
476
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
raise errors[0]
|
|
464
|
-
raise MultipleValidationErrors(errors)
|
|
477
|
+
_raise_collected(errors)
|
|
478
|
+
_raise_collected(self.post_load())
|
|
465
479
|
|
|
466
480
|
@classmethod
|
|
467
481
|
def load(
|
|
@@ -667,6 +681,72 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
667
681
|
instance._loaded = True
|
|
668
682
|
return instance
|
|
669
683
|
|
|
684
|
+
def post_load(self) -> list[ValidationError] | None:
|
|
685
|
+
"""Normalize derived values and run cross-field validation after loading.
|
|
686
|
+
|
|
687
|
+
Runs once after all fields are loaded and validated, on every load
|
|
688
|
+
path: `load()`, `load_from_dict()`, `reload()`, and nested config
|
|
689
|
+
loading. Always runs, including with `validate=False` (consistent
|
|
690
|
+
with the per-field `validator` hook: transformation is part of
|
|
691
|
+
loading). The default implementation is a no-op.
|
|
692
|
+
|
|
693
|
+
Usage modes (combinable in one body):
|
|
694
|
+
|
|
695
|
+
- Fix / transform: mutate `self` (e.g. apply fallback values),
|
|
696
|
+
return `None`.
|
|
697
|
+
- Cross-validate: return a list of `ValidationError`. One error is
|
|
698
|
+
raised directly; several are raised as `MultipleValidationErrors`.
|
|
699
|
+
- Continue: log or swallow issues internally, return `None`.
|
|
700
|
+
- Fatal: raise; an exception that is neither `ValidationError`
|
|
701
|
+
nor `MultipleValidationErrors` propagates unchanged.
|
|
702
|
+
|
|
703
|
+
Tag each returned error with the primary field name and reference
|
|
704
|
+
other participating fields in `error_msg`. Do not embed secret
|
|
705
|
+
values in `error_msg` — the library redacts the `value` attribute
|
|
706
|
+
but cannot mask prose. The same applies to exceptions raised from
|
|
707
|
+
the hook: they propagate unmasked, so never interpolate secrets
|
|
708
|
+
into a raised exception's message. The hook runs only when every
|
|
709
|
+
field loaded cleanly, and does not run on bare `Cls()` construction.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
`None` or an empty list on success; a list of `ValidationError`
|
|
713
|
+
describing cross-field violations otherwise.
|
|
714
|
+
|
|
715
|
+
Example:
|
|
716
|
+
```python
|
|
717
|
+
class DatabaseConfig(DotEnvConfig):
|
|
718
|
+
primary_dsn: str = Field()
|
|
719
|
+
replica_dsn: str | None = Field(default=None)
|
|
720
|
+
pool_min: int = Field(default=1)
|
|
721
|
+
pool_max: int = Field(default=10)
|
|
722
|
+
|
|
723
|
+
def post_load(self) -> list[ValidationError] | None:
|
|
724
|
+
# Fix / transform: fall back to the primary DSN.
|
|
725
|
+
if self.replica_dsn is None:
|
|
726
|
+
self.replica_dsn = self.primary_dsn
|
|
727
|
+
|
|
728
|
+
# Cross-validate: pool bounds must stay coherent.
|
|
729
|
+
if self.pool_min > self.pool_max:
|
|
730
|
+
return [
|
|
731
|
+
ValidationError(
|
|
732
|
+
field_name="pool_min",
|
|
733
|
+
value=self.pool_min,
|
|
734
|
+
error_msg="pool_min must be <= pool_max",
|
|
735
|
+
)
|
|
736
|
+
]
|
|
737
|
+
return None
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
See Also:
|
|
741
|
+
- [`load`][dotenvmodel.config.DotEnvConfig.load]: Triggers this hook.
|
|
742
|
+
- [`reload`][dotenvmodel.config.DotEnvConfig.reload]: Re-runs this hook.
|
|
743
|
+
- [`Field`][dotenvmodel.fields.Field]: Per-field `validator` hook for
|
|
744
|
+
single-field validation and transformation.
|
|
745
|
+
- [`MultipleValidationErrors`][dotenvmodel.exceptions.MultipleValidationErrors]:
|
|
746
|
+
Raised when this hook returns several errors.
|
|
747
|
+
"""
|
|
748
|
+
return None
|
|
749
|
+
|
|
670
750
|
def dict(self) -> dict[str, Any]:
|
|
671
751
|
"""Return configuration as a dictionary with actual values.
|
|
672
752
|
|
|
@@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|
|
10
10
|
from datetime import timedelta
|
|
11
11
|
from enum import Enum
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Any, Union, get_args, get_origin
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
|
|
14
14
|
|
|
15
15
|
from typing_extensions import TypeForm
|
|
16
16
|
|
|
@@ -19,6 +19,9 @@ from dotenvmodel.coercion import is_string_like_type
|
|
|
19
19
|
from dotenvmodel.fields import _MISSING, FieldInfo, _validator_name
|
|
20
20
|
from dotenvmodel.types import BaseDsn, SecretStr
|
|
21
21
|
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from dotenvmodel.config import DotEnvConfig
|
|
24
|
+
|
|
22
25
|
# Maximum column widths to prevent unbounded table growth
|
|
23
26
|
MAX_WIDTHS = {
|
|
24
27
|
0: 40, # ENV Variable
|
|
@@ -349,7 +352,7 @@ def format_constraints(
|
|
|
349
352
|
return ", ".join(constraints) if constraints else "-"
|
|
350
353
|
|
|
351
354
|
|
|
352
|
-
def _union_members(field_type: TypeForm[Any]) -> list[Any]:
|
|
355
|
+
def _union_members(field_type: TypeForm[Any] | types.UnionType) -> list[Any]:
|
|
353
356
|
"""Return the non-None members of an Optional/Union, else ``[field_type]``.
|
|
354
357
|
|
|
355
358
|
Handles multi-member unions (``PostgresDsn | RedisDsn | None``) so a DSN or
|
|
@@ -361,7 +364,7 @@ def _union_members(field_type: TypeForm[Any]) -> list[Any]:
|
|
|
361
364
|
return [field_type]
|
|
362
365
|
|
|
363
366
|
|
|
364
|
-
def _is_type_in_union(field_type: TypeForm[Any], target: type) -> bool:
|
|
367
|
+
def _is_type_in_union(field_type: TypeForm[Any] | types.UnionType, target: type) -> bool:
|
|
365
368
|
"""True if any member of ``field_type`` is a subclass of ``target``."""
|
|
366
369
|
return any(isinstance(m, type) and issubclass(m, target) for m in _union_members(field_type))
|
|
367
370
|
|
|
@@ -422,7 +425,7 @@ def format_default(field_info: FieldInfo, field_type: TypeForm[Any], truncate: b
|
|
|
422
425
|
|
|
423
426
|
|
|
424
427
|
def describe_class(
|
|
425
|
-
config_cls: type
|
|
428
|
+
config_cls: type[DotEnvConfig],
|
|
426
429
|
truncate: bool = True,
|
|
427
430
|
) -> tuple[str, str, list[FieldDescription]]:
|
|
428
431
|
"""Extract field descriptions from a config class.
|
|
@@ -295,7 +295,8 @@ class MultipleValidationErrors(DotEnvModelError):
|
|
|
295
295
|
if error.value is not None:
|
|
296
296
|
msg += f" Value: {_value_repr(error.value)}\n"
|
|
297
297
|
msg += f" Environment variable: {error.env_var_name}\n"
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
constraint = getattr(error, "constraint", None)
|
|
299
|
+
if constraint is not None:
|
|
300
|
+
msg += f" Constraint: {constraint}\n"
|
|
300
301
|
msg += "\n"
|
|
301
302
|
return msg.rstrip()
|