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.
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/ci.yml +1 -1
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/publish.yml +1 -1
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.gitignore +3 -0
- dotenvmodel-0.6.0/.release-please-manifest.json +3 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/CHANGELOG.md +12 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/Makefile +4 -3
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/PKG-INFO +24 -2
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/README.md +23 -1
- dotenvmodel-0.6.0/docs/api-reference/caching.md +5 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/loading.md +89 -1
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/__init__.py +4 -1
- dotenvmodel-0.6.0/dotenvmodel/caching.py +195 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/config.py +194 -2
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/mkdocs.yml +1 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/pyproject.toml +1 -1
- dotenvmodel-0.6.0/tests/conftest.py +78 -0
- dotenvmodel-0.6.0/tests/test_cached.py +448 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/uv.lock +1 -1
- dotenvmodel-0.5.4/.release-please-manifest.json +0 -3
- dotenvmodel-0.5.4/tests/conftest.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/.python-version +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/LICENSE +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/SECURITY.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/changelog.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/fields.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/types.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/guides/validation.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/docs/index.md +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/_redaction.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/coercion.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/describe/formatters.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/exceptions.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/fields.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/metaclass.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/types.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/dotenvmodel/validation.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/examples/logging_example.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/release-please-config.json +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/__init__.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_basic.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_coercion.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_defaults_coercion.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_describe.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_adoption.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_path_options.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_enum.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_errors.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_field_constraints.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_future_annotations.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_json.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_loading.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_nested_config.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_post_load.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_reload.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_secret_redaction.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_secretstr_security.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_string_affixes.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_strip.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_strip_integration.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_types.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_urls.py +0 -0
- {dotenvmodel-0.5.4 → dotenvmodel-0.6.0}/tests/test_validator.py +0 -0
|
@@ -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.
|
|
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
|
|
|
@@ -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.
|
|
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)
|