dotenvmodel 0.6.0__tar.gz → 0.6.2__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.6.2/.release-please-manifest.json +3 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/CHANGELOG.md +14 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/PKG-INFO +1 -1
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/caching.md +2 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/loading.md +5 -1
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/__init__.py +1 -1
- dotenvmodel-0.6.2/dotenvmodel/caching.py +278 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/config.py +55 -10
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/pyproject.toml +1 -1
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/conftest.py +17 -12
- dotenvmodel-0.6.2/tests/test_cached.py +1082 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/uv.lock +1 -1
- dotenvmodel-0.6.0/.release-please-manifest.json +0 -3
- dotenvmodel-0.6.0/dotenvmodel/caching.py +0 -195
- dotenvmodel-0.6.0/tests/test_cached.py +0 -448
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/ci.yml +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/publish.yml +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.gitignore +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.python-version +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/LICENSE +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/Makefile +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/README.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/SECURITY.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/changelog.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/fields.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/types.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/validation.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/index.md +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/_redaction.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/coercion.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/describe/formatters.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/exceptions.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/fields.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/metaclass.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/types.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/validation.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/logging_example.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/mkdocs.yml +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/release-please-config.json +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/__init__.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_basic.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_coercion.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_defaults_coercion.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_describe.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_adoption.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_path_options.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_enum.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_errors.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_field_constraints.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_future_annotations.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_json.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_loading.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_nested_config.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_post_load.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_reload.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_secret_redaction.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_secretstr_security.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_string_affixes.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_strip.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_strip_integration.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_types.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_urls.py +0 -0
- {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_validator.py +0 -0
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.2](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.6.1...v0.6.2) (2026-07-28)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* warn on cached() argument disagreement, not on non-default args ([#54](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/54)) ([ba80777](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/ba80777e7486797d8a33d27b124b069f90664de6))
|
|
14
|
+
|
|
15
|
+
## [0.6.1](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.6.0...v0.6.1) (2026-07-27)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* 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))
|
|
21
|
+
|
|
8
22
|
## [0.6.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.5.4...v0.6.0) (2026-07-27)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotenvmodel
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -228,7 +228,11 @@ Calling `.reload()` on the cached instance mutates it in place; since `cached()`
|
|
|
228
228
|
|
|
229
229
|
!!! warning "Reentrant `cached()` calls raise `RuntimeError`"
|
|
230
230
|
|
|
231
|
-
Calling `cached()` reentrantly for the same class from within that class's own `load()` / `post_load()` / field `validator` hooks raises `RuntimeError
|
|
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.
|
|
232
236
|
|
|
233
237
|
### Scoped Overrides for Tests
|
|
234
238
|
|
|
@@ -0,0 +1,278 @@
|
|
|
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.RLock`` guards the double-checked-locking
|
|
18
|
+
initialization path and the save/restore operations in
|
|
19
|
+
``begin_override`` / ``end_override``. A reentrant lock (not a plain
|
|
20
|
+
``Lock``) is required because the lock is held across ``cls.load()``:
|
|
21
|
+
``post_load()`` and field ``validator`` hooks may legitimately touch
|
|
22
|
+
the cache for *other* classes, and a non-reentrant lock would
|
|
23
|
+
self-deadlock that thread. With a single module-level lock no
|
|
24
|
+
cross-thread circular wait is possible — only one thread holds the
|
|
25
|
+
lock and the holder always proceeds — so same-thread nesting is the
|
|
26
|
+
only reentrancy case, and the RLock permits it.
|
|
27
|
+
|
|
28
|
+
Same-class operations from within that class's own in-flight load
|
|
29
|
+
remain invalid: ``cached()`` cannot return an instance that does not
|
|
30
|
+
exist yet (the nested call would see a cold cache and recurse into
|
|
31
|
+
``load()`` without bound), and ``reset_cached()`` /
|
|
32
|
+
``cached_override()`` would be silently overwritten when the
|
|
33
|
+
in-flight load installs its instance. A ``threading.local`` set
|
|
34
|
+
tracks classes currently loading on each thread; all three entry
|
|
35
|
+
points raise ``RuntimeError`` in that case. A circular cross-class
|
|
36
|
+
chain (A's hook loads B, B's hook loads A) collapses back onto the
|
|
37
|
+
first class and is likewise reported as a same-class
|
|
38
|
+
``RuntimeError``.
|
|
39
|
+
|
|
40
|
+
One residual hazard sits outside this design: the lock is held for
|
|
41
|
+
the duration of a load, so a hook must not block on another thread
|
|
42
|
+
(e.g. ``thread.join()``) that touches the cache — the joining thread
|
|
43
|
+
holds the lock while the joined thread waits for it, deadlocking
|
|
44
|
+
both.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import logging
|
|
50
|
+
import threading
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import TYPE_CHECKING, cast
|
|
53
|
+
|
|
54
|
+
from dotenvmodel._constants import LOGGER_NAME
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from dotenvmodel.config import DotEnvConfig
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
60
|
+
|
|
61
|
+
_CACHED_ATTR = "_cached_instance"
|
|
62
|
+
_cache_lock = threading.RLock()
|
|
63
|
+
_loading_local = threading.local()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_loading_set() -> set[type[DotEnvConfig]]:
|
|
67
|
+
"""Get or create this thread's set of classes currently loading via ``cached()``.
|
|
68
|
+
|
|
69
|
+
Used to reject same-class cache operations issued from within that
|
|
70
|
+
class's own in-flight load (see the module docstring's "Thread safety"
|
|
71
|
+
section). Each thread gets its own independent set, so only same-thread
|
|
72
|
+
reentrancy is detected (the only possible nesting case with a single
|
|
73
|
+
module-level reentrant lock). Cross-thread contention is handled by
|
|
74
|
+
``_cache_lock``.
|
|
75
|
+
"""
|
|
76
|
+
loading = cast("set[type[DotEnvConfig]] | None", getattr(_loading_local, "loading", None))
|
|
77
|
+
if loading is None:
|
|
78
|
+
loading = set()
|
|
79
|
+
_loading_local.__dict__["loading"] = loading
|
|
80
|
+
return loading
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_cached(cls: type[DotEnvConfig]) -> DotEnvConfig | None:
|
|
84
|
+
"""Return the cached instance stored in *cls*'s own ``__dict__``, or ``None``.
|
|
85
|
+
|
|
86
|
+
Reads ``cls.__dict__`` (not ``getattr``) so that only an entry set
|
|
87
|
+
directly on *cls* — not one inherited from a parent class via the MRO —
|
|
88
|
+
is considered a cache hit.
|
|
89
|
+
"""
|
|
90
|
+
return cls.__dict__.get(_CACHED_ATTR)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def has_cached(cls: type[DotEnvConfig]) -> bool:
|
|
94
|
+
"""Return ``True`` if *cls* has its own ``_cached_instance`` entry in ``__dict__``."""
|
|
95
|
+
return _CACHED_ATTR in cls.__dict__
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def set_cached(cls: type[DotEnvConfig], instance: DotEnvConfig) -> None:
|
|
99
|
+
"""Store *instance* as the cached singleton on *cls*.
|
|
100
|
+
|
|
101
|
+
This is an unlocked primitive — callers are responsible for acquiring
|
|
102
|
+
``_cache_lock`` when thread-safety is required.
|
|
103
|
+
"""
|
|
104
|
+
setattr(cls, _CACHED_ATTR, instance)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def clear_cached(cls: type[DotEnvConfig]) -> None:
|
|
108
|
+
"""Remove *cls*'s own cached instance entry if one exists.
|
|
109
|
+
|
|
110
|
+
Safe to call when no entry is present (no-op). Acquires ``_cache_lock``
|
|
111
|
+
internally.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
RuntimeError: If called for *cls* from within that class's own
|
|
115
|
+
in-flight load (``load()`` / ``post_load()`` / field
|
|
116
|
+
``validator`` on this thread): the load installs its instance
|
|
117
|
+
when it completes, which would silently undo this reset.
|
|
118
|
+
Cross-class calls (e.g. one class's hook resetting another
|
|
119
|
+
class) are permitted.
|
|
120
|
+
"""
|
|
121
|
+
if cls in _get_loading_set():
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"reset_cached() called for {cls.__name__} from within that "
|
|
124
|
+
f"class's own in-flight load (load() / post_load() / "
|
|
125
|
+
f"validator): the load installs its instance when it "
|
|
126
|
+
f"completes, which would silently undo this reset. Call "
|
|
127
|
+
f"reset_cached() after the load finishes instead."
|
|
128
|
+
)
|
|
129
|
+
with _cache_lock:
|
|
130
|
+
if has_cached(cls):
|
|
131
|
+
delattr(cls, _CACHED_ATTR)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def acquire_cached(
|
|
135
|
+
cls: type[DotEnvConfig],
|
|
136
|
+
env: str | None,
|
|
137
|
+
override: bool,
|
|
138
|
+
env_dir: Path | None,
|
|
139
|
+
) -> DotEnvConfig:
|
|
140
|
+
"""Return the cached instance for *cls*, loading on first call.
|
|
141
|
+
|
|
142
|
+
Implements double-checked locking: a lock-free fast path checks
|
|
143
|
+
``cls.__dict__``; if the cache is warm, the existing instance is returned
|
|
144
|
+
immediately (with a warning if arguments that disagree with the ones
|
|
145
|
+
that populated the cache are passed). If the cache is cold, a module-level lock is
|
|
146
|
+
acquired and the check is repeated before calling ``cls.load()``.
|
|
147
|
+
|
|
148
|
+
Reentrant calls for the same class from within that class's own
|
|
149
|
+
``load()`` / ``post_load()`` / field ``validator`` hooks are detected
|
|
150
|
+
via a thread-local loading set and raise ``RuntimeError``: the internal
|
|
151
|
+
lock is reentrant, so the nested call would not deadlock — it would see
|
|
152
|
+
a cold cache and recurse into ``load()`` without bound. Nested calls
|
|
153
|
+
for *other* classes from those hooks proceed normally.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
cls: The config class to cache for.
|
|
157
|
+
env: Environment name (only used on first call).
|
|
158
|
+
override: Whether .env files override env vars (only used on first call).
|
|
159
|
+
env_dir: Custom .env directory (only used on first call).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The cached ``DotEnvConfig`` instance.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
RuntimeError: If ``cached()`` is called reentrantly for *cls* from
|
|
166
|
+
within that class's own load path (including via a circular
|
|
167
|
+
cross-class hook chain that collapses back onto *cls*).
|
|
168
|
+
"""
|
|
169
|
+
cached = get_cached(cls)
|
|
170
|
+
if cached is not None:
|
|
171
|
+
# Warn on DISAGREEMENT, not on non-default arguments. An application accessor that
|
|
172
|
+
# consistently passes the same non-default arguments (e.g. `override=False`) on every
|
|
173
|
+
# call is asking for exactly what it already got, and warning it every time would train
|
|
174
|
+
# readers to filter the message out. A caller passing something the cache was NOT built
|
|
175
|
+
# with is the real bug this catches, and it still fires.
|
|
176
|
+
#
|
|
177
|
+
# Compared against the INSTANCE's own record of how it was loaded — the same fields
|
|
178
|
+
# `reload()` reuses — rather than a second copy kept beside the cache. One source of
|
|
179
|
+
# truth, and self-correcting: `reload(env="prod")` updates them, so a later `cached()`
|
|
180
|
+
# is judged against what the cached object actually holds now, not against whatever
|
|
181
|
+
# populated it originally.
|
|
182
|
+
loaded = cached.loaded_with()
|
|
183
|
+
if (env, override, env_dir) != loaded:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"cached() called on %s with arguments (env=%r, override=%r, "
|
|
186
|
+
"env_dir=%r) but the cache is already populated with "
|
|
187
|
+
"(env=%r, override=%r, env_dir=%r); arguments were ignored.",
|
|
188
|
+
cls.__name__,
|
|
189
|
+
env,
|
|
190
|
+
override,
|
|
191
|
+
env_dir,
|
|
192
|
+
*loaded,
|
|
193
|
+
)
|
|
194
|
+
return cached
|
|
195
|
+
|
|
196
|
+
loading = _get_loading_set()
|
|
197
|
+
if cls in loading:
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
f"Reentrant cached() call detected for {cls.__name__}: "
|
|
200
|
+
f"cached() was called for this class while its first "
|
|
201
|
+
f"cached() call is still loading (inside load() / "
|
|
202
|
+
f"post_load() / validator). The instance cannot exist until "
|
|
203
|
+
f"that first load completes, and the internal lock is "
|
|
204
|
+
f"reentrant, so the nested call would recurse into load() "
|
|
205
|
+
f"without bound. If a hook needs the instance mid-load, call "
|
|
206
|
+
f"cls.load() directly or use 'self' instead."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# add() lives inside the try so a (vanishingly unlikely) async
|
|
211
|
+
# exception between add and try-entry cannot strand cls in this
|
|
212
|
+
# thread's loading set; discard() on a never-added class is a no-op.
|
|
213
|
+
loading.add(cls)
|
|
214
|
+
with _cache_lock:
|
|
215
|
+
cached = get_cached(cls)
|
|
216
|
+
if cached is not None:
|
|
217
|
+
return cached
|
|
218
|
+
|
|
219
|
+
instance = cls.load(env=env, override=override, env_dir=env_dir)
|
|
220
|
+
set_cached(cls, instance)
|
|
221
|
+
return instance
|
|
222
|
+
finally:
|
|
223
|
+
loading.discard(cls)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def begin_override(
|
|
227
|
+
cls: type[DotEnvConfig],
|
|
228
|
+
instance: DotEnvConfig,
|
|
229
|
+
) -> tuple[bool, DotEnvConfig | None]:
|
|
230
|
+
"""Save the current cached state on *cls* and install *instance* as the override.
|
|
231
|
+
|
|
232
|
+
Returns ``(had_cached, previous)`` so the caller can restore the exact
|
|
233
|
+
pre-override state via :func:`end_override`. Acquires ``_cache_lock``
|
|
234
|
+
internally.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
RuntimeError: If called for *cls* from within that class's own
|
|
238
|
+
in-flight load (``load()`` / ``post_load()`` / field
|
|
239
|
+
``validator`` on this thread): the load installs its instance
|
|
240
|
+
when it completes, which would silently discard the override.
|
|
241
|
+
Cross-class calls (e.g. one class's hook overriding another
|
|
242
|
+
class) are permitted.
|
|
243
|
+
"""
|
|
244
|
+
if cls in _get_loading_set():
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
f"cached_override() entered for {cls.__name__} from within "
|
|
247
|
+
f"that class's own in-flight load (load() / post_load() / "
|
|
248
|
+
f"validator): the load installs its instance when it "
|
|
249
|
+
f"completes, which would silently discard the override. "
|
|
250
|
+
f"Enter the override after the load finishes instead."
|
|
251
|
+
)
|
|
252
|
+
with _cache_lock:
|
|
253
|
+
had_cached = has_cached(cls)
|
|
254
|
+
previous = get_cached(cls)
|
|
255
|
+
set_cached(cls, instance)
|
|
256
|
+
return had_cached, previous
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def end_override(
|
|
260
|
+
cls: type[DotEnvConfig],
|
|
261
|
+
had_cached: bool,
|
|
262
|
+
previous: DotEnvConfig | None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Restore the cached state saved by :func:`begin_override`.
|
|
265
|
+
|
|
266
|
+
If *had_cached* is ``True``, *previous* is restored. If *had_cached* is
|
|
267
|
+
``False`` and *cls* now has its own entry (e.g. a concurrent ``cached()``
|
|
268
|
+
call set one), the entry is removed. Acquires ``_cache_lock`` internally.
|
|
269
|
+
|
|
270
|
+
No same-class loading guard here by design: via ``cached_override()`` this
|
|
271
|
+
only runs after a successful :func:`begin_override`, which already
|
|
272
|
+
rejected entry while *cls* was loading on this thread.
|
|
273
|
+
"""
|
|
274
|
+
with _cache_lock:
|
|
275
|
+
if had_cached:
|
|
276
|
+
set_cached(cls, cast("DotEnvConfig", previous))
|
|
277
|
+
elif has_cached(cls):
|
|
278
|
+
delattr(cls, _CACHED_ATTR)
|
|
@@ -303,7 +303,13 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
303
303
|
_load_env: str | None = None # Store the env used during load
|
|
304
304
|
_load_override: bool = True # Store the override flag used during load
|
|
305
305
|
_load_env_dir: Path | None = None # Store the env_dir used during load
|
|
306
|
-
|
|
306
|
+
# Bare annotation only — no assignment. An actual `= None` here would
|
|
307
|
+
# place a real entry in DotEnvConfig.__dict__, making has_cached() treat
|
|
308
|
+
# the base class as already-cached and letting reset_cached() delete the
|
|
309
|
+
# class-body default (an irreversible process-wide mutation). The
|
|
310
|
+
# annotation alone gives type checkers the declaration without a runtime
|
|
311
|
+
# entry; per-subclass entries are set by the caching module.
|
|
312
|
+
_cached_instance: ClassVar["DotEnvConfig | None"]
|
|
307
313
|
env_prefix: str = "" # Class-level prefix for environment variables (default: no prefix)
|
|
308
314
|
strip_strings: bool = False # Class-level default for stripping string values
|
|
309
315
|
|
|
@@ -561,6 +567,20 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
561
567
|
instance._load_env_dir = env_dir
|
|
562
568
|
return instance
|
|
563
569
|
|
|
570
|
+
def loaded_with(self) -> tuple[str | None, bool, Path | None]:
|
|
571
|
+
"""The ``(env, override, env_dir)`` this instance was last loaded with.
|
|
572
|
+
|
|
573
|
+
`reload()` uses it to repeat a load without restating its arguments — so a SIGHUP
|
|
574
|
+
handler calling `reload()` with no arguments keeps the original precedence rather than
|
|
575
|
+
silently reverting to `override=True`. `cached()`'s warm path uses it to tell a caller
|
|
576
|
+
who agrees with how the cache was built from one who disagrees.
|
|
577
|
+
|
|
578
|
+
Exposed rather than read field-by-field so there is one definition of "how was this
|
|
579
|
+
loaded", and callers outside this class do not reach into three private attributes.
|
|
580
|
+
Values reflect the most recent `reload()`, not only the original `load()`.
|
|
581
|
+
"""
|
|
582
|
+
return (self._load_env, self._load_override, self._load_env_dir)
|
|
583
|
+
|
|
564
584
|
def reload(
|
|
565
585
|
self,
|
|
566
586
|
env: str | None = None,
|
|
@@ -622,9 +642,10 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
622
642
|
"""
|
|
623
643
|
logger.info(f"Reloading {self.__class__.__name__} configuration")
|
|
624
644
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
645
|
+
loaded_env, loaded_override, loaded_env_dir = self.loaded_with()
|
|
646
|
+
reload_env = env if env is not None else loaded_env
|
|
647
|
+
reload_override = override if override is not None else loaded_override
|
|
648
|
+
reload_env_dir = env_dir if env_dir is not None else loaded_env_dir
|
|
628
649
|
|
|
629
650
|
load_env_files(env=reload_env, override=reload_override, env_dir=reload_env_dir)
|
|
630
651
|
|
|
@@ -703,8 +724,8 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
703
724
|
calls `load()`, the rest block and receive the same instance. Subsequent
|
|
704
725
|
calls (from any thread) return the cached instance immediately without
|
|
705
726
|
re-reading the environment, ignoring any arguments passed after the first
|
|
706
|
-
call (a warning is logged if
|
|
707
|
-
already-warm cache).
|
|
727
|
+
call (a warning is logged if arguments that disagree with the ones
|
|
728
|
+
that populated the cache are passed against an already-warm cache).
|
|
708
729
|
|
|
709
730
|
The cached instance is stored as a private class attribute on the config
|
|
710
731
|
class itself (not in a module-level registry), so its lifetime is tied
|
|
@@ -735,7 +756,9 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
735
756
|
- When you need multiple instances with different parameters
|
|
736
757
|
- From within a `post_load()` hook or field `validator` on the same
|
|
737
758
|
class: a reentrant `cached()` call for the same class while its
|
|
738
|
-
first load is still in flight raises `RuntimeError` (see below)
|
|
759
|
+
first load is still in flight raises `RuntimeError` (see below).
|
|
760
|
+
Calling `cached()` for *other* classes from those hooks is
|
|
761
|
+
supported.
|
|
739
762
|
|
|
740
763
|
Args:
|
|
741
764
|
env: Environment name (e.g., "dev", "prod", "test"). If None, reads
|
|
@@ -765,9 +788,17 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
765
788
|
simultaneously (only on first call)
|
|
766
789
|
RuntimeError: If `cached()` is called reentrantly for the same
|
|
767
790
|
class from within that class's own `load()` / `post_load()` /
|
|
768
|
-
field `validator` hooks.
|
|
769
|
-
|
|
770
|
-
|
|
791
|
+
field `validator` hooks. The internal lock is reentrant, so
|
|
792
|
+
the nested call would not deadlock — it would see a cold
|
|
793
|
+
cache and recurse into `load()` without bound; it is rejected
|
|
794
|
+
instead. A circular cross-class hook chain (A's hook loads B,
|
|
795
|
+
B's hook loads A) collapses back onto the first class and
|
|
796
|
+
raises the same `RuntimeError`. Calling `cached()` for other
|
|
797
|
+
classes from hooks is supported. Hooks that need the instance
|
|
798
|
+
mid-load should use `self`, or call `cls.load()` directly with
|
|
799
|
+
re-entry guarding (an unconditional `cls.load()` inside
|
|
800
|
+
`post_load()` re-runs the hooks and recurses until
|
|
801
|
+
`RecursionError`).
|
|
771
802
|
|
|
772
803
|
Example:
|
|
773
804
|
```python
|
|
@@ -811,6 +842,13 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
811
842
|
- After changing environment variables to force `cached()` to
|
|
812
843
|
re-read the environment
|
|
813
844
|
|
|
845
|
+
Raises:
|
|
846
|
+
RuntimeError: If called for this class from within that same
|
|
847
|
+
class's own in-flight `load()` / `post_load()` / field
|
|
848
|
+
`validator` hook: the load installs its instance when it
|
|
849
|
+
completes, which would silently undo the reset. Calling
|
|
850
|
+
`reset_cached()` for *other* classes from hooks is fine.
|
|
851
|
+
|
|
814
852
|
Example:
|
|
815
853
|
```python
|
|
816
854
|
@pytest.fixture(autouse=True)
|
|
@@ -855,6 +893,13 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
855
893
|
Yields:
|
|
856
894
|
`instance`, unchanged, for convenience in a `with ... as` binding.
|
|
857
895
|
|
|
896
|
+
Raises:
|
|
897
|
+
RuntimeError: If entered for this class from within that same
|
|
898
|
+
class's own in-flight `load()` / `post_load()` / field
|
|
899
|
+
`validator` hook: the load installs its instance when it
|
|
900
|
+
completes, which would silently discard the override. Calling
|
|
901
|
+
`cached_override()` for *other* classes from hooks is fine.
|
|
902
|
+
|
|
858
903
|
Example:
|
|
859
904
|
```python
|
|
860
905
|
test_config = AppConfig.load_from_dict({"PORT": "9000"})
|
|
@@ -9,15 +9,20 @@ from dotenvmodel.caching import _CACHED_ATTR
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _all_dotenv_subclasses() -> Iterator[type[DotEnvConfig]]:
|
|
12
|
-
"""Yield every currently-alive
|
|
12
|
+
"""Yield DotEnvConfig itself and every currently-alive subclass, recursively.
|
|
13
|
+
|
|
14
|
+
The stack is seeded with the base class so its own cached state (if any)
|
|
15
|
+
is snapshotted/restored too — the caching module stores instances as
|
|
16
|
+
per-class ``__dict__`` entries, and the base class is itself a valid
|
|
17
|
+
``cached()`` target.
|
|
13
18
|
|
|
14
19
|
``type.__subclasses__()`` returns only direct subclasses and uses weak
|
|
15
20
|
references internally — it does not prevent garbage collection of classes
|
|
16
21
|
with no other referents (verified empirically). We walk the tree
|
|
17
22
|
depth-first, deduplicating by identity to handle diamond hierarchies.
|
|
18
23
|
"""
|
|
19
|
-
seen: set[type] = set()
|
|
20
|
-
stack: list[type] =
|
|
24
|
+
seen: set[type[DotEnvConfig]] = set()
|
|
25
|
+
stack: list[type[DotEnvConfig]] = [DotEnvConfig]
|
|
21
26
|
while stack:
|
|
22
27
|
cls = stack.pop()
|
|
23
28
|
if cls in seen:
|
|
@@ -29,19 +34,19 @@ def _all_dotenv_subclasses() -> Iterator[type[DotEnvConfig]]:
|
|
|
29
34
|
|
|
30
35
|
@pytest.fixture(autouse=True)
|
|
31
36
|
def _restore_cached_state() -> Iterator[None]:
|
|
32
|
-
"""Snapshot and restore ``_cached_instance`` on every
|
|
37
|
+
"""Snapshot and restore ``_cached_instance`` on DotEnvConfig and every subclass.
|
|
33
38
|
|
|
34
39
|
Function-scoped (autouse) because cached state must be restored after
|
|
35
40
|
every single test — a session-scoped fixture would allow one test's
|
|
36
41
|
cached instance to leak into the next.
|
|
37
42
|
|
|
38
|
-
At setup, walks
|
|
39
|
-
ones have their *own* ``_cached_instance`` entry in
|
|
40
|
-
(not inherited) and what the value is. At teardown,
|
|
41
|
-
pre-test state: classes that had an entry get it
|
|
42
|
-
discovered after the test that have an entry but did
|
|
43
|
-
get it removed. Classes that had no entry before
|
|
44
|
-
are untouched.
|
|
43
|
+
At setup, walks ``DotEnvConfig`` itself and every alive subclass and
|
|
44
|
+
records which ones have their *own* ``_cached_instance`` entry in
|
|
45
|
+
``cls.__dict__`` (not inherited) and what the value is. At teardown,
|
|
46
|
+
restores the exact pre-test state: classes that had an entry get it
|
|
47
|
+
restored; classes discovered after the test that have an entry but did
|
|
48
|
+
not have one before get it removed. Classes that had no entry before
|
|
49
|
+
and still have none are untouched.
|
|
45
50
|
|
|
46
51
|
This is a belt-and-suspenders safety net. For test files where every
|
|
47
52
|
test defines its own locally-scoped subclass, isolation is already
|
|
@@ -54,7 +59,7 @@ def _restore_cached_state() -> Iterator[None]:
|
|
|
54
59
|
|
|
55
60
|
|
|
56
61
|
def _snapshot_and_restore_cached_state() -> Iterator[None]:
|
|
57
|
-
"""Generator that snapshots
|
|
62
|
+
"""Generator that snapshots/restores ``_cached_instance`` on the base class and all subclasses.
|
|
58
63
|
|
|
59
64
|
This is the underlying logic for :func:`_restore_cached_state`, extracted
|
|
60
65
|
as a plain (non-fixture) generator so it can also be driven manually in
|