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.

Files changed (39) hide show
  1. lib_layered_config/__init__.py +60 -0
  2. lib_layered_config/__main__.py +19 -0
  3. lib_layered_config/_layers.py +457 -0
  4. lib_layered_config/_platform.py +200 -0
  5. lib_layered_config/adapters/__init__.py +13 -0
  6. lib_layered_config/adapters/dotenv/__init__.py +1 -0
  7. lib_layered_config/adapters/dotenv/default.py +438 -0
  8. lib_layered_config/adapters/env/__init__.py +5 -0
  9. lib_layered_config/adapters/env/default.py +509 -0
  10. lib_layered_config/adapters/file_loaders/__init__.py +1 -0
  11. lib_layered_config/adapters/file_loaders/structured.py +410 -0
  12. lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
  13. lib_layered_config/adapters/path_resolvers/default.py +727 -0
  14. lib_layered_config/application/__init__.py +12 -0
  15. lib_layered_config/application/merge.py +442 -0
  16. lib_layered_config/application/ports.py +109 -0
  17. lib_layered_config/cli/__init__.py +162 -0
  18. lib_layered_config/cli/common.py +232 -0
  19. lib_layered_config/cli/constants.py +12 -0
  20. lib_layered_config/cli/deploy.py +70 -0
  21. lib_layered_config/cli/fail.py +21 -0
  22. lib_layered_config/cli/generate.py +60 -0
  23. lib_layered_config/cli/info.py +31 -0
  24. lib_layered_config/cli/read.py +117 -0
  25. lib_layered_config/core.py +384 -0
  26. lib_layered_config/domain/__init__.py +7 -0
  27. lib_layered_config/domain/config.py +490 -0
  28. lib_layered_config/domain/errors.py +65 -0
  29. lib_layered_config/examples/__init__.py +29 -0
  30. lib_layered_config/examples/deploy.py +305 -0
  31. lib_layered_config/examples/generate.py +537 -0
  32. lib_layered_config/observability.py +306 -0
  33. lib_layered_config/py.typed +0 -0
  34. lib_layered_config/testing.py +55 -0
  35. lib_layered_config-1.0.0.dist-info/METADATA +366 -0
  36. lib_layered_config-1.0.0.dist-info/RECORD +39 -0
  37. lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
  38. lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
  39. 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)