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.
Files changed (115) hide show
  1. dotenvmodel-0.5.0/.release-please-manifest.json +3 -0
  2. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/CHANGELOG.md +22 -0
  3. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/PKG-INFO +69 -2
  4. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/README.md +68 -1
  5. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/SECURITY.md +1 -1
  6. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/loading.md +4 -0
  7. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/validation.md +81 -0
  8. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/index.md +2 -1
  9. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/__init__.py +3 -1
  10. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/config.py +110 -30
  11. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/describe/formatters.py +7 -4
  12. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/exceptions.py +3 -2
  13. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/metaclass.py +12 -7
  14. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/types.py +9 -7
  15. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/mkdocs.yml +4 -0
  16. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/pyproject.toml +1 -1
  17. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_basic.py +1 -1
  18. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_coercion.py +11 -2
  19. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_adoption.py +3 -1
  20. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_path_options.py +4 -5
  21. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_field_constraints.py +1 -1
  22. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_future_annotations.py +3 -7
  23. dotenvmodel-0.5.0/tests/test_post_load.py +541 -0
  24. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_secret_redaction.py +10 -9
  25. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_secretstr_security.py +6 -6
  26. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_string_affixes.py +6 -6
  27. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_strip.py +5 -5
  28. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_validator.py +41 -6
  29. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/uv.lock +1 -1
  30. dotenvmodel-0.4.0/.release-please-manifest.json +0 -3
  31. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/CODEOWNERS +0 -0
  32. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  33. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  34. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/dependabot.yml +0 -0
  35. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/pull_request_template.md +0 -0
  36. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/ci.yml +0 -0
  37. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/docs.yml +0 -0
  38. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/publish.yml +0 -0
  39. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.github/workflows/release-please.yml +0 -0
  40. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.gitignore +0 -0
  41. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/.python-version +0 -0
  42. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  43. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/CONTRIBUTING.md +0 -0
  44. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/LICENSE +0 -0
  45. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/Makefile +0 -0
  46. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/coercion.md +0 -0
  47. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/config.md +0 -0
  48. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/constants.md +0 -0
  49. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/describe-formatters.md +0 -0
  50. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/describe-renderers.md +0 -0
  51. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/describe.md +0 -0
  52. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/dotenvmodel.md +0 -0
  53. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/exceptions.md +0 -0
  54. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/fields.md +0 -0
  55. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/loading.md +0 -0
  56. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/logging-config.md +0 -0
  57. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/metaclass.md +0 -0
  58. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/types.md +0 -0
  59. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/api-reference/validation.md +0 -0
  60. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/changelog.md +0 -0
  61. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/examples/complete-app.md +0 -0
  62. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/getting-started/installation.md +0 -0
  63. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/getting-started/quick-start.md +0 -0
  64. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/configuration-docs.md +0 -0
  65. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/error-handling.md +0 -0
  66. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/fields.md +0 -0
  67. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/logging.md +0 -0
  68. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/prefixes.md +0 -0
  69. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/docs/guides/types.md +0 -0
  70. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/_constants.py +0 -0
  71. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/_redaction.py +0 -0
  72. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/coercion.py +0 -0
  73. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/describe/__init__.py +0 -0
  74. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/describe/renderers.py +0 -0
  75. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/fields.py +0 -0
  76. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/loading.py +0 -0
  77. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/logging_config.py +0 -0
  78. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/py.typed +0 -0
  79. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/dotenvmodel/validation.py +0 -0
  80. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/advanced_types.py +0 -0
  81. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/basic_usage.py +0 -0
  82. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/describe_documentation.py +0 -0
  83. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/generate_env_example.py +0 -0
  84. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/examples/logging_example.py +0 -0
  85. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/release-please-config.json +0 -0
  86. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/__init__.py +0 -0
  87. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/conftest.py +0 -0
  88. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_collection_validators.py +0 -0
  89. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_defaults_coercion.py +0 -0
  90. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_describe.py +0 -0
  91. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_datetime.py +0 -0
  92. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_describe_output.py +0 -0
  93. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_enum.py +0 -0
  94. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_field_options.py +0 -0
  95. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_numbers.py +0 -0
  96. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_edge_strings.py +0 -0
  97. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_empty_collection_items.py +0 -0
  98. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_empty_strings.py +0 -0
  99. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_enum.py +0 -0
  100. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_errors.py +0 -0
  101. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_inheritance.py +0 -0
  102. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_json.py +0 -0
  103. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_json_and_optional.py +0 -0
  104. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_loading.py +0 -0
  105. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_logging_config.py +0 -0
  106. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_nested_config.py +0 -0
  107. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_prefix.py +0 -0
  108. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_reload.py +0 -0
  109. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_strip_integration.py +0 -0
  110. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_types.py +0 -0
  111. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_union_types.py +0 -0
  112. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_url_dsn.py +0 -0
  113. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_url_password_decoding.py +0 -0
  114. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_url_unquote.py +0 -0
  115. {dotenvmodel-0.4.0 → dotenvmodel-0.5.0}/tests/test_urls.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.5.0"
3
+ }
@@ -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.4.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, string `strip` processing, and collection size constraints.
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.4.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's exception text which may embed the plaintext secret
57
- or a URL password is never embedded, and the masked error is raised
58
- outside the ``except`` block with ``__cause__``/``__context__`` cleared so
59
- the hook exception appears nowhere in the chain or traceback frame locals.
60
- A plain-``str`` return value is re-wrapped in the declared type so the
61
- secret stays masked in ``repr``.
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
- # Drop the bound exception entirely; raise the masked error outside the
82
- # except so __context__ stays None.
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=report_value,
98
- constraint=constraint,
99
- error_msg="Custom validator rejected the value",
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
- if errors:
462
- if len(errors) == 1:
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, # type: ignore[type-arg]
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
- if hasattr(error, "constraint") and error.constraint is not None: # type: ignore[attr-defined]
299
- msg += f" Constraint: {error.constraint}\n" # type: ignore[attr-defined]
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()