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.
Files changed (47) hide show
  1. lib_layered_config/__init__.py +58 -0
  2. lib_layered_config/__init__conf__.py +74 -0
  3. lib_layered_config/__main__.py +18 -0
  4. lib_layered_config/_layers.py +310 -0
  5. lib_layered_config/_platform.py +166 -0
  6. lib_layered_config/adapters/__init__.py +13 -0
  7. lib_layered_config/adapters/_nested_keys.py +126 -0
  8. lib_layered_config/adapters/dotenv/__init__.py +1 -0
  9. lib_layered_config/adapters/dotenv/default.py +143 -0
  10. lib_layered_config/adapters/env/__init__.py +5 -0
  11. lib_layered_config/adapters/env/default.py +288 -0
  12. lib_layered_config/adapters/file_loaders/__init__.py +1 -0
  13. lib_layered_config/adapters/file_loaders/structured.py +376 -0
  14. lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
  15. lib_layered_config/adapters/path_resolvers/_base.py +166 -0
  16. lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
  17. lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
  18. lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
  19. lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
  20. lib_layered_config/adapters/path_resolvers/default.py +194 -0
  21. lib_layered_config/application/__init__.py +12 -0
  22. lib_layered_config/application/merge.py +379 -0
  23. lib_layered_config/application/ports.py +115 -0
  24. lib_layered_config/cli/__init__.py +92 -0
  25. lib_layered_config/cli/common.py +381 -0
  26. lib_layered_config/cli/constants.py +12 -0
  27. lib_layered_config/cli/deploy.py +71 -0
  28. lib_layered_config/cli/fail.py +19 -0
  29. lib_layered_config/cli/generate.py +57 -0
  30. lib_layered_config/cli/info.py +29 -0
  31. lib_layered_config/cli/read.py +120 -0
  32. lib_layered_config/core.py +301 -0
  33. lib_layered_config/domain/__init__.py +7 -0
  34. lib_layered_config/domain/config.py +372 -0
  35. lib_layered_config/domain/errors.py +59 -0
  36. lib_layered_config/domain/identifiers.py +366 -0
  37. lib_layered_config/examples/__init__.py +29 -0
  38. lib_layered_config/examples/deploy.py +333 -0
  39. lib_layered_config/examples/generate.py +406 -0
  40. lib_layered_config/observability.py +209 -0
  41. lib_layered_config/py.typed +0 -0
  42. lib_layered_config/testing.py +46 -0
  43. lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
  44. lib_layered_config-4.1.0.dist-info/RECORD +47 -0
  45. lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
  46. lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
  47. 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()