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,59 @@
1
+ """Domain error taxonomy shared across layers.
2
+
3
+ Codifies the error classes referenced throughout ``docs/systemdesign`` so the
4
+ application and adapter layers can communicate failures without depending on
5
+ concrete implementations.
6
+
7
+ Contents:
8
+ - ``ConfigError``: base class for every library-specific exception.
9
+ - ``InvalidFormatError``: raised when structured configuration cannot be parsed.
10
+ - ``ValidationError``: reserved for semantic validation of configuration
11
+ payloads once implemented.
12
+ - ``NotFoundError``: indicates optional configuration sources were absent.
13
+
14
+ System Role:
15
+ Adapters raise these exceptions; the composition root and CLI translate them
16
+ into operator-facing messages without leaking implementation details.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ __all__ = [
22
+ "ConfigError",
23
+ "InvalidFormatError",
24
+ "ValidationError",
25
+ "NotFoundError",
26
+ ]
27
+
28
+
29
+ class ConfigError(Exception):
30
+ """Base class for all configuration-related errors in the library.
31
+
32
+ Centralises exception handling so callers can catch a single type when
33
+ operating at library boundaries.
34
+ """
35
+
36
+
37
+ class InvalidFormatError(ConfigError):
38
+ """Raised when a configuration source cannot be parsed.
39
+
40
+ Typical sources include malformed TOML, JSON, YAML, or dotenv files. The
41
+ message should reference the offending path for operator debugging.
42
+ """
43
+
44
+
45
+ class ValidationError(ConfigError):
46
+ """Placeholder for semantic configuration validation failures.
47
+
48
+ The current release does not perform semantic validation, but the class is
49
+ reserved so downstream integrations already depend on a stable type.
50
+ """
51
+
52
+
53
+ class NotFoundError(ConfigError):
54
+ """Indicates an optional configuration source was not discovered.
55
+
56
+ Used when files, directory entries, or environment variable namespaces are
57
+ genuinely missing; callers generally treat this as informational rather than
58
+ fatal.
59
+ """
@@ -0,0 +1,366 @@
1
+ """Identifier validation and layer enumeration.
2
+
3
+ Provide safe identifier handling and layer name constants used throughout the
4
+ library, preventing path traversal attacks and ensuring cross-platform filesystem
5
+ compatibility.
6
+
7
+ Contents:
8
+ - ``Layer``: enumeration of configuration layer names.
9
+ - ``validate_path_segment``: core validation for filesystem path segments (strict, no spaces).
10
+ - ``validate_identifier``: validate slug/profile identifiers (strict, no spaces).
11
+ - ``validate_vendor_app``: validate vendor/app names (permissive, allows spaces).
12
+ - ``validate_profile``: validate optional profile names (strict, no spaces).
13
+ - ``validate_hostname``: validate hostname for filesystem paths (allows dots for FQDN).
14
+
15
+ Validation Strategy:
16
+ - **vendor/app**: Use ``validate_vendor_app()`` - allows spaces for macOS/Windows paths
17
+ (e.g., ``/Library/Application Support/Acme Corp/My App/``).
18
+ - **slug/profile**: Use ``validate_identifier()`` - strict, no spaces allowed
19
+ (used in Linux paths and environment variable prefixes).
20
+ - **hostname**: Use ``validate_hostname()`` - allows dots for FQDNs.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from enum import Enum
27
+
28
+ # Windows reserved device names (case-insensitive)
29
+ _WINDOWS_RESERVED_NAMES: frozenset[str] = frozenset(
30
+ {
31
+ "CON",
32
+ "PRN",
33
+ "AUX",
34
+ "NUL",
35
+ "COM1",
36
+ "COM2",
37
+ "COM3",
38
+ "COM4",
39
+ "COM5",
40
+ "COM6",
41
+ "COM7",
42
+ "COM8",
43
+ "COM9",
44
+ "LPT1",
45
+ "LPT2",
46
+ "LPT3",
47
+ "LPT4",
48
+ "LPT5",
49
+ "LPT6",
50
+ "LPT7",
51
+ "LPT8",
52
+ "LPT9",
53
+ }
54
+ )
55
+
56
+ # Characters invalid in filenames on Windows and/or problematic on Linux
57
+ # < > : " | ? * / \ and null byte
58
+ _INVALID_CHARS_PATTERN: re.Pattern[str] = re.compile(r'[<>:"|?*\\/\x00]')
59
+
60
+ # Valid strict identifier pattern: ASCII alphanumeric, hyphen, underscore, dot (not at start)
61
+ # Used for: slug, profile
62
+ _VALID_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
63
+
64
+ # Valid permissive identifier pattern: ASCII alphanumeric, hyphen, underscore, dot, space
65
+ # Used for: vendor, app (which can have spaces on macOS/Windows paths)
66
+ _VALID_PERMISSIVE_PATTERN: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._\- ]*$")
67
+
68
+ # Valid hostname pattern: ASCII alphanumeric, hyphen, dot (FQDN support)
69
+ # Hostnames can start with alphanumeric, contain hyphens and dots
70
+ _VALID_HOSTNAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9.-]*$")
71
+
72
+
73
+ # ============================================================================
74
+ # Validation Helper Functions (reduce cyclomatic complexity)
75
+ # ============================================================================
76
+
77
+
78
+ def _check_not_empty(value: str, name: str) -> None:
79
+ """Raise ValueError if value is empty."""
80
+ if not value:
81
+ raise ValueError(f"{name} cannot be empty")
82
+
83
+
84
+ def _check_ascii_only(value: str, name: str) -> None:
85
+ """Raise ValueError if value contains non-ASCII characters."""
86
+ try:
87
+ value.encode("ascii")
88
+ except UnicodeEncodeError:
89
+ raise ValueError(f"{name} contains non-ASCII characters: {value}") from None
90
+
91
+
92
+ def _check_no_invalid_chars(value: str, name: str) -> None:
93
+ """Raise ValueError if value contains filesystem-invalid characters."""
94
+ if _INVALID_CHARS_PATTERN.search(value):
95
+ raise ValueError(f"{name} contains invalid characters: {value}")
96
+
97
+
98
+ def _check_not_windows_reserved(value: str, name: str, *, split_on_space: bool = False) -> None:
99
+ """Raise ValueError if value is a Windows reserved name."""
100
+ # Extract base name (before first dot, optionally before first space)
101
+ base_name = value.split(".")[0]
102
+ if split_on_space:
103
+ base_name = base_name.split()[0]
104
+ if base_name.upper() in _WINDOWS_RESERVED_NAMES:
105
+ raise ValueError(f"{name} is a Windows reserved name: {value}")
106
+
107
+
108
+ def _check_no_trailing_dot_or_space(value: str, name: str) -> None:
109
+ """Raise ValueError if value ends with dot or space (Windows restriction)."""
110
+ if value.endswith(".") or value.endswith(" "):
111
+ raise ValueError(f"{name} cannot end with a dot or space: {value}")
112
+
113
+
114
+ def _check_permissive_pattern(value: str, name: str) -> None:
115
+ """Check value matches permissive pattern (allows spaces for vendor/app)."""
116
+ if _VALID_PERMISSIVE_PATTERN.match(value):
117
+ return
118
+ if value.startswith("."):
119
+ raise ValueError(f"{name} cannot start with a dot: {value}")
120
+ if value.startswith("-") or value.startswith("_") or value.startswith(" "):
121
+ raise ValueError(f"{name} must start with an alphanumeric character: {value}")
122
+ raise ValueError(f"{name} contains invalid characters: {value}")
123
+
124
+
125
+ def _check_hostname_pattern(value: str, name: str) -> None:
126
+ """Check value matches hostname pattern (alphanumeric, hyphen, dot)."""
127
+ if _VALID_HOSTNAME_PATTERN.match(value):
128
+ return
129
+ if value.startswith(".") or value.startswith("-"):
130
+ raise ValueError(f"{name} must start with an alphanumeric character: {value}")
131
+ raise ValueError(f"{name} contains invalid characters: {value}")
132
+
133
+
134
+ def _check_no_trailing_space(value: str, name: str) -> None:
135
+ """Raise ValueError if value ends with space."""
136
+ if value.endswith(" "):
137
+ raise ValueError(f"{name} cannot end with a space: {value}")
138
+
139
+
140
+ class Layer(str, Enum):
141
+ """Configuration layer names in precedence order.
142
+
143
+ Replace magic strings with type-safe enumeration, enabling IDE completion
144
+ and preventing typos in layer name references.
145
+
146
+ Attributes:
147
+ DEFAULTS: Lowest precedence - bundled application defaults.
148
+ APP: System-wide application configuration.
149
+ HOST: Machine-specific overrides.
150
+ USER: Per-user preferences.
151
+ DOTENV: Project-local `.env` file values.
152
+ ENV: Environment variable overrides (highest precedence).
153
+ """
154
+
155
+ DEFAULTS = "defaults"
156
+ APP = "app"
157
+ HOST = "host"
158
+ USER = "user"
159
+ DOTENV = "dotenv"
160
+ ENV = "env"
161
+
162
+
163
+ def validate_path_segment(value: str, name: str, *, allow_dots: bool = False) -> str:
164
+ """Validate a string for safe use as a filesystem path segment.
165
+
166
+ Ensures identifiers (vendor, app, slug, profile) are safe on Windows and Linux,
167
+ preventing path traversal attacks and encoding issues.
168
+
169
+ Args:
170
+ value: The string to validate.
171
+ name: Parameter name for error messages (e.g., "vendor", "slug").
172
+ allow_dots: If True, allow dots within the value (for hostnames).
173
+
174
+ Returns:
175
+ The validated string (unchanged if valid).
176
+
177
+ Raises:
178
+ ValueError: When the value fails validation (empty, non-ASCII, invalid chars,
179
+ path separators, Windows reserved names, or trailing dot/space).
180
+
181
+ Examples:
182
+ >>> validate_path_segment("myapp", "slug")
183
+ 'myapp'
184
+ >>> validate_path_segment("Acme", "vendor")
185
+ 'Acme'
186
+ """
187
+ _check_not_empty(value, name)
188
+ _check_ascii_only(value, name)
189
+ _check_no_invalid_chars(value, name)
190
+ _check_strict_pattern(value, name, allow_dots)
191
+ _check_not_windows_reserved(value, name)
192
+ _check_no_trailing_dot_or_space(value, name)
193
+ return value
194
+
195
+
196
+ def _check_strict_pattern(value: str, name: str, allow_dots: bool) -> None:
197
+ """Check value matches strict identifier pattern (no spaces)."""
198
+ if allow_dots:
199
+ return
200
+ if _VALID_IDENTIFIER_PATTERN.match(value):
201
+ return
202
+ if value.startswith("."):
203
+ raise ValueError(f"{name} cannot start with a dot: {value}")
204
+ if value.startswith("-") or value.startswith("_"):
205
+ raise ValueError(f"{name} must start with an alphanumeric character: {value}")
206
+ raise ValueError(f"{name} contains invalid characters: {value}")
207
+
208
+
209
+ def validate_identifier(value: str, name: str) -> str:
210
+ """Validate a strict identifier (slug, profile) for filesystem safety.
211
+
212
+ Prevent path traversal attacks and ensure cross-platform filesystem compatibility
213
+ when identifiers are used to construct directory paths.
214
+
215
+ Note:
216
+ This is for strict identifiers (slug, profile) that should not contain spaces.
217
+ For vendor/app which allow spaces, use ``validate_vendor_app()``.
218
+
219
+ Args:
220
+ value: The identifier value to validate.
221
+ name: Parameter name for error messages (e.g., "slug", "profile").
222
+
223
+ Returns:
224
+ The validated identifier (unchanged if valid).
225
+
226
+ Raises:
227
+ ValueError: When the identifier contains invalid characters or patterns.
228
+
229
+ Examples:
230
+ >>> validate_identifier("myapp", "slug")
231
+ 'myapp'
232
+ >>> validate_identifier("my-app_v2", "slug")
233
+ 'my-app_v2'
234
+ >>> validate_identifier("../etc", "slug")
235
+ Traceback (most recent call last):
236
+ ...
237
+ ValueError: slug contains invalid characters: ../etc
238
+ """
239
+ return validate_path_segment(value, name, allow_dots=False)
240
+
241
+
242
+ def validate_vendor_app(value: str, name: str) -> str:
243
+ """Validate vendor or app identifier for filesystem safety (allows spaces).
244
+
245
+ Vendor and app names are used in macOS and Windows paths which support spaces
246
+ (e.g., ``/Library/Application Support/Acme Corp/My App/``).
247
+ This function allows spaces while still preventing path traversal attacks.
248
+
249
+ Args:
250
+ value: The vendor or app value to validate.
251
+ name: Parameter name for error messages ("vendor" or "app").
252
+
253
+ Returns:
254
+ The validated value (unchanged if valid).
255
+
256
+ Raises:
257
+ ValueError: When the value contains invalid characters or patterns.
258
+
259
+ Examples:
260
+ >>> validate_vendor_app("Acme Corp", "vendor")
261
+ 'Acme Corp'
262
+ >>> validate_vendor_app("My App", "app")
263
+ 'My App'
264
+ >>> validate_vendor_app("Btx Fix Mcp", "app")
265
+ 'Btx Fix Mcp'
266
+ >>> validate_vendor_app("../etc", "vendor")
267
+ Traceback (most recent call last):
268
+ ...
269
+ ValueError: vendor contains invalid characters: ../etc
270
+ """
271
+ _check_not_empty(value, name)
272
+ _check_ascii_only(value, name)
273
+ _check_no_invalid_chars(value, name)
274
+ _check_permissive_pattern(value, name)
275
+ _check_not_windows_reserved(value, name, split_on_space=True)
276
+ _check_no_trailing_dot_or_space(value, name)
277
+ return value
278
+
279
+
280
+ def validate_profile(value: str | None) -> str | None:
281
+ """Validate profile name or return None if not provided.
282
+
283
+ Profile names become path segments, so they must be validated against
284
+ path traversal attacks and ensured cross-platform filesystem compatibility.
285
+
286
+ Args:
287
+ value: The profile name to validate, or None for no profile.
288
+
289
+ Returns:
290
+ The validated profile name, or None if no profile.
291
+
292
+ Raises:
293
+ ValueError: When the profile name contains invalid characters.
294
+
295
+ Examples:
296
+ >>> validate_profile(None) is None
297
+ True
298
+ >>> validate_profile("test")
299
+ 'test'
300
+ >>> validate_profile("prod-v1")
301
+ 'prod-v1'
302
+ >>> validate_profile("../etc")
303
+ Traceback (most recent call last):
304
+ ...
305
+ ValueError: profile contains invalid characters: ../etc
306
+ """
307
+ if value is None:
308
+ return None
309
+ return validate_identifier(value, "profile")
310
+
311
+
312
+ def validate_hostname(value: str) -> str:
313
+ """Ensure hostname is safe for use in filesystem paths.
314
+
315
+ Hostnames are used to construct file paths like ``hosts/{hostname}.toml``.
316
+ While hostnames from ``socket.gethostname()`` are typically safe, defensive
317
+ validation prevents path traversal and ensures cross-platform safety.
318
+
319
+ Validation Rules:
320
+ 1. Must not be empty
321
+ 2. Must contain only ASCII characters
322
+ 3. Must start with alphanumeric character
323
+ 4. May contain alphanumeric, hyphen, and dot (for FQDNs)
324
+ 5. Must not contain path separators or Windows-invalid characters
325
+ 6. Must not be a Windows reserved name
326
+
327
+ Args:
328
+ value: The hostname to validate.
329
+
330
+ Returns:
331
+ The validated hostname (unchanged if valid).
332
+
333
+ Raises:
334
+ ValueError: When the hostname fails validation.
335
+
336
+ Examples:
337
+ >>> validate_hostname("web-server-01")
338
+ 'web-server-01'
339
+ >>> validate_hostname("server.local")
340
+ 'server.local'
341
+ >>> validate_hostname("../etc")
342
+ Traceback (most recent call last):
343
+ ...
344
+ ValueError: hostname contains invalid characters: ../etc
345
+ >>> validate_hostname("café")
346
+ Traceback (most recent call last):
347
+ ...
348
+ ValueError: hostname contains non-ASCII characters: café
349
+ """
350
+ _check_not_empty(value, "hostname")
351
+ _check_ascii_only(value, "hostname")
352
+ _check_no_invalid_chars(value, "hostname")
353
+ _check_hostname_pattern(value, "hostname")
354
+ _check_not_windows_reserved(value, "hostname")
355
+ _check_no_trailing_space(value, "hostname")
356
+ return value
357
+
358
+
359
+ __all__ = [
360
+ "Layer",
361
+ "validate_path_segment",
362
+ "validate_identifier",
363
+ "validate_vendor_app",
364
+ "validate_profile",
365
+ "validate_hostname",
366
+ ]
@@ -0,0 +1,29 @@
1
+ """Expose example-generation and deployment helpers as a tidy façade.
2
+
3
+ Purpose
4
+ Provide a single import point for notebooks and docs that showcase layered
5
+ configuration scenarios. Keeps consumers away from internal module layout.
6
+
7
+ Contents
8
+ - :func:`deploy_config`: copy template files into etc/xdg directories.
9
+ - :class:`ExampleSpec`: describes example assets to generate.
10
+ - :data:`DEFAULT_HOST_PLACEHOLDER`: default hostname marker for templates.
11
+ - :func:`generate_examples`: materialise example configs on disk.
12
+
13
+ System Integration
14
+ Re-exports live in the ``examples`` namespace so tutorials can call
15
+ ``lib_layered_config.examples.generate_examples`` without traversing the
16
+ package internals.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .deploy import deploy_config
22
+ from .generate import DEFAULT_HOST_PLACEHOLDER, ExampleSpec, generate_examples
23
+
24
+ __all__ = (
25
+ "deploy_config",
26
+ "ExampleSpec",
27
+ "DEFAULT_HOST_PLACEHOLDER",
28
+ "generate_examples",
29
+ )