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,89 @@
1
+ """Linux-specific path resolution strategy.
2
+
3
+ Implement path resolution following XDG Base Directory specification
4
+ and traditional ``/etc`` conventions.
5
+
6
+ Contents:
7
+ - ``LinuxStrategy``: yields paths for app, host, user, and dotenv layers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+ from pathlib import Path
14
+
15
+ from ._base import PlatformStrategy, collect_layer
16
+
17
+
18
+ class LinuxStrategy(PlatformStrategy):
19
+ """Resolve paths following Linux/XDG conventions.
20
+
21
+ Mirror the XDG specification and ``/etc`` conventions documented in the
22
+ system design.
23
+
24
+ Path Layouts:
25
+ - App: ``/etc/xdg/<slug>`` then ``/etc/<slug>``
26
+ - Host: ``<etc>/<slug>/hosts/<hostname>.toml``
27
+ - User: ``$XDG_CONFIG_HOME/<slug>`` or ``~/.config/<slug>``
28
+ - Dotenv: ``$XDG_CONFIG_HOME/<slug>/.env``
29
+ """
30
+
31
+ def app_paths(self) -> Iterable[str]:
32
+ """Yield Linux application-default configuration paths.
33
+
34
+ Provide deterministic discovery following XDG Base Directory specification.
35
+ Checks ``/etc/xdg/<slug>`` first (XDG system-wide default), then falls back
36
+ to ``/etc/<slug>`` for backwards compatibility.
37
+
38
+ Returns:
39
+ Paths under ``/etc/xdg`` and ``/etc`` (or overridden root).
40
+ """
41
+ etc_root = Path(self.ctx.env.get("LIB_LAYERED_CONFIG_ETC", "/etc"))
42
+ profile_seg = self._profile_segment()
43
+ # Check XDG-compliant location first
44
+ yield from collect_layer(etc_root / "xdg" / self.ctx.slug / profile_seg)
45
+ # Fall back to traditional /etc location for backwards compatibility
46
+ yield from collect_layer(etc_root / self.ctx.slug / profile_seg)
47
+
48
+ def host_paths(self) -> Iterable[str]:
49
+ """Yield Linux host-specific configuration paths.
50
+
51
+ Allow installations to override defaults per hostname following XDG specification.
52
+ Checks ``/etc/xdg/<slug>/hosts`` first, then falls back to ``/etc/<slug>/hosts``.
53
+
54
+ Returns:
55
+ Host-level configuration paths (empty when missing).
56
+ """
57
+ etc_root = Path(self.ctx.env.get("LIB_LAYERED_CONFIG_ETC", "/etc"))
58
+ profile_seg = self._profile_segment()
59
+ # Check XDG-compliant location first
60
+ xdg_candidate = etc_root / "xdg" / self.ctx.slug / profile_seg / "hosts" / f"{self.ctx.hostname}.toml"
61
+ if xdg_candidate.is_file():
62
+ yield str(xdg_candidate)
63
+ # Fall back to traditional /etc location for backwards compatibility
64
+ candidate = etc_root / self.ctx.slug / profile_seg / "hosts" / f"{self.ctx.hostname}.toml"
65
+ if candidate.is_file():
66
+ yield str(candidate)
67
+
68
+ def user_paths(self) -> Iterable[str]:
69
+ """Yield Linux user-specific configuration paths.
70
+
71
+ Honour XDG directories while falling back to ``~/.config``.
72
+
73
+ Returns:
74
+ User-level configuration paths.
75
+ """
76
+ xdg = self.ctx.env.get("XDG_CONFIG_HOME")
77
+ base = Path(xdg) if xdg else Path.home() / ".config"
78
+ profile_seg = self._profile_segment()
79
+ yield from collect_layer(base / self.ctx.slug / profile_seg)
80
+
81
+ def dotenv_path(self) -> Path | None:
82
+ """Return Linux-specific ``.env`` fallback path.
83
+
84
+ Returns:
85
+ Path to ``$XDG_CONFIG_HOME/<slug>/.env``.
86
+ """
87
+ base = Path(self.ctx.env.get("XDG_CONFIG_HOME", Path.home() / ".config"))
88
+ profile_seg = self._profile_segment()
89
+ return base / self.ctx.slug / profile_seg / ".env"
@@ -0,0 +1,93 @@
1
+ """macOS-specific path resolution strategy.
2
+
3
+ Implement path resolution following macOS Application Support conventions.
4
+
5
+ Contents:
6
+ - ``MacOSStrategy``: yields paths for app, host, user, and dotenv layers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+
14
+ from ._base import PlatformStrategy, collect_layer
15
+
16
+
17
+ class MacOSStrategy(PlatformStrategy):
18
+ """Resolve paths following macOS Application Support conventions.
19
+
20
+ Follow macOS conventions for vendor/app directories under
21
+ ``/Library/Application Support`` and ``~/Library/Application Support``.
22
+
23
+ Path Layouts:
24
+ - App: ``/Library/Application Support/<Vendor>/<App>``
25
+ - Host: ``<app>/hosts/<hostname>.toml``
26
+ - User: ``~/Library/Application Support/<Vendor>/<App>``
27
+ - Dotenv: ``~/Library/Application Support/<Vendor>/<App>/.env``
28
+ """
29
+
30
+ def _app_root(self) -> Path:
31
+ """Return the base directory for system-wide Application Support.
32
+
33
+ Returns:
34
+ ``/Library/Application Support`` or overridden root.
35
+ """
36
+ default_root = Path("/Library/Application Support")
37
+ return Path(self.ctx.env.get("LIB_LAYERED_CONFIG_MAC_APP_ROOT", default_root))
38
+
39
+ def _home_root(self) -> Path:
40
+ """Return the base directory for user-level Application Support.
41
+
42
+ Returns:
43
+ ``~/Library/Application Support`` or overridden root.
44
+ """
45
+ home_default = Path.home() / "Library/Application Support"
46
+ return Path(self.ctx.env.get("LIB_LAYERED_CONFIG_MAC_HOME_ROOT", home_default))
47
+
48
+ def app_paths(self) -> Iterable[str]:
49
+ """Yield macOS application-default configuration paths.
50
+
51
+ Follow macOS Application Support directory conventions.
52
+
53
+ Returns:
54
+ Application-level configuration paths.
55
+ """
56
+ profile_seg = self._profile_segment()
57
+ yield from collect_layer(self._app_root() / self.ctx.vendor / self.ctx.app / profile_seg)
58
+
59
+ def host_paths(self) -> Iterable[str]:
60
+ """Yield macOS host-specific configuration paths.
61
+
62
+ Support host overrides stored under ``hosts/<hostname>.toml`` within
63
+ Application Support.
64
+
65
+ Returns:
66
+ Host-level macOS configuration paths (empty when missing).
67
+ """
68
+ profile_seg = self._profile_segment()
69
+ candidate = (
70
+ self._app_root() / self.ctx.vendor / self.ctx.app / profile_seg / "hosts" / f"{self.ctx.hostname}.toml"
71
+ )
72
+ if candidate.is_file():
73
+ yield str(candidate)
74
+
75
+ def user_paths(self) -> Iterable[str]:
76
+ """Yield macOS user-specific configuration paths.
77
+
78
+ Honour per-user Application Support directories with optional overrides.
79
+
80
+ Returns:
81
+ User-level macOS configuration paths.
82
+ """
83
+ profile_seg = self._profile_segment()
84
+ yield from collect_layer(self._home_root() / self.ctx.vendor / self.ctx.app / profile_seg)
85
+
86
+ def dotenv_path(self) -> Path | None:
87
+ """Return macOS-specific ``.env`` fallback path.
88
+
89
+ Returns:
90
+ Path to ``~/Library/Application Support/<Vendor>/<App>/.env``.
91
+ """
92
+ profile_seg = self._profile_segment()
93
+ return self._home_root() / self.ctx.vendor / self.ctx.app / profile_seg / ".env"
@@ -0,0 +1,126 @@
1
+ """Windows-specific path resolution strategy.
2
+
3
+ Implement path resolution following Windows ProgramData/AppData conventions.
4
+
5
+ Contents:
6
+ - ``WindowsStrategy``: yields paths for app, host, user, and dotenv layers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+
14
+ from ._base import PlatformStrategy, collect_layer
15
+
16
+
17
+ class WindowsStrategy(PlatformStrategy):
18
+ """Resolve paths following Windows directory conventions.
19
+
20
+ Respect ``%ProgramData%`` and ``%APPDATA%/%LOCALAPPDATA%`` layouts with
21
+ override support for portable deployments.
22
+
23
+ Path Layouts:
24
+ - App: ``%ProgramData%/<Vendor>/<App>``
25
+ - Host: ``<app>/hosts/<hostname>.toml``
26
+ - User: ``%APPDATA%/<Vendor>/<App>`` (fallback to ``%LOCALAPPDATA%``)
27
+ - Dotenv: ``%APPDATA%/<Vendor>/<App>/.env``
28
+ """
29
+
30
+ def _program_data_root(self) -> Path:
31
+ """Return the base directory for ProgramData lookups.
32
+
33
+ Centralise overrides for ``%ProgramData%`` so tests can supply temporary roots.
34
+
35
+ Returns:
36
+ Resolved ProgramData root directory.
37
+ """
38
+ return Path(
39
+ self.ctx.env.get(
40
+ "LIB_LAYERED_CONFIG_PROGRAMDATA",
41
+ self.ctx.env.get("ProgramData", r"C:\ProgramData"),
42
+ )
43
+ )
44
+
45
+ def _appdata_root(self) -> Path:
46
+ """Return the user AppData root used for ``%APPDATA%`` lookups.
47
+
48
+ Support overrides in tests or portable deployments.
49
+
50
+ Returns:
51
+ Resolved AppData root directory.
52
+ """
53
+ return Path(
54
+ self.ctx.env.get(
55
+ "LIB_LAYERED_CONFIG_APPDATA",
56
+ self.ctx.env.get("APPDATA", Path.home() / "AppData" / "Roaming"),
57
+ )
58
+ )
59
+
60
+ def _localappdata_root(self) -> Path:
61
+ """Return the fallback LocalAppData root.
62
+
63
+ Provide a deterministic fallback when ``%APPDATA%`` does not exist.
64
+
65
+ Returns:
66
+ Resolved LocalAppData root directory.
67
+ """
68
+ return Path(
69
+ self.ctx.env.get(
70
+ "LIB_LAYERED_CONFIG_LOCALAPPDATA",
71
+ self.ctx.env.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"),
72
+ )
73
+ )
74
+
75
+ def app_paths(self) -> Iterable[str]:
76
+ """Yield Windows application-default configuration paths.
77
+
78
+ Mirror ``%ProgramData%/<Vendor>/<App>`` layouts with override support.
79
+
80
+ Returns:
81
+ Application-level Windows configuration paths.
82
+ """
83
+ profile_seg = self._profile_segment()
84
+ base = self._program_data_root() / self.ctx.vendor / self.ctx.app / profile_seg
85
+ yield from collect_layer(base)
86
+
87
+ def host_paths(self) -> Iterable[str]:
88
+ """Yield Windows host-specific configuration paths.
89
+
90
+ Enable host overrides within ``%ProgramData%/<Vendor>/<App>/hosts``.
91
+
92
+ Returns:
93
+ Host-level Windows configuration paths.
94
+ """
95
+ profile_seg = self._profile_segment()
96
+ base = self._program_data_root() / self.ctx.vendor / self.ctx.app / profile_seg
97
+ candidate = base / "hosts" / f"{self.ctx.hostname}.toml"
98
+ if candidate.is_file():
99
+ yield str(candidate)
100
+
101
+ def user_paths(self) -> Iterable[str]:
102
+ """Yield Windows user-specific configuration paths.
103
+
104
+ Honour ``%APPDATA%`` with a fallback to ``%LOCALAPPDATA%`` for portable setups.
105
+
106
+ Returns:
107
+ User-level Windows configuration paths.
108
+ """
109
+ profile_seg = self._profile_segment()
110
+ roaming_base = self._appdata_root() / self.ctx.vendor / self.ctx.app / profile_seg
111
+ roaming_paths = list(collect_layer(roaming_base))
112
+ if roaming_paths:
113
+ yield from roaming_paths
114
+ return
115
+
116
+ local_base = self._localappdata_root() / self.ctx.vendor / self.ctx.app / profile_seg
117
+ yield from collect_layer(local_base)
118
+
119
+ def dotenv_path(self) -> Path | None:
120
+ """Return Windows-specific ``.env`` fallback path.
121
+
122
+ Returns:
123
+ Path to ``%APPDATA%/<Vendor>/<App>/.env``.
124
+ """
125
+ profile_seg = self._profile_segment()
126
+ return self._appdata_root() / self.ctx.vendor / self.ctx.app / profile_seg / ".env"
@@ -0,0 +1,194 @@
1
+ """Filesystem path resolution using platform-specific strategies.
2
+
3
+ Implement the :class:`lib_layered_config.application.ports.PathResolver`
4
+ port using the Strategy pattern for clean platform-specific handling.
5
+
6
+ Contents:
7
+ - ``DefaultPathResolver``: public adapter consumed by the composition root.
8
+ - Platform strategies in ``_linux.py``, ``_macos.py``, ``_windows.py``.
9
+
10
+ System Integration:
11
+ Produces ordered path lists for the core merge pipeline. All filesystem
12
+ knowledge stays here so inner layers remain filesystem-agnostic.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import socket
19
+ import sys
20
+ from collections.abc import Iterable
21
+ from pathlib import Path
22
+
23
+ from ...domain.identifiers import (
24
+ validate_hostname,
25
+ validate_identifier,
26
+ validate_profile,
27
+ validate_vendor_app,
28
+ )
29
+ from ...observability import log_debug
30
+ from ._base import PlatformContext, PlatformStrategy
31
+ from ._dotenv import DotenvPathFinder
32
+ from ._linux import LinuxStrategy
33
+ from ._macos import MacOSStrategy
34
+ from ._windows import WindowsStrategy
35
+
36
+
37
+ class DefaultPathResolver:
38
+ """Resolve candidate paths for each configuration layer.
39
+
40
+ Centralise path discovery so the composition root stays platform-agnostic
41
+ and easy to test.
42
+
43
+ Architecture:
44
+ Uses the Strategy pattern to delegate platform-specific logic to dedicated
45
+ classes (``LinuxStrategy``, ``MacOSStrategy``, ``WindowsStrategy``),
46
+ keeping the main resolver focused on orchestration.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ vendor: str,
53
+ app: str,
54
+ slug: str,
55
+ profile: str | None = None,
56
+ cwd: Path | None = None,
57
+ env: dict[str, str] | None = None,
58
+ platform: str | None = None,
59
+ hostname: str | None = None,
60
+ ) -> None:
61
+ """Store context required to resolve filesystem locations.
62
+
63
+ Args:
64
+ vendor / app / slug: Naming context injected into platform-specific directory structures.
65
+ profile: Optional profile name for environment-specific configurations
66
+ (e.g., "test", "production"). When set, paths include a
67
+ ``profile/<name>/`` subdirectory.
68
+ cwd: Working directory to use when searching for ``.env`` files.
69
+ env: Optional environment mapping that overrides ``os.environ`` values
70
+ (useful for deterministic tests).
71
+ platform: Platform identifier (``sys.platform`` clone). Defaults to the
72
+ current interpreter platform.
73
+ hostname: Hostname used for host-specific configuration lookups.
74
+
75
+ Raises:
76
+ ValueError: When vendor, app, slug, profile, or hostname contain invalid path characters.
77
+ """
78
+ self.vendor = validate_vendor_app(vendor, "vendor")
79
+ self.application = validate_vendor_app(app, "app")
80
+ self.slug = validate_identifier(slug, "slug")
81
+ self.profile = validate_profile(profile)
82
+ self.cwd = cwd or Path.cwd()
83
+ self.env = {**os.environ, **(env or {})}
84
+ self.platform = platform or sys.platform
85
+ self.hostname = validate_hostname(hostname or socket.gethostname())
86
+
87
+ # Build the platform context and select the appropriate strategy
88
+ self._ctx = PlatformContext(
89
+ vendor=self.vendor,
90
+ app=self.application,
91
+ slug=self.slug,
92
+ cwd=self.cwd,
93
+ env=self.env,
94
+ hostname=self.hostname,
95
+ profile=self.profile,
96
+ )
97
+ self._strategy = self._select_strategy()
98
+ self._dotenv_finder = DotenvPathFinder(self.cwd, self._strategy)
99
+
100
+ @property
101
+ def strategy(self) -> PlatformStrategy | None:
102
+ """Return the current platform strategy for direct access in tests."""
103
+ return self._strategy
104
+
105
+ @property
106
+ def context(self) -> PlatformContext:
107
+ """Return the platform context for direct access in tests."""
108
+ return self._ctx
109
+
110
+ def _select_strategy(self) -> PlatformStrategy | None:
111
+ """Select the appropriate platform strategy based on the current platform."""
112
+ if self.platform.startswith("linux"):
113
+ return LinuxStrategy(self._ctx)
114
+ if self.platform == "darwin":
115
+ return MacOSStrategy(self._ctx)
116
+ if self.platform.startswith("win"):
117
+ return WindowsStrategy(self._ctx)
118
+ return None
119
+
120
+ def app(self) -> Iterable[str]:
121
+ """Return candidate system-wide configuration paths.
122
+
123
+ Provide the lowest-precedence defaults shared across machines.
124
+
125
+ Returns:
126
+ Ordered path strings for the application defaults layer.
127
+
128
+ Examples:
129
+ >>> import os
130
+ >>> from pathlib import Path
131
+ >>> from tempfile import TemporaryDirectory
132
+ >>> tmp = TemporaryDirectory()
133
+ >>> root = Path(tmp.name)
134
+ >>> (root / 'demo').mkdir(parents=True, exist_ok=True)
135
+ >>> body = os.linesep.join(['[settings]', 'value=1'])
136
+ >>> _ = (root / 'demo' / 'config.toml').write_text(body, encoding='utf-8')
137
+ >>> resolver = DefaultPathResolver(vendor='Acme', app='Demo', slug='demo', env={'LIB_LAYERED_CONFIG_ETC': str(root)}, platform='linux')
138
+ >>> [Path(p).name for p in resolver.app()]
139
+ ['config.toml']
140
+ >>> tmp.cleanup()
141
+ """
142
+ return self._iter_layer("app")
143
+
144
+ def host(self) -> Iterable[str]:
145
+ """Return host-specific overrides.
146
+
147
+ Allow operators to tailor configuration to individual hosts (e.g.
148
+ ``demo-host.toml``).
149
+
150
+ Returns:
151
+ Ordered host-level configuration paths.
152
+ """
153
+ return self._iter_layer("host")
154
+
155
+ def user(self) -> Iterable[str]:
156
+ """Return user-level configuration locations.
157
+
158
+ Capture per-user preferences stored in XDG/macOS/Windows user config
159
+ directories.
160
+
161
+ Returns:
162
+ Ordered user-level configuration paths.
163
+ """
164
+ return self._iter_layer("user")
165
+
166
+ def dotenv(self) -> Iterable[str]:
167
+ """Return candidate ``.env`` locations discovered during path resolution.
168
+
169
+ `.env` files often live near the project root; this helper provides the
170
+ ordered search list for the dotenv adapter.
171
+
172
+ Returns:
173
+ Ordered `.env` path strings.
174
+ """
175
+ return list(self._dotenv_finder.find_paths())
176
+
177
+ def _iter_layer(self, layer: str) -> list[str]:
178
+ """Dispatch to the strategy for *layer* with logging."""
179
+ if self._strategy is None:
180
+ return []
181
+
182
+ method_map = {
183
+ "app": self._strategy.app_paths,
184
+ "host": self._strategy.host_paths,
185
+ "user": self._strategy.user_paths,
186
+ }
187
+ method = method_map.get(layer)
188
+ if method is None:
189
+ return []
190
+
191
+ paths = list(method())
192
+ if paths:
193
+ log_debug("path_candidates", layer=layer, path=None, count=len(paths))
194
+ return paths
@@ -0,0 +1,12 @@
1
+ """Application layer orchestrators (ports + merge policy).
2
+
3
+ Purpose
4
+ -------
5
+ Define pure coordination code that glues domain value objects to adapter
6
+ interfaces.
7
+
8
+ Contents
9
+ --------
10
+ * :mod:`lib_layered_config.application.ports`
11
+ * :mod:`lib_layered_config.application.merge`
12
+ """