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,117 @@
1
+ """CLI commands related to reading configuration layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, Sequence
7
+
8
+ import rich_click as click
9
+
10
+ from .common import (
11
+ build_read_query,
12
+ human_payload,
13
+ json_payload,
14
+ resolve_indent,
15
+ wants_json,
16
+ )
17
+ from .constants import CLICK_CONTEXT_SETTINGS
18
+
19
+
20
+ @click.command("read", context_settings=CLICK_CONTEXT_SETTINGS)
21
+ @click.option("--vendor", required=True, help="Vendor namespace")
22
+ @click.option("--app", required=True, help="Application name")
23
+ @click.option("--slug", required=True, help="Slug identifying the configuration set")
24
+ @click.option("--prefer", multiple=True, help="Preferred file suffix ordering (repeatable)")
25
+ @click.option(
26
+ "--start-dir",
27
+ type=click.Path(path_type=Path, exists=True, file_okay=False, dir_okay=True, readable=True),
28
+ default=None,
29
+ help="Starting directory for .env upward search",
30
+ )
31
+ @click.option(
32
+ "--default-file",
33
+ type=click.Path(path_type=Path, exists=True, file_okay=True, dir_okay=False, readable=True),
34
+ default=None,
35
+ help="Optional lowest-precedence defaults file",
36
+ )
37
+ @click.option(
38
+ "--format",
39
+ "output_format",
40
+ type=click.Choice(["human", "json"], case_sensitive=False),
41
+ default="human",
42
+ show_default=True,
43
+ help="Choose between human prose or JSON",
44
+ )
45
+ @click.option(
46
+ "--indent/--no-indent",
47
+ default=True,
48
+ show_default=True,
49
+ help="Pretty-print JSON output",
50
+ )
51
+ @click.option(
52
+ "--provenance/--no-provenance",
53
+ default=True,
54
+ show_default=True,
55
+ help="Include provenance metadata in JSON output",
56
+ )
57
+ def read_command(
58
+ vendor: str,
59
+ app: str,
60
+ slug: str,
61
+ prefer: Sequence[str],
62
+ start_dir: Optional[Path],
63
+ default_file: Optional[Path],
64
+ output_format: str,
65
+ indent: bool,
66
+ provenance: bool,
67
+ ) -> None:
68
+ """Read configuration and print either human prose or JSON."""
69
+
70
+ query = build_read_query(vendor, app, slug, prefer, start_dir, default_file)
71
+ if wants_json(output_format):
72
+ click.echo(json_payload(query, resolve_indent(indent), provenance))
73
+ return
74
+ click.echo(human_payload(query))
75
+
76
+
77
+ @click.command("read-json", context_settings=CLICK_CONTEXT_SETTINGS)
78
+ @click.option("--vendor", required=True)
79
+ @click.option("--app", required=True)
80
+ @click.option("--slug", required=True)
81
+ @click.option("--prefer", multiple=True)
82
+ @click.option(
83
+ "--start-dir",
84
+ type=click.Path(path_type=Path, exists=True, file_okay=False, dir_okay=True, readable=True),
85
+ default=None,
86
+ )
87
+ @click.option(
88
+ "--default-file",
89
+ type=click.Path(path_type=Path, exists=True, file_okay=True, dir_okay=False, readable=True),
90
+ default=None,
91
+ )
92
+ @click.option(
93
+ "--indent/--no-indent",
94
+ default=True,
95
+ show_default=True,
96
+ help="Pretty-print JSON output",
97
+ )
98
+ def read_json_command(
99
+ vendor: str,
100
+ app: str,
101
+ slug: str,
102
+ prefer: Sequence[str],
103
+ start_dir: Optional[Path],
104
+ default_file: Optional[Path],
105
+ indent: bool,
106
+ ) -> None:
107
+ """Always emit combined JSON (config + provenance)."""
108
+
109
+ query = build_read_query(vendor, app, slug, prefer, start_dir, default_file)
110
+ click.echo(json_payload(query, resolve_indent(indent), include_provenance=True))
111
+
112
+
113
+ def register(cli_group: click.Group) -> None:
114
+ """Register CLI commands defined in this module."""
115
+
116
+ cli_group.add_command(read_command)
117
+ cli_group.add_command(read_json_command)
@@ -0,0 +1,384 @@
1
+ """Composition root tying adapters, merge policy, and domain objects together.
2
+
3
+ Purpose
4
+ -------
5
+ Implement the orchestration described in ``docs/systemdesign/concept.md`` by
6
+ discovering configuration layers, merging them with provenance, and returning a
7
+ domain-level :class:`Config` value object. Also provides convenience helpers for
8
+ JSON output and CLI wiring.
9
+
10
+ Contents
11
+ --------
12
+ - ``read_config`` / ``read_config_json`` / ``read_config_raw``: public APIs used
13
+ by library consumers and the CLI.
14
+ - ``LayerLoadError``: wraps adapter failures with a consistent exception type.
15
+ - Private helpers for resolver/builder construction, JSON dumping, and
16
+ configuration composition.
17
+
18
+ System Role
19
+ -----------
20
+ This module sits at the composition layer of the architecture. It instantiates
21
+ adapters from ``lib_layered_config.adapters.*``, invokes
22
+ ``lib_layered_config._layers.collect_layers``, and converts merge results into
23
+ domain objects returned to callers.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from pathlib import Path
30
+ from typing import Sequence, cast
31
+
32
+ from ._layers import collect_layers, merge_or_empty
33
+ from .adapters.dotenv.default import DefaultDotEnvLoader
34
+ from .adapters.env.default import DefaultEnvLoader, default_env_prefix
35
+ from .adapters.path_resolvers.default import DefaultPathResolver
36
+ from .application.merge import SourceInfoPayload
37
+ from .domain.config import Config, EMPTY_CONFIG, SourceInfo
38
+ from .domain.errors import ConfigError, InvalidFormat, NotFound, ValidationError
39
+ from .observability import bind_trace_id
40
+
41
+
42
+ class LayerLoadError(ConfigError):
43
+ """Adapter failure raised during layer collection.
44
+
45
+ Why
46
+ ----
47
+ Provides a single exception type for callers who need to distinguish merge
48
+ orchestration errors from other configuration issues.
49
+ """
50
+
51
+
52
+ def read_config(
53
+ *,
54
+ vendor: str,
55
+ app: str,
56
+ slug: str,
57
+ prefer: Sequence[str] | None = None,
58
+ start_dir: str | None = None,
59
+ default_file: str | Path | None = None,
60
+ ) -> Config:
61
+ """Return an immutable :class:`Config` built from all reachable layers.
62
+
63
+ Why
64
+ ----
65
+ Most consumers want the merged configuration value object rather than raw
66
+ dictionaries. This function wraps the lower-level helper and constructs the
67
+ domain aggregate in one step.
68
+
69
+ Parameters
70
+ ----------
71
+ vendor / app / slug:
72
+ Identifiers used by adapters to compute filesystem paths and prefixes.
73
+ prefer:
74
+ Optional sequence of preferred file suffixes (``["toml", "json"]``).
75
+ start_dir:
76
+ Optional directory that seeds `.env` discovery.
77
+ default_file:
78
+ Optional lowest-precedence file injected before filesystem layers.
79
+
80
+ Returns
81
+ -------
82
+ Config
83
+ Immutable configuration with provenance metadata.
84
+
85
+ Examples
86
+ --------
87
+ >>> from pathlib import Path
88
+ >>> tmp = Path('.') # doctest: +SKIP (illustrative)
89
+ >>> config = read_config(vendor="Acme", app="Demo", slug="demo", start_dir=str(tmp)) # doctest: +SKIP
90
+ >>> isinstance(config, Config)
91
+ True
92
+ """
93
+
94
+ data, raw_meta = read_config_raw(
95
+ vendor=vendor,
96
+ app=app,
97
+ slug=slug,
98
+ prefer=prefer,
99
+ start_dir=start_dir,
100
+ default_file=_stringify_path(default_file),
101
+ )
102
+ return _compose_config(data, raw_meta)
103
+
104
+
105
+ def read_config_json(
106
+ *,
107
+ vendor: str,
108
+ app: str,
109
+ slug: str,
110
+ prefer: Sequence[str] | None = None,
111
+ start_dir: str | Path | None = None,
112
+ indent: int | None = None,
113
+ default_file: str | Path | None = None,
114
+ ) -> str:
115
+ """Return configuration and provenance as JSON suitable for tooling.
116
+
117
+ Why
118
+ ----
119
+ CLI commands and automation scripts often prefer JSON to Python objects.
120
+
121
+ Parameters
122
+ ----------
123
+ vendor / app / slug / prefer / start_dir / default_file:
124
+ Same meaning as :func:`read_config`.
125
+ indent:
126
+ Optional indentation level passed to ``json.dumps``.
127
+
128
+ Returns
129
+ -------
130
+ str
131
+ JSON document containing ``{"config": ..., "provenance": ...}``.
132
+ """
133
+
134
+ data, meta = read_config_raw(
135
+ vendor=vendor,
136
+ app=app,
137
+ slug=slug,
138
+ prefer=prefer,
139
+ start_dir=_stringify_path(start_dir),
140
+ default_file=_stringify_path(default_file),
141
+ )
142
+ return _dump_json({"config": data, "provenance": meta}, indent)
143
+
144
+
145
+ def read_config_raw(
146
+ *,
147
+ vendor: str,
148
+ app: str,
149
+ slug: str,
150
+ prefer: Sequence[str] | None = None,
151
+ start_dir: str | None = None,
152
+ default_file: str | Path | None = None,
153
+ ) -> tuple[dict[str, object], dict[str, SourceInfoPayload]]:
154
+ """Return raw data and provenance mappings for advanced tooling.
155
+
156
+ Why
157
+ ----
158
+ Some consumers need dictionaries they can mutate or serialise differently
159
+ without enforcing the :class:`Config` abstraction, while reusing the same
160
+ precedence pipeline and provenance metadata as the public API.
161
+
162
+ Parameters
163
+ ----------
164
+ vendor / app / slug:
165
+ Identifiers passed to the path resolver to compute search roots and
166
+ prefixes.
167
+ prefer:
168
+ Optional ordered sequence of preferred file suffixes (lower precedence
169
+ when omitted).
170
+ start_dir:
171
+ Optional directory seeding the upward `.env` search. ``None`` keeps the
172
+ resolver default.
173
+ default_file:
174
+ Optional path injected as the lowest-precedence layer. Accepts either
175
+ :class:`pathlib.Path` or string values.
176
+
177
+ Returns
178
+ -------
179
+ tuple[dict[str, object], dict[str, SourceInfoPayload]]
180
+ Pair of mutable dictionaries mirroring the merge results prior to
181
+ construction of the domain value object.
182
+
183
+ Side Effects
184
+ ------------
185
+ Resets the active trace identifier and emits structured logging events via
186
+ the layer collection helpers.
187
+
188
+ Raises
189
+ ------
190
+ LayerLoadError
191
+ When a structured file loader raises :class:`InvalidFormat`.
192
+ """
193
+
194
+ resolver = _build_resolver(vendor=vendor, app=app, slug=slug, start_dir=start_dir)
195
+ dotenv_loader, env_loader = _build_loaders(resolver)
196
+
197
+ bind_trace_id(None)
198
+
199
+ try:
200
+ layers = collect_layers(
201
+ resolver=resolver,
202
+ prefer=prefer,
203
+ default_file=_stringify_path(default_file),
204
+ dotenv_loader=dotenv_loader,
205
+ env_loader=env_loader,
206
+ slug=slug,
207
+ start_dir=start_dir,
208
+ )
209
+ except InvalidFormat as exc: # pragma: no cover - adapter tests exercise
210
+ raise LayerLoadError(str(exc)) from exc
211
+
212
+ return merge_or_empty(layers)
213
+
214
+
215
+ def _compose_config(
216
+ data: dict[str, object],
217
+ raw_meta: dict[str, SourceInfoPayload],
218
+ ) -> Config:
219
+ """Wrap merged data and provenance into an immutable :class:`Config`.
220
+
221
+ Why
222
+ ----
223
+ Keep the boundary between application-layer dictionaries and the domain
224
+ value object explicit so provenance typing stays consistent.
225
+
226
+ Parameters
227
+ ----------
228
+ data:
229
+ Mutable mapping returned by :func:`merge_layers`.
230
+ raw_meta:
231
+ Provenance mapping keyed by dotted path as produced by the merge policy.
232
+
233
+ Returns
234
+ -------
235
+ Config
236
+ Immutable configuration aggregate. Returns :data:`EMPTY_CONFIG` when
237
+ *data* is empty.
238
+
239
+ Side Effects
240
+ ------------
241
+ None beyond constructing the dataclass instance.
242
+
243
+ Examples
244
+ --------
245
+ >>> cfg = _compose_config({'debug': True}, {'debug': {'layer': 'env', 'path': None, 'key': 'debug'}})
246
+ >>> cfg['debug'], cfg.origin('debug')['layer']
247
+ (True, 'env')
248
+ """
249
+
250
+ if not data:
251
+ return EMPTY_CONFIG
252
+ meta = {key: cast(SourceInfo, details) for key, details in raw_meta.items()}
253
+ return Config(data, meta)
254
+
255
+
256
+ def _build_resolver(
257
+ *,
258
+ vendor: str,
259
+ app: str,
260
+ slug: str,
261
+ start_dir: str | None,
262
+ ) -> DefaultPathResolver:
263
+ """Create a path resolver configured with optional ``start_dir`` context.
264
+
265
+ Why
266
+ ----
267
+ Reuse the same resolver wiring for CLI and library entry points while
268
+ keeping construction logic centralised for testing.
269
+
270
+ Parameters
271
+ ----------
272
+ vendor / app / slug:
273
+ Identifiers forwarded to :class:`DefaultPathResolver`.
274
+ start_dir:
275
+ Optional directory that seeds project-relative resolution (used for
276
+ `.env` discovery); ``None`` preserves resolver defaults.
277
+
278
+ Returns
279
+ -------
280
+ DefaultPathResolver
281
+ Resolver instance ready for layer discovery.
282
+
283
+ Examples
284
+ --------
285
+ >>> resolver = _build_resolver(vendor='Acme', app='Demo', slug='demo', start_dir=None)
286
+ >>> resolver.slug
287
+ 'demo'
288
+ """
289
+
290
+ return DefaultPathResolver(vendor=vendor, app=app, slug=slug, cwd=Path(start_dir) if start_dir else None)
291
+
292
+
293
+ def _build_loaders(resolver: DefaultPathResolver) -> tuple[DefaultDotEnvLoader, DefaultEnvLoader]:
294
+ """Instantiate dotenv and environment loaders sharing resolver context.
295
+
296
+ Why
297
+ ----
298
+ Keeps loader construction aligned with the resolver extras (e.g., additional
299
+ dotenv directories) and centralises wiring for tests.
300
+
301
+ Parameters
302
+ ----------
303
+ resolver:
304
+ Resolver supplying platform-specific extras for dotenv discovery.
305
+
306
+ Returns
307
+ -------
308
+ tuple[DefaultDotEnvLoader, DefaultEnvLoader]
309
+ Pair of loader instances ready for layer collection.
310
+ """
311
+
312
+ return DefaultDotEnvLoader(extras=resolver.dotenv()), DefaultEnvLoader()
313
+
314
+
315
+ def _stringify_path(value: str | Path | None) -> str | None:
316
+ """Convert ``Path`` or string inputs into plain string values for adapters.
317
+
318
+ Why
319
+ ----
320
+ Adapters expect plain strings while public APIs accept :class:`Path` objects
321
+ for user convenience. Centralising the conversion avoids duplicate logic.
322
+
323
+ Parameters
324
+ ----------
325
+ value:
326
+ Optional path expressed as either a string or :class:`pathlib.Path`.
327
+
328
+ Returns
329
+ -------
330
+ str | None
331
+ Stringified path or ``None`` when *value* is ``None``.
332
+
333
+ Examples
334
+ --------
335
+ >>> _stringify_path(Path('/tmp/config.toml'))
336
+ '/tmp/config.toml'
337
+ >>> _stringify_path(None) is None
338
+ True
339
+ """
340
+
341
+ if isinstance(value, Path):
342
+ return str(value)
343
+ return value
344
+
345
+
346
+ def _dump_json(payload: object, indent: int | None) -> str:
347
+ """Serialise *payload* to JSON while preserving non-ASCII characters.
348
+
349
+ Parameters
350
+ ----------
351
+ payload:
352
+ JSON-serialisable object to dump.
353
+ indent:
354
+ Optional indentation level mirroring :func:`json.dumps`. ``None`` produces
355
+ the most compact output.
356
+
357
+ Returns
358
+ -------
359
+ str
360
+ JSON document encoded as UTF-8 friendly text.
361
+
362
+ Examples
363
+ --------
364
+ >>> _dump_json({"a": 1}, indent=None)
365
+ '{"a":1}'
366
+ >>> "\n" in _dump_json({"a": 1}, indent=2)
367
+ True
368
+ """
369
+
370
+ return json.dumps(payload, indent=indent, separators=(",", ":"), ensure_ascii=False)
371
+
372
+
373
+ __all__ = [
374
+ "Config",
375
+ "ConfigError",
376
+ "InvalidFormat",
377
+ "ValidationError",
378
+ "NotFound",
379
+ "LayerLoadError",
380
+ "read_config",
381
+ "read_config_json",
382
+ "read_config_raw",
383
+ "default_env_prefix",
384
+ ]
@@ -0,0 +1,7 @@
1
+ """Domain entities for ``lib_layered_config`` (value objects + errors).
2
+
3
+ Purpose
4
+ -------
5
+ Federate immutable value objects and the shared error hierarchy so outer layers
6
+ can depend on them without pulling in adapters.
7
+ """