dotenvmodel 0.5.4__tar.gz → 0.6.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 (119) hide show
  1. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/ci.yml +1 -1
  2. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/publish.yml +1 -1
  3. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.gitignore +3 -0
  4. dotenvmodel-0.6.0/.release-please-manifest.json +3 -0
  5. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/CHANGELOG.md +12 -0
  6. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/Makefile +4 -3
  7. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/PKG-INFO +24 -2
  8. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/README.md +23 -1
  9. dotenvmodel-0.6.0/docs/api-reference/caching.md +5 -0
  10. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/loading.md +89 -1
  11. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/__init__.py +4 -1
  12. dotenvmodel-0.6.0/dotenvmodel/caching.py +195 -0
  13. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/config.py +194 -2
  14. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/mkdocs.yml +1 -0
  15. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/pyproject.toml +1 -1
  16. dotenvmodel-0.6.0/tests/conftest.py +78 -0
  17. dotenvmodel-0.6.0/tests/test_cached.py +448 -0
  18. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/uv.lock +1 -1
  19. dotenvmodel-0.5.4/.release-please-manifest.json +0 -3
  20. dotenvmodel-0.5.4/tests/conftest.py +0 -0
  21. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/CODEOWNERS +0 -0
  22. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  23. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  24. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/dependabot.yml +0 -0
  25. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/pull_request_template.md +0 -0
  26. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/docs.yml +0 -0
  27. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/release-please.yml +0 -0
  28. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.python-version +0 -0
  29. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/CODE_OF_CONDUCT.md +0 -0
  30. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/CONTRIBUTING.md +0 -0
  31. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/LICENSE +0 -0
  32. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/SECURITY.md +0 -0
  33. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/coercion.md +0 -0
  34. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/config.md +0 -0
  35. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/constants.md +0 -0
  36. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/describe-formatters.md +0 -0
  37. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/describe-renderers.md +0 -0
  38. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/describe.md +0 -0
  39. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/dotenvmodel.md +0 -0
  40. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/exceptions.md +0 -0
  41. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/fields.md +0 -0
  42. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/loading.md +0 -0
  43. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/logging-config.md +0 -0
  44. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/metaclass.md +0 -0
  45. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/types.md +0 -0
  46. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/validation.md +0 -0
  47. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/changelog.md +0 -0
  48. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/examples/complete-app.md +0 -0
  49. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/getting-started/installation.md +0 -0
  50. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/getting-started/quick-start.md +0 -0
  51. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/configuration-docs.md +0 -0
  52. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/error-handling.md +0 -0
  53. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/fields.md +0 -0
  54. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/logging.md +0 -0
  55. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/prefixes.md +0 -0
  56. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/types.md +0 -0
  57. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/validation.md +0 -0
  58. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/index.md +0 -0
  59. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/_constants.py +0 -0
  60. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/_redaction.py +0 -0
  61. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/coercion.py +0 -0
  62. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/describe/__init__.py +0 -0
  63. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/describe/formatters.py +0 -0
  64. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/describe/renderers.py +0 -0
  65. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/exceptions.py +0 -0
  66. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/fields.py +0 -0
  67. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/loading.py +0 -0
  68. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/logging_config.py +0 -0
  69. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/metaclass.py +0 -0
  70. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/py.typed +0 -0
  71. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/types.py +0 -0
  72. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/validation.py +0 -0
  73. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/advanced_types.py +0 -0
  74. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/basic_usage.py +0 -0
  75. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/describe_documentation.py +0 -0
  76. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/generate_env_example.py +0 -0
  77. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/logging_example.py +0 -0
  78. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/release-please-config.json +0 -0
  79. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/__init__.py +0 -0
  80. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_basic.py +0 -0
  81. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_coercion.py +0 -0
  82. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_collection_validators.py +0 -0
  83. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_defaults_coercion.py +0 -0
  84. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_describe.py +0 -0
  85. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_adoption.py +0 -0
  86. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_datetime.py +0 -0
  87. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_describe_output.py +0 -0
  88. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_enum.py +0 -0
  89. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_field_options.py +0 -0
  90. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_numbers.py +0 -0
  91. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_path_options.py +0 -0
  92. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_strings.py +0 -0
  93. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_empty_collection_items.py +0 -0
  94. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_empty_strings.py +0 -0
  95. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_enum.py +0 -0
  96. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_errors.py +0 -0
  97. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_field_constraints.py +0 -0
  98. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_future_annotations.py +0 -0
  99. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_inheritance.py +0 -0
  100. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_json.py +0 -0
  101. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_json_and_optional.py +0 -0
  102. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_loading.py +0 -0
  103. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_logging_config.py +0 -0
  104. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_nested_config.py +0 -0
  105. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_post_load.py +0 -0
  106. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_prefix.py +0 -0
  107. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_reload.py +0 -0
  108. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_secret_redaction.py +0 -0
  109. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_secretstr_security.py +0 -0
  110. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_string_affixes.py +0 -0
  111. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_strip.py +0 -0
  112. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_strip_integration.py +0 -0
  113. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_types.py +0 -0
  114. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_union_types.py +0 -0
  115. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_url_dsn.py +0 -0
  116. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_url_password_decoding.py +0 -0
  117. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_url_unquote.py +0 -0
  118. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_urls.py +0 -0
  119. {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/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.0"
3
+ }
@@ -5,6 +5,18 @@ 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.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.5.4...v0.6.0) (2026-07-27)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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))
14
+
15
+
16
+ ### Continuous Integration
17
+
18
+ * 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))
19
+
8
20
  ## [0.5.4](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.5.3...v0.5.4) (2026-07-22)
9
21
 
10
22
 
@@ -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.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
@@ -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,5 @@
1
+ # Caching
2
+
3
+ Cached singleton-instance machinery for DotEnvConfig subclasses.
4
+
5
+ ::: dotenvmodel.caching
@@ -201,9 +201,97 @@ config.reload(override=False)
201
201
 
202
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
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` instead of deadlocking. This prevents a self-deadlock that would otherwise occur because the internal lock is not reentrant. If a hook needs the config instance mid-load, call `cls.load()` directly or use `self`.
232
+
233
+ ### Scoped Overrides for Tests
234
+
235
+ `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.
236
+
237
+ ```python
238
+ def test_with_custom_config():
239
+ test_config = AppConfig.load_from_dict({"DATABASE_URL": "postgresql://localhost/test"})
240
+ with AppConfig.cached_override(test_config):
241
+ assert AppConfig.cached() is test_config
242
+ # Previous cached() state (or absence of one) is restored here.
243
+ ```
244
+
245
+ 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.
246
+
247
+ !!! warning "Not for concurrent use"
248
+
249
+ `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.
250
+
251
+ ### Resetting the Cache for Tests
252
+
253
+ `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.
254
+
255
+ ```python
256
+ import pytest
257
+
258
+
259
+ @pytest.fixture(autouse=True)
260
+ def reset_config_cache():
261
+ yield
262
+ AppConfig.reset_cached()
263
+
264
+
265
+ def test_dev_config(monkeypatch):
266
+ monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/dev")
267
+ config = AppConfig.cached()
268
+ assert "dev" in config.database_url
269
+
270
+
271
+ def test_prod_config(monkeypatch):
272
+ monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/prod")
273
+ config = AppConfig.cached()
274
+ assert "prod" in config.database_url
275
+ ```
276
+
277
+ `reset_cached()` only affects the exact class it is called on — other `DotEnvConfig` subclasses keep their own cached instances.
278
+
279
+ !!! note "Per-class isolation"
280
+
281
+ 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.
282
+
283
+ ### Not a Substitute for Dependency Injection
284
+
285
+ `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.
286
+
287
+ - **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.
288
+ - **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.
289
+
290
+ **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.
291
+
204
292
  ## See Also
205
293
 
206
294
  - [Loading API Reference](../api-reference/loading.md) — `load_env_files()`, `get_env_var()`, `get_env_var_name()`
207
- - [DotEnvConfig API Reference](../api-reference/config.md) — `load()`, `reload()`, `load_from_dict()`
295
+ - [DotEnvConfig API Reference](../api-reference/config.md) — `load()`, `reload()`, `load_from_dict()`, `cached()`, `reset_cached()`, `cached_override()`
208
296
  - [Field Definitions](fields.md) — Defining config fields with `Field()`
209
297
  - [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.0" # x-release-please-version
38
41
  __author__ = "AZX, PBC."
39
42
  __email__ = "oss@azx.io"
40
43
  __license__ = "MIT"
@@ -0,0 +1,195 @@
1
+ """Cached singleton-instance machinery for DotEnvConfig subclasses.
2
+
3
+ Provides the internal implementation behind ``DotEnvConfig.cached()``,
4
+ ``DotEnvConfig.reset_cached()``, and ``DotEnvConfig.cached_override()``.
5
+ The public API lives on ``DotEnvConfig`` itself as thin classmethod wrappers
6
+ that preserve concrete-subclass typing (``Self``); this module operates on
7
+ plain ``type[DotEnvConfig]`` / ``DotEnvConfig`` and is not part of the
8
+ package's public API.
9
+
10
+ The cached instance is stored as a private class attribute
11
+ (``_cached_instance``) on each config subclass's own ``__dict__`` rather than
12
+ in a module-level registry. This ties the cache lifetime to the class object:
13
+ when nothing else references the class, both the class and its cached instance
14
+ become collectible together.
15
+
16
+ Thread safety:
17
+ A module-level ``threading.Lock`` guards the double-checked-locking
18
+ initialization path and the save/restore operations in
19
+ ``begin_override`` / ``end_override``. A ``threading.local`` set tracks
20
+ same-thread reentrant ``cached()`` calls to detect and raise on
21
+ self-deadlock scenarios.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import threading
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING, cast
30
+
31
+ from dotenvmodel._constants import LOGGER_NAME
32
+
33
+ if TYPE_CHECKING:
34
+ from dotenvmodel.config import DotEnvConfig
35
+
36
+ logger = logging.getLogger(LOGGER_NAME)
37
+
38
+ _CACHED_ATTR = "_cached_instance"
39
+ _cache_lock = threading.Lock()
40
+ _loading_local = threading.local()
41
+
42
+
43
+ def _get_loading_set() -> set[type[DotEnvConfig]]:
44
+ """Get or create this thread's set of classes currently loading via ``cached()``.
45
+
46
+ Used for reentrant-``cached()`` deadlock detection. Each thread gets its
47
+ own independent set, so only same-thread reentrancy is detected (the actual
48
+ deadlock scenario). Cross-thread contention is handled by ``_cache_lock``.
49
+ """
50
+ loading = cast("set[type[DotEnvConfig]] | None", getattr(_loading_local, "loading", None))
51
+ if loading is None:
52
+ loading = set()
53
+ _loading_local.__dict__["loading"] = loading
54
+ return loading
55
+
56
+
57
+ def get_cached(cls: type[DotEnvConfig]) -> DotEnvConfig | None:
58
+ """Return the cached instance stored in *cls*'s own ``__dict__``, or ``None``.
59
+
60
+ Reads ``cls.__dict__`` (not ``getattr``) so that only an entry set
61
+ directly on *cls* — not one inherited from a parent class via the MRO —
62
+ is considered a cache hit.
63
+ """
64
+ return cls.__dict__.get(_CACHED_ATTR)
65
+
66
+
67
+ def has_cached(cls: type[DotEnvConfig]) -> bool:
68
+ """Return ``True`` if *cls* has its own ``_cached_instance`` entry in ``__dict__``."""
69
+ return _CACHED_ATTR in cls.__dict__
70
+
71
+
72
+ def set_cached(cls: type[DotEnvConfig], instance: DotEnvConfig) -> None:
73
+ """Store *instance* as the cached singleton on *cls*.
74
+
75
+ This is an unlocked primitive — callers are responsible for acquiring
76
+ ``_cache_lock`` when thread-safety is required.
77
+ """
78
+ setattr(cls, _CACHED_ATTR, instance)
79
+
80
+
81
+ def clear_cached(cls: type[DotEnvConfig]) -> None:
82
+ """Remove *cls*'s own cached instance entry if one exists.
83
+
84
+ Safe to call when no entry is present (no-op). Acquires ``_cache_lock``
85
+ internally.
86
+ """
87
+ with _cache_lock:
88
+ if has_cached(cls):
89
+ delattr(cls, _CACHED_ATTR)
90
+
91
+
92
+ def acquire_cached(
93
+ cls: type[DotEnvConfig],
94
+ env: str | None,
95
+ override: bool,
96
+ env_dir: Path | None,
97
+ ) -> DotEnvConfig:
98
+ """Return the cached instance for *cls*, loading on first call.
99
+
100
+ Implements double-checked locking: a lock-free fast path checks
101
+ ``cls.__dict__``; if the cache is warm, the existing instance is returned
102
+ immediately (with a warning if non-default arguments were passed against
103
+ an already-warm cache). If the cache is cold, a module-level lock is
104
+ acquired and the check is repeated before calling ``cls.load()``.
105
+
106
+ Reentrant calls for the same class from within that class's own
107
+ ``load()`` / ``post_load()`` / field ``validator`` hooks are detected
108
+ via a thread-local loading set and raise ``RuntimeError`` instead of
109
+ deadlocking on the non-reentrant lock.
110
+
111
+ Args:
112
+ cls: The config class to cache for.
113
+ env: Environment name (only used on first call).
114
+ override: Whether .env files override env vars (only used on first call).
115
+ env_dir: Custom .env directory (only used on first call).
116
+
117
+ Returns:
118
+ The cached ``DotEnvConfig`` instance.
119
+
120
+ Raises:
121
+ RuntimeError: If ``cached()`` is called reentrantly for *cls* from
122
+ within that class's own load path.
123
+ """
124
+ cached = get_cached(cls)
125
+ if cached is not None:
126
+ if env is not None or override is not True or env_dir is not None:
127
+ logger.warning(
128
+ "cached() called on %s with arguments (env=%r, override=%r, "
129
+ "env_dir=%r) but the cache is already populated; "
130
+ "arguments were ignored.",
131
+ cls.__name__,
132
+ env,
133
+ override,
134
+ env_dir,
135
+ )
136
+ return cached
137
+
138
+ loading = _get_loading_set()
139
+ if cls in loading:
140
+ raise RuntimeError(
141
+ f"Reentrant cached() call detected for {cls.__name__}: "
142
+ f"cached() was called for this class while its first "
143
+ f"cached() call is still loading (inside load() / "
144
+ f"post_load() / validator). This would deadlock. "
145
+ f"If a hook needs the instance mid-load, call cls.load() "
146
+ f"directly or use 'self' instead."
147
+ )
148
+
149
+ loading.add(cls)
150
+ try:
151
+ with _cache_lock:
152
+ cached = get_cached(cls)
153
+ if cached is not None:
154
+ return cached
155
+
156
+ instance = cls.load(env=env, override=override, env_dir=env_dir)
157
+ set_cached(cls, instance)
158
+ return instance
159
+ finally:
160
+ loading.discard(cls)
161
+
162
+
163
+ def begin_override(
164
+ cls: type[DotEnvConfig],
165
+ instance: DotEnvConfig,
166
+ ) -> tuple[bool, DotEnvConfig | None]:
167
+ """Save the current cached state on *cls* and install *instance* as the override.
168
+
169
+ Returns ``(had_cached, previous)`` so the caller can restore the exact
170
+ pre-override state via :func:`end_override`. Acquires ``_cache_lock``
171
+ internally.
172
+ """
173
+ with _cache_lock:
174
+ had_cached = has_cached(cls)
175
+ previous = get_cached(cls)
176
+ set_cached(cls, instance)
177
+ return had_cached, previous
178
+
179
+
180
+ def end_override(
181
+ cls: type[DotEnvConfig],
182
+ had_cached: bool,
183
+ previous: DotEnvConfig | None,
184
+ ) -> None:
185
+ """Restore the cached state saved by :func:`begin_override`.
186
+
187
+ If *had_cached* is ``True``, *previous* is restored. If *had_cached* is
188
+ ``False`` and *cls* now has its own entry (e.g. a concurrent ``cached()``
189
+ call set one), the entry is removed. Acquires ``_cache_lock`` internally.
190
+ """
191
+ with _cache_lock:
192
+ if had_cached:
193
+ set_cached(cls, cast("DotEnvConfig", previous))
194
+ elif has_cached(cls):
195
+ delattr(cls, _CACHED_ATTR)