lib-layered-config 1.0.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.
Potentially problematic release.
This version of lib-layered-config might be problematic. Click here for more details.
- lib_layered_config/__init__.py +60 -0
- lib_layered_config/__main__.py +19 -0
- lib_layered_config/_layers.py +457 -0
- lib_layered_config/_platform.py +200 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +438 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +509 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +410 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
- lib_layered_config/adapters/path_resolvers/default.py +727 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +442 -0
- lib_layered_config/application/ports.py +109 -0
- lib_layered_config/cli/__init__.py +162 -0
- lib_layered_config/cli/common.py +232 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +70 -0
- lib_layered_config/cli/fail.py +21 -0
- lib_layered_config/cli/generate.py +60 -0
- lib_layered_config/cli/info.py +31 -0
- lib_layered_config/cli/read.py +117 -0
- lib_layered_config/core.py +384 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +490 -0
- lib_layered_config/domain/errors.py +65 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +305 -0
- lib_layered_config/examples/generate.py +537 -0
- lib_layered_config/observability.py +306 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +55 -0
- lib_layered_config-1.0.0.dist-info/METADATA +366 -0
- lib_layered_config-1.0.0.dist-info/RECORD +39 -0
- lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
- lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-1.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""Filesystem path resolution composed of small platform-specific verses.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
Implement the :class:`lib_layered_config.application.ports.PathResolver`
|
|
5
|
+
port while keeping operating-system branches readable and testable.
|
|
6
|
+
|
|
7
|
+
Contents
|
|
8
|
+
- ``DefaultPathResolver``: public adapter consumed by the composition root.
|
|
9
|
+
- ``_linux_paths`` / ``_mac_paths`` / ``_windows_paths``: platform poems that
|
|
10
|
+
describe how each layer is built.
|
|
11
|
+
- ``_dotenv_paths`` helpers: narrate how ``.env`` locations are discovered
|
|
12
|
+
near the project root and within OS-specific config directories.
|
|
13
|
+
- ``_collect_layer``: shared helper that enumerates canonical files within a
|
|
14
|
+
base directory.
|
|
15
|
+
|
|
16
|
+
System Integration
|
|
17
|
+
Produces ordered path lists for the core merge pipeline. All filesystem
|
|
18
|
+
knowledge stays here so inner layers remain filesystem-agnostic.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import socket
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Iterable, List
|
|
28
|
+
|
|
29
|
+
from ...observability import log_debug
|
|
30
|
+
|
|
31
|
+
#: Supported structured configuration file extensions used when expanding
|
|
32
|
+
#: ``config.d`` directories.
|
|
33
|
+
_ALLOWED_EXTENSIONS = (".toml", ".yaml", ".yml", ".json")
|
|
34
|
+
"""File suffixes considered when expanding ``config.d`` directories.
|
|
35
|
+
|
|
36
|
+
Why
|
|
37
|
+
----
|
|
38
|
+
Ensure platform-specific discovery yields consistent formats and avoids
|
|
39
|
+
non-structured files.
|
|
40
|
+
|
|
41
|
+
What
|
|
42
|
+
----
|
|
43
|
+
Tuple of lowercase extensions in precedence order.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DefaultPathResolver:
|
|
48
|
+
"""Resolve candidate paths for each configuration layer.
|
|
49
|
+
|
|
50
|
+
Why
|
|
51
|
+
----
|
|
52
|
+
Centralise path discovery so the composition root stays platform-agnostic
|
|
53
|
+
and easy to test.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
vendor: str,
|
|
60
|
+
app: str,
|
|
61
|
+
slug: str,
|
|
62
|
+
cwd: Path | None = None,
|
|
63
|
+
env: dict[str, str] | None = None,
|
|
64
|
+
platform: str | None = None,
|
|
65
|
+
hostname: str | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Store context required to resolve filesystem locations.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
vendor / app / slug:
|
|
72
|
+
Naming context injected into platform-specific directory structures.
|
|
73
|
+
cwd:
|
|
74
|
+
Working directory to use when searching for ``.env`` files.
|
|
75
|
+
env:
|
|
76
|
+
Optional environment mapping that overrides ``os.environ`` values
|
|
77
|
+
(useful for deterministic tests).
|
|
78
|
+
platform:
|
|
79
|
+
Platform identifier (``sys.platform`` clone). Defaults to the
|
|
80
|
+
current interpreter platform.
|
|
81
|
+
hostname:
|
|
82
|
+
Hostname used for host-specific configuration lookups.
|
|
83
|
+
|
|
84
|
+
Side Effects
|
|
85
|
+
------------
|
|
86
|
+
Reads from :mod:`os.environ` and :func:`socket.gethostname` to populate
|
|
87
|
+
defaults.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
self.vendor = vendor
|
|
91
|
+
self.application = app
|
|
92
|
+
self.slug = slug
|
|
93
|
+
self.cwd = cwd or Path.cwd()
|
|
94
|
+
self.env = {**os.environ, **(env or {})}
|
|
95
|
+
self.platform = platform or sys.platform
|
|
96
|
+
self.hostname = hostname or socket.gethostname()
|
|
97
|
+
|
|
98
|
+
def app(self) -> Iterable[str]:
|
|
99
|
+
"""Return candidate system-wide configuration paths.
|
|
100
|
+
|
|
101
|
+
Why
|
|
102
|
+
----
|
|
103
|
+
Provide the lowest-precedence defaults shared across machines.
|
|
104
|
+
|
|
105
|
+
What
|
|
106
|
+
----
|
|
107
|
+
Delegates to :meth:`_iter_layer` with the ``"app"`` label so platform
|
|
108
|
+
helpers can enumerate canonical locations.
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
Iterable[str]
|
|
113
|
+
Ordered path strings for the application defaults layer.
|
|
114
|
+
|
|
115
|
+
Examples
|
|
116
|
+
--------
|
|
117
|
+
>>> import os
|
|
118
|
+
>>> from pathlib import Path
|
|
119
|
+
>>> from tempfile import TemporaryDirectory
|
|
120
|
+
>>> tmp = TemporaryDirectory()
|
|
121
|
+
>>> root = Path(tmp.name)
|
|
122
|
+
>>> (root / 'demo').mkdir(parents=True, exist_ok=True)
|
|
123
|
+
>>> body = os.linesep.join(['[settings]', 'value=1'])
|
|
124
|
+
>>> _ = (root / 'demo' / 'config.toml').write_text(body, encoding='utf-8')
|
|
125
|
+
>>> resolver = DefaultPathResolver(vendor='Acme', app='Demo', slug='demo', env={'LIB_LAYERED_CONFIG_ETC': str(root)}, platform='linux')
|
|
126
|
+
>>> [Path(p).name for p in resolver.app()]
|
|
127
|
+
['config.toml']
|
|
128
|
+
>>> tmp.cleanup()
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
return self._iter_layer("app")
|
|
132
|
+
|
|
133
|
+
def host(self) -> Iterable[str]:
|
|
134
|
+
"""Return host-specific overrides.
|
|
135
|
+
|
|
136
|
+
Why
|
|
137
|
+
----
|
|
138
|
+
Allow operators to tailor configuration to individual hosts (e.g.
|
|
139
|
+
``demo-host.toml``).
|
|
140
|
+
|
|
141
|
+
What
|
|
142
|
+
----
|
|
143
|
+
Delegates to :meth:`_iter_layer` with the ``"host"`` label to collect
|
|
144
|
+
hostname-specific files.
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
Iterable[str]
|
|
149
|
+
Ordered host-level configuration paths.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
return self._iter_layer("host")
|
|
153
|
+
|
|
154
|
+
def user(self) -> Iterable[str]:
|
|
155
|
+
"""Return user-level configuration locations.
|
|
156
|
+
|
|
157
|
+
Why
|
|
158
|
+
----
|
|
159
|
+
Capture per-user preferences stored in XDG/macOS/Windows user config
|
|
160
|
+
directories.
|
|
161
|
+
|
|
162
|
+
What
|
|
163
|
+
----
|
|
164
|
+
Delegates to :meth:`_iter_layer` with the ``"user"`` label, leveraging
|
|
165
|
+
platform helpers to enumerate per-user directories.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
Iterable[str]
|
|
170
|
+
Ordered user-level configuration paths.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
return self._iter_layer("user")
|
|
174
|
+
|
|
175
|
+
def dotenv(self) -> Iterable[str]:
|
|
176
|
+
"""Return candidate ``.env`` locations discovered during path resolution.
|
|
177
|
+
|
|
178
|
+
Why
|
|
179
|
+
----
|
|
180
|
+
`.env` files often live near the project root; this helper provides the
|
|
181
|
+
ordered search list for the dotenv adapter.
|
|
182
|
+
|
|
183
|
+
What
|
|
184
|
+
----
|
|
185
|
+
Materialises the iterator produced by :meth:`_dotenv_paths` so callers
|
|
186
|
+
can inspect the ordered candidates.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
Iterable[str]
|
|
191
|
+
Ordered `.env` path strings.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
return list(self._dotenv_paths())
|
|
195
|
+
|
|
196
|
+
def _iter_layer(self, layer: str) -> Iterable[str]:
|
|
197
|
+
"""Dispatch to the platform-specific implementation for *layer*.
|
|
198
|
+
|
|
199
|
+
Why
|
|
200
|
+
----
|
|
201
|
+
Centralises logging and platform dispatch so public helpers stay tiny.
|
|
202
|
+
|
|
203
|
+
What
|
|
204
|
+
----
|
|
205
|
+
Delegates to :meth:`_platform_paths`, emits a debug event when candidates
|
|
206
|
+
exist, and returns the resulting iterable.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
layer:
|
|
211
|
+
Logical layer name (``"app"``, ``"host"``, ``"user"", ``"dotenv"``).
|
|
212
|
+
|
|
213
|
+
Returns
|
|
214
|
+
-------
|
|
215
|
+
Iterable[str]
|
|
216
|
+
Candidate path strings.
|
|
217
|
+
|
|
218
|
+
Side Effects
|
|
219
|
+
------------
|
|
220
|
+
Emits ``path_candidates`` debug events when paths are discovered.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
paths = self._platform_paths(layer)
|
|
224
|
+
if paths:
|
|
225
|
+
log_debug("path_candidates", layer=layer, path=None, count=len(paths))
|
|
226
|
+
return paths
|
|
227
|
+
|
|
228
|
+
def _platform_paths(self, layer: str) -> List[str]:
|
|
229
|
+
"""Return discovered paths for *layer* based on the current platform.
|
|
230
|
+
|
|
231
|
+
Why
|
|
232
|
+
----
|
|
233
|
+
Encapsulate platform branching in one place for readability and testing.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
layer:
|
|
238
|
+
Logical layer name passed through to platform helpers.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
list[str]
|
|
243
|
+
List of candidate paths (may be empty).
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
if self._is_linux:
|
|
247
|
+
return list(self._linux_paths(layer))
|
|
248
|
+
if self._is_macos:
|
|
249
|
+
return list(self._mac_paths(layer))
|
|
250
|
+
if self._is_windows:
|
|
251
|
+
return list(self._windows_paths(layer))
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def _is_linux(self) -> bool:
|
|
256
|
+
"""Return ``True`` when running on a Linux platform.
|
|
257
|
+
|
|
258
|
+
Why
|
|
259
|
+
----
|
|
260
|
+
Determines which helper method to invoke during resolution.
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
bool
|
|
265
|
+
``True`` when ``sys.platform`` starts with ``"linux"``.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
return self.platform.startswith("linux")
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def _is_macos(self) -> bool:
|
|
272
|
+
"""Return ``True`` when running on macOS.
|
|
273
|
+
|
|
274
|
+
Why
|
|
275
|
+
----
|
|
276
|
+
Selects macOS-specific directory builders for path resolution.
|
|
277
|
+
|
|
278
|
+
Returns
|
|
279
|
+
-------
|
|
280
|
+
bool
|
|
281
|
+
``True`` when the platform equals ``"darwin"``.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
return self.platform == "darwin"
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def _is_windows(self) -> bool:
|
|
288
|
+
"""Return ``True`` when running on Windows.
|
|
289
|
+
|
|
290
|
+
Why
|
|
291
|
+
----
|
|
292
|
+
Chooses Windows-specific directory builders during resolution.
|
|
293
|
+
|
|
294
|
+
Returns
|
|
295
|
+
-------
|
|
296
|
+
bool
|
|
297
|
+
``True`` when the platform starts with ``"win"``.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
return self.platform.startswith("win")
|
|
301
|
+
|
|
302
|
+
def _linux_paths(self, layer: str) -> Iterable[str]:
|
|
303
|
+
"""Yield Linux-specific candidates for *layer*.
|
|
304
|
+
|
|
305
|
+
Why
|
|
306
|
+
----
|
|
307
|
+
Mirror the XDG specification and `/etc` conventions documented in the
|
|
308
|
+
system design.
|
|
309
|
+
|
|
310
|
+
What
|
|
311
|
+
----
|
|
312
|
+
Dispatches to helpers that encode Linux directory layouts for the given
|
|
313
|
+
layer and yields their paths.
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
layer:
|
|
318
|
+
Logical layer identifier passed to the helper lookup.
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
Iterable[str]
|
|
323
|
+
Candidate Linux paths (may be empty).
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
builders = {
|
|
327
|
+
"app": self._linux_app_paths,
|
|
328
|
+
"host": self._linux_host_paths,
|
|
329
|
+
"user": self._linux_user_paths,
|
|
330
|
+
}
|
|
331
|
+
yield from builders.get(layer, lambda: [])()
|
|
332
|
+
|
|
333
|
+
def _mac_paths(self, layer: str) -> Iterable[str]:
|
|
334
|
+
"""Yield macOS-specific candidates for *layer*.
|
|
335
|
+
|
|
336
|
+
Why
|
|
337
|
+
----
|
|
338
|
+
Follow macOS Application Support conventions for vendor/app directories.
|
|
339
|
+
|
|
340
|
+
What
|
|
341
|
+
----
|
|
342
|
+
Dispatches to helpers that encode macOS directory layouts and yields the
|
|
343
|
+
resulting path strings.
|
|
344
|
+
|
|
345
|
+
Parameters
|
|
346
|
+
----------
|
|
347
|
+
layer:
|
|
348
|
+
Logical layer identifier used to pick the helper.
|
|
349
|
+
|
|
350
|
+
Returns
|
|
351
|
+
-------
|
|
352
|
+
Iterable[str]
|
|
353
|
+
Candidate macOS paths.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
builders = {
|
|
357
|
+
"app": self._mac_app_paths,
|
|
358
|
+
"host": self._mac_host_paths,
|
|
359
|
+
"user": self._mac_user_paths,
|
|
360
|
+
}
|
|
361
|
+
yield from builders.get(layer, lambda: [])()
|
|
362
|
+
|
|
363
|
+
def _windows_paths(self, layer: str) -> Iterable[str]:
|
|
364
|
+
"""Yield Windows-specific candidates for *layer*.
|
|
365
|
+
|
|
366
|
+
Why
|
|
367
|
+
----
|
|
368
|
+
Respect ProgramData/AppData directory layouts and allow overrides for
|
|
369
|
+
portable setups.
|
|
370
|
+
|
|
371
|
+
What
|
|
372
|
+
----
|
|
373
|
+
Dispatches to helpers that encode Windows directory layouts and yields
|
|
374
|
+
the resulting path strings.
|
|
375
|
+
|
|
376
|
+
Parameters
|
|
377
|
+
----------
|
|
378
|
+
layer:
|
|
379
|
+
Logical layer identifier used to pick the helper.
|
|
380
|
+
|
|
381
|
+
Returns
|
|
382
|
+
-------
|
|
383
|
+
Iterable[str]
|
|
384
|
+
Candidate Windows paths.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
builders = {
|
|
388
|
+
"app": self._windows_app_paths,
|
|
389
|
+
"host": self._windows_host_paths,
|
|
390
|
+
"user": self._windows_user_paths,
|
|
391
|
+
}
|
|
392
|
+
yield from builders.get(layer, lambda: [])()
|
|
393
|
+
|
|
394
|
+
def _linux_app_paths(self) -> Iterable[str]:
|
|
395
|
+
"""Yield Linux application-default configuration paths.
|
|
396
|
+
|
|
397
|
+
Why
|
|
398
|
+
----
|
|
399
|
+
Provide deterministic discovery for `/etc/<slug>` layouts.
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
Iterable[str]
|
|
404
|
+
Paths under `/etc` (or overridden root) relevant to the app layer.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
etc_root = Path(self.env.get("LIB_LAYERED_CONFIG_ETC", "/etc"))
|
|
408
|
+
yield from _collect_layer(etc_root / self.slug)
|
|
409
|
+
|
|
410
|
+
def _linux_host_paths(self) -> Iterable[str]:
|
|
411
|
+
"""Yield Linux host-specific configuration paths.
|
|
412
|
+
|
|
413
|
+
Why
|
|
414
|
+
----
|
|
415
|
+
Allow installations to override defaults per hostname using `/etc/<slug>/hosts`.
|
|
416
|
+
|
|
417
|
+
Returns
|
|
418
|
+
-------
|
|
419
|
+
Iterable[str]
|
|
420
|
+
Host-level configuration paths (empty when missing).
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
etc_root = Path(self.env.get("LIB_LAYERED_CONFIG_ETC", "/etc"))
|
|
424
|
+
candidate = etc_root / self.slug / "hosts" / f"{self.hostname}.toml"
|
|
425
|
+
if candidate.is_file():
|
|
426
|
+
yield str(candidate)
|
|
427
|
+
|
|
428
|
+
def _linux_user_paths(self) -> Iterable[str]:
|
|
429
|
+
"""Yield Linux user-specific configuration paths.
|
|
430
|
+
|
|
431
|
+
Why
|
|
432
|
+
----
|
|
433
|
+
Honour XDG directories while falling back to `~/.config`.
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
Iterable[str]
|
|
438
|
+
User-level configuration paths.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
xdg = self.env.get("XDG_CONFIG_HOME")
|
|
442
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
443
|
+
yield from _collect_layer(base / self.slug)
|
|
444
|
+
|
|
445
|
+
def _mac_app_paths(self) -> Iterable[str]:
|
|
446
|
+
"""Yield macOS application-default configuration paths.
|
|
447
|
+
|
|
448
|
+
Why
|
|
449
|
+
----
|
|
450
|
+
Follow macOS Application Support directory conventions.
|
|
451
|
+
|
|
452
|
+
Returns
|
|
453
|
+
-------
|
|
454
|
+
Iterable[str]
|
|
455
|
+
Application-level configuration paths.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
default_root = Path("/Library/Application Support")
|
|
459
|
+
root = Path(self.env.get("LIB_LAYERED_CONFIG_MAC_APP_ROOT", default_root))
|
|
460
|
+
yield from _collect_layer(root / self.vendor / self.application)
|
|
461
|
+
|
|
462
|
+
def _mac_host_paths(self) -> Iterable[str]:
|
|
463
|
+
"""Yield macOS host-specific configuration paths.
|
|
464
|
+
|
|
465
|
+
Why
|
|
466
|
+
----
|
|
467
|
+
Support host overrides stored under `hosts/<hostname>.toml` within
|
|
468
|
+
Application Support.
|
|
469
|
+
|
|
470
|
+
Returns
|
|
471
|
+
-------
|
|
472
|
+
Iterable[str]
|
|
473
|
+
Host-level macOS configuration paths (empty when missing).
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
default_root = Path("/Library/Application Support")
|
|
477
|
+
root = Path(self.env.get("LIB_LAYERED_CONFIG_MAC_APP_ROOT", default_root))
|
|
478
|
+
candidate = root / self.vendor / self.application / "hosts" / f"{self.hostname}.toml"
|
|
479
|
+
if candidate.is_file():
|
|
480
|
+
yield str(candidate)
|
|
481
|
+
|
|
482
|
+
def _mac_user_paths(self) -> Iterable[str]:
|
|
483
|
+
"""Yield macOS user-specific configuration paths.
|
|
484
|
+
|
|
485
|
+
Why
|
|
486
|
+
----
|
|
487
|
+
Honour per-user Application Support directories with optional overrides.
|
|
488
|
+
|
|
489
|
+
Returns
|
|
490
|
+
-------
|
|
491
|
+
Iterable[str]
|
|
492
|
+
User-level macOS configuration paths.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
home_default = Path.home() / "Library/Application Support"
|
|
496
|
+
home_root = Path(self.env.get("LIB_LAYERED_CONFIG_MAC_HOME_ROOT", home_default))
|
|
497
|
+
yield from _collect_layer(home_root / self.vendor / self.application)
|
|
498
|
+
|
|
499
|
+
def _windows_app_paths(self) -> Iterable[str]:
|
|
500
|
+
"""Yield Windows application-default configuration paths.
|
|
501
|
+
|
|
502
|
+
Why
|
|
503
|
+
----
|
|
504
|
+
Mirror `%ProgramData%/<Vendor>/<App>` layouts with override support.
|
|
505
|
+
|
|
506
|
+
Returns
|
|
507
|
+
-------
|
|
508
|
+
Iterable[str]
|
|
509
|
+
Application-level Windows configuration paths.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
base = self._program_data_root() / self.vendor / self.application
|
|
513
|
+
yield from _collect_layer(base)
|
|
514
|
+
|
|
515
|
+
def _windows_host_paths(self) -> Iterable[str]:
|
|
516
|
+
"""Yield Windows host-specific configuration paths.
|
|
517
|
+
|
|
518
|
+
Why
|
|
519
|
+
----
|
|
520
|
+
Enable host overrides within `%ProgramData%/<Vendor>/<App>/hosts`.
|
|
521
|
+
|
|
522
|
+
Returns
|
|
523
|
+
-------
|
|
524
|
+
Iterable[str]
|
|
525
|
+
Host-level Windows configuration paths.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
base = self._program_data_root() / self.vendor / self.application
|
|
529
|
+
candidate = base / "hosts" / f"{self.hostname}.toml"
|
|
530
|
+
if candidate.is_file():
|
|
531
|
+
yield str(candidate)
|
|
532
|
+
|
|
533
|
+
def _windows_user_paths(self) -> Iterable[str]:
|
|
534
|
+
"""Yield Windows user-specific configuration paths.
|
|
535
|
+
|
|
536
|
+
Why
|
|
537
|
+
----
|
|
538
|
+
Honour `%APPDATA%` with a fallback to `%LOCALAPPDATA%` for portable setups.
|
|
539
|
+
|
|
540
|
+
Returns
|
|
541
|
+
-------
|
|
542
|
+
Iterable[str]
|
|
543
|
+
User-level Windows configuration paths.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
roaming_base = self._appdata_root() / self.vendor / self.application
|
|
547
|
+
roaming_paths = list(_collect_layer(roaming_base))
|
|
548
|
+
if roaming_paths:
|
|
549
|
+
yield from roaming_paths
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
local_base = self._localappdata_root() / self.vendor / self.application
|
|
553
|
+
yield from _collect_layer(local_base)
|
|
554
|
+
|
|
555
|
+
def _program_data_root(self) -> Path:
|
|
556
|
+
"""Return the base directory for ProgramData lookups.
|
|
557
|
+
|
|
558
|
+
Why
|
|
559
|
+
----
|
|
560
|
+
Centralise overrides for `%ProgramData%` so tests can supply temporary roots.
|
|
561
|
+
|
|
562
|
+
Returns
|
|
563
|
+
-------
|
|
564
|
+
Path
|
|
565
|
+
Resolved ProgramData root directory.
|
|
566
|
+
"""
|
|
567
|
+
|
|
568
|
+
return Path(self.env.get("LIB_LAYERED_CONFIG_PROGRAMDATA", self.env.get("ProgramData", r"C:\ProgramData")))
|
|
569
|
+
|
|
570
|
+
def _appdata_root(self) -> Path:
|
|
571
|
+
"""Return the user AppData root used for `%APPDATA%` lookups.
|
|
572
|
+
|
|
573
|
+
Why
|
|
574
|
+
----
|
|
575
|
+
Support overrides in tests or portable deployments.
|
|
576
|
+
|
|
577
|
+
Returns
|
|
578
|
+
-------
|
|
579
|
+
Path
|
|
580
|
+
Resolved AppData root directory.
|
|
581
|
+
"""
|
|
582
|
+
|
|
583
|
+
return Path(
|
|
584
|
+
self.env.get("LIB_LAYERED_CONFIG_APPDATA", self.env.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def _localappdata_root(self) -> Path:
|
|
588
|
+
"""Return the fallback LocalAppData root.
|
|
589
|
+
|
|
590
|
+
Why
|
|
591
|
+
----
|
|
592
|
+
Provide a deterministic fallback when `%APPDATA%` does not exist.
|
|
593
|
+
|
|
594
|
+
Returns
|
|
595
|
+
-------
|
|
596
|
+
Path
|
|
597
|
+
Resolved LocalAppData root directory.
|
|
598
|
+
"""
|
|
599
|
+
|
|
600
|
+
return Path(
|
|
601
|
+
self.env.get(
|
|
602
|
+
"LIB_LAYERED_CONFIG_LOCALAPPDATA",
|
|
603
|
+
self.env.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"),
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _dotenv_paths(self) -> Iterable[str]:
|
|
608
|
+
"""Return candidate dotenv paths discovered via upward search and OS-specific directories.
|
|
609
|
+
|
|
610
|
+
Why
|
|
611
|
+
----
|
|
612
|
+
`.env` files may live near the project root or in configuration
|
|
613
|
+
directories; both need to be considered to honour precedence rules.
|
|
614
|
+
|
|
615
|
+
What
|
|
616
|
+
----
|
|
617
|
+
Yields paths discovered from the project upward search and appends a
|
|
618
|
+
platform-specific fallback when present.
|
|
619
|
+
|
|
620
|
+
Returns
|
|
621
|
+
-------
|
|
622
|
+
Iterable[str]
|
|
623
|
+
Ordered `.env` candidate paths.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
yield from self._project_dotenv_paths()
|
|
627
|
+
extra = self._platform_dotenv_path()
|
|
628
|
+
if extra and extra.is_file():
|
|
629
|
+
yield str(extra)
|
|
630
|
+
|
|
631
|
+
def _project_dotenv_paths(self) -> Iterable[str]:
|
|
632
|
+
"""Yield `.env` files discovered by walking from the current working directory upward.
|
|
633
|
+
|
|
634
|
+
Why
|
|
635
|
+
----
|
|
636
|
+
Projects often co-locate `.env` files near the repository root; walking
|
|
637
|
+
upward mirrors `dotenv` tooling semantics.
|
|
638
|
+
|
|
639
|
+
Returns
|
|
640
|
+
-------
|
|
641
|
+
Iterable[str]
|
|
642
|
+
`.env` paths discovered while traversing parent directories.
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
seen: set[Path] = set()
|
|
646
|
+
for directory in [self.cwd, *self.cwd.parents]:
|
|
647
|
+
candidate = directory / ".env"
|
|
648
|
+
if candidate in seen:
|
|
649
|
+
continue
|
|
650
|
+
seen.add(candidate)
|
|
651
|
+
if candidate.is_file():
|
|
652
|
+
yield str(candidate)
|
|
653
|
+
|
|
654
|
+
def _platform_dotenv_path(self) -> Path | None:
|
|
655
|
+
"""Return platform-specific `.env` fallback paths.
|
|
656
|
+
|
|
657
|
+
Why
|
|
658
|
+
----
|
|
659
|
+
Provide a deterministic location when the upward search does not find an
|
|
660
|
+
`.env` file.
|
|
661
|
+
|
|
662
|
+
Returns
|
|
663
|
+
-------
|
|
664
|
+
Path | None
|
|
665
|
+
Resolved fallback path or ``None`` when unsupported.
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
if self._is_linux:
|
|
669
|
+
base = Path(self.env.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
670
|
+
return base / self.slug / ".env"
|
|
671
|
+
if self._is_macos:
|
|
672
|
+
home_default = Path.home() / "Library/Application Support"
|
|
673
|
+
home_root = Path(self.env.get("LIB_LAYERED_CONFIG_MAC_HOME_ROOT", home_default))
|
|
674
|
+
return home_root / self.vendor / self.application / ".env"
|
|
675
|
+
if self._is_windows:
|
|
676
|
+
return self._appdata_root() / self.vendor / self.application / ".env"
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _collect_layer(base: Path) -> Iterable[str]:
|
|
681
|
+
"""Yield canonical config files and ``config.d`` entries under *base*.
|
|
682
|
+
|
|
683
|
+
Why
|
|
684
|
+
----
|
|
685
|
+
Normalise discovery across operating systems while respecting preferred
|
|
686
|
+
configuration formats.
|
|
687
|
+
|
|
688
|
+
What
|
|
689
|
+
----
|
|
690
|
+
Emits ``config.toml`` when present and lexicographically ordered entries
|
|
691
|
+
from ``config.d`` limited to supported extensions.
|
|
692
|
+
|
|
693
|
+
Parameters
|
|
694
|
+
----------
|
|
695
|
+
base:
|
|
696
|
+
Base directory for a particular layer.
|
|
697
|
+
|
|
698
|
+
Returns
|
|
699
|
+
-------
|
|
700
|
+
Iterable[str]
|
|
701
|
+
Absolute file paths discovered under ``base``.
|
|
702
|
+
|
|
703
|
+
Examples
|
|
704
|
+
--------
|
|
705
|
+
>>> from tempfile import TemporaryDirectory
|
|
706
|
+
>>> from pathlib import Path
|
|
707
|
+
>>> import os
|
|
708
|
+
>>> tmp = TemporaryDirectory()
|
|
709
|
+
>>> root = Path(tmp.name)
|
|
710
|
+
>>> file_a = root / 'config.toml'
|
|
711
|
+
>>> file_b = root / 'config.d' / '10-extra.json'
|
|
712
|
+
>>> file_b.parent.mkdir(parents=True, exist_ok=True)
|
|
713
|
+
>>> _ = file_a.write_text(os.linesep.join(['[settings]', 'value=1']), encoding='utf-8')
|
|
714
|
+
>>> _ = file_b.write_text('{"value": 2}', encoding='utf-8')
|
|
715
|
+
>>> sorted(Path(p).name for p in _collect_layer(root))
|
|
716
|
+
['10-extra.json', 'config.toml']
|
|
717
|
+
>>> tmp.cleanup()
|
|
718
|
+
"""
|
|
719
|
+
|
|
720
|
+
config_file = base / "config.toml"
|
|
721
|
+
if config_file.is_file():
|
|
722
|
+
yield str(config_file)
|
|
723
|
+
config_dir = base / "config.d"
|
|
724
|
+
if config_dir.is_dir():
|
|
725
|
+
for path in sorted(config_dir.iterdir()):
|
|
726
|
+
if path.is_file() and path.suffix.lower() in _ALLOWED_EXTENSIONS:
|
|
727
|
+
yield str(path)
|