dotenvmodel 0.3.1__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.
- dotenvmodel-0.4.0/.release-please-manifest.json +3 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/CHANGELOG.md +14 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/PKG-INFO +121 -2
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/README.md +120 -1
- dotenvmodel-0.4.0/SECURITY.md +30 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/fields.md +47 -1
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/validation.md +50 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/index.md +2 -1
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/__init__.py +4 -2
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/coercion.py +69 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/config.py +293 -5
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/describe/formatters.py +40 -3
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/fields.py +152 -2
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/metaclass.py +15 -1
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/types.py +35 -1
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/validation.py +20 -3
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/pyproject.toml +5 -5
- dotenvmodel-0.4.0/tests/test_defaults_coercion.py +245 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_describe.py +269 -0
- dotenvmodel-0.4.0/tests/test_nested_config.py +238 -0
- dotenvmodel-0.4.0/tests/test_string_affixes.py +310 -0
- dotenvmodel-0.4.0/tests/test_strip.py +558 -0
- dotenvmodel-0.4.0/tests/test_strip_integration.py +124 -0
- dotenvmodel-0.4.0/tests/test_validator.py +730 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/uv.lock +181 -193
- dotenvmodel-0.3.1/.release-please-manifest.json +0 -3
- dotenvmodel-0.3.1/SECURITY.md +0 -23
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/workflows/ci.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/workflows/publish.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.gitignore +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/.python-version +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/LICENSE +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/Makefile +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/changelog.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/loading.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/docs/guides/types.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/_redaction.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/exceptions.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/examples/logging_example.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/mkdocs.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/release-please-config.json +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/__init__.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/conftest.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_basic.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_coercion.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_adoption.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_path_options.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_enum.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_errors.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_field_constraints.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_future_annotations.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_json.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_loading.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_reload.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_secret_redaction.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_secretstr_security.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_types.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.4.0}/tests/test_urls.py +0 -0
|
@@ -5,6 +5,20 @@ 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
|
+
|
|
15
|
+
## [0.3.2](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.1...v0.3.2) (2026-07-15)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* resolve nested DotEnvConfig field defaults not being loaded ([#34](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/34)) ([4a3bb88](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/4a3bb8864dad044977d7b72be41e15307fff8f4d))
|
|
21
|
+
|
|
8
22
|
## [0.3.1](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.0...v0.3.1) (2026-07-13)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotenvmodel
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -1036,6 +1112,30 @@ redis = RedisConfig.load() # Reads REDIS_HOST, REDIS_PORT
|
|
|
1036
1112
|
app = AppConfig.load() # Reads APP_NAME, APP_VERSION
|
|
1037
1113
|
```
|
|
1038
1114
|
|
|
1115
|
+
### Nested Configuration
|
|
1116
|
+
|
|
1117
|
+
A field typed as another `DotEnvConfig` subclass is loaded as a nested config, resolving its own fields (including defaults) from its own `env_prefix` — independently of the parent's prefix:
|
|
1118
|
+
|
|
1119
|
+
```python
|
|
1120
|
+
class OIDCSettings(DotEnvConfig):
|
|
1121
|
+
env_prefix = "WARDEN_OIDC_"
|
|
1122
|
+
issuer: str = Field(default="")
|
|
1123
|
+
session_max_age_seconds: int = Field(default=28800)
|
|
1124
|
+
|
|
1125
|
+
class Settings(DotEnvConfig):
|
|
1126
|
+
env_prefix = "WARDEN_"
|
|
1127
|
+
service_name: str = Field(default="warden")
|
|
1128
|
+
oidc: OIDCSettings = Field(default_factory=OIDCSettings)
|
|
1129
|
+
|
|
1130
|
+
settings = Settings.load_from_dict({
|
|
1131
|
+
"WARDEN_OIDC_SESSION_MAX_AGE_SECONDS": "3600",
|
|
1132
|
+
})
|
|
1133
|
+
settings.oidc.session_max_age_seconds # 3600 (overridden)
|
|
1134
|
+
settings.oidc.issuer # "" (nested default, not the parent's prefix)
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
Nested `env_prefix` values are **absolute, not concatenated** with the parent's — `OIDCSettings.env_prefix` above is `WARDEN_OIDC_` in full, not appended to `Settings.env_prefix`. `.load()`, `.load_from_dict()`, and `.reload()` all resolve nested fields the same way. See [Known Limitations](#known-limitations) for `Optional[NestedConfig]` and `describe()` caveats.
|
|
1138
|
+
|
|
1039
1139
|
## Error Handling
|
|
1040
1140
|
|
|
1041
1141
|
### Missing Required Field
|
|
@@ -1264,6 +1364,25 @@ config = Config.load()
|
|
|
1264
1364
|
value_as_int = int(config.value) if config.value.isdigit() else config.value
|
|
1265
1365
|
```
|
|
1266
1366
|
|
|
1367
|
+
### Optional Nested Configuration
|
|
1368
|
+
|
|
1369
|
+
`Optional[NestedConfig]` / `NestedConfig | None` fields are **not** supported the way a plain `nested: NestedConfig = Field(default_factory=NestedConfig)` field is — an Optional-typed nested field falls through to ordinary Optional-scalar handling and silently resolves to `None`, discarding any env vars set at the nested prefix:
|
|
1370
|
+
|
|
1371
|
+
```python
|
|
1372
|
+
class Config(DotEnvConfig):
|
|
1373
|
+
env_prefix = "APP_"
|
|
1374
|
+
nested: NestedConfig | None = Field(default=None)
|
|
1375
|
+
|
|
1376
|
+
config = Config.load_from_dict({"APP_NESTED_PORT": "8080"})
|
|
1377
|
+
config.nested # None — the override above is silently ignored
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
**Workaround**: give the nested field a non-Optional type with `default_factory`, as in [Nested Configuration](#nested-configuration) above. If the nested config is conditionally absent in your app, model that with a `bool`/enum field inside the nested config itself rather than making the field Optional.
|
|
1381
|
+
|
|
1382
|
+
### `describe()` / `generate_env_example()` and Nested Configuration
|
|
1383
|
+
|
|
1384
|
+
`describe()` and `generate_env_example()` do not recurse into nested config fields — a nested field renders as an opaque, non-functional placeholder (e.g. `APP_NESTED=<NestedConfig()>`) rather than expanding to the nested class's real, settable env vars (e.g. `APP_NESTED_PORT`). Don't rely on generated `.env.example` output to discover a nested config's real variables — read the nested class's own fields (or its own `describe()` output) directly.
|
|
1385
|
+
|
|
1267
1386
|
## License
|
|
1268
1387
|
|
|
1269
1388
|
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -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
|
|
@@ -1008,6 +1084,30 @@ redis = RedisConfig.load() # Reads REDIS_HOST, REDIS_PORT
|
|
|
1008
1084
|
app = AppConfig.load() # Reads APP_NAME, APP_VERSION
|
|
1009
1085
|
```
|
|
1010
1086
|
|
|
1087
|
+
### Nested Configuration
|
|
1088
|
+
|
|
1089
|
+
A field typed as another `DotEnvConfig` subclass is loaded as a nested config, resolving its own fields (including defaults) from its own `env_prefix` — independently of the parent's prefix:
|
|
1090
|
+
|
|
1091
|
+
```python
|
|
1092
|
+
class OIDCSettings(DotEnvConfig):
|
|
1093
|
+
env_prefix = "WARDEN_OIDC_"
|
|
1094
|
+
issuer: str = Field(default="")
|
|
1095
|
+
session_max_age_seconds: int = Field(default=28800)
|
|
1096
|
+
|
|
1097
|
+
class Settings(DotEnvConfig):
|
|
1098
|
+
env_prefix = "WARDEN_"
|
|
1099
|
+
service_name: str = Field(default="warden")
|
|
1100
|
+
oidc: OIDCSettings = Field(default_factory=OIDCSettings)
|
|
1101
|
+
|
|
1102
|
+
settings = Settings.load_from_dict({
|
|
1103
|
+
"WARDEN_OIDC_SESSION_MAX_AGE_SECONDS": "3600",
|
|
1104
|
+
})
|
|
1105
|
+
settings.oidc.session_max_age_seconds # 3600 (overridden)
|
|
1106
|
+
settings.oidc.issuer # "" (nested default, not the parent's prefix)
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
Nested `env_prefix` values are **absolute, not concatenated** with the parent's — `OIDCSettings.env_prefix` above is `WARDEN_OIDC_` in full, not appended to `Settings.env_prefix`. `.load()`, `.load_from_dict()`, and `.reload()` all resolve nested fields the same way. See [Known Limitations](#known-limitations) for `Optional[NestedConfig]` and `describe()` caveats.
|
|
1110
|
+
|
|
1011
1111
|
## Error Handling
|
|
1012
1112
|
|
|
1013
1113
|
### Missing Required Field
|
|
@@ -1236,6 +1336,25 @@ config = Config.load()
|
|
|
1236
1336
|
value_as_int = int(config.value) if config.value.isdigit() else config.value
|
|
1237
1337
|
```
|
|
1238
1338
|
|
|
1339
|
+
### Optional Nested Configuration
|
|
1340
|
+
|
|
1341
|
+
`Optional[NestedConfig]` / `NestedConfig | None` fields are **not** supported the way a plain `nested: NestedConfig = Field(default_factory=NestedConfig)` field is — an Optional-typed nested field falls through to ordinary Optional-scalar handling and silently resolves to `None`, discarding any env vars set at the nested prefix:
|
|
1342
|
+
|
|
1343
|
+
```python
|
|
1344
|
+
class Config(DotEnvConfig):
|
|
1345
|
+
env_prefix = "APP_"
|
|
1346
|
+
nested: NestedConfig | None = Field(default=None)
|
|
1347
|
+
|
|
1348
|
+
config = Config.load_from_dict({"APP_NESTED_PORT": "8080"})
|
|
1349
|
+
config.nested # None — the override above is silently ignored
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
**Workaround**: give the nested field a non-Optional type with `default_factory`, as in [Nested Configuration](#nested-configuration) above. If the nested config is conditionally absent in your app, model that with a `bool`/enum field inside the nested config itself rather than making the field Optional.
|
|
1353
|
+
|
|
1354
|
+
### `describe()` / `generate_env_example()` and Nested Configuration
|
|
1355
|
+
|
|
1356
|
+
`describe()` and `generate_env_example()` do not recurse into nested config fields — a nested field renders as an opaque, non-functional placeholder (e.g. `APP_NESTED=<NestedConfig()>`) rather than expanding to the nested class's real, settable env vars (e.g. `APP_NESTED_PORT`). Don't rely on generated `.env.example` output to discover a nested config's real variables — read the nested class's own fields (or its own `describe()` output) directly.
|
|
1357
|
+
|
|
1239
1358
|
## License
|
|
1240
1359
|
|
|
1241
1360
|
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -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,
|
|
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.
|
|
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",
|