dotenvmodel 0.5.4__tar.gz → 0.6.1__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 (120) hide show
  1. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/workflows/ci.yml +1 -1
  2. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/workflows/publish.yml +1 -1
  3. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.gitignore +3 -0
  4. dotenvmodel-0.6.1/.release-please-manifest.json +3 -0
  5. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/CHANGELOG.md +19 -0
  6. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/Makefile +4 -3
  7. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/PKG-INFO +24 -2
  8. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/README.md +23 -1
  9. dotenvmodel-0.6.1/docs/api-reference/caching.md +7 -0
  10. dotenvmodel-0.6.1/docs/guides/loading.md +301 -0
  11. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/__init__.py +4 -1
  12. dotenvmodel-0.6.1/dotenvmodel/caching.py +265 -0
  13. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/config.py +224 -2
  14. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/mkdocs.yml +1 -0
  15. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/pyproject.toml +1 -1
  16. dotenvmodel-0.6.1/tests/conftest.py +83 -0
  17. dotenvmodel-0.6.1/tests/test_cached.py +978 -0
  18. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/uv.lock +1 -1
  19. dotenvmodel-0.5.4/.release-please-manifest.json +0 -3
  20. dotenvmodel-0.5.4/docs/guides/loading.md +0 -209
  21. dotenvmodel-0.5.4/tests/conftest.py +0 -0
  22. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/CODEOWNERS +0 -0
  23. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/dependabot.yml +0 -0
  26. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/pull_request_template.md +0 -0
  27. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/workflows/docs.yml +0 -0
  28. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.github/workflows/release-please.yml +0 -0
  29. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/.python-version +0 -0
  30. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/CODE_OF_CONDUCT.md +0 -0
  31. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/CONTRIBUTING.md +0 -0
  32. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/LICENSE +0 -0
  33. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/SECURITY.md +0 -0
  34. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/coercion.md +0 -0
  35. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/config.md +0 -0
  36. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/constants.md +0 -0
  37. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/describe-formatters.md +0 -0
  38. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/describe-renderers.md +0 -0
  39. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/describe.md +0 -0
  40. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/dotenvmodel.md +0 -0
  41. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/exceptions.md +0 -0
  42. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/fields.md +0 -0
  43. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/loading.md +0 -0
  44. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/logging-config.md +0 -0
  45. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/metaclass.md +0 -0
  46. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/types.md +0 -0
  47. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/api-reference/validation.md +0 -0
  48. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/changelog.md +0 -0
  49. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/examples/complete-app.md +0 -0
  50. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/getting-started/installation.md +0 -0
  51. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/getting-started/quick-start.md +0 -0
  52. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/guides/configuration-docs.md +0 -0
  53. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/guides/error-handling.md +0 -0
  54. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/guides/fields.md +0 -0
  55. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/guides/logging.md +0 -0
  56. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/guides/prefixes.md +0 -0
  57. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/guides/types.md +0 -0
  58. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/guides/validation.md +0 -0
  59. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/docs/index.md +0 -0
  60. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/_constants.py +0 -0
  61. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/_redaction.py +0 -0
  62. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/coercion.py +0 -0
  63. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/describe/__init__.py +0 -0
  64. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/describe/formatters.py +0 -0
  65. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/describe/renderers.py +0 -0
  66. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/exceptions.py +0 -0
  67. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/fields.py +0 -0
  68. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/loading.py +0 -0
  69. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/logging_config.py +0 -0
  70. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/metaclass.py +0 -0
  71. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/py.typed +0 -0
  72. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/types.py +0 -0
  73. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/dotenvmodel/validation.py +0 -0
  74. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/examples/advanced_types.py +0 -0
  75. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/examples/basic_usage.py +0 -0
  76. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/examples/describe_documentation.py +0 -0
  77. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/examples/generate_env_example.py +0 -0
  78. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/examples/logging_example.py +0 -0
  79. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/release-please-config.json +0 -0
  80. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/__init__.py +0 -0
  81. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_basic.py +0 -0
  82. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_coercion.py +0 -0
  83. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_collection_validators.py +0 -0
  84. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_defaults_coercion.py +0 -0
  85. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_describe.py +0 -0
  86. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_adoption.py +0 -0
  87. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_datetime.py +0 -0
  88. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_describe_output.py +0 -0
  89. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_enum.py +0 -0
  90. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_field_options.py +0 -0
  91. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_numbers.py +0 -0
  92. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_path_options.py +0 -0
  93. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_edge_strings.py +0 -0
  94. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_empty_collection_items.py +0 -0
  95. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_empty_strings.py +0 -0
  96. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_enum.py +0 -0
  97. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_errors.py +0 -0
  98. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_field_constraints.py +0 -0
  99. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_future_annotations.py +0 -0
  100. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_inheritance.py +0 -0
  101. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_json.py +0 -0
  102. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_json_and_optional.py +0 -0
  103. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_loading.py +0 -0
  104. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_logging_config.py +0 -0
  105. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_nested_config.py +0 -0
  106. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_post_load.py +0 -0
  107. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_prefix.py +0 -0
  108. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_reload.py +0 -0
  109. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_secret_redaction.py +0 -0
  110. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_secretstr_security.py +0 -0
  111. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_string_affixes.py +0 -0
  112. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_strip.py +0 -0
  113. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_strip_integration.py +0 -0
  114. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_types.py +0 -0
  115. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_union_types.py +0 -0
  116. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_url_dsn.py +0 -0
  117. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_url_password_decoding.py +0 -0
  118. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_url_unquote.py +0 -0
  119. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_urls.py +0 -0
  120. {dotenvmodel-0.5.4 → dotenvmodel-0.6.1}/tests/test_validator.py +0 -0
@@ -53,7 +53,7 @@ jobs:
53
53
  run: uv sync --group dev
54
54
 
55
55
  - name: Type check with pyright
56
- run: uv run pyright dotenvmodel
56
+ run: uv run pyright dotenvmodel tests
57
57
 
58
58
  test:
59
59
  runs-on: ubuntu-latest
@@ -28,7 +28,7 @@ jobs:
28
28
  ref: ${{ inputs.tag_name || github.ref }}
29
29
 
30
30
  - name: Install uv
31
- uses: astral-sh/setup-uv@v8.3.2
31
+ uses: astral-sh/setup-uv@v9.0.0
32
32
  with:
33
33
  enable-cache: true
34
34
 
@@ -193,6 +193,9 @@ cython_debug/
193
193
  # Serena
194
194
  .serena/
195
195
 
196
+ # Cross-family review scratch output
197
+ .xreview/
198
+
196
199
  # Ruff stuff:
197
200
  .ruff_cache/
198
201
 
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.1"
3
+ }
@@ -5,6 +5,25 @@ 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.6.1](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.6.0...v0.6.1) (2026-07-27)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * eliminate cross-class reentrancy deadlock in cached() locking ([#52](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/52)) ([592297a](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/592297a1823b2c26b00f974882834e5ace8bc968))
14
+
15
+ ## [0.6.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.5.4...v0.6.0) (2026-07-27)
16
+
17
+
18
+ ### Features
19
+
20
+ * add cached() / reset_cached() / cached_override() singleton accessor ([#51](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/51)) ([638f8cc](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/638f8cc8515398ad983bc321dd906effe4df17f8))
21
+
22
+
23
+ ### Continuous Integration
24
+
25
+ * bump setup-uv to v9.0.0 ([#48](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/48)) ([21bcc4f](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/21bcc4f96d16a1e075d3c59ba3291cb1f9af32d6))
26
+
8
27
  ## [0.5.4](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.5.3...v0.5.4) (2026-07-22)
9
28
 
10
29
 
@@ -4,9 +4,9 @@ help:
4
4
  @echo "Available commands:"
5
5
  @echo " make install - Install package and dev dependencies"
6
6
  @echo " make test - Run tests with coverage"
7
- @echo " make lint - Run ruff linter"
7
+ @echo " make lint - Run ruff linter and formatting check (matches CI)"
8
8
  @echo " make format - Format code with ruff"
9
- @echo " make type-check - Run pyright type checker"
9
+ @echo " make type-check - Run pyright type checker (dotenvmodel + tests)"
10
10
  @echo " make clean - Clean build artifacts"
11
11
  @echo " make build - Build package"
12
12
  @echo " make publish - Publish to PyPI"
@@ -19,13 +19,14 @@ test:
19
19
 
20
20
  lint:
21
21
  uv run ruff check .
22
+ uv run ruff format --check .
22
23
 
23
24
  format:
24
25
  uv run ruff format .
25
26
  uv run ruff check --fix .
26
27
 
27
28
  type-check:
28
- uv run pyright dotenvmodel
29
+ uv run pyright dotenvmodel tests
29
30
 
30
31
  clean:
31
32
  rm -rf build/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotenvmodel
3
- Version: 0.5.4
3
+ Version: 0.6.1
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
@@ -1357,6 +1357,27 @@ def test_with_fixture(test_config):
1357
1357
  assert test_config.database_url == "sqlite:///:memory:"
1358
1358
  ```
1359
1359
 
1360
+ The `load_from_dict()` examples above test a config's field-parsing logic directly. If your application code calls `AppConfig.cached()` — the process-wide singleton — one test's cached instance will leak into the next. Use `cached_override()` for a scoped, self-restoring per-test override (the recommended approach, structurally similar to Django's `override_settings`):
1361
+
1362
+ ```python
1363
+ def test_with_custom_config():
1364
+ test_config = AppConfig.load_from_dict({"DATABASE_URL": "postgresql://localhost/test"})
1365
+ with AppConfig.cached_override(test_config):
1366
+ assert AppConfig.cached() is test_config
1367
+ # Previous cached() state is automatically restored here — even if the test raised.
1368
+ ```
1369
+
1370
+ For a blanket teardown between test modules, use `reset_cached()` in an `autouse` fixture:
1371
+
1372
+ ```python
1373
+ @pytest.fixture(autouse=True)
1374
+ def reset_config_cache():
1375
+ yield
1376
+ AppConfig.reset_cached()
1377
+ ```
1378
+
1379
+ See the [Caching guide](docs/guides/loading.md#caching-a-singleton-instance) for the full lazy/thread-safe singleton pattern, test idioms, and guidance on when `cached()` is appropriate vs. dependency injection.
1380
+
1360
1381
  ## Best Practices
1361
1382
 
1362
1383
  1. **Use Type Hints**: Always specify type hints for proper validation
@@ -1401,9 +1422,10 @@ def test_with_fixture(test_config):
1401
1422
 
1402
1423
  `DotEnvConfig` instances are **not thread-safe** during `reload()`. If multiple threads call `reload()` on the same instance concurrently, field values may become inconsistent. In multi-threaded server environments (FastAPI, gunicorn, etc.):
1403
1424
 
1404
- - Call `load()` once at startup and share the immutable instance
1425
+ - Call `load()` once at startup and share the immutable instance, or use `cached()` for a built-in lazy, thread-safe singleton that does this automatically
1405
1426
  - If you need to reload, use a lock or create a new instance via `load()` instead of calling `reload()` on a shared instance
1406
1427
  - Avoid calling `reload()` while request handlers are reading config values
1428
+ - In tests that need different configurations per test, use `cached_override()` for a scoped, self-restoring override, or `reset_cached()` in a fixture for a blanket teardown between test modules
1407
1429
 
1408
1430
  ### Union Types (Non-Optional)
1409
1431
 
@@ -1329,6 +1329,27 @@ def test_with_fixture(test_config):
1329
1329
  assert test_config.database_url == "sqlite:///:memory:"
1330
1330
  ```
1331
1331
 
1332
+ The `load_from_dict()` examples above test a config's field-parsing logic directly. If your application code calls `AppConfig.cached()` — the process-wide singleton — one test's cached instance will leak into the next. Use `cached_override()` for a scoped, self-restoring per-test override (the recommended approach, structurally similar to Django's `override_settings`):
1333
+
1334
+ ```python
1335
+ def test_with_custom_config():
1336
+ test_config = AppConfig.load_from_dict({"DATABASE_URL": "postgresql://localhost/test"})
1337
+ with AppConfig.cached_override(test_config):
1338
+ assert AppConfig.cached() is test_config
1339
+ # Previous cached() state is automatically restored here — even if the test raised.
1340
+ ```
1341
+
1342
+ For a blanket teardown between test modules, use `reset_cached()` in an `autouse` fixture:
1343
+
1344
+ ```python
1345
+ @pytest.fixture(autouse=True)
1346
+ def reset_config_cache():
1347
+ yield
1348
+ AppConfig.reset_cached()
1349
+ ```
1350
+
1351
+ See the [Caching guide](docs/guides/loading.md#caching-a-singleton-instance) for the full lazy/thread-safe singleton pattern, test idioms, and guidance on when `cached()` is appropriate vs. dependency injection.
1352
+
1332
1353
  ## Best Practices
1333
1354
 
1334
1355
  1. **Use Type Hints**: Always specify type hints for proper validation
@@ -1373,9 +1394,10 @@ def test_with_fixture(test_config):
1373
1394
 
1374
1395
  `DotEnvConfig` instances are **not thread-safe** during `reload()`. If multiple threads call `reload()` on the same instance concurrently, field values may become inconsistent. In multi-threaded server environments (FastAPI, gunicorn, etc.):
1375
1396
 
1376
- - Call `load()` once at startup and share the immutable instance
1397
+ - Call `load()` once at startup and share the immutable instance, or use `cached()` for a built-in lazy, thread-safe singleton that does this automatically
1377
1398
  - If you need to reload, use a lock or create a new instance via `load()` instead of calling `reload()` on a shared instance
1378
1399
  - Avoid calling `reload()` while request handlers are reading config values
1400
+ - In tests that need different configurations per test, use `cached_override()` for a scoped, self-restoring override, or `reset_cached()` in a fixture for a blanket teardown between test modules
1379
1401
 
1380
1402
  ### Union Types (Non-Optional)
1381
1403
 
@@ -0,0 +1,7 @@
1
+ # Caching
2
+
3
+ Cached singleton-instance machinery for DotEnvConfig subclasses.
4
+
5
+ ::: dotenvmodel.caching
6
+ options:
7
+ members: []
@@ -0,0 +1,301 @@
1
+ # Loading Configuration
2
+
3
+ dotenvmodel provides flexible configuration loading from environment variables and `.env` files with Node.js-style cascading. This guide covers all loading methods and their parameters.
4
+
5
+ ## .env File Cascading
6
+
7
+ When you call `load()`, dotenvmodel automatically reads `.env` files in a cascading order. Later files override earlier ones, giving you layered configuration from shared base values to local overrides.
8
+
9
+ !!! info "Cascade Order"
10
+
11
+ Files are loaded in the following order (later files override earlier ones):
12
+
13
+ 1. **`.env`** — Base configuration (usually gitignored)
14
+ 2. **`.env.local`** — Local base overrides (gitignored, never committed)
15
+ 3. **`.env.{env}`** — Environment-specific (committed to repo)
16
+ 4. **`.env.{env}.local`** — Local environment overrides (gitignored, never committed)
17
+
18
+ ### Practical Example
19
+
20
+ ```bash
21
+ # .env (base - usually gitignored)
22
+ DATABASE_URL=postgresql://localhost/myapp
23
+ REDIS_URL=redis://localhost:6379
24
+ DEBUG=false
25
+
26
+ # .env.local (local base overrides - gitignored)
27
+ DATABASE_URL=postgresql://localhost/myapp_local
28
+
29
+ # .env.dev (development - committed to repo)
30
+ DEBUG=true
31
+ LOG_LEVEL=DEBUG
32
+
33
+ # .env.dev.local (local dev overrides - gitignored)
34
+ ENABLE_PROFILING=true
35
+ API_KEY=dev-key-local-override
36
+ ```
37
+
38
+ When you load with `env="dev"`:
39
+
40
+ ```python
41
+ config = AppConfig.load(env="dev")
42
+ # Loads in order: .env -> .env.local -> .env.dev -> .env.dev.local
43
+ # Final DATABASE_URL: postgresql://localhost/myapp_local (from .env.local)
44
+ # Final DEBUG: true (from .env.dev)
45
+ # Final ENABLE_PROFILING: true (from .env.dev.local)
46
+ ```
47
+
48
+ !!! tip "Committing .env files"
49
+
50
+ - **Commit**: `.env.{env}` files (e.g., `.env.dev`, `.env.prod`) — shared defaults
51
+ - **Gitignore**: `.env`, `.env.local`, `.env.{env}.local` — contain secrets and local overrides
52
+
53
+ ## The `env` Parameter
54
+
55
+ The `env` parameter selects which environment-specific files to load. If not provided, it reads from the `ENV` environment variable, defaulting to `"dev"`.
56
+
57
+ ```python
58
+ # Auto-detect from ENV environment variable, default "dev"
59
+ config = AppConfig.load()
60
+
61
+ # Explicit environment
62
+ config = AppConfig.load(env="prod")
63
+
64
+ # Test environment
65
+ config = AppConfig.load(env="test")
66
+ ```
67
+
68
+ !!! warning "Valid environment names"
69
+
70
+ Environment names must only contain alphanumeric characters, hyphens, and underscores. This prevents path traversal attacks. Invalid names raise `ValueError`.
71
+
72
+ ## The `override` Parameter
73
+
74
+ Controls whether `.env` file values override existing environment variables.
75
+
76
+ ```python
77
+ # .env files override env vars (default)
78
+ config = AppConfig.load(override=True)
79
+
80
+ # Env vars take precedence over .env files
81
+ config = AppConfig.load(override=False)
82
+ ```
83
+
84
+ | `override` | Behavior |
85
+ |------------|----------|
86
+ | `True` (default) | `.env` file values override existing environment variables |
87
+ | `False` | Existing environment variables take precedence over `.env` files |
88
+
89
+ !!! tip "When to use `override=False`"
90
+
91
+ Use `override=False` in containerized environments where you inject config via environment variables and want them to take precedence over any `.env` files that might exist in the image.
92
+
93
+ ## The `env_dir` Parameter
94
+
95
+ By default, dotenvmodel looks for `.env` files in the current working directory. You can specify a custom directory.
96
+
97
+ ```python
98
+ from pathlib import Path
99
+
100
+ # Custom .env file directory
101
+ config = AppConfig.load(env_dir=Path("/app/config"))
102
+ ```
103
+
104
+ ### `DOTENV_DIR` Environment Variable
105
+
106
+ If `env_dir` is not provided, dotenvmodel checks the `DOTENV_DIR` environment variable:
107
+
108
+ ```bash
109
+ # Set via environment variable
110
+ export DOTENV_DIR=/app/config
111
+ python your_app.py
112
+ ```
113
+
114
+ ```python
115
+ # No env_dir needed — reads from DOTENV_DIR
116
+ config = AppConfig.load()
117
+ ```
118
+
119
+ !!! note "Precedence"
120
+
121
+ The `env_dir` parameter takes precedence over the `DOTENV_DIR` environment variable, which takes precedence over the current working directory.
122
+
123
+ ## Loading from a Dictionary
124
+
125
+ Use `load_from_dict()` for testing or when you have config values from a non-environment source. This bypasses `.env` file loading entirely.
126
+
127
+ ```python
128
+ # Load from dictionary for testing
129
+ config = AppConfig.load_from_dict({
130
+ "DATABASE_URL": "postgresql://localhost/test",
131
+ "API_KEY": "test-key",
132
+ "DEBUG": "true",
133
+ "PORT": "8000",
134
+ })
135
+
136
+ # Skip validation if needed
137
+ config = AppConfig.load_from_dict(data, validate=False)
138
+ ```
139
+
140
+ !!! tip "Keys can be field names or env var names"
141
+
142
+ Dictionary keys can be either the env var name (e.g., `"DATABASE_URL"`) or the field name (e.g., `"database_url"`). Env var names take precedence.
143
+
144
+ !!! warning "Use `load()` in production"
145
+
146
+ `load_from_dict()` is designed for testing. In production, always use `load()` to read from environment variables and `.env` files.
147
+
148
+ ## Reloading Configuration
149
+
150
+ The `reload()` method lets you refresh configuration at runtime without creating a new instance. This is useful for picking up environment changes or switching environments.
151
+
152
+ ```python
153
+ import os
154
+
155
+ # Load initial configuration
156
+ config = AppConfig.load(env="dev")
157
+ print(config.port) # 8000
158
+
159
+ # Later, when environment variables change...
160
+ os.environ["PORT"] = "9000"
161
+
162
+ # Reload the configuration
163
+ config.reload()
164
+ print(config.port) # 9000
165
+ ```
166
+
167
+ ### Reusing Original Parameters
168
+
169
+ By default, `reload()` reuses the same `env`, `override`, and `env_dir` from the original `load()` call:
170
+
171
+ ```python
172
+ config = AppConfig.load(env="dev", override=True)
173
+ config.reload() # Uses env="dev", override=True
174
+ ```
175
+
176
+ ### Overriding Parameters During Reload
177
+
178
+ You can override any parameter by passing new values:
179
+
180
+ ```python
181
+ # Switch to production environment
182
+ config.reload(env="prod")
183
+
184
+ # Change override behavior
185
+ config.reload(override=False)
186
+ ```
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
+
192
+ !!! info "reload() returns the same instance"
193
+
194
+ `reload()` returns `self`, making it useful for method chaining:
195
+
196
+ ```python
197
+ config.reload(env="prod").port
198
+ ```
199
+
200
+ !!! warning "Thread safety"
201
+
202
+ `DotEnvConfig` instances are **not thread-safe** during `reload()`. In multi-threaded environments, use a lock or create a new instance via `load()` instead of calling `reload()` on a shared instance.
203
+
204
+ ## Caching a Singleton Instance
205
+
206
+ Application code often needs a single shared config instance that is loaded once and reused everywhere. `cached()` provides this as a built-in: it loads on first call and returns the same instance on every subsequent call, with no re-reading of the environment.
207
+
208
+ ```python
209
+ class AppConfig(DotEnvConfig):
210
+ database_url: str = Field()
211
+ port: int = Field(default=8000)
212
+
213
+ # First call loads from the environment.
214
+ config = AppConfig.cached()
215
+
216
+ # Subsequent calls return the same instance — no re-read.
217
+ same_config = AppConfig.cached()
218
+ assert config is same_config
219
+ ```
220
+
221
+ ### Lazy and Thread-Safe
222
+
223
+ `cached()` is lazy — the environment is only read on the very first call. It is also thread-safe: if multiple threads call `cached()` simultaneously before the first load completes, they race on a lock and only one thread calls `load()`; the rest block and receive the same instance. Once the cache is warm, all calls return immediately without acquiring the lock.
224
+
225
+ Arguments (`env`, `override`, `env_dir`) are only used on the first call. Once the instance is cached, subsequent calls ignore any arguments and return the existing instance. A warning is logged if non-default arguments are passed against an already-warm cache.
226
+
227
+ Calling `.reload()` on the cached instance mutates it in place; since `cached()` always returns the same object, subsequent `cached()` calls see the reloaded values.
228
+
229
+ !!! warning "Reentrant `cached()` calls raise `RuntimeError`"
230
+
231
+ Calling `cached()` reentrantly for the same class from within that class's own `load()` / `post_load()` / field `validator` hooks raises `RuntimeError`. The internal lock is reentrant, so the nested call would not deadlock — it would see a cold cache and recurse into `load()` without bound, which is why it is rejected. If a hook needs the config instance mid-load, use `self`, or call `cls.load()` directly with re-entry guarding (an unconditional `cls.load()` inside `post_load()` re-runs the hooks and recurses until `RecursionError`).
232
+
233
+ Calling `cached()`, `reset_cached()`, or `cached_override()` for **other** classes from those hooks is supported — for example, one class's `post_load()` may call `cached()` on another config class. However, calling `reset_cached()` or entering `cached_override()` for the **same** class whose first load is still in flight raises `RuntimeError` (the load installs its instance when it completes, which would silently undo the reset or discard the override). A circular cross-class hook chain (A's hook loads B, B's hook loads A) collapses back onto the first class and likewise raises `RuntimeError`.
234
+
235
+ Hooks must not block on another thread (e.g. `thread.join()`) that touches the cache: the internal lock is held for the duration of a load, so the joining thread would hold the lock while the joined thread waits for it — a deadlock the reentrant lock cannot prevent.
236
+
237
+ ### Scoped Overrides for Tests
238
+
239
+ `cached_override()` is a context manager that temporarily replaces the cached instance for the duration of a `with` block and automatically restores the previous state on exit — even if the block raises an exception. This is the **primary recommended tool** for test isolation when a single test needs a different config: it is structurally the same shape as Django's `override_settings` — scoped, self-restoring, and failure-safe.
240
+
241
+ ```python
242
+ def test_with_custom_config():
243
+ test_config = AppConfig.load_from_dict({"DATABASE_URL": "postgresql://localhost/test"})
244
+ with AppConfig.cached_override(test_config):
245
+ assert AppConfig.cached() is test_config
246
+ # Previous cached() state (or absence of one) is restored here.
247
+ ```
248
+
249
+ Because restoration is automatic and unconditional, `cached_override()` cannot forget to clean up — unlike a bare `reset_cached()` call that a test author might omit, silently leaking state into the next test. Reach for `cached_override()` first when a single test needs a different config; use `reset_cached()` (below) for the coarser case of a blanket fixture teardown between test modules.
250
+
251
+ !!! warning "Not for concurrent use"
252
+
253
+ `cached_override()` is not designed for use while other threads may concurrently call `cached()` on the same class. The override window is not synchronized against concurrent readers beyond the lock-protected set/restore operations, so overlapping a `cached_override()` block with genuinely concurrent cross-thread `cached()` calls is racy in terms of which threads observe the override vs. the restored value. No data corruption occurs, but the observed value is unspecified during the transition.
254
+
255
+ ### Resetting the Cache for Tests
256
+
257
+ `reset_cached()` is the coarser fallback: it unconditionally clears this class's cached instance so the next `cached()` call will call `load()` again. It is useful as a blanket fixture teardown — for example, clearing everything between test modules — but `cached_override()` (above) should be reached for first when a single test needs a different config, because `reset_cached()` cannot auto-restore and a forgotten call leaks state.
258
+
259
+ ```python
260
+ import pytest
261
+
262
+
263
+ @pytest.fixture(autouse=True)
264
+ def reset_config_cache():
265
+ yield
266
+ AppConfig.reset_cached()
267
+
268
+
269
+ def test_dev_config(monkeypatch):
270
+ monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/dev")
271
+ config = AppConfig.cached()
272
+ assert "dev" in config.database_url
273
+
274
+
275
+ def test_prod_config(monkeypatch):
276
+ monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/prod")
277
+ config = AppConfig.cached()
278
+ assert "prod" in config.database_url
279
+ ```
280
+
281
+ `reset_cached()` only affects the exact class it is called on — other `DotEnvConfig` subclasses keep their own cached instances.
282
+
283
+ !!! note "Per-class isolation"
284
+
285
+ The cache is keyed by the exact class object. `SubA.cached()` and `SubB.cached()` cache independently, and a subclass of a subclass does not inherit its parent's cached instance.
286
+
287
+ ### Not a Substitute for Dependency Injection
288
+
289
+ `cached()` is a single-config-per-process convenience for the common case where there is no DI framework already in place — scripts, simple services, cases where threading a config parameter through every call is impractical. It is **not** a substitute for dependency injection in applications that already have one.
290
+
291
+ - **pydantic-settings** deliberately does not ship a singleton/caching accessor itself; the maintainers' position (see [pydantic/pydantic-settings#410](https://github.com/pydantic/pydantic-settings/issues/410)) is that singleton lifecycle is an application concern, not the settings library's.
292
+ - **FastAPI**'s own documented settings pattern uses `functools.lru_cache` to avoid re-loading, but the load-bearing mechanism for *testability* is `app.dependency_overrides` — FastAPI's docs steer people toward DI-based overriding for tests, with the cache being an optimization, not the override mechanism.
293
+
294
+ **Conclusion:** if your application already has an app/request object and a DI mechanism (FastAPI, Flask with a DI extension, `svcs`-style service locators, etc.), prefer injecting the loaded config instance through that mechanism rather than calling `cached()` from deep in the call stack. `cached()` and `cached_override()` exist for the case where there is no DI framework to thread the config through — not to encourage a global-singleton-everywhere style in apps that already have DI.
295
+
296
+ ## See Also
297
+
298
+ - [Loading API Reference](../api-reference/loading.md) — `load_env_files()`, `get_env_var()`, `get_env_var_name()`
299
+ - [DotEnvConfig API Reference](../api-reference/config.md) — `load()`, `reload()`, `load_from_dict()`, `cached()`, `reset_cached()`, `cached_override()`
300
+ - [Field Definitions](fields.md) — Defining config fields with `Field()`
301
+ - [Validation](validation.md) — Constraint validation
@@ -23,6 +23,9 @@ Public API:
23
23
  - `ValidatorContext`: Context passed to `Field(validator=...)` hooks
24
24
  - `DotEnvConfig.post_load`: Model-level hook for cross-field validation
25
25
  and normalization after loading
26
+ - `DotEnvConfig.cached` / `DotEnvConfig.reset_cached` / `DotEnvConfig.cached_override`:
27
+ Process-wide thread-safe singleton accessor, its reset, and a scoped
28
+ self-restoring override for test isolation
26
29
  - `SecretStr`: String type that hides values in logs
27
30
  - `HttpUrl`, `PostgresDsn`, `RedisDsn`: URL/DSN types with validation
28
31
  - `Json`: Type for parsing JSON strings
@@ -34,7 +37,7 @@ Public API:
34
37
  Exception hierarchy
35
38
  """
36
39
 
37
- __version__ = "0.5.4" # x-release-please-version
40
+ __version__ = "0.6.1" # x-release-please-version
38
41
  __author__ = "AZX, PBC."
39
42
  __email__ = "oss@azx.io"
40
43
  __license__ = "MIT"