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,305 @@
|
|
|
1
|
+
"""Deploy configuration artifacts into layered directories with per-platform strategies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterator, Sequence
|
|
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
|
+
self.resolver = resolver
|
|
32
|
+
|
|
33
|
+
def iter_destinations(self, targets: Sequence[str]) -> Iterator[Path]:
|
|
34
|
+
for raw_target in targets:
|
|
35
|
+
target = raw_target.lower()
|
|
36
|
+
if target not in _VALID_TARGETS:
|
|
37
|
+
raise ValueError(f"Unsupported deployment target: {raw_target}")
|
|
38
|
+
destination = self.destination_for(target)
|
|
39
|
+
if destination is not None:
|
|
40
|
+
yield destination
|
|
41
|
+
|
|
42
|
+
def destination_for(self, target: str) -> Path | None: # pragma: no cover - abstract
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LinuxDeployment(DeploymentStrategy):
|
|
47
|
+
def destination_for(self, target: str) -> Path | None:
|
|
48
|
+
mapping = {
|
|
49
|
+
"app": self._app_path,
|
|
50
|
+
"host": self._host_path,
|
|
51
|
+
"user": self._user_path,
|
|
52
|
+
}
|
|
53
|
+
builder = mapping.get(target)
|
|
54
|
+
return builder() if builder else None
|
|
55
|
+
|
|
56
|
+
def _etc_root(self) -> Path:
|
|
57
|
+
return Path(self.resolver.env.get("LIB_LAYERED_CONFIG_ETC", "/etc"))
|
|
58
|
+
|
|
59
|
+
def _app_path(self) -> Path:
|
|
60
|
+
return self._etc_root() / self.resolver.slug / "config.toml"
|
|
61
|
+
|
|
62
|
+
def _host_path(self) -> Path:
|
|
63
|
+
return self._etc_root() / self.resolver.slug / "hosts" / f"{self.resolver.hostname}.toml"
|
|
64
|
+
|
|
65
|
+
def _user_path(self) -> Path:
|
|
66
|
+
candidate = self.resolver.env.get("XDG_CONFIG_HOME")
|
|
67
|
+
base = Path(candidate) if candidate else Path.home() / ".config"
|
|
68
|
+
return base / self.resolver.slug / "config.toml"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MacDeployment(DeploymentStrategy):
|
|
72
|
+
def destination_for(self, target: str) -> Path | None:
|
|
73
|
+
mapping = {
|
|
74
|
+
"app": self._app_path,
|
|
75
|
+
"host": self._host_path,
|
|
76
|
+
"user": self._user_path,
|
|
77
|
+
}
|
|
78
|
+
builder = mapping.get(target)
|
|
79
|
+
return builder() if builder else None
|
|
80
|
+
|
|
81
|
+
def _app_root(self) -> Path:
|
|
82
|
+
default_root = Path("/Library/Application Support")
|
|
83
|
+
base = Path(self.resolver.env.get("LIB_LAYERED_CONFIG_MAC_APP_ROOT", default_root))
|
|
84
|
+
return base / self.resolver.vendor / self.resolver.application
|
|
85
|
+
|
|
86
|
+
def _home_root(self) -> Path:
|
|
87
|
+
home_default = Path.home() / "Library/Application Support"
|
|
88
|
+
return Path(self.resolver.env.get("LIB_LAYERED_CONFIG_MAC_HOME_ROOT", home_default))
|
|
89
|
+
|
|
90
|
+
def _app_path(self) -> Path:
|
|
91
|
+
return self._app_root() / "config.toml"
|
|
92
|
+
|
|
93
|
+
def _host_path(self) -> Path:
|
|
94
|
+
return self._app_root() / "hosts" / f"{self.resolver.hostname}.toml"
|
|
95
|
+
|
|
96
|
+
def _user_path(self) -> Path:
|
|
97
|
+
return self._home_root() / self.resolver.vendor / self.resolver.application / "config.toml"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class WindowsDeployment(DeploymentStrategy):
|
|
101
|
+
def destination_for(self, target: str) -> Path | None:
|
|
102
|
+
mapping = {
|
|
103
|
+
"app": self._app_path,
|
|
104
|
+
"host": self._host_path,
|
|
105
|
+
"user": self._user_path,
|
|
106
|
+
}
|
|
107
|
+
builder = mapping.get(target)
|
|
108
|
+
return builder() if builder else None
|
|
109
|
+
|
|
110
|
+
def _program_data_root(self) -> Path:
|
|
111
|
+
return Path(
|
|
112
|
+
self.resolver.env.get(
|
|
113
|
+
"LIB_LAYERED_CONFIG_PROGRAMDATA",
|
|
114
|
+
self.resolver.env.get("ProgramData", os.environ.get("ProgramData", r"C:\\ProgramData")),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _appdata_root(self) -> Path:
|
|
119
|
+
return Path(
|
|
120
|
+
self.resolver.env.get(
|
|
121
|
+
"LIB_LAYERED_CONFIG_APPDATA",
|
|
122
|
+
self.resolver.env.get(
|
|
123
|
+
"APPDATA",
|
|
124
|
+
os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _localappdata_root(self) -> Path:
|
|
130
|
+
return Path(
|
|
131
|
+
self.resolver.env.get(
|
|
132
|
+
"LIB_LAYERED_CONFIG_LOCALAPPDATA",
|
|
133
|
+
self.resolver.env.get(
|
|
134
|
+
"LOCALAPPDATA",
|
|
135
|
+
os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _app_path(self) -> Path:
|
|
141
|
+
return self._program_data_root() / self.resolver.vendor / self.resolver.application / "config.toml"
|
|
142
|
+
|
|
143
|
+
def _host_path(self) -> Path:
|
|
144
|
+
host_root = self._program_data_root() / self.resolver.vendor / self.resolver.application / "hosts"
|
|
145
|
+
return host_root / f"{self.resolver.hostname}.toml"
|
|
146
|
+
|
|
147
|
+
def _user_path(self) -> Path:
|
|
148
|
+
appdata_root = self._appdata_root()
|
|
149
|
+
chosen_root = appdata_root
|
|
150
|
+
if "LIB_LAYERED_CONFIG_APPDATA" not in self.resolver.env and not appdata_root.exists():
|
|
151
|
+
chosen_root = self._localappdata_root()
|
|
152
|
+
return chosen_root / self.resolver.vendor / self.resolver.application / "config.toml"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def deploy_config(
|
|
156
|
+
source: str | Path,
|
|
157
|
+
*,
|
|
158
|
+
vendor: str,
|
|
159
|
+
app: str,
|
|
160
|
+
targets: Sequence[str],
|
|
161
|
+
slug: str | None = None,
|
|
162
|
+
platform: str | None = None,
|
|
163
|
+
force: bool = False,
|
|
164
|
+
) -> list[Path]:
|
|
165
|
+
"""Copy *source* into the requested configuration layers without overwriting existing files."""
|
|
166
|
+
|
|
167
|
+
source_path = Path(source)
|
|
168
|
+
if not source_path.is_file():
|
|
169
|
+
raise FileNotFoundError(f"Configuration source not found: {source_path}")
|
|
170
|
+
|
|
171
|
+
resolver = _prepare_resolver(vendor=vendor, app=app, slug=slug or app, platform=platform)
|
|
172
|
+
payload = source_path.read_bytes()
|
|
173
|
+
created: list[Path] = []
|
|
174
|
+
for destination in _destinations_for(resolver, targets):
|
|
175
|
+
if not _should_copy(source_path, destination, force):
|
|
176
|
+
continue
|
|
177
|
+
_copy_payload(destination, payload)
|
|
178
|
+
created.append(destination)
|
|
179
|
+
return created
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _prepare_resolver(
|
|
183
|
+
*,
|
|
184
|
+
vendor: str,
|
|
185
|
+
app: str,
|
|
186
|
+
slug: str,
|
|
187
|
+
platform: str | None,
|
|
188
|
+
) -> DefaultPathResolver:
|
|
189
|
+
if platform is None:
|
|
190
|
+
return DefaultPathResolver(vendor=vendor, app=app, slug=slug)
|
|
191
|
+
return DefaultPathResolver(vendor=vendor, app=app, slug=slug, platform=platform)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _platform_family(platform: str) -> str:
|
|
195
|
+
if platform.startswith("win"):
|
|
196
|
+
return "windows"
|
|
197
|
+
if platform == "darwin":
|
|
198
|
+
return "mac"
|
|
199
|
+
return "linux"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _strategy_for(resolver: DefaultPathResolver) -> DeploymentStrategy:
|
|
203
|
+
family = _platform_family(resolver.platform)
|
|
204
|
+
if family == "windows":
|
|
205
|
+
return WindowsDeployment(resolver)
|
|
206
|
+
if family == "mac":
|
|
207
|
+
return MacDeployment(resolver)
|
|
208
|
+
return LinuxDeployment(resolver)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _destinations_for(resolver: DefaultPathResolver, targets: Sequence[str]) -> Iterator[Path]:
|
|
212
|
+
for raw_target in targets:
|
|
213
|
+
destination = _resolve_destination(resolver, raw_target)
|
|
214
|
+
if destination is not None:
|
|
215
|
+
yield destination
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _resolve_destination(resolver: DefaultPathResolver, target: str) -> Path | None:
|
|
219
|
+
normalised = _validate_target(target)
|
|
220
|
+
return _strategy_for(resolver).destination_for(normalised)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _linux_destination_for(resolver: DefaultPathResolver, target: str) -> Path | None: # pyright: ignore[reportUnusedFunction]
|
|
224
|
+
return LinuxDeployment(resolver).destination_for(_validate_target(target))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _mac_destination_for(resolver: DefaultPathResolver, target: str) -> Path | None: # pyright: ignore[reportUnusedFunction]
|
|
228
|
+
return MacDeployment(resolver).destination_for(_validate_target(target))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _windows_destination_for(resolver: DefaultPathResolver, target: str) -> Path | None: # pyright: ignore[reportUnusedFunction]
|
|
232
|
+
return WindowsDeployment(resolver).destination_for(_validate_target(target))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _linux_app_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
236
|
+
return _ensure_path(_linux_destination_for(resolver, "app"), "app")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _linux_host_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
240
|
+
return _ensure_path(_linux_destination_for(resolver, "host"), "host")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _linux_user_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
244
|
+
return _ensure_path(_linux_destination_for(resolver, "user"), "user")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _mac_app_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
248
|
+
return _ensure_path(_mac_destination_for(resolver, "app"), "app")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _mac_host_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
252
|
+
return _ensure_path(_mac_destination_for(resolver, "host"), "host")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _mac_user_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
256
|
+
return _ensure_path(_mac_destination_for(resolver, "user"), "user")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _windows_app_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
260
|
+
return _ensure_path(_windows_destination_for(resolver, "app"), "app")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _windows_host_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
264
|
+
return _ensure_path(_windows_destination_for(resolver, "host"), "host")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _windows_user_path(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
268
|
+
return _ensure_path(_windows_destination_for(resolver, "user"), "user")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _windows_program_data(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
272
|
+
return WindowsDeployment(resolver)._program_data_root() # pyright: ignore[reportPrivateUsage]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _windows_appdata(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
276
|
+
return WindowsDeployment(resolver)._appdata_root() # pyright: ignore[reportPrivateUsage]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _windows_localappdata(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
280
|
+
return WindowsDeployment(resolver)._localappdata_root() # pyright: ignore[reportPrivateUsage]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _mac_app_root(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
284
|
+
return MacDeployment(resolver)._app_root() # pyright: ignore[reportPrivateUsage]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _mac_home_root(resolver: DefaultPathResolver) -> Path: # pyright: ignore[reportUnusedFunction]
|
|
288
|
+
return MacDeployment(resolver)._home_root() # pyright: ignore[reportPrivateUsage]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _should_copy(source: Path, destination: Path, force: bool) -> bool:
|
|
292
|
+
if destination.resolve() == source.resolve():
|
|
293
|
+
return False
|
|
294
|
+
if destination.exists() and not force:
|
|
295
|
+
return False
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _copy_payload(destination: Path, payload: bytes) -> None:
|
|
300
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
_write_bytes(destination, payload)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _write_bytes(path: Path, payload: bytes) -> None:
|
|
305
|
+
path.write_bytes(payload)
|