lib-layered-config 4.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lib_layered_config/__init__.py +58 -0
- lib_layered_config/__init__conf__.py +74 -0
- lib_layered_config/__main__.py +18 -0
- lib_layered_config/_layers.py +310 -0
- lib_layered_config/_platform.py +166 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/_nested_keys.py +126 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +143 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +288 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +376 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
- lib_layered_config/adapters/path_resolvers/_base.py +166 -0
- lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
- lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
- lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
- lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
- lib_layered_config/adapters/path_resolvers/default.py +194 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +379 -0
- lib_layered_config/application/ports.py +115 -0
- lib_layered_config/cli/__init__.py +92 -0
- lib_layered_config/cli/common.py +381 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +71 -0
- lib_layered_config/cli/fail.py +19 -0
- lib_layered_config/cli/generate.py +57 -0
- lib_layered_config/cli/info.py +29 -0
- lib_layered_config/cli/read.py +120 -0
- lib_layered_config/core.py +301 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +372 -0
- lib_layered_config/domain/errors.py +59 -0
- lib_layered_config/domain/identifiers.py +366 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +333 -0
- lib_layered_config/examples/generate.py +406 -0
- lib_layered_config/observability.py +209 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +46 -0
- lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
- lib_layered_config-4.1.0.dist-info/RECORD +47 -0
- lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
- lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-4.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Deploy configuration artifacts into layered directories with per-platform strategies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterator, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..adapters.path_resolvers.default import DefaultPathResolver
|
|
10
|
+
|
|
11
|
+
_VALID_TARGETS = {"app", "host", "user"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ensure_path(path: Path | None, target: str) -> Path:
|
|
15
|
+
if path is None:
|
|
16
|
+
raise ValueError(f"No destination available for {target!r}")
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_target(target: str) -> str:
|
|
21
|
+
normalised = target.lower()
|
|
22
|
+
if normalised not in _VALID_TARGETS:
|
|
23
|
+
raise ValueError(f"Unsupported deployment target: {target}")
|
|
24
|
+
return normalised
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DeploymentStrategy:
|
|
28
|
+
"""Base class for computing deployment destinations on a specific platform."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, resolver: DefaultPathResolver) -> None:
|
|
31
|
+
"""Initialise strategy with a path resolver providing identifiers."""
|
|
32
|
+
self.resolver = resolver
|
|
33
|
+
|
|
34
|
+
def _profile_segment(self) -> Path:
|
|
35
|
+
"""Return the profile path segment or an empty path."""
|
|
36
|
+
if self.resolver.profile:
|
|
37
|
+
return Path("profile") / self.resolver.profile
|
|
38
|
+
return Path()
|
|
39
|
+
|
|
40
|
+
def iter_destinations(self, targets: Sequence[str]) -> Iterator[Path]:
|
|
41
|
+
"""Yield destination paths for each valid target in *targets*."""
|
|
42
|
+
for raw_target in targets:
|
|
43
|
+
target = raw_target.lower()
|
|
44
|
+
if target not in _VALID_TARGETS:
|
|
45
|
+
raise ValueError(f"Unsupported deployment target: {raw_target}")
|
|
46
|
+
destination = self.destination_for(target)
|
|
47
|
+
if destination is not None:
|
|
48
|
+
yield destination
|
|
49
|
+
|
|
50
|
+
def destination_for(self, target: str) -> Path | None: # pragma: no cover - abstract
|
|
51
|
+
"""Return the destination path for *target*, or None if unsupported."""
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LinuxDeployment(DeploymentStrategy):
|
|
56
|
+
"""Linux deployment using XDG Base Directory paths."""
|
|
57
|
+
|
|
58
|
+
def destination_for(self, target: str) -> Path | None:
|
|
59
|
+
"""Return Linux-specific destination path for *target*."""
|
|
60
|
+
mapping = {
|
|
61
|
+
"app": self._app_path,
|
|
62
|
+
"host": self._host_path,
|
|
63
|
+
"user": self._user_path,
|
|
64
|
+
}
|
|
65
|
+
builder = mapping.get(target)
|
|
66
|
+
return builder() if builder else None
|
|
67
|
+
|
|
68
|
+
def _etc_root(self) -> Path:
|
|
69
|
+
return Path(self.resolver.env.get("LIB_LAYERED_CONFIG_ETC", "/etc"))
|
|
70
|
+
|
|
71
|
+
def _app_path(self) -> Path:
|
|
72
|
+
profile_seg = self._profile_segment()
|
|
73
|
+
return self._etc_root() / "xdg" / self.resolver.slug / profile_seg / "config.toml"
|
|
74
|
+
|
|
75
|
+
def _host_path(self) -> Path:
|
|
76
|
+
profile_seg = self._profile_segment()
|
|
77
|
+
return self._etc_root() / "xdg" / self.resolver.slug / profile_seg / "hosts" / f"{self.resolver.hostname}.toml"
|
|
78
|
+
|
|
79
|
+
def _user_path(self) -> Path:
|
|
80
|
+
candidate = self.resolver.env.get("XDG_CONFIG_HOME")
|
|
81
|
+
base = Path(candidate) if candidate else Path.home() / ".config"
|
|
82
|
+
profile_seg = self._profile_segment()
|
|
83
|
+
return base / self.resolver.slug / profile_seg / "config.toml"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MacDeployment(DeploymentStrategy):
|
|
87
|
+
"""macOS deployment using Application Support paths."""
|
|
88
|
+
|
|
89
|
+
def destination_for(self, target: str) -> Path | None:
|
|
90
|
+
"""Return macOS-specific destination path for *target*."""
|
|
91
|
+
mapping = {
|
|
92
|
+
"app": self._app_path,
|
|
93
|
+
"host": self._host_path,
|
|
94
|
+
"user": self._user_path,
|
|
95
|
+
}
|
|
96
|
+
builder = mapping.get(target)
|
|
97
|
+
return builder() if builder else None
|
|
98
|
+
|
|
99
|
+
def _app_root(self) -> Path:
|
|
100
|
+
default_root = Path("/Library/Application Support")
|
|
101
|
+
base = Path(self.resolver.env.get("LIB_LAYERED_CONFIG_MAC_APP_ROOT", default_root))
|
|
102
|
+
return base / self.resolver.vendor / self.resolver.application
|
|
103
|
+
|
|
104
|
+
def _home_root(self) -> Path:
|
|
105
|
+
home_default = Path.home() / "Library/Application Support"
|
|
106
|
+
return Path(self.resolver.env.get("LIB_LAYERED_CONFIG_MAC_HOME_ROOT", home_default))
|
|
107
|
+
|
|
108
|
+
def _app_path(self) -> Path:
|
|
109
|
+
profile_seg = self._profile_segment()
|
|
110
|
+
return self._app_root() / profile_seg / "config.toml"
|
|
111
|
+
|
|
112
|
+
def _host_path(self) -> Path:
|
|
113
|
+
profile_seg = self._profile_segment()
|
|
114
|
+
return self._app_root() / profile_seg / "hosts" / f"{self.resolver.hostname}.toml"
|
|
115
|
+
|
|
116
|
+
def _user_path(self) -> Path:
|
|
117
|
+
profile_seg = self._profile_segment()
|
|
118
|
+
return self._home_root() / self.resolver.vendor / self.resolver.application / profile_seg / "config.toml"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class WindowsDeployment(DeploymentStrategy):
|
|
122
|
+
"""Windows deployment using ProgramData and AppData paths."""
|
|
123
|
+
|
|
124
|
+
def destination_for(self, target: str) -> Path | None:
|
|
125
|
+
"""Return Windows-specific destination path for *target*."""
|
|
126
|
+
mapping = {
|
|
127
|
+
"app": self._app_path,
|
|
128
|
+
"host": self._host_path,
|
|
129
|
+
"user": self._user_path,
|
|
130
|
+
}
|
|
131
|
+
builder = mapping.get(target)
|
|
132
|
+
return builder() if builder else None
|
|
133
|
+
|
|
134
|
+
def _program_data_root(self) -> Path:
|
|
135
|
+
return Path(
|
|
136
|
+
self.resolver.env.get(
|
|
137
|
+
"LIB_LAYERED_CONFIG_PROGRAMDATA",
|
|
138
|
+
self.resolver.env.get("ProgramData", os.environ.get("ProgramData", r"C:\\ProgramData")), # noqa: SIM112
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _appdata_root(self) -> Path:
|
|
143
|
+
return Path(
|
|
144
|
+
self.resolver.env.get(
|
|
145
|
+
"LIB_LAYERED_CONFIG_APPDATA",
|
|
146
|
+
self.resolver.env.get(
|
|
147
|
+
"APPDATA",
|
|
148
|
+
os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _localappdata_root(self) -> Path:
|
|
154
|
+
return Path(
|
|
155
|
+
self.resolver.env.get(
|
|
156
|
+
"LIB_LAYERED_CONFIG_LOCALAPPDATA",
|
|
157
|
+
self.resolver.env.get(
|
|
158
|
+
"LOCALAPPDATA",
|
|
159
|
+
os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _app_path(self) -> Path:
|
|
165
|
+
profile_seg = self._profile_segment()
|
|
166
|
+
return (
|
|
167
|
+
self._program_data_root() / self.resolver.vendor / self.resolver.application / profile_seg / "config.toml"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _host_path(self) -> Path:
|
|
171
|
+
profile_seg = self._profile_segment()
|
|
172
|
+
host_root = self._program_data_root() / self.resolver.vendor / self.resolver.application / profile_seg / "hosts"
|
|
173
|
+
return host_root / f"{self.resolver.hostname}.toml"
|
|
174
|
+
|
|
175
|
+
def _user_path(self) -> Path:
|
|
176
|
+
profile_seg = self._profile_segment()
|
|
177
|
+
appdata_root = self._appdata_root()
|
|
178
|
+
chosen_root = appdata_root
|
|
179
|
+
if "LIB_LAYERED_CONFIG_APPDATA" not in self.resolver.env and not appdata_root.exists():
|
|
180
|
+
chosen_root = self._localappdata_root()
|
|
181
|
+
return chosen_root / self.resolver.vendor / self.resolver.application / profile_seg / "config.toml"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def deploy_config(
|
|
185
|
+
source: str | Path,
|
|
186
|
+
*,
|
|
187
|
+
vendor: str,
|
|
188
|
+
app: str,
|
|
189
|
+
targets: Sequence[str],
|
|
190
|
+
slug: str | None = None,
|
|
191
|
+
profile: str | None = None,
|
|
192
|
+
platform: str | None = None,
|
|
193
|
+
force: bool = False,
|
|
194
|
+
) -> list[Path]:
|
|
195
|
+
"""Copy source into the requested configuration layers without overwriting existing files."""
|
|
196
|
+
source_path = Path(source)
|
|
197
|
+
if not source_path.is_file():
|
|
198
|
+
raise FileNotFoundError(f"Configuration source not found: {source_path}")
|
|
199
|
+
|
|
200
|
+
resolver = _prepare_resolver(vendor=vendor, app=app, slug=slug or app, profile=profile, platform=platform)
|
|
201
|
+
payload = source_path.read_bytes()
|
|
202
|
+
created: list[Path] = []
|
|
203
|
+
for destination in _destinations_for(resolver, targets):
|
|
204
|
+
if not _should_copy(source_path, destination, force):
|
|
205
|
+
continue
|
|
206
|
+
_copy_payload(destination, payload)
|
|
207
|
+
created.append(destination)
|
|
208
|
+
return created
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _prepare_resolver(
|
|
212
|
+
*,
|
|
213
|
+
vendor: str,
|
|
214
|
+
app: str,
|
|
215
|
+
slug: str,
|
|
216
|
+
profile: str | None,
|
|
217
|
+
platform: str | None,
|
|
218
|
+
) -> DefaultPathResolver:
|
|
219
|
+
if platform is None:
|
|
220
|
+
return DefaultPathResolver(vendor=vendor, app=app, slug=slug, profile=profile)
|
|
221
|
+
return DefaultPathResolver(vendor=vendor, app=app, slug=slug, profile=profile, platform=platform)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _platform_family(platform: str) -> str:
|
|
225
|
+
if platform.startswith("win"):
|
|
226
|
+
return "windows"
|
|
227
|
+
if platform == "darwin":
|
|
228
|
+
return "mac"
|
|
229
|
+
return "linux"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _strategy_for(resolver: DefaultPathResolver) -> DeploymentStrategy:
|
|
233
|
+
family = _platform_family(resolver.platform)
|
|
234
|
+
if family == "windows":
|
|
235
|
+
return WindowsDeployment(resolver)
|
|
236
|
+
if family == "mac":
|
|
237
|
+
return MacDeployment(resolver)
|
|
238
|
+
return LinuxDeployment(resolver)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _destinations_for(resolver: DefaultPathResolver, targets: Sequence[str]) -> Iterator[Path]:
|
|
242
|
+
for raw_target in targets:
|
|
243
|
+
destination = _resolve_destination(resolver, raw_target)
|
|
244
|
+
if destination is not None:
|
|
245
|
+
yield destination
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _resolve_destination(resolver: DefaultPathResolver, target: str) -> Path | None:
|
|
249
|
+
normalised = _validate_target(target)
|
|
250
|
+
return _strategy_for(resolver).destination_for(normalised)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _linux_destination_for(resolver: DefaultPathResolver, target: str) -> Path | None: # pyright: ignore[reportUnusedFunction]
|
|
254
|
+
return LinuxDeployment(resolver).destination_for(_validate_target(target))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _mac_destination_for(resolver: DefaultPathResolver, target: str) -> Path | None: # pyright: ignore[reportUnusedFunction]
|
|
258
|
+
return MacDeployment(resolver).destination_for(_validate_target(target))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _windows_destination_for(resolver: DefaultPathResolver, target: str) -> Path | None: # pyright: ignore[reportUnusedFunction]
|
|
262
|
+
return WindowsDeployment(resolver).destination_for(_validate_target(target))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _linux_app_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
266
|
+
return _ensure_path(_linux_destination_for(resolver, "app"), "app")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _linux_host_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
270
|
+
return _ensure_path(_linux_destination_for(resolver, "host"), "host")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _linux_user_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
274
|
+
return _ensure_path(_linux_destination_for(resolver, "user"), "user")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _mac_app_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
278
|
+
return _ensure_path(_mac_destination_for(resolver, "app"), "app")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _mac_host_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
282
|
+
return _ensure_path(_mac_destination_for(resolver, "host"), "host")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _mac_user_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
286
|
+
return _ensure_path(_mac_destination_for(resolver, "user"), "user")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _windows_app_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
290
|
+
return _ensure_path(_windows_destination_for(resolver, "app"), "app")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _windows_host_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
294
|
+
return _ensure_path(_windows_destination_for(resolver, "host"), "host")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _windows_user_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
298
|
+
return _ensure_path(_windows_destination_for(resolver, "user"), "user")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _windows_program_data(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
302
|
+
return WindowsDeployment(resolver)._program_data_root() # pyright: ignore[reportPrivateUsage]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _windows_appdata(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
306
|
+
return WindowsDeployment(resolver)._appdata_root() # pyright: ignore[reportPrivateUsage]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _windows_localappdata(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
310
|
+
return WindowsDeployment(resolver)._localappdata_root() # pyright: ignore[reportPrivateUsage]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _mac_app_root(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
314
|
+
return MacDeployment(resolver)._app_root() # pyright: ignore[reportPrivateUsage]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _mac_home_root(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
318
|
+
return MacDeployment(resolver)._home_root() # pyright: ignore[reportPrivateUsage]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _should_copy(source: Path, destination: Path, force: bool) -> bool:
|
|
322
|
+
if destination.resolve() == source.resolve():
|
|
323
|
+
return False
|
|
324
|
+
return not (destination.exists() and not force)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _copy_payload(destination: Path, payload: bytes) -> None:
|
|
328
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
_write_bytes(destination, payload)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _write_bytes(path: Path, payload: bytes) -> None:
|
|
333
|
+
path.write_bytes(payload)
|