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.

Files changed (39) hide show
  1. lib_layered_config/__init__.py +60 -0
  2. lib_layered_config/__main__.py +19 -0
  3. lib_layered_config/_layers.py +457 -0
  4. lib_layered_config/_platform.py +200 -0
  5. lib_layered_config/adapters/__init__.py +13 -0
  6. lib_layered_config/adapters/dotenv/__init__.py +1 -0
  7. lib_layered_config/adapters/dotenv/default.py +438 -0
  8. lib_layered_config/adapters/env/__init__.py +5 -0
  9. lib_layered_config/adapters/env/default.py +509 -0
  10. lib_layered_config/adapters/file_loaders/__init__.py +1 -0
  11. lib_layered_config/adapters/file_loaders/structured.py +410 -0
  12. lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
  13. lib_layered_config/adapters/path_resolvers/default.py +727 -0
  14. lib_layered_config/application/__init__.py +12 -0
  15. lib_layered_config/application/merge.py +442 -0
  16. lib_layered_config/application/ports.py +109 -0
  17. lib_layered_config/cli/__init__.py +162 -0
  18. lib_layered_config/cli/common.py +232 -0
  19. lib_layered_config/cli/constants.py +12 -0
  20. lib_layered_config/cli/deploy.py +70 -0
  21. lib_layered_config/cli/fail.py +21 -0
  22. lib_layered_config/cli/generate.py +60 -0
  23. lib_layered_config/cli/info.py +31 -0
  24. lib_layered_config/cli/read.py +117 -0
  25. lib_layered_config/core.py +384 -0
  26. lib_layered_config/domain/__init__.py +7 -0
  27. lib_layered_config/domain/config.py +490 -0
  28. lib_layered_config/domain/errors.py +65 -0
  29. lib_layered_config/examples/__init__.py +29 -0
  30. lib_layered_config/examples/deploy.py +305 -0
  31. lib_layered_config/examples/generate.py +537 -0
  32. lib_layered_config/observability.py +306 -0
  33. lib_layered_config/py.typed +0 -0
  34. lib_layered_config/testing.py +55 -0
  35. lib_layered_config-1.0.0.dist-info/METADATA +366 -0
  36. lib_layered_config-1.0.0.dist-info/RECORD +39 -0
  37. lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
  38. lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
  39. 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)