dotenvmodel 0.3.2__tar.gz → 0.5.0__tar.gz

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