dotenvmodel 0.3.2__tar.gz → 0.4.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.4.0/.release-please-manifest.json +3 -0
  2. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/CHANGELOG.md +7 -0
  3. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/PKG-INFO +78 -2
  4. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/README.md +77 -1
  5. dotenvmodel-0.4.0/SECURITY.md +30 -0
  6. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/fields.md +47 -1
  7. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/validation.md +50 -0
  8. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/index.md +2 -1
  9. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/__init__.py +4 -2
  10. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/coercion.py +69 -0
  11. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/config.py +254 -5
  12. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/describe/formatters.py +40 -3
  13. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/fields.py +152 -2
  14. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/metaclass.py +15 -1
  15. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/types.py +35 -1
  16. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/validation.py +20 -3
  17. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/pyproject.toml +4 -4
  18. dotenvmodel-0.4.0/tests/test_defaults_coercion.py +245 -0
  19. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_describe.py +269 -0
  20. dotenvmodel-0.4.0/tests/test_string_affixes.py +310 -0
  21. dotenvmodel-0.4.0/tests/test_strip.py +558 -0
  22. dotenvmodel-0.4.0/tests/test_strip_integration.py +124 -0
  23. dotenvmodel-0.4.0/tests/test_validator.py +730 -0
  24. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/uv.lock +120 -120
  25. dotenvmodel-0.3.2/.release-please-manifest.json +0 -3
  26. dotenvmodel-0.3.2/SECURITY.md +0 -23
  27. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/CODEOWNERS +0 -0
  28. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  29. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  30. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/dependabot.yml +0 -0
  31. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/pull_request_template.md +0 -0
  32. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/workflows/ci.yml +0 -0
  33. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/workflows/docs.yml +0 -0
  34. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/workflows/publish.yml +0 -0
  35. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.github/workflows/release-please.yml +0 -0
  36. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.gitignore +0 -0
  37. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/.python-version +0 -0
  38. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  39. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/CONTRIBUTING.md +0 -0
  40. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/LICENSE +0 -0
  41. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/Makefile +0 -0
  42. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/coercion.md +0 -0
  43. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/config.md +0 -0
  44. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/constants.md +0 -0
  45. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/describe-formatters.md +0 -0
  46. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/describe-renderers.md +0 -0
  47. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/describe.md +0 -0
  48. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/dotenvmodel.md +0 -0
  49. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/exceptions.md +0 -0
  50. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/fields.md +0 -0
  51. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/loading.md +0 -0
  52. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/logging-config.md +0 -0
  53. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/metaclass.md +0 -0
  54. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/types.md +0 -0
  55. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/api-reference/validation.md +0 -0
  56. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/changelog.md +0 -0
  57. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/examples/complete-app.md +0 -0
  58. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/getting-started/installation.md +0 -0
  59. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/getting-started/quick-start.md +0 -0
  60. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/configuration-docs.md +0 -0
  61. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/error-handling.md +0 -0
  62. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/loading.md +0 -0
  63. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/logging.md +0 -0
  64. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/prefixes.md +0 -0
  65. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/docs/guides/types.md +0 -0
  66. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/_constants.py +0 -0
  67. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/_redaction.py +0 -0
  68. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/describe/__init__.py +0 -0
  69. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/describe/renderers.py +0 -0
  70. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/exceptions.py +0 -0
  71. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/loading.py +0 -0
  72. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/logging_config.py +0 -0
  73. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/dotenvmodel/py.typed +0 -0
  74. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/examples/advanced_types.py +0 -0
  75. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/examples/basic_usage.py +0 -0
  76. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/examples/describe_documentation.py +0 -0
  77. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/examples/generate_env_example.py +0 -0
  78. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/examples/logging_example.py +0 -0
  79. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/mkdocs.yml +0 -0
  80. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/release-please-config.json +0 -0
  81. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/__init__.py +0 -0
  82. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/conftest.py +0 -0
  83. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_basic.py +0 -0
  84. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_coercion.py +0 -0
  85. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_collection_validators.py +0 -0
  86. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_adoption.py +0 -0
  87. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_datetime.py +0 -0
  88. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_describe_output.py +0 -0
  89. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_enum.py +0 -0
  90. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_field_options.py +0 -0
  91. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_numbers.py +0 -0
  92. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_path_options.py +0 -0
  93. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_edge_strings.py +0 -0
  94. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_empty_collection_items.py +0 -0
  95. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_empty_strings.py +0 -0
  96. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_enum.py +0 -0
  97. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_errors.py +0 -0
  98. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_field_constraints.py +0 -0
  99. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_future_annotations.py +0 -0
  100. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_inheritance.py +0 -0
  101. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_json.py +0 -0
  102. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_json_and_optional.py +0 -0
  103. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_loading.py +0 -0
  104. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_logging_config.py +0 -0
  105. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_nested_config.py +0 -0
  106. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_prefix.py +0 -0
  107. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_reload.py +0 -0
  108. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_secret_redaction.py +0 -0
  109. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_secretstr_security.py +0 -0
  110. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_types.py +0 -0
  111. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_union_types.py +0 -0
  112. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_url_dsn.py +0 -0
  113. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_url_password_decoding.py +0 -0
  114. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_url_unquote.py +0 -0
  115. {dotenvmodel-0.3.2 → dotenvmodel-0.4.0}/tests/test_urls.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.0"
3
+ }
@@ -5,6 +5,13 @@ 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.4.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.2...v0.4.0) (2026-07-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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))
14
+
8
15
  ## [0.3.2](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.1...v0.3.2) (2026-07-15)
9
16
 
10
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotenvmodel
3
- Version: 0.3.2
3
+ Version: 0.4.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, 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,39 @@ 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
+
452
528
  ## Loading Configuration
453
529
 
454
530
  ### 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, 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,39 @@ 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
+
424
500
  ## Loading Configuration
425
501
 
426
502
  ### 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`). 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:
@@ -6,6 +6,9 @@ Constraints are defined as parameters to `Field()`. This page covers every suppo
6
6
 
7
7
  For the complete API reference, see [Validation API](../api-reference/validation.md).
8
8
 
9
+ !!! note "Empty vs missing values"
10
+ `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=...)`.
11
+
9
12
  ---
10
13
 
11
14
  ## Numeric Constraints
@@ -95,6 +98,8 @@ String constraints apply to `str` and `SecretStr` fields.
95
98
  | `min_length` | Minimum string length (inclusive) |
96
99
  | `max_length` | Maximum string length (inclusive) |
97
100
  | `regex` | Regular expression pattern the string must match (uses `re.match`) |
101
+ | `starts_with` | Required string prefix |
102
+ | `ends_with` | Required string suffix |
98
103
 
99
104
  ### Examples
100
105
 
@@ -112,6 +117,10 @@ class Config(DotEnvConfig):
112
117
  # Regex pattern
113
118
  email: str = Field(regex=r'^[\w\.-]+@[\w\.-]+\.\w+$')
114
119
 
120
+ # Required prefix / suffix
121
+ client_key: str = Field(starts_with="sk-")
122
+ signed_token: str = Field(ends_with=".sig")
123
+
115
124
  # Combined constraints
116
125
  password: str = Field(
117
126
  min_length=8,
@@ -159,6 +168,9 @@ class Config(DotEnvConfig):
159
168
  !!! note "Regex uses `re.match`"
160
169
  The `regex` constraint uses `re.match`, which anchors at the **start** of the string. Include `^` and `$` in your pattern to anchor both ends, as shown in the examples above.
161
170
 
171
+ !!! warning "Use linear-time patterns (ReDoS)"
172
+ `regex` 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.
173
+
162
174
  ---
163
175
 
164
176
  ## Choice Validation
@@ -214,6 +226,44 @@ class Config(DotEnvConfig):
214
226
 
215
227
  ---
216
228
 
229
+ ## Custom Validators
230
+
231
+ For logic the built-in constraints can't express, attach a `validator` hook. It receives the **coerced, built-in-constraint-validated** value plus a `ValidatorContext` (field name and resolved env var name), and its return value becomes the final field value — so a hook can also transform.
232
+
233
+ ```python
234
+ from dotenvmodel import DotEnvConfig, Field, SecretStr, ValidatorContext
235
+
236
+ def check_api_key(value: SecretStr, ctx: ValidatorContext) -> SecretStr:
237
+ # The hook receives the coerced value: a SecretStr stays wrapped,
238
+ # so call get_secret_value() to inspect the plaintext.
239
+ if not value.get_secret_value().startswith("sk-"):
240
+ # ValueError/TypeError are wrapped in ConstraintViolationError and
241
+ # aggregate into MultipleValidationErrors like any other failure
242
+ raise ValueError(f"{ctx.env_var_name} must start with 'sk-'")
243
+ return value
244
+
245
+ class Config(DotEnvConfig):
246
+ api_key: SecretStr = Field(validator=check_api_key)
247
+
248
+ # Transform example: normalize to lowercase
249
+ region: str = Field(default="us-east-1", validator=lambda v, ctx: v.lower())
250
+ ```
251
+
252
+ Semantics:
253
+
254
+ - The hook receives the **coerced** value. A `SecretStr` stays wrapped — use `get_secret_value()` to inspect the plaintext.
255
+ - Runs **after** built-in constraints, on non-`None` values only, and even with `validate=False` (transformation is part of loading, not validation).
256
+ - Built-in constraints are **not** re-run on a transformed value (pydantic "after"-mode semantics).
257
+ - For sensitive fields (`SecretStr`, DSN types), returning a plain `str` re-wraps it in the declared type so the secret stays masked in `repr`.
258
+ - Returning `None` on a non-`Optional` field raises `TypeCoercionError`.
259
+ - A `ValueError` or `TypeError` from the hook is wrapped in `ConstraintViolationError` with `constraint="validator=<fn name>"`.
260
+ - Raise `ConstraintViolationError` directly for a fully custom message on non-sensitive fields (passed through untouched).
261
+
262
+ !!! warning "Sensitive-field leak prevention"
263
+ For sensitive fields (`SecretStr`, DSN types), **any** exception from the hook is masked to a generic `ConstraintViolationError` with no exception chaining — the hook's exception text is never embedded in the error or its `__cause__`/`__context__` chain, so a carelessly written hook cannot leak the secret or URL password into logs. For non-sensitive fields, `str(e)` is embedded in the error message and the original exception is chained.
264
+
265
+ ---
266
+
217
267
  ## Collection Size Constraints
218
268
 
219
269
  The `min_items` and `max_items` parameters constrain the number of items in `list`, `set`, `tuple`, and `dict` fields.
@@ -44,7 +44,8 @@ safety, validation, and automatic `.env` loading — with only one runtime depen
44
44
  ---
45
45
 
46
46
  Numeric constraints (`ge`, `le`, `gt`, `lt`), string constraints (`min_length`,
47
- `max_length`, `regex`), choice validation, and collection size constraints.
47
+ `max_length`, `regex`, `starts_with`, `ends_with`), choice validation, custom
48
+ `validator` hooks, string `strip` processing, and collection size constraints.
48
49
 
49
50
  - :material-file-document-multiple:{ .lg .middle } **Configuration Documentation**
50
51
 
@@ -20,6 +20,7 @@ Public API:
20
20
  - `DotEnvConfig`: Base class for type-safe configuration
21
21
  - `Field`: Define fields with defaults, validation, and aliases
22
22
  - `Required`: Sentinel for required fields (alternative to `Field()`)
23
+ - `ValidatorContext`: Context passed to `Field(validator=...)` hooks
23
24
  - `SecretStr`: String type that hides values in logs
24
25
  - `HttpUrl`, `PostgresDsn`, `RedisDsn`: URL/DSN types with validation
25
26
  - `Json`: Type for parsing JSON strings
@@ -31,7 +32,7 @@ Public API:
31
32
  Exception hierarchy
32
33
  """
33
34
 
34
- __version__ = "0.3.2"
35
+ __version__ = "0.4.0"
35
36
  __author__ = "AZX, PBC."
36
37
  __email__ = "oss@azx.io"
37
38
  __license__ = "MIT"
@@ -48,7 +49,7 @@ from dotenvmodel.exceptions import (
48
49
  TypeCoercionError,
49
50
  ValidationError,
50
51
  )
51
- from dotenvmodel.fields import Field, Required
52
+ from dotenvmodel.fields import Field, Required, ValidatorContext
52
53
  from dotenvmodel.logging_config import configure_logging, disable_logging
53
54
  from dotenvmodel.types import (
54
55
  HttpUrl,
@@ -74,6 +75,7 @@ __all__ = [
74
75
  "SecretStr",
75
76
  "TypeCoercionError",
76
77
  "ValidationError",
78
+ "ValidatorContext",
77
79
  "__version__",
78
80
  "configure_logging",
79
81
  "describe_configs",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import inspect
4
4
  import logging
5
+ import re
5
6
  import types
6
7
  from datetime import datetime, timedelta
7
8
  from decimal import Decimal
@@ -14,6 +15,7 @@ from typing_extensions import TypeForm
14
15
 
15
16
  from dotenvmodel._constants import LOGGER_NAME
16
17
  from dotenvmodel.exceptions import TypeCoercionError
18
+ from dotenvmodel.types import SecretStr
17
19
 
18
20
  if TYPE_CHECKING:
19
21
  from dotenvmodel.fields import FieldInfo
@@ -21,6 +23,73 @@ if TYPE_CHECKING:
21
23
  logger = logging.getLogger(LOGGER_NAME)
22
24
 
23
25
 
26
+ def unwrap_optional(field_type: TypeForm[Any]) -> TypeForm[Any]:
27
+ """Return the non-None member of an ``Optional[T]`` / ``T | None`` type.
28
+
29
+ ``Optional[T]`` and ``T | None`` resolve to ``T``. Any other type —
30
+ including non-Optional Unions with multiple non-None members, which this
31
+ library rejects elsewhere — is returned unchanged.
32
+
33
+ Args:
34
+ field_type: The field's type annotation
35
+
36
+ Returns:
37
+ The Optional-unwrapped type, or ``field_type`` itself when it is not
38
+ a single-member Optional.
39
+ """
40
+ origin = get_origin(field_type)
41
+ if origin is types.UnionType or origin is Union:
42
+ non_none = [arg for arg in get_args(field_type) if arg is not type(None)]
43
+ if len(non_none) == 1:
44
+ return non_none[0]
45
+ return field_type
46
+
47
+
48
+ def is_string_like_type(field_type: TypeForm[Any]) -> bool:
49
+ """Check whether a field type is string-like (eligible for ``strip``).
50
+
51
+ Unwraps ``Optional[T]`` and checks for ``str``, ``SecretStr``, a
52
+ ``str`` subclass (e.g. ``HttpUrl``, ``PostgresDsn``, ``RedisDsn``), or a
53
+ ``Literal[...]`` whose every argument is ``str`` (e.g.
54
+ ``Literal["dev", "prod"]``), so that strip is applied before coercion.
55
+
56
+ Args:
57
+ field_type: The field's type annotation
58
+
59
+ Returns:
60
+ True if the field holds a string-like value
61
+ """
62
+ unwrapped = unwrap_optional(field_type)
63
+ if inspect.isclass(unwrapped):
64
+ return issubclass(unwrapped, (str, SecretStr))
65
+ origin = get_origin(unwrapped)
66
+ if origin is Literal:
67
+ return all(isinstance(arg, str) for arg in get_args(unwrapped))
68
+ return False
69
+
70
+
71
+ def apply_strip(value: str, mode: bool | str | re.Pattern[str]) -> str:
72
+ """Apply a strip mode to a raw string value.
73
+
74
+ Args:
75
+ value: The raw string from the environment
76
+ mode: Strip mode — ``True`` for whitespace stripping, ``False`` for no
77
+ stripping, a non-empty ``str`` for char-set stripping
78
+ (``value.strip(chars)``), or a compiled ``re.Pattern`` to remove
79
+ every match (``pattern.sub("", value)``)
80
+
81
+ Returns:
82
+ The stripped string
83
+ """
84
+ if mode is False:
85
+ return value
86
+ if mode is True:
87
+ return value.strip()
88
+ if isinstance(mode, str):
89
+ return value.strip(mode)
90
+ return mode.sub("", value)
91
+
92
+
24
93
  def coerce_value(
25
94
  field_name: str,
26
95
  value: str | None,