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.
Files changed (120) hide show
  1. dotenvmodel-0.6.2/.release-please-manifest.json +3 -0
  2. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/CHANGELOG.md +14 -0
  3. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/PKG-INFO +1 -1
  4. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/caching.md +2 -0
  5. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/loading.md +5 -1
  6. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/__init__.py +1 -1
  7. dotenvmodel-0.6.2/dotenvmodel/caching.py +278 -0
  8. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/config.py +55 -10
  9. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/pyproject.toml +1 -1
  10. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/conftest.py +17 -12
  11. dotenvmodel-0.6.2/tests/test_cached.py +1082 -0
  12. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/uv.lock +1 -1
  13. dotenvmodel-0.6.0/.release-please-manifest.json +0 -3
  14. dotenvmodel-0.6.0/dotenvmodel/caching.py +0 -195
  15. dotenvmodel-0.6.0/tests/test_cached.py +0 -448
  16. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/CODEOWNERS +0 -0
  17. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/dependabot.yml +0 -0
  20. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/pull_request_template.md +0 -0
  21. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/ci.yml +0 -0
  22. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/docs.yml +0 -0
  23. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/publish.yml +0 -0
  24. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.github/workflows/release-please.yml +0 -0
  25. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.gitignore +0 -0
  26. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/.python-version +0 -0
  27. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/CODE_OF_CONDUCT.md +0 -0
  28. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/CONTRIBUTING.md +0 -0
  29. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/LICENSE +0 -0
  30. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/Makefile +0 -0
  31. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/README.md +0 -0
  32. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/SECURITY.md +0 -0
  33. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/coercion.md +0 -0
  34. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/config.md +0 -0
  35. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/constants.md +0 -0
  36. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/describe-formatters.md +0 -0
  37. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/describe-renderers.md +0 -0
  38. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/describe.md +0 -0
  39. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/dotenvmodel.md +0 -0
  40. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/exceptions.md +0 -0
  41. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/fields.md +0 -0
  42. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/loading.md +0 -0
  43. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/logging-config.md +0 -0
  44. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/metaclass.md +0 -0
  45. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/types.md +0 -0
  46. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/api-reference/validation.md +0 -0
  47. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/changelog.md +0 -0
  48. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/examples/complete-app.md +0 -0
  49. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/getting-started/installation.md +0 -0
  50. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/getting-started/quick-start.md +0 -0
  51. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/configuration-docs.md +0 -0
  52. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/error-handling.md +0 -0
  53. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/fields.md +0 -0
  54. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/logging.md +0 -0
  55. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/prefixes.md +0 -0
  56. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/types.md +0 -0
  57. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/guides/validation.md +0 -0
  58. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/docs/index.md +0 -0
  59. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/_constants.py +0 -0
  60. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/_redaction.py +0 -0
  61. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/coercion.py +0 -0
  62. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/describe/__init__.py +0 -0
  63. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/describe/formatters.py +0 -0
  64. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/describe/renderers.py +0 -0
  65. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/exceptions.py +0 -0
  66. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/fields.py +0 -0
  67. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/loading.py +0 -0
  68. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/logging_config.py +0 -0
  69. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/metaclass.py +0 -0
  70. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/py.typed +0 -0
  71. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/types.py +0 -0
  72. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/dotenvmodel/validation.py +0 -0
  73. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/advanced_types.py +0 -0
  74. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/basic_usage.py +0 -0
  75. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/describe_documentation.py +0 -0
  76. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/generate_env_example.py +0 -0
  77. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/examples/logging_example.py +0 -0
  78. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/mkdocs.yml +0 -0
  79. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/release-please-config.json +0 -0
  80. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/__init__.py +0 -0
  81. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_basic.py +0 -0
  82. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_coercion.py +0 -0
  83. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_collection_validators.py +0 -0
  84. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_defaults_coercion.py +0 -0
  85. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_describe.py +0 -0
  86. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_adoption.py +0 -0
  87. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_datetime.py +0 -0
  88. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_describe_output.py +0 -0
  89. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_enum.py +0 -0
  90. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_field_options.py +0 -0
  91. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_numbers.py +0 -0
  92. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_path_options.py +0 -0
  93. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_edge_strings.py +0 -0
  94. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_empty_collection_items.py +0 -0
  95. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_empty_strings.py +0 -0
  96. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_enum.py +0 -0
  97. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_errors.py +0 -0
  98. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_field_constraints.py +0 -0
  99. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_future_annotations.py +0 -0
  100. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_inheritance.py +0 -0
  101. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_json.py +0 -0
  102. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_json_and_optional.py +0 -0
  103. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_loading.py +0 -0
  104. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_logging_config.py +0 -0
  105. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_nested_config.py +0 -0
  106. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_post_load.py +0 -0
  107. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_prefix.py +0 -0
  108. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_reload.py +0 -0
  109. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_secret_redaction.py +0 -0
  110. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_secretstr_security.py +0 -0
  111. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_string_affixes.py +0 -0
  112. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_strip.py +0 -0
  113. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_strip_integration.py +0 -0
  114. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_types.py +0 -0
  115. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_union_types.py +0 -0
  116. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_url_dsn.py +0 -0
  117. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_url_password_decoding.py +0 -0
  118. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_url_unquote.py +0 -0
  119. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_urls.py +0 -0
  120. {dotenvmodel-0.6.0 → dotenvmodel-0.6.2}/tests/test_validator.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.6.2"
3
+ }
@@ -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.0
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
@@ -3,3 +3,5 @@
3
3
  Cached singleton-instance machinery for DotEnvConfig subclasses.
4
4
 
5
5
  ::: dotenvmodel.caching
6
+ options:
7
+ members: []
@@ -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` 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`.
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
 
@@ -37,7 +37,7 @@ Public API:
37
37
  Exception hierarchy
38
38
  """
39
39
 
40
- __version__ = "0.6.0" # x-release-please-version
40
+ __version__ = "0.6.2" # x-release-please-version
41
41
  __author__ = "AZX, PBC."
42
42
  __email__ = "oss@azx.io"
43
43
  __license__ = "MIT"
@@ -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
- _cached_instance: ClassVar["DotEnvConfig | None"] = None
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
- reload_env = env if env is not None else self._load_env
626
- reload_override = override if override is not None else self._load_override
627
- reload_env_dir = env_dir if env_dir is not None else self._load_env_dir
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 non-default arguments are passed against an
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. This would otherwise deadlock on the
769
- non-reentrant internal lock. Hooks that need the instance
770
- mid-load should call `cls.load()` directly or use `self`.
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"})
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dotenvmodel"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Type-safe environment configuration with automatic .env file loading"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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 DotEnvConfig subclass, recursively.
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] = list(DotEnvConfig.__subclasses__())
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 DotEnvConfig subclass.
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 every alive ``DotEnvConfig`` subclass and records which
39
- ones have their *own* ``_cached_instance`` entry in ``cls.__dict__``
40
- (not inherited) and what the value is. At teardown, restores the exact
41
- pre-test state: classes that had an entry get it restored; classes
42
- discovered after the test that have an entry but did not have one before
43
- get it removed. Classes that had no entry before and still have none
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 and restores ``_cached_instance`` on all subclasses.
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