lib-layered-config 4.1.0__py3-none-any.whl
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.
- lib_layered_config/__init__.py +58 -0
- lib_layered_config/__init__conf__.py +74 -0
- lib_layered_config/__main__.py +18 -0
- lib_layered_config/_layers.py +310 -0
- lib_layered_config/_platform.py +166 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/_nested_keys.py +126 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +143 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +288 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +376 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
- lib_layered_config/adapters/path_resolvers/_base.py +166 -0
- lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
- lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
- lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
- lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
- lib_layered_config/adapters/path_resolvers/default.py +194 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +379 -0
- lib_layered_config/application/ports.py +115 -0
- lib_layered_config/cli/__init__.py +92 -0
- lib_layered_config/cli/common.py +381 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +71 -0
- lib_layered_config/cli/fail.py +19 -0
- lib_layered_config/cli/generate.py +57 -0
- lib_layered_config/cli/info.py +29 -0
- lib_layered_config/cli/read.py +120 -0
- lib_layered_config/core.py +301 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +372 -0
- lib_layered_config/domain/errors.py +59 -0
- lib_layered_config/domain/identifiers.py +366 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +333 -0
- lib_layered_config/examples/generate.py +406 -0
- lib_layered_config/observability.py +209 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +46 -0
- lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
- lib_layered_config-4.1.0.dist-info/RECORD +47 -0
- lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
- lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-4.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Structured configuration file loaders.
|
|
2
|
+
|
|
3
|
+
Convert on-disk artifacts into Python mappings that the merge layer understands.
|
|
4
|
+
Adapters are small wrappers around ``tomllib``/``json``/``yaml.safe_load`` so
|
|
5
|
+
error handling, observability, and immutability policies live in one place.
|
|
6
|
+
|
|
7
|
+
Contents:
|
|
8
|
+
- ``BaseFileLoader``: shared primitives for reading files and asserting
|
|
9
|
+
mapping outputs.
|
|
10
|
+
- ``TOMLFileLoader`` / ``JSONFileLoader`` / ``YAMLFileLoader``: thin
|
|
11
|
+
adapters that delegate to parser-specific helpers.
|
|
12
|
+
- ``_log_file_read`` / ``_log_file_loaded`` / ``_log_file_invalid``:
|
|
13
|
+
structured logging helpers reused across loaders.
|
|
14
|
+
- ``_ensure_yaml_available``: guard ensuring YAML support is present before
|
|
15
|
+
attempting to parse.
|
|
16
|
+
|
|
17
|
+
Invoked by :func:`lib_layered_config.core._load_files` to parse structured files
|
|
18
|
+
before passing the results to the merge policy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
if sys.version_info >= (3, 11):
|
|
27
|
+
import tomllib
|
|
28
|
+
else:
|
|
29
|
+
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
|
30
|
+
from collections.abc import Mapping
|
|
31
|
+
from importlib import import_module
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from types import ModuleType
|
|
34
|
+
from typing import Any, NoReturn
|
|
35
|
+
|
|
36
|
+
from ...domain.errors import InvalidFormatError, NotFoundError
|
|
37
|
+
from ...observability import log_debug, log_error
|
|
38
|
+
|
|
39
|
+
yaml: ModuleType | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
FILE_LAYER = "file"
|
|
43
|
+
"""Layer label used in structured logging for file-oriented events.
|
|
44
|
+
|
|
45
|
+
Tag observability events originating from file loaders with a consistent name.
|
|
46
|
+
|
|
47
|
+
Constant referenced by logging helpers within this module.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _log_file_read(path: str, size: int) -> None:
|
|
52
|
+
"""Record that *path* was read with *size* bytes.
|
|
53
|
+
|
|
54
|
+
Provide insight into which files were accessed and their size for
|
|
55
|
+
troubleshooting.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path: Absolute path read from disk.
|
|
59
|
+
size: Number of bytes read.
|
|
60
|
+
"""
|
|
61
|
+
log_debug("config_file_read", layer=FILE_LAYER, path=path, size=size)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _log_file_loaded(path: str, format_name: str) -> None:
|
|
65
|
+
"""Record a successful parse for *path* and *format_name*.
|
|
66
|
+
|
|
67
|
+
Trace successful parsing events and note which parser handled the file.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
path: Absolute file path.
|
|
71
|
+
format_name: Parser identifier (e.g., ``"toml"``).
|
|
72
|
+
"""
|
|
73
|
+
log_debug("config_file_loaded", layer=FILE_LAYER, path=path, format=format_name)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _log_file_invalid(path: str, format_name: str, exc: Exception) -> None:
|
|
77
|
+
"""Capture parser failures for diagnostics.
|
|
78
|
+
|
|
79
|
+
Surface parse errors with enough context (path, format, message) for quick
|
|
80
|
+
troubleshooting.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
path: File path that failed to parse.
|
|
84
|
+
format_name: Parser identifier.
|
|
85
|
+
exc: Exception raised by the parser.
|
|
86
|
+
"""
|
|
87
|
+
log_error(
|
|
88
|
+
"config_file_invalid",
|
|
89
|
+
layer=FILE_LAYER,
|
|
90
|
+
path=path,
|
|
91
|
+
format=format_name,
|
|
92
|
+
error=str(exc),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _raise_invalid_format(path: str, format_name: str, exc: Exception) -> NoReturn:
|
|
97
|
+
"""Log and raise :class:`InvalidFormatError` for parser errors.
|
|
98
|
+
|
|
99
|
+
Reuse logging side-effects while presenting callers with a uniform
|
|
100
|
+
exception type.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
path: File path being parsed.
|
|
104
|
+
format_name: Parser identifier.
|
|
105
|
+
exc: Original exception raised by the parser.
|
|
106
|
+
"""
|
|
107
|
+
_log_file_invalid(path, format_name, exc)
|
|
108
|
+
raise InvalidFormatError(f"Invalid {format_name.upper()} in {path}: {exc}") from exc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _ensure_yaml_available() -> None:
|
|
112
|
+
"""Announce clearly whether PyYAML can be reached.
|
|
113
|
+
|
|
114
|
+
YAML support is optional; the loader must fail fast with guidance when the
|
|
115
|
+
dependency is absent so callers can install the expected extra.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
NotFoundError: When the PyYAML package cannot be imported.
|
|
119
|
+
"""
|
|
120
|
+
_require_yaml_module()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _require_yaml_module() -> ModuleType:
|
|
124
|
+
"""Fetch the PyYAML module or explain its absence.
|
|
125
|
+
|
|
126
|
+
Downstream helpers need the module object for access to both ``safe_load``
|
|
127
|
+
and the package-specific ``YAMLError`` type.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The imported PyYAML module.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
NotFoundError: When PyYAML is not installed.
|
|
134
|
+
"""
|
|
135
|
+
module = _load_yaml_module()
|
|
136
|
+
if module is None:
|
|
137
|
+
raise NotFoundError("PyYAML is required for YAML configuration support")
|
|
138
|
+
return module
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_yaml_module() -> ModuleType | None:
|
|
142
|
+
"""Import PyYAML on demand, caching the result for future readers.
|
|
143
|
+
|
|
144
|
+
Avoid importing optional dependencies unless they are genuinely needed,
|
|
145
|
+
while still ensuring subsequent calls reuse the same module object.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The PyYAML module when available; otherwise ``None``.
|
|
149
|
+
"""
|
|
150
|
+
global yaml
|
|
151
|
+
if yaml is not None:
|
|
152
|
+
return yaml
|
|
153
|
+
try:
|
|
154
|
+
yaml = import_module("yaml")
|
|
155
|
+
except ModuleNotFoundError: # pragma: no cover - optional dependency
|
|
156
|
+
yaml = None
|
|
157
|
+
return yaml
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class BaseFileLoader:
|
|
161
|
+
"""Common utilities shared by the structured file loaders.
|
|
162
|
+
|
|
163
|
+
Avoid duplicating file I/O, error handling, and mapping validation across
|
|
164
|
+
individual loaders.
|
|
165
|
+
|
|
166
|
+
Provides reusable helpers for reading files and asserting parser outputs.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def _read(self, path: str) -> bytes:
|
|
170
|
+
"""Read *path* as bytes, raising :class:`NotFoundError` when the file is missing.
|
|
171
|
+
|
|
172
|
+
Centralise file existence checks and logging so all loaders behave
|
|
173
|
+
consistently.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
path: Absolute file path expected to exist.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Raw file contents.
|
|
180
|
+
|
|
181
|
+
Side Effects:
|
|
182
|
+
Emits ``config_file_read`` debug events.
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
>>> from tempfile import NamedTemporaryFile
|
|
186
|
+
>>> tmp = NamedTemporaryFile(delete=False)
|
|
187
|
+
>>> _ = tmp.write(b"key = 'value'")
|
|
188
|
+
>>> tmp.close()
|
|
189
|
+
>>> BaseFileLoader()._read(tmp.name)[:3]
|
|
190
|
+
b'key'
|
|
191
|
+
>>> Path(tmp.name).unlink()
|
|
192
|
+
"""
|
|
193
|
+
file_path = Path(path)
|
|
194
|
+
if not file_path.is_file():
|
|
195
|
+
raise NotFoundError(f"Configuration file not found: {path}")
|
|
196
|
+
payload = file_path.read_bytes()
|
|
197
|
+
_log_file_read(path, len(payload))
|
|
198
|
+
return payload
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _ensure_mapping(data: object, *, path: str) -> Mapping[str, object]:
|
|
202
|
+
"""Ensure *data* behaves like a mapping, otherwise raise ``InvalidFormatError``.
|
|
203
|
+
|
|
204
|
+
Merging logic expects mapping-like structures; other types indicate a
|
|
205
|
+
malformed configuration file.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
data: Object produced by the parser.
|
|
209
|
+
path: Originating file path used for error messaging.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
The validated mapping.
|
|
213
|
+
|
|
214
|
+
Examples:
|
|
215
|
+
>>> BaseFileLoader._ensure_mapping({"key": 1}, path="demo")
|
|
216
|
+
{'key': 1}
|
|
217
|
+
>>> BaseFileLoader._ensure_mapping(42, path="demo")
|
|
218
|
+
Traceback (most recent call last):
|
|
219
|
+
...
|
|
220
|
+
lib_layered_config.domain.errors.InvalidFormatError: File demo did not produce a mapping
|
|
221
|
+
"""
|
|
222
|
+
if not isinstance(data, Mapping):
|
|
223
|
+
raise InvalidFormatError(f"File {path} did not produce a mapping")
|
|
224
|
+
return data # type: ignore[return-value]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TOMLFileLoader(BaseFileLoader):
|
|
228
|
+
"""Load TOML documents using the standard library parser."""
|
|
229
|
+
|
|
230
|
+
def load(self, path: str) -> Mapping[str, object]:
|
|
231
|
+
"""Return mapping extracted from TOML file at *path*.
|
|
232
|
+
|
|
233
|
+
TOML is the primary structured format in the documentation; this loader
|
|
234
|
+
provides friendly error messages and structured logging.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
path: Absolute path to a TOML document.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Parsed configuration data.
|
|
241
|
+
|
|
242
|
+
Side Effects:
|
|
243
|
+
Emits ``config_file_loaded`` debug events.
|
|
244
|
+
|
|
245
|
+
Examples:
|
|
246
|
+
>>> from tempfile import NamedTemporaryFile
|
|
247
|
+
>>> tmp = NamedTemporaryFile('w', delete=False, encoding='utf-8')
|
|
248
|
+
>>> _ = tmp.write('key = "value"')
|
|
249
|
+
>>> tmp.close()
|
|
250
|
+
>>> TOMLFileLoader().load(tmp.name)["key"]
|
|
251
|
+
'value'
|
|
252
|
+
>>> Path(tmp.name).unlink()
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
raw_bytes = self._read(path)
|
|
256
|
+
decoded = raw_bytes.decode("utf-8")
|
|
257
|
+
parsed = tomllib.loads(decoded)
|
|
258
|
+
except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: # type: ignore[attr-defined]
|
|
259
|
+
_raise_invalid_format(path, "toml", exc)
|
|
260
|
+
result = self._ensure_mapping(parsed, path=path)
|
|
261
|
+
_log_file_loaded(path, "toml")
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class JSONFileLoader(BaseFileLoader):
|
|
266
|
+
"""Load JSON documents.
|
|
267
|
+
|
|
268
|
+
Provide a drop-in parser for JSON configuration files.
|
|
269
|
+
|
|
270
|
+
Uses :mod:`json` to parse files and delegates validation/logging to the base class.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def load(self, path: str) -> Mapping[str, object]:
|
|
274
|
+
"""Return mapping extracted from JSON file at *path*.
|
|
275
|
+
|
|
276
|
+
Provide parity with TOML for teams that prefer JSON configuration.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
path: Absolute path to a JSON document.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Parsed configuration mapping.
|
|
283
|
+
|
|
284
|
+
Side Effects:
|
|
285
|
+
Emits ``config_file_loaded`` debug events.
|
|
286
|
+
|
|
287
|
+
Examples:
|
|
288
|
+
>>> from tempfile import NamedTemporaryFile
|
|
289
|
+
>>> tmp = NamedTemporaryFile('w', delete=False, encoding='utf-8')
|
|
290
|
+
>>> _ = tmp.write('{"enabled": true}')
|
|
291
|
+
>>> tmp.close()
|
|
292
|
+
>>> JSONFileLoader().load(tmp.name)["enabled"]
|
|
293
|
+
True
|
|
294
|
+
>>> Path(tmp.name).unlink()
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
payload: Any = json.loads(self._read(path))
|
|
298
|
+
except json.JSONDecodeError as exc:
|
|
299
|
+
_raise_invalid_format(path, "json", exc)
|
|
300
|
+
result = self._ensure_mapping(payload, path=path)
|
|
301
|
+
_log_file_loaded(path, "json")
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class YAMLFileLoader(BaseFileLoader):
|
|
306
|
+
"""Load YAML documents when PyYAML is available.
|
|
307
|
+
|
|
308
|
+
Support teams that rely on YAML without imposing a mandatory dependency.
|
|
309
|
+
|
|
310
|
+
Guards on PyYAML availability before delegating to :func:`yaml.safe_load`.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def load(self, path: str) -> Mapping[str, object]:
|
|
314
|
+
"""Return mapping extracted from YAML file at *path*.
|
|
315
|
+
|
|
316
|
+
Some teams rely on YAML for configuration; this loader keeps behaviour
|
|
317
|
+
consistent with TOML/JSON while remaining optional.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
path: Absolute path to a YAML document.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Parsed configuration mapping.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
NotFoundError: When PyYAML is not installed.
|
|
327
|
+
|
|
328
|
+
Side Effects:
|
|
329
|
+
Emits ``config_file_loaded`` debug events.
|
|
330
|
+
|
|
331
|
+
Examples:
|
|
332
|
+
>>> if _load_yaml_module() is not None: # doctest: +SKIP
|
|
333
|
+
... from tempfile import NamedTemporaryFile
|
|
334
|
+
... tmp = NamedTemporaryFile('w', delete=False, encoding='utf-8')
|
|
335
|
+
... _ = tmp.write('key: 1')
|
|
336
|
+
... tmp.close()
|
|
337
|
+
... YAMLFileLoader().load(tmp.name)["key"]
|
|
338
|
+
... Path(tmp.name).unlink()
|
|
339
|
+
"""
|
|
340
|
+
_ensure_yaml_available()
|
|
341
|
+
yaml_module = _require_yaml_module()
|
|
342
|
+
raw_bytes = self._read(path)
|
|
343
|
+
parsed = _parse_yaml_bytes(raw_bytes, yaml_module, path)
|
|
344
|
+
mapping = self._ensure_mapping(parsed, path=path)
|
|
345
|
+
_log_file_loaded(path, "yaml")
|
|
346
|
+
return mapping
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _parse_yaml_bytes(payload: bytes, module: ModuleType, path: str) -> object:
|
|
350
|
+
"""Turn YAML bytes into a Python shape that mirrors the file.
|
|
351
|
+
|
|
352
|
+
Normalise the PyYAML parsing contract so callers always receive a mapping,
|
|
353
|
+
raising a domain-specific error when the parser signals invalid syntax.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
payload: Raw YAML document supplied as bytes.
|
|
357
|
+
module: PyYAML module providing ::func:`safe_load` and the ``YAMLError`` base class.
|
|
358
|
+
path: Source identifier used to enrich error messages.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Parsed document; an empty dict when the YAML payload evaluates to ``None``.
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
InvalidFormatError: When PyYAML raises ``YAMLError`` while parsing the payload.
|
|
365
|
+
|
|
366
|
+
Examples:
|
|
367
|
+
>>> from types import SimpleNamespace
|
|
368
|
+
>>> fake = SimpleNamespace(safe_load=lambda data: {"key": data.decode("utf-8")}, YAMLError=Exception)
|
|
369
|
+
>>> _parse_yaml_bytes(b"value", fake, "memory.yaml") # doctest: +ELLIPSIS
|
|
370
|
+
{'key': 'value'}
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
document = module.safe_load(payload)
|
|
374
|
+
except module.YAMLError as exc: # type: ignore[attr-defined]
|
|
375
|
+
_raise_invalid_format(path, "yaml", exc)
|
|
376
|
+
return {} if document is None else document
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Path resolver adapters for platform-specific search order.
|
|
2
|
+
|
|
3
|
+
Contents
|
|
4
|
+
--------
|
|
5
|
+
- ``DefaultPathResolver``: main adapter using Strategy pattern
|
|
6
|
+
- ``PlatformStrategy``, ``PlatformContext``: base classes for strategies
|
|
7
|
+
- ``LinuxStrategy``, ``MacOSStrategy``, ``WindowsStrategy``: platform implementations
|
|
8
|
+
- ``DotenvPathFinder``: utility for discovering ``.env`` files
|
|
9
|
+
- ``collect_layer``: helper for enumerating config files in a directory
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._base import PlatformContext, PlatformStrategy, collect_layer
|
|
13
|
+
from ._dotenv import DotenvPathFinder
|
|
14
|
+
from ._linux import LinuxStrategy
|
|
15
|
+
from ._macos import MacOSStrategy
|
|
16
|
+
from ._windows import WindowsStrategy
|
|
17
|
+
from .default import DefaultPathResolver
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DefaultPathResolver",
|
|
21
|
+
"PlatformContext",
|
|
22
|
+
"PlatformStrategy",
|
|
23
|
+
"LinuxStrategy",
|
|
24
|
+
"MacOSStrategy",
|
|
25
|
+
"WindowsStrategy",
|
|
26
|
+
"DotenvPathFinder",
|
|
27
|
+
"collect_layer",
|
|
28
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Base classes and shared utilities for platform-specific path resolution.
|
|
2
|
+
|
|
3
|
+
Define the contract for platform strategies and provide shared utilities
|
|
4
|
+
used across all platform implementations.
|
|
5
|
+
|
|
6
|
+
Contents:
|
|
7
|
+
- ``PlatformContext``: dataclass holding resolution context (vendor, app, etc.)
|
|
8
|
+
- ``PlatformStrategy``: abstract base for platform-specific resolvers
|
|
9
|
+
- ``_collect_layer``: shared helper for enumerating config files
|
|
10
|
+
- ``_ALLOWED_EXTENSIONS``: supported config file extensions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
#: Supported structured configuration file extensions used when expanding
|
|
21
|
+
#: ``config.d`` directories.
|
|
22
|
+
_ALLOWED_EXTENSIONS = (".toml", ".yaml", ".yml", ".json")
|
|
23
|
+
"""File suffixes considered when expanding ``config.d`` directories.
|
|
24
|
+
|
|
25
|
+
Ensure platform-specific discovery yields consistent formats and avoids
|
|
26
|
+
non-structured files.
|
|
27
|
+
|
|
28
|
+
Tuple of lowercase extensions in precedence order.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class PlatformContext:
|
|
34
|
+
"""Immutable context required for path resolution.
|
|
35
|
+
|
|
36
|
+
Encapsulate all inputs needed by platform strategies to resolve paths,
|
|
37
|
+
enabling dependency injection and simplified testing.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
vendor: Vendor name used in platform-specific directory structures.
|
|
41
|
+
app: Application name used in platform-specific directory structures.
|
|
42
|
+
slug: Short identifier used in Linux/XDG paths.
|
|
43
|
+
cwd: Current working directory for project-relative searches.
|
|
44
|
+
env: Environment variable mapping (for overrides and XDG lookups).
|
|
45
|
+
hostname: Hostname for host-specific configuration lookups.
|
|
46
|
+
profile: Optional profile name for environment-specific configurations.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
vendor: str
|
|
50
|
+
app: str
|
|
51
|
+
slug: str
|
|
52
|
+
cwd: Path
|
|
53
|
+
env: dict[str, str]
|
|
54
|
+
hostname: str
|
|
55
|
+
profile: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PlatformStrategy(abc.ABC):
|
|
59
|
+
"""Abstract base class for platform-specific path resolution strategies.
|
|
60
|
+
|
|
61
|
+
Encapsulate platform-specific logic in dedicated classes, keeping each
|
|
62
|
+
implementation small and testable.
|
|
63
|
+
|
|
64
|
+
Subclasses:
|
|
65
|
+
- ``LinuxStrategy``: XDG and ``/etc`` based resolution
|
|
66
|
+
- ``MacOSStrategy``: Application Support based resolution
|
|
67
|
+
- ``WindowsStrategy``: ProgramData/AppData based resolution
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, ctx: PlatformContext) -> None:
|
|
71
|
+
"""Store the resolution context.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
ctx: Immutable context containing vendor, app, slug, env, etc.
|
|
75
|
+
"""
|
|
76
|
+
self.ctx = ctx
|
|
77
|
+
|
|
78
|
+
def _profile_segment(self) -> Path:
|
|
79
|
+
"""Return the profile path segment or an empty path.
|
|
80
|
+
|
|
81
|
+
When a profile is configured, all paths should include a
|
|
82
|
+
``profile/<name>/`` subdirectory. This helper centralises that logic.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
``Path("profile/<name>")`` when profile is set, otherwise ``Path()``.
|
|
86
|
+
"""
|
|
87
|
+
if self.ctx.profile:
|
|
88
|
+
return Path("profile") / self.ctx.profile
|
|
89
|
+
return Path()
|
|
90
|
+
|
|
91
|
+
@abc.abstractmethod
|
|
92
|
+
def app_paths(self) -> Iterable[str]:
|
|
93
|
+
"""Yield application-default configuration paths.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Paths for the app layer (lowest precedence system-wide defaults).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
@abc.abstractmethod
|
|
100
|
+
def host_paths(self) -> Iterable[str]:
|
|
101
|
+
"""Yield host-specific configuration paths.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Paths for the host layer (machine-specific overrides).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
@abc.abstractmethod
|
|
108
|
+
def user_paths(self) -> Iterable[str]:
|
|
109
|
+
"""Yield user-specific configuration paths.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Paths for the user layer (per-user preferences).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
@abc.abstractmethod
|
|
116
|
+
def dotenv_path(self) -> Path | None:
|
|
117
|
+
"""Return the platform-specific ``.env`` fallback path.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Fallback ``.env`` location or ``None`` if unsupported.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def collect_layer(base: Path) -> Iterable[str]:
|
|
125
|
+
"""Yield canonical config files and ``config.d`` entries under *base*.
|
|
126
|
+
|
|
127
|
+
Normalise discovery across operating systems while respecting preferred
|
|
128
|
+
configuration formats.
|
|
129
|
+
|
|
130
|
+
Emits ``config.toml`` when present and lexicographically ordered entries
|
|
131
|
+
from ``config.d`` limited to supported extensions.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
base: Base directory for a particular layer.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Absolute file paths discovered under ``base``.
|
|
138
|
+
|
|
139
|
+
Examples:
|
|
140
|
+
>>> from tempfile import TemporaryDirectory
|
|
141
|
+
>>> from pathlib import Path
|
|
142
|
+
>>> import os
|
|
143
|
+
>>> tmp = TemporaryDirectory()
|
|
144
|
+
>>> root = Path(tmp.name)
|
|
145
|
+
>>> file_a = root / 'config.toml'
|
|
146
|
+
>>> file_b = root / 'config.d' / '10-extra.json'
|
|
147
|
+
>>> file_b.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
>>> _ = file_a.write_text(os.linesep.join(['[settings]', 'value=1']), encoding='utf-8')
|
|
149
|
+
>>> _ = file_b.write_text('{"value": 2}', encoding='utf-8')
|
|
150
|
+
>>> sorted(Path(p).name for p in collect_layer(root))
|
|
151
|
+
['10-extra.json', 'config.toml']
|
|
152
|
+
>>> tmp.cleanup()
|
|
153
|
+
"""
|
|
154
|
+
config_file = base / "config.toml"
|
|
155
|
+
if config_file.is_file():
|
|
156
|
+
yield str(config_file)
|
|
157
|
+
yield from _collect_config_d(base / "config.d")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _collect_config_d(config_dir: Path) -> Iterable[str]:
|
|
161
|
+
"""Yield config files from a config.d directory."""
|
|
162
|
+
if not config_dir.is_dir():
|
|
163
|
+
return
|
|
164
|
+
for path in sorted(config_dir.iterdir()):
|
|
165
|
+
if path.is_file() and path.suffix.lower() in _ALLOWED_EXTENSIONS:
|
|
166
|
+
yield str(path)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Dotenv path discovery utilities.
|
|
2
|
+
|
|
3
|
+
Provide reusable helpers for discovering ``.env`` files via upward
|
|
4
|
+
directory traversal and platform-specific fallback locations.
|
|
5
|
+
|
|
6
|
+
Contents:
|
|
7
|
+
- ``DotenvPathFinder``: encapsulates dotenv discovery logic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._base import PlatformStrategy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DotenvPathFinder:
|
|
21
|
+
"""Discover ``.env`` files by walking upward and checking platform fallbacks.
|
|
22
|
+
|
|
23
|
+
``.env`` files may live near the project root or in OS-specific config
|
|
24
|
+
directories. This class provides unified discovery respecting precedence rules.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, cwd: Path, strategy: PlatformStrategy | None) -> None:
|
|
28
|
+
"""Store context for dotenv discovery.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cwd: Starting directory for upward traversal.
|
|
32
|
+
strategy: Platform strategy providing the fallback ``.env`` location.
|
|
33
|
+
"""
|
|
34
|
+
self.cwd = cwd
|
|
35
|
+
self.strategy = strategy
|
|
36
|
+
|
|
37
|
+
def find_paths(self) -> Iterable[str]:
|
|
38
|
+
"""Yield candidate ``.env`` paths in precedence order.
|
|
39
|
+
|
|
40
|
+
Projects often co-locate ``.env`` files near the repository root;
|
|
41
|
+
walking upward mirrors ``dotenv`` tooling semantics.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Ordered ``.env`` path strings.
|
|
45
|
+
"""
|
|
46
|
+
yield from self._project_paths()
|
|
47
|
+
extra = self._platform_path()
|
|
48
|
+
if extra and extra.is_file():
|
|
49
|
+
yield str(extra)
|
|
50
|
+
|
|
51
|
+
def _project_paths(self) -> Iterable[str]:
|
|
52
|
+
"""Yield ``.env`` files discovered by walking upward from cwd.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
``.env`` paths discovered while traversing parent directories.
|
|
56
|
+
"""
|
|
57
|
+
seen: set[Path] = set()
|
|
58
|
+
for directory in [self.cwd, *self.cwd.parents]:
|
|
59
|
+
candidate = directory / ".env"
|
|
60
|
+
if candidate in seen:
|
|
61
|
+
continue
|
|
62
|
+
seen.add(candidate)
|
|
63
|
+
if candidate.is_file():
|
|
64
|
+
yield str(candidate)
|
|
65
|
+
|
|
66
|
+
def _platform_path(self) -> Path | None:
|
|
67
|
+
"""Return the platform-specific ``.env`` fallback location.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Platform fallback path or ``None`` if no strategy is set.
|
|
71
|
+
"""
|
|
72
|
+
if self.strategy is None:
|
|
73
|
+
return None
|
|
74
|
+
return self.strategy.dotenv_path()
|