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,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)