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.
- lib_layered_config/__init__.py +58 -0
- lib_layered_config/__init__conf__.py +74 -0
- lib_layered_config/__main__.py +18 -0
- lib_layered_config/_layers.py +310 -0
- lib_layered_config/_platform.py +166 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/_nested_keys.py +126 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +143 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +288 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +376 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
- lib_layered_config/adapters/path_resolvers/_base.py +166 -0
- lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
- lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
- lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
- lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
- lib_layered_config/adapters/path_resolvers/default.py +194 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +379 -0
- lib_layered_config/application/ports.py +115 -0
- lib_layered_config/cli/__init__.py +92 -0
- lib_layered_config/cli/common.py +381 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +71 -0
- lib_layered_config/cli/fail.py +19 -0
- lib_layered_config/cli/generate.py +57 -0
- lib_layered_config/cli/info.py +29 -0
- lib_layered_config/cli/read.py +120 -0
- lib_layered_config/core.py +301 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +372 -0
- lib_layered_config/domain/errors.py +59 -0
- lib_layered_config/domain/identifiers.py +366 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +333 -0
- lib_layered_config/examples/generate.py +406 -0
- lib_layered_config/observability.py +209 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +46 -0
- lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
- lib_layered_config-4.1.0.dist-info/RECORD +47 -0
- lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
- lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-4.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shared helpers for assigning nested keys using ``__`` delimiters.
|
|
2
|
+
|
|
3
|
+
Both dotenv and environment adapters need identical logic for converting
|
|
4
|
+
``SERVICE__TIMEOUT`` style keys into nested ``{"service": {"timeout": ...}}``
|
|
5
|
+
structures. This module centralises that logic to eliminate duplication.
|
|
6
|
+
|
|
7
|
+
Contents:
|
|
8
|
+
- ``assign_nested``: public function to assign a value at a nested key path.
|
|
9
|
+
- ``resolve_key``: case-insensitive key resolution.
|
|
10
|
+
- ``ensure_child_mapping``: ensure intermediate mappings exist.
|
|
11
|
+
- ``NESTED_KEY_DELIMITER``: constant for the ``__`` separator.
|
|
12
|
+
|
|
13
|
+
Used by ``adapters.dotenv.default`` and ``adapters.env.default`` to share
|
|
14
|
+
the nested key assignment algorithm.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Final, cast
|
|
20
|
+
|
|
21
|
+
NESTED_KEY_DELIMITER: Final[str] = "__"
|
|
22
|
+
"""Delimiter used to separate nested key segments in environment variables.
|
|
23
|
+
|
|
24
|
+
Environment variables like ``SERVICE__TIMEOUT`` are split on this delimiter
|
|
25
|
+
to create nested structures like ``{"service": {"timeout": ...}}``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def assign_nested(
|
|
30
|
+
target: dict[str, object],
|
|
31
|
+
key: str,
|
|
32
|
+
value: object,
|
|
33
|
+
*,
|
|
34
|
+
error_cls: type[Exception],
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Assign ``value`` in ``target`` using case-insensitive ``__`` delimited syntax.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
target: Mapping being mutated.
|
|
40
|
+
key: Key using ``__`` separators (e.g., ``SERVICE__TIMEOUT``).
|
|
41
|
+
value: Value to assign at the nested location.
|
|
42
|
+
error_cls: Exception type raised on scalar collisions.
|
|
43
|
+
|
|
44
|
+
Side Effects:
|
|
45
|
+
Mutates ``target`` in place.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> data: dict[str, object] = {}
|
|
49
|
+
>>> assign_nested(data, 'SERVICE__TOKEN', 'secret', error_cls=ValueError)
|
|
50
|
+
>>> data
|
|
51
|
+
{'service': {'token': 'secret'}}
|
|
52
|
+
"""
|
|
53
|
+
parts = key.split(NESTED_KEY_DELIMITER)
|
|
54
|
+
cursor = target
|
|
55
|
+
for part in parts[:-1]:
|
|
56
|
+
cursor = ensure_child_mapping(cursor, part, error_cls=error_cls)
|
|
57
|
+
final_key = resolve_key(cursor, parts[-1])
|
|
58
|
+
cursor[final_key] = value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_key(mapping: dict[str, object], key: str) -> str:
|
|
62
|
+
"""Return an existing key matching ``key`` case-insensitively, or a new lowercase key.
|
|
63
|
+
|
|
64
|
+
Preserve case stability while avoiding duplicates that differ only by case.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
mapping: Mutable mapping being inspected.
|
|
68
|
+
key: Incoming key to resolve.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Existing key name or newly normalised lowercase variant.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
>>> resolve_key({'timeout': 5}, 'TIMEOUT')
|
|
75
|
+
'timeout'
|
|
76
|
+
>>> resolve_key({}, 'Endpoint')
|
|
77
|
+
'endpoint'
|
|
78
|
+
"""
|
|
79
|
+
lower = key.lower()
|
|
80
|
+
for existing in mapping:
|
|
81
|
+
if existing.lower() == lower:
|
|
82
|
+
return existing
|
|
83
|
+
return lower
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ensure_child_mapping(
|
|
87
|
+
mapping: dict[str, object],
|
|
88
|
+
key: str,
|
|
89
|
+
*,
|
|
90
|
+
error_cls: type[Exception],
|
|
91
|
+
) -> dict[str, object]:
|
|
92
|
+
"""Ensure ``mapping[key]`` is a ``dict``, creating or validating as necessary.
|
|
93
|
+
|
|
94
|
+
Prevent accidental overwrites of scalar values when nested keys are introduced.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
mapping: Mutable mapping being mutated.
|
|
98
|
+
key: Candidate key to ensure.
|
|
99
|
+
error_cls: Exception type raised when a scalar collision occurs.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Child mapping stored at the resolved key.
|
|
103
|
+
|
|
104
|
+
Side Effects:
|
|
105
|
+
Mutates ``mapping`` by inserting a new child mapping when missing.
|
|
106
|
+
|
|
107
|
+
Examples:
|
|
108
|
+
>>> target: dict[str, object] = {}
|
|
109
|
+
>>> child = ensure_child_mapping(target, 'SERVICE', error_cls=ValueError)
|
|
110
|
+
>>> child == {}
|
|
111
|
+
True
|
|
112
|
+
>>> target
|
|
113
|
+
{'service': {}}
|
|
114
|
+
"""
|
|
115
|
+
resolved = resolve_key(mapping, key)
|
|
116
|
+
if resolved not in mapping:
|
|
117
|
+
mapping[resolved] = dict[str, object]()
|
|
118
|
+
child = mapping[resolved]
|
|
119
|
+
if not isinstance(child, dict):
|
|
120
|
+
raise error_cls(f"Cannot override scalar with mapping for key {key}")
|
|
121
|
+
typed_child = cast(dict[str, object], child)
|
|
122
|
+
mapping[resolved] = typed_child
|
|
123
|
+
return typed_child
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["assign_nested", "resolve_key", "ensure_child_mapping"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Dotenv adapter implementations."""
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""`.env` adapter.
|
|
2
|
+
|
|
3
|
+
Implement the :class:`lib_layered_config.application.ports.DotEnvLoader`
|
|
4
|
+
protocol by scanning for `.env` files using the search discipline captured in
|
|
5
|
+
``docs/systemdesign/module_reference.md``.
|
|
6
|
+
|
|
7
|
+
Contents:
|
|
8
|
+
- ``DefaultDotEnvLoader``: public loader that composes the helpers.
|
|
9
|
+
- ``_iter_candidates`` / ``_build_search_list``: gather candidate paths.
|
|
10
|
+
- ``_parse_dotenv``: strict parser converting dotenv files into nested dicts.
|
|
11
|
+
- ``_log_dotenv_*``: logging helpers that narrate discovery and parsing outcomes.
|
|
12
|
+
- Constants for parsing quote characters and delimiters.
|
|
13
|
+
|
|
14
|
+
Feeds `.env` key/value pairs into the merge pipeline using the same nesting
|
|
15
|
+
semantics as the environment adapter.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Mapping
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from ...domain.errors import InvalidFormatError
|
|
25
|
+
from ...observability import log_debug, log_error
|
|
26
|
+
from .._nested_keys import assign_nested
|
|
27
|
+
|
|
28
|
+
# Constants for dotenv parsing
|
|
29
|
+
_QUOTE_CHARS: Final[frozenset[str]] = frozenset({'"', "'"})
|
|
30
|
+
_COMMENT_CHAR: Final[str] = "#"
|
|
31
|
+
_INLINE_COMMENT_DELIMITER: Final[str] = " #"
|
|
32
|
+
_KEY_VALUE_DELIMITER: Final[str] = "="
|
|
33
|
+
|
|
34
|
+
DOTENV_LAYER = "dotenv"
|
|
35
|
+
"""Layer name for structured logging calls."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _log_dotenv_loaded(path: Path, keys: Mapping[str, object]) -> None:
|
|
39
|
+
"""Log which dotenv file was loaded and its key names."""
|
|
40
|
+
log_debug("dotenv_loaded", layer=DOTENV_LAYER, path=str(path), keys=sorted(keys.keys()))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _log_dotenv_missing() -> None:
|
|
44
|
+
"""Log that no dotenv file was found in the search path."""
|
|
45
|
+
log_debug("dotenv_not_found", layer=DOTENV_LAYER, path=None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _log_dotenv_error(path: Path, line_number: int) -> None:
|
|
49
|
+
"""Log a malformed line error with file path and line number."""
|
|
50
|
+
log_error("dotenv_invalid_line", layer=DOTENV_LAYER, path=str(path), line=line_number)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DefaultDotEnvLoader:
|
|
54
|
+
"""Load a dotenv file into a nested configuration dictionary.
|
|
55
|
+
|
|
56
|
+
Searches candidate paths, parses the first existing file, and tracks
|
|
57
|
+
the loaded path for provenance.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, *, extras: Iterable[str] | None = None) -> None:
|
|
61
|
+
"""Initialise with optional extra search paths from the path resolver."""
|
|
62
|
+
self._extras = [Path(p) for p in extras or []]
|
|
63
|
+
self.last_loaded_path: str | None = None
|
|
64
|
+
|
|
65
|
+
def load(self, start_dir: str | None = None) -> Mapping[str, object]:
|
|
66
|
+
"""Return the first parsed dotenv file discovered in the search order.
|
|
67
|
+
|
|
68
|
+
Searches from *start_dir* upward plus any extras, parses the first
|
|
69
|
+
existing file into a nested mapping, and sets :attr:`last_loaded_path`.
|
|
70
|
+
"""
|
|
71
|
+
candidates = _build_search_list(start_dir, self._extras)
|
|
72
|
+
self.last_loaded_path = None
|
|
73
|
+
for candidate in candidates:
|
|
74
|
+
if not candidate.is_file():
|
|
75
|
+
continue
|
|
76
|
+
self.last_loaded_path = str(candidate)
|
|
77
|
+
data = _parse_dotenv(candidate)
|
|
78
|
+
_log_dotenv_loaded(candidate, data)
|
|
79
|
+
return data
|
|
80
|
+
_log_dotenv_missing()
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_search_list(start_dir: str | None, extras: Iterable[Path]) -> list[Path]:
|
|
85
|
+
"""Combine upward-search candidates with platform-specific extras."""
|
|
86
|
+
return [*list(_iter_candidates(start_dir)), *extras]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _iter_candidates(start_dir: str | None) -> Iterable[Path]:
|
|
90
|
+
"""Yield `.env` paths from start_dir upward to filesystem root."""
|
|
91
|
+
base = Path(start_dir) if start_dir else Path.cwd()
|
|
92
|
+
for directory in [base, *base.parents]:
|
|
93
|
+
yield directory / ".env"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_dotenv(path: Path) -> Mapping[str, object]:
|
|
97
|
+
"""Parse dotenv file into nested dict. Raises InvalidFormatError on malformed lines."""
|
|
98
|
+
result: dict[str, object] = {}
|
|
99
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
100
|
+
for line_number, raw_line in enumerate(handle, start=1):
|
|
101
|
+
_process_line(result, raw_line, line_number, path)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_ignorable(line: str) -> bool:
|
|
106
|
+
"""Return True if line is empty or a comment."""
|
|
107
|
+
return not line or line.startswith(_COMMENT_CHAR)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _process_line(
|
|
111
|
+
result: dict[str, object],
|
|
112
|
+
raw_line: str,
|
|
113
|
+
line_number: int,
|
|
114
|
+
path: Path,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Process a single dotenv line, updating result in place."""
|
|
117
|
+
line = raw_line.strip()
|
|
118
|
+
if _is_ignorable(line):
|
|
119
|
+
return
|
|
120
|
+
if _KEY_VALUE_DELIMITER not in line:
|
|
121
|
+
_log_dotenv_error(path, line_number)
|
|
122
|
+
raise InvalidFormatError(f"Malformed line {line_number} in {path}")
|
|
123
|
+
key, value = line.split(_KEY_VALUE_DELIMITER, 1)
|
|
124
|
+
assign_nested(result, key.strip(), _strip_quotes(value.strip()), error_cls=InvalidFormatError)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _is_quoted(value: str) -> bool:
|
|
128
|
+
"""Check if value is wrapped in matching quotes."""
|
|
129
|
+
return len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTE_CHARS
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _strip_inline_comment(value: str) -> str:
|
|
133
|
+
"""Remove trailing inline comment from value."""
|
|
134
|
+
return value.split(_INLINE_COMMENT_DELIMITER, 1)[0].strip() if _INLINE_COMMENT_DELIMITER in value else value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _strip_quotes(value: str) -> str:
|
|
138
|
+
"""Remove surrounding quotes and trailing inline comments from value."""
|
|
139
|
+
if _is_quoted(value):
|
|
140
|
+
return value[1:-1]
|
|
141
|
+
if value.startswith(_COMMENT_CHAR):
|
|
142
|
+
return ""
|
|
143
|
+
return _strip_inline_comment(value)
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Environment variable adapter.
|
|
2
|
+
|
|
3
|
+
Translate process environment variables into nested configuration dictionaries.
|
|
4
|
+
It implements the port described in ``docs/systemdesign/module_reference.md``
|
|
5
|
+
and forms the final precedence layer in ``lib_layered_config``.
|
|
6
|
+
|
|
7
|
+
Contents:
|
|
8
|
+
- ``default_env_prefix``: canonical prefix builder for a slug.
|
|
9
|
+
- ``DefaultEnvLoader``: orchestrates filtering, coercion, and nesting.
|
|
10
|
+
- ``_coerce`` plus tiny predicate helpers that translate strings into
|
|
11
|
+
Python primitives.
|
|
12
|
+
- ``_normalize_prefix`` / ``_iter_namespace_entries`` / ``_collect_keys``:
|
|
13
|
+
small verbs that keep the loader body declarative.
|
|
14
|
+
- Constants for boolean and null literal detection.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Iterable, Iterator
|
|
21
|
+
from typing import Final
|
|
22
|
+
|
|
23
|
+
from ...observability import log_debug
|
|
24
|
+
from .._nested_keys import assign_nested
|
|
25
|
+
|
|
26
|
+
# Constants for environment variable value coercion
|
|
27
|
+
_BOOL_TRUE: Final[str] = "true"
|
|
28
|
+
_BOOL_FALSE: Final[str] = "false"
|
|
29
|
+
_BOOL_LITERALS: Final[frozenset[str]] = frozenset({_BOOL_TRUE, _BOOL_FALSE})
|
|
30
|
+
_NULL_LITERALS: Final[frozenset[str]] = frozenset({"null", "none"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_env_prefix(slug: str) -> str:
|
|
34
|
+
"""Return the canonical environment prefix for *slug*.
|
|
35
|
+
|
|
36
|
+
Namespacing prevents unrelated environment variables from leaking into the
|
|
37
|
+
configuration payload. The triple underscore (``___``) separator clearly
|
|
38
|
+
distinguishes the application prefix from section/key separators which use
|
|
39
|
+
double underscores (``__``).
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
slug: Package/application slug (typically ``kebab-case``).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Upper-case prefix with dashes converted to underscores, ending with
|
|
46
|
+
triple underscore separator.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
>>> default_env_prefix('lib-layered-config')
|
|
50
|
+
'LIB_LAYERED_CONFIG___'
|
|
51
|
+
"""
|
|
52
|
+
return slug.replace("-", "_").upper() + "___"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DefaultEnvLoader:
|
|
56
|
+
"""Load environment variables that belong to the configuration namespace.
|
|
57
|
+
|
|
58
|
+
Implements the :class:`lib_layered_config.application.ports.EnvLoader` port,
|
|
59
|
+
translating process environment variables into merge-ready payloads.
|
|
60
|
+
|
|
61
|
+
Filters environment entries by prefix, nests values using ``__`` separators,
|
|
62
|
+
performs primitive coercion, and emits observability events.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, *, environ: dict[str, str] | None = None) -> None:
|
|
66
|
+
"""Initialise the loader with a specific ``environ`` mapping for testability.
|
|
67
|
+
|
|
68
|
+
Allow tests and callers to supply deterministic environments.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
environ: Mapping to read from. Defaults to :data:`os.environ`.
|
|
72
|
+
"""
|
|
73
|
+
self._environ = os.environ if environ is None else environ
|
|
74
|
+
|
|
75
|
+
def load(self, prefix: str) -> dict[str, object]:
|
|
76
|
+
"""Return a nested mapping containing variables with the supplied *prefix*.
|
|
77
|
+
|
|
78
|
+
Environment variables should integrate with the merge pipeline using the
|
|
79
|
+
same nesting semantics as `.env` files.
|
|
80
|
+
|
|
81
|
+
Normalises the prefix, filters matching entries, coerces values, nests
|
|
82
|
+
keys via :func:`assign_nested`, and logs the summarised result.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
prefix: Prefix filter (upper-case). The loader appends ``___`` if missing.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Nested mapping suitable for the merge algorithm. Keys are stored in
|
|
89
|
+
lowercase to align with file-based layers.
|
|
90
|
+
|
|
91
|
+
Side Effects:
|
|
92
|
+
Emits ``env_variables_loaded`` debug events with summarised keys.
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
>>> env = {
|
|
96
|
+
... 'DEMO___SERVICE__ENABLED': 'true',
|
|
97
|
+
... 'DEMO___SERVICE__RETRIES': '3',
|
|
98
|
+
... }
|
|
99
|
+
>>> loader = DefaultEnvLoader(environ=env)
|
|
100
|
+
>>> payload = loader.load('DEMO')
|
|
101
|
+
>>> payload['service']['retries']
|
|
102
|
+
3
|
|
103
|
+
>>> payload['service']['enabled']
|
|
104
|
+
True
|
|
105
|
+
"""
|
|
106
|
+
normalized_prefix = _normalize_prefix(prefix)
|
|
107
|
+
collected: dict[str, object] = {}
|
|
108
|
+
for raw_key, value in _iter_namespace_entries(self._environ.items(), normalized_prefix):
|
|
109
|
+
assign_nested(collected, raw_key, _coerce(value), error_cls=ValueError)
|
|
110
|
+
log_debug("env_variables_loaded", layer="env", path=None, keys=_collect_keys(collected))
|
|
111
|
+
return collected
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _normalize_prefix(prefix: str) -> str:
|
|
115
|
+
"""Ensure the prefix ends with triple underscore when non-empty.
|
|
116
|
+
|
|
117
|
+
Aligns environment variable filtering semantics regardless of user input.
|
|
118
|
+
The triple underscore (``___``) separator clearly distinguishes the
|
|
119
|
+
application prefix from section/key separators (``__``).
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
prefix: Raw prefix string (upper-case expected but not enforced).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Prefix guaranteed to end with ``___`` when non-empty.
|
|
126
|
+
|
|
127
|
+
Examples:
|
|
128
|
+
>>> _normalize_prefix('DEMO')
|
|
129
|
+
'DEMO___'
|
|
130
|
+
>>> _normalize_prefix('DEMO___')
|
|
131
|
+
'DEMO___'
|
|
132
|
+
>>> _normalize_prefix('')
|
|
133
|
+
''
|
|
134
|
+
"""
|
|
135
|
+
if prefix and not prefix.endswith("___"):
|
|
136
|
+
return f"{prefix}___"
|
|
137
|
+
return prefix
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _iter_namespace_entries(
|
|
141
|
+
items: Iterable[tuple[str, str]],
|
|
142
|
+
prefix: str,
|
|
143
|
+
) -> Iterator[tuple[str, str]]:
|
|
144
|
+
"""Yield ``(stripped_key, value)`` pairs that match *prefix*.
|
|
145
|
+
|
|
146
|
+
Encapsulate prefix filtering so caller code stays declarative.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
items: Iterable of environment items to examine.
|
|
150
|
+
prefix: Normalised prefix (including trailing triple underscore) to filter on.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Pairs whose keys share the prefix with the prefix removed.
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
>>> list(_iter_namespace_entries([('DEMO___FLAG', '1'), ('OTHER', '0')], 'DEMO___'))
|
|
157
|
+
[('FLAG', '1')]
|
|
158
|
+
>>> list(_iter_namespace_entries([('DEMO', '1')], 'DEMO___'))
|
|
159
|
+
[]
|
|
160
|
+
"""
|
|
161
|
+
for key, value in items:
|
|
162
|
+
if prefix and not key.startswith(prefix):
|
|
163
|
+
continue
|
|
164
|
+
stripped = key[len(prefix) :] if prefix else key
|
|
165
|
+
if not stripped:
|
|
166
|
+
continue
|
|
167
|
+
yield stripped, value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _collect_keys(mapping: dict[str, object]) -> list[str]:
|
|
171
|
+
"""Return sorted top-level keys for logging.
|
|
172
|
+
|
|
173
|
+
Provide compact telemetry context without dumping entire payloads.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
mapping: Nested mapping produced by environment parsing.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Sorted list of top-level keys.
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> _collect_keys({'service': {}, 'logging': {}})
|
|
183
|
+
['logging', 'service']
|
|
184
|
+
"""
|
|
185
|
+
return sorted(mapping.keys())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _coerce(value: str) -> object:
|
|
189
|
+
"""Coerce textual environment values to Python primitives where possible.
|
|
190
|
+
|
|
191
|
+
Convert human-friendly strings (``true``, ``5``, ``3.14``) into their Python
|
|
192
|
+
equivalents before merging.
|
|
193
|
+
|
|
194
|
+
Applies boolean, null, integer, and float heuristics in sequence, returning
|
|
195
|
+
the original string when none match.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Parsed primitive or original string when coercion is not possible.
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
>>> _coerce('true'), _coerce('10'), _coerce('3.5'), _coerce('hello'), _coerce('null')
|
|
202
|
+
(True, 10, 3.5, 'hello', None)
|
|
203
|
+
"""
|
|
204
|
+
lowered = value.lower()
|
|
205
|
+
if _looks_like_bool(lowered):
|
|
206
|
+
return lowered == _BOOL_TRUE
|
|
207
|
+
if _looks_like_null(lowered):
|
|
208
|
+
return None
|
|
209
|
+
if _looks_like_int(value):
|
|
210
|
+
return int(value)
|
|
211
|
+
return _maybe_float(value)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _looks_like_bool(value: str) -> bool:
|
|
215
|
+
"""Return ``True`` when *value* spells a boolean literal.
|
|
216
|
+
|
|
217
|
+
Support `_coerce` in recognising booleans without repeated literal sets.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
value: Lower-cased string to inspect.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
``True`` when the value is ``"true"`` or ``"false"``.
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
>>> _looks_like_bool('true'), _looks_like_bool('false'), _looks_like_bool('maybe')
|
|
227
|
+
(True, True, False)
|
|
228
|
+
"""
|
|
229
|
+
return value in _BOOL_LITERALS
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _looks_like_null(value: str) -> bool:
|
|
233
|
+
"""Return ``True`` when *value* represents a null literal.
|
|
234
|
+
|
|
235
|
+
Allow `_coerce` to map textual null representations to ``None``.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
value: Lower-cased string to inspect.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
``True`` when the value is ``"null"`` or ``"none"``.
|
|
242
|
+
|
|
243
|
+
Examples:
|
|
244
|
+
>>> _looks_like_null('null'), _looks_like_null('none'), _looks_like_null('nil')
|
|
245
|
+
(True, True, False)
|
|
246
|
+
"""
|
|
247
|
+
return value in _NULL_LITERALS
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _looks_like_int(value: str) -> bool:
|
|
251
|
+
"""Return ``True`` when *value* can be parsed as an integer.
|
|
252
|
+
|
|
253
|
+
Let `_coerce` distinguish integers before attempting float conversion.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
value: String to inspect (not yet normalised).
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
``True`` when the value represents a base-10 integer literal.
|
|
260
|
+
|
|
261
|
+
Examples:
|
|
262
|
+
>>> _looks_like_int('42'), _looks_like_int('-7'), _looks_like_int('3.14')
|
|
263
|
+
(True, True, False)
|
|
264
|
+
"""
|
|
265
|
+
if value.startswith("-"):
|
|
266
|
+
return value[1:].isdigit()
|
|
267
|
+
return value.isdigit()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _maybe_float(value: str) -> object:
|
|
271
|
+
"""Return a float when *value* looks numeric; otherwise return the original string.
|
|
272
|
+
|
|
273
|
+
Provide a final numeric coercion step after integer detection fails.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
value: String candidate for float conversion.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Float value or the original string when conversion fails.
|
|
280
|
+
|
|
281
|
+
Examples:
|
|
282
|
+
>>> _maybe_float('2.5'), _maybe_float('not-a-number')
|
|
283
|
+
(2.5, 'not-a-number')
|
|
284
|
+
"""
|
|
285
|
+
try:
|
|
286
|
+
return float(value)
|
|
287
|
+
except ValueError:
|
|
288
|
+
return value
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Structured configuration file loaders."""
|