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.
- lib_layered_config/__init__.py +60 -0
- lib_layered_config/__main__.py +19 -0
- lib_layered_config/_layers.py +457 -0
- lib_layered_config/_platform.py +200 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +438 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +509 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +410 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
- lib_layered_config/adapters/path_resolvers/default.py +727 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +442 -0
- lib_layered_config/application/ports.py +109 -0
- lib_layered_config/cli/__init__.py +162 -0
- lib_layered_config/cli/common.py +232 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +70 -0
- lib_layered_config/cli/fail.py +21 -0
- lib_layered_config/cli/generate.py +60 -0
- lib_layered_config/cli/info.py +31 -0
- lib_layered_config/cli/read.py +117 -0
- lib_layered_config/core.py +384 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +490 -0
- lib_layered_config/domain/errors.py +65 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +305 -0
- lib_layered_config/examples/generate.py +537 -0
- lib_layered_config/observability.py +306 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +55 -0
- lib_layered_config-1.0.0.dist-info/METADATA +366 -0
- lib_layered_config-1.0.0.dist-info/RECORD +39 -0
- lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
- lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-1.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Shared helpers for normalising user-provided platform aliases.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Bridge CLI/example inputs with resolver internals by translating human-friendly
|
|
6
|
+
platform strings into the canonical identifiers expected across adapters and
|
|
7
|
+
documentation.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
--------
|
|
11
|
+
- ``normalise_resolver_platform``: map CLI adapter aliases to ``sys.platform``
|
|
12
|
+
style identifiers.
|
|
13
|
+
- ``normalise_examples_platform``: map example-generation aliases to the two
|
|
14
|
+
supported documentation families.
|
|
15
|
+
- ``_sanitize`` plus canonical mapping constants that keep user inputs tidy and
|
|
16
|
+
predictable.
|
|
17
|
+
|
|
18
|
+
System Role
|
|
19
|
+
-----------
|
|
20
|
+
Reusable utilities consumed by CLI commands and example tooling to ensure
|
|
21
|
+
terminology matches ``docs/systemdesign/concept.md`` regardless of user input
|
|
22
|
+
quirks.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Final
|
|
28
|
+
|
|
29
|
+
#: Canonical resolver identifiers used when wiring the path resolver adapter.
|
|
30
|
+
#: Values mirror ``sys.platform`` strings so downstream code can branch safely.
|
|
31
|
+
_CANONICAL_RESOLVER: Final[dict[str, str]] = {
|
|
32
|
+
"linux": "linux",
|
|
33
|
+
"posix": "linux",
|
|
34
|
+
"darwin": "darwin",
|
|
35
|
+
"mac": "darwin",
|
|
36
|
+
"macos": "darwin",
|
|
37
|
+
"windows": "win32",
|
|
38
|
+
"win": "win32",
|
|
39
|
+
"win32": "win32",
|
|
40
|
+
"wine": "win32",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#: Canonical families used by documentation/example helpers. They collapse the
|
|
44
|
+
#: wide variety of aliases into the two supported directory layouts.
|
|
45
|
+
_CANONICAL_EXAMPLES: Final[dict[str, str]] = {
|
|
46
|
+
"posix": "posix",
|
|
47
|
+
"linux": "posix",
|
|
48
|
+
"darwin": "posix",
|
|
49
|
+
"mac": "posix",
|
|
50
|
+
"macos": "posix",
|
|
51
|
+
"windows": "windows",
|
|
52
|
+
"win": "windows",
|
|
53
|
+
"win32": "windows",
|
|
54
|
+
"wine": "windows",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _sanitize(alias: str | None) -> str | None:
|
|
59
|
+
"""Return a lower-cased alias stripped of whitespace when *alias* is truthy.
|
|
60
|
+
|
|
61
|
+
Why
|
|
62
|
+
----
|
|
63
|
+
User input may include spacing or mixed casing; sanitising up front keeps the
|
|
64
|
+
canonical lookup tables compact and dependable.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
alias:
|
|
69
|
+
Optional raw alias provided by a user or CLI flag. ``None`` indicates no
|
|
70
|
+
override.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
str | None
|
|
75
|
+
Lower-case alias when *alias* contains characters, otherwise ``None`` when
|
|
76
|
+
no override is requested.
|
|
77
|
+
|
|
78
|
+
Raises
|
|
79
|
+
------
|
|
80
|
+
ValueError
|
|
81
|
+
If *alias* contains only whitespace, because such inputs indicate a user
|
|
82
|
+
error that should surface immediately.
|
|
83
|
+
|
|
84
|
+
Examples
|
|
85
|
+
--------
|
|
86
|
+
>>> _sanitize(' MacOS ')
|
|
87
|
+
'macos'
|
|
88
|
+
>>> _sanitize(None) is None
|
|
89
|
+
True
|
|
90
|
+
>>> _sanitize(' ')
|
|
91
|
+
Traceback (most recent call last):
|
|
92
|
+
...
|
|
93
|
+
ValueError: Platform alias cannot be empty.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
if alias is None:
|
|
97
|
+
return None
|
|
98
|
+
stripped = alias.strip().lower()
|
|
99
|
+
if not stripped:
|
|
100
|
+
raise ValueError("Platform alias cannot be empty.")
|
|
101
|
+
return stripped
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def normalise_resolver_platform(alias: str | None) -> str | None:
|
|
105
|
+
"""Return canonical resolver platform identifiers for *alias*.
|
|
106
|
+
|
|
107
|
+
Why
|
|
108
|
+
----
|
|
109
|
+
The path resolver adapter expects ``sys.platform`` style identifiers. This
|
|
110
|
+
helper converts human-friendly values (``"mac"``, ``"win"``) into the canonical
|
|
111
|
+
tokens documented in the system design.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
alias:
|
|
116
|
+
User-provided alias or ``None``. ``None`` preserves auto-detection.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
str | None
|
|
121
|
+
Canonical resolver identifier or ``None`` when auto-detection should be
|
|
122
|
+
used.
|
|
123
|
+
|
|
124
|
+
Raises
|
|
125
|
+
------
|
|
126
|
+
ValueError
|
|
127
|
+
If *alias* is not recognised. The error message enumerates valid options
|
|
128
|
+
so CLI tooling can surface helpful guidance.
|
|
129
|
+
|
|
130
|
+
Examples
|
|
131
|
+
--------
|
|
132
|
+
>>> normalise_resolver_platform('mac')
|
|
133
|
+
'darwin'
|
|
134
|
+
>>> normalise_resolver_platform('win32')
|
|
135
|
+
'win32'
|
|
136
|
+
>>> normalise_resolver_platform(None) is None
|
|
137
|
+
True
|
|
138
|
+
>>> normalise_resolver_platform('beos')
|
|
139
|
+
Traceback (most recent call last):
|
|
140
|
+
...
|
|
141
|
+
ValueError: Platform must be one of: darwin, linux, mac, macos, posix, win, win32, windows, wine.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
sanitized = _sanitize(alias)
|
|
145
|
+
if sanitized is None:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
return _CANONICAL_RESOLVER[sanitized]
|
|
149
|
+
except KeyError as exc: # pragma: no cover - exercised via caller tests
|
|
150
|
+
allowed = ", ".join(sorted(_CANONICAL_RESOLVER))
|
|
151
|
+
raise ValueError(f"Platform must be one of: {allowed}.") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def normalise_examples_platform(alias: str | None) -> str | None:
|
|
155
|
+
"""Return the example-generation platform family for *alias*.
|
|
156
|
+
|
|
157
|
+
Why
|
|
158
|
+
----
|
|
159
|
+
Documentation and example helpers target two directory layouts (POSIX and
|
|
160
|
+
Windows). This function collapses a wide variety of synonyms into those
|
|
161
|
+
families for predictable template generation.
|
|
162
|
+
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
alias:
|
|
166
|
+
User-provided alias or ``None`` to let the caller choose a default.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
str | None
|
|
171
|
+
Canonical example platform (``"posix"`` or ``"windows"``) or ``None`` when
|
|
172
|
+
the caller should rely on runtime defaults.
|
|
173
|
+
|
|
174
|
+
Raises
|
|
175
|
+
------
|
|
176
|
+
ValueError
|
|
177
|
+
If *alias* is provided but not known.
|
|
178
|
+
|
|
179
|
+
Examples
|
|
180
|
+
--------
|
|
181
|
+
>>> normalise_examples_platform('darwin')
|
|
182
|
+
'posix'
|
|
183
|
+
>>> normalise_examples_platform('windows')
|
|
184
|
+
'windows'
|
|
185
|
+
>>> normalise_examples_platform(None) is None
|
|
186
|
+
True
|
|
187
|
+
>>> normalise_examples_platform('amiga')
|
|
188
|
+
Traceback (most recent call last):
|
|
189
|
+
...
|
|
190
|
+
ValueError: Platform must be one of: darwin, linux, mac, macos, posix, win, win32, windows, wine.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
sanitized = _sanitize(alias)
|
|
194
|
+
if sanitized is None:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
return _CANONICAL_EXAMPLES[sanitized]
|
|
198
|
+
except KeyError as exc: # pragma: no cover - exercised via caller tests
|
|
199
|
+
allowed = ", ".join(sorted(_CANONICAL_EXAMPLES))
|
|
200
|
+
raise ValueError(f"Platform must be one of: {allowed}.") from exc
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Adapter implementations for ``lib_layered_config``.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Group concrete boundary code (filesystem, dotenv, environment, file parsers)
|
|
6
|
+
that fulfils the application layer's ports.
|
|
7
|
+
|
|
8
|
+
System Role
|
|
9
|
+
-----------
|
|
10
|
+
Modules inside this package implement contracts defined in
|
|
11
|
+
:mod:`lib_layered_config.application.ports` and are wired together by the
|
|
12
|
+
composition root.
|
|
13
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Dotenv adapter implementations."""
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""`.env` adapter.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Implement the :class:`lib_layered_config.application.ports.DotEnvLoader`
|
|
6
|
+
protocol by scanning for `.env` files using the search discipline captured in
|
|
7
|
+
``docs/systemdesign/module_reference.md``.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
- ``DefaultDotEnvLoader``: public loader that composes the helpers.
|
|
11
|
+
- ``_iter_candidates`` / ``_build_search_list``: gather candidate paths.
|
|
12
|
+
- ``_parse_dotenv``: strict parser converting dotenv files into nested dicts.
|
|
13
|
+
- ``_assign_nested`` and friends: ensure ``__`` nesting mirrors environment
|
|
14
|
+
variable semantics.
|
|
15
|
+
- ``_log_dotenv_*``: appetite of logging helpers that narrate discovery and
|
|
16
|
+
parsing outcomes.
|
|
17
|
+
|
|
18
|
+
System Role
|
|
19
|
+
-----------
|
|
20
|
+
Feeds `.env` key/value pairs into the merge pipeline using the same nesting
|
|
21
|
+
semantics as the environment adapter.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Iterable, cast
|
|
29
|
+
|
|
30
|
+
from ...domain.errors import InvalidFormat
|
|
31
|
+
from ...observability import log_debug, log_error
|
|
32
|
+
|
|
33
|
+
DOTENV_LAYER = "dotenv"
|
|
34
|
+
"""Layer name used for structured logging calls.
|
|
35
|
+
|
|
36
|
+
Why
|
|
37
|
+
----
|
|
38
|
+
Tag observability events with a stable layer identifier.
|
|
39
|
+
|
|
40
|
+
What
|
|
41
|
+
----
|
|
42
|
+
Constant shared across logging helpers within this module.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _log_dotenv_loaded(path: Path, keys: Mapping[str, object]) -> None:
|
|
47
|
+
"""Record a successful dotenv load with sorted key names.
|
|
48
|
+
|
|
49
|
+
Why
|
|
50
|
+
----
|
|
51
|
+
Provide visibility into which dotenv file was applied and which keys were
|
|
52
|
+
present without dumping values.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
path:
|
|
57
|
+
Path to the loaded dotenv file.
|
|
58
|
+
keys:
|
|
59
|
+
Mapping of parsed keys (values are ignored; only key names are logged).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
log_debug("dotenv_loaded", layer=DOTENV_LAYER, path=str(path), keys=sorted(keys.keys()))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _log_dotenv_missing() -> None:
|
|
66
|
+
"""Record that no dotenv file was discovered.
|
|
67
|
+
|
|
68
|
+
Why
|
|
69
|
+
----
|
|
70
|
+
Signal to operators that the dotenv layer was absent (useful for debugging
|
|
71
|
+
precedence expectations).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
log_debug("dotenv_not_found", layer=DOTENV_LAYER, path=None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _log_dotenv_error(path: Path, line_number: int) -> None:
|
|
78
|
+
"""Capture malformed line diagnostics.
|
|
79
|
+
|
|
80
|
+
Why
|
|
81
|
+
----
|
|
82
|
+
Provide actionable telemetry when dotenv parsing fails on a particular line.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
path:
|
|
87
|
+
Path to the dotenv file being parsed.
|
|
88
|
+
line_number:
|
|
89
|
+
Line number containing the malformed entry.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
log_error("dotenv_invalid_line", layer=DOTENV_LAYER, path=str(path), line=line_number)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DefaultDotEnvLoader:
|
|
96
|
+
"""Load a dotenv file into a nested configuration dictionary.
|
|
97
|
+
|
|
98
|
+
Why
|
|
99
|
+
----
|
|
100
|
+
`.env` files supply secrets and developer overrides. They need deterministic
|
|
101
|
+
discovery and identical nesting semantics to environment variables.
|
|
102
|
+
|
|
103
|
+
What
|
|
104
|
+
----
|
|
105
|
+
Searches for candidate files, parses the first hit, records provenance, and
|
|
106
|
+
exposes the loaded path for diagnostics.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, *, extras: Iterable[str] | None = None) -> None:
|
|
110
|
+
"""Initialise the loader with optional *extras* supplied by the path resolver.
|
|
111
|
+
|
|
112
|
+
Why
|
|
113
|
+
----
|
|
114
|
+
Allow callers to append OS-specific directories to the search order.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
extras:
|
|
119
|
+
Additional absolute paths (typically OS-specific config directories)
|
|
120
|
+
appended to the search order.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
self._extras = [Path(p) for p in extras or []]
|
|
124
|
+
self.last_loaded_path: str | None = None
|
|
125
|
+
|
|
126
|
+
def load(self, start_dir: str | None = None) -> Mapping[str, object]:
|
|
127
|
+
"""Return the first parsed dotenv file discovered in the search order.
|
|
128
|
+
|
|
129
|
+
Why
|
|
130
|
+
----
|
|
131
|
+
Provide the precedence layer ``dotenv`` used by the composition root.
|
|
132
|
+
|
|
133
|
+
What
|
|
134
|
+
----
|
|
135
|
+
Builds the search list, parses the first existing file into a nested
|
|
136
|
+
mapping, stores the loaded path, and logs success or absence.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
start_dir:
|
|
141
|
+
Directory that seeds the upward search (often the project root).
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
Mapping[str, object]
|
|
146
|
+
Nested mapping representing parsed key/value pairs.
|
|
147
|
+
|
|
148
|
+
Side Effects
|
|
149
|
+
------------
|
|
150
|
+
Sets :attr:`last_loaded_path` and emits structured logging events.
|
|
151
|
+
|
|
152
|
+
Examples
|
|
153
|
+
--------
|
|
154
|
+
>>> from tempfile import TemporaryDirectory
|
|
155
|
+
>>> tmp = TemporaryDirectory()
|
|
156
|
+
>>> path = Path(tmp.name) / '.env'
|
|
157
|
+
>>> _ = path.write_text(
|
|
158
|
+
... 'SERVICE__TOKEN=secret',
|
|
159
|
+
... encoding='utf-8',
|
|
160
|
+
... )
|
|
161
|
+
>>> loader = DefaultDotEnvLoader()
|
|
162
|
+
>>> loader.load(tmp.name)["service"]["token"]
|
|
163
|
+
'secret'
|
|
164
|
+
>>> loader.last_loaded_path == str(path)
|
|
165
|
+
True
|
|
166
|
+
>>> tmp.cleanup()
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
candidates = _build_search_list(start_dir, self._extras)
|
|
170
|
+
self.last_loaded_path = None
|
|
171
|
+
for candidate in candidates:
|
|
172
|
+
if not candidate.is_file():
|
|
173
|
+
continue
|
|
174
|
+
self.last_loaded_path = str(candidate)
|
|
175
|
+
data = _parse_dotenv(candidate)
|
|
176
|
+
_log_dotenv_loaded(candidate, data)
|
|
177
|
+
return data
|
|
178
|
+
_log_dotenv_missing()
|
|
179
|
+
return {}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_search_list(start_dir: str | None, extras: Iterable[Path]) -> list[Path]:
|
|
183
|
+
"""Return ordered candidate paths including *extras* supplied by adapters.
|
|
184
|
+
|
|
185
|
+
Why
|
|
186
|
+
----
|
|
187
|
+
Combine project-relative candidates with platform-specific extras while
|
|
188
|
+
preserving precedence order.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
start_dir:
|
|
193
|
+
Directory that seeds the upward search.
|
|
194
|
+
extras:
|
|
195
|
+
Additional absolute paths appended after the upward search.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
list[Path]
|
|
200
|
+
Ordered candidate paths for dotenv discovery.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
return [*list(_iter_candidates(start_dir)), *extras]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _iter_candidates(start_dir: str | None) -> Iterable[Path]:
|
|
207
|
+
"""Yield candidate dotenv paths walking from ``start_dir`` to filesystem root.
|
|
208
|
+
|
|
209
|
+
Why
|
|
210
|
+
----
|
|
211
|
+
Support layered overrides by checking the working directory and all parent
|
|
212
|
+
directories.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
start_dir:
|
|
217
|
+
Starting directory for the upward search; ``None`` uses the current
|
|
218
|
+
working directory.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
Iterable[Path]
|
|
223
|
+
Sequence of candidate `.env` paths ordered from closest to farthest.
|
|
224
|
+
|
|
225
|
+
Examples
|
|
226
|
+
--------
|
|
227
|
+
>>> from pathlib import Path
|
|
228
|
+
>>> base = Path('.')
|
|
229
|
+
>>> next(_iter_candidates(str(base))).name
|
|
230
|
+
'.env'
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
base = Path(start_dir) if start_dir else Path.cwd()
|
|
234
|
+
for directory in [base, *base.parents]:
|
|
235
|
+
yield directory / ".env"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _parse_dotenv(path: Path) -> Mapping[str, object]:
|
|
239
|
+
"""Parse ``path`` into a nested dictionary, raising ``InvalidFormat`` on malformed lines.
|
|
240
|
+
|
|
241
|
+
Why
|
|
242
|
+
----
|
|
243
|
+
Ensure dotenv parsing is strict and produces dictionaries compatible with
|
|
244
|
+
the merge algorithm.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
path:
|
|
249
|
+
Absolute path to the dotenv file to parse.
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
Mapping[str, object]
|
|
254
|
+
Nested dictionary representing the parsed file.
|
|
255
|
+
|
|
256
|
+
Raises
|
|
257
|
+
------
|
|
258
|
+
InvalidFormat
|
|
259
|
+
When a line lacks an ``=`` delimiter or contains invalid syntax.
|
|
260
|
+
|
|
261
|
+
Examples
|
|
262
|
+
--------
|
|
263
|
+
>>> import os
|
|
264
|
+
>>> tmp = Path('example.env')
|
|
265
|
+
>>> body = os.linesep.join(['FEATURE=true', 'SERVICE__TIMEOUT=10']) + os.linesep
|
|
266
|
+
>>> _ = tmp.write_text(body, encoding='utf-8')
|
|
267
|
+
>>> parsed = _parse_dotenv(tmp)
|
|
268
|
+
>>> parsed["service"]["timeout"]
|
|
269
|
+
'10'
|
|
270
|
+
>>> tmp.unlink()
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
result: dict[str, object] = {}
|
|
274
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
275
|
+
for line_number, raw_line in enumerate(handle, start=1):
|
|
276
|
+
line = raw_line.strip()
|
|
277
|
+
if not line or line.startswith("#"):
|
|
278
|
+
continue
|
|
279
|
+
if "=" not in line:
|
|
280
|
+
_log_dotenv_error(path, line_number)
|
|
281
|
+
raise InvalidFormat(f"Malformed line {line_number} in {path}")
|
|
282
|
+
key, value = line.split("=", 1)
|
|
283
|
+
key = key.strip()
|
|
284
|
+
value = _strip_quotes(value.strip())
|
|
285
|
+
_assign_nested(result, key, value)
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _strip_quotes(value: str) -> str:
|
|
290
|
+
"""Trim surrounding quotes and inline comments from ``value``.
|
|
291
|
+
|
|
292
|
+
Why
|
|
293
|
+
----
|
|
294
|
+
`.env` syntax allows quoted strings and trailing inline comments; stripping
|
|
295
|
+
them keeps behaviour aligned with community conventions.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
value:
|
|
300
|
+
Raw value token read from the dotenv file.
|
|
301
|
+
|
|
302
|
+
Returns
|
|
303
|
+
-------
|
|
304
|
+
str
|
|
305
|
+
Cleaned value with quotes and trailing comments removed.
|
|
306
|
+
|
|
307
|
+
Examples
|
|
308
|
+
--------
|
|
309
|
+
>>> _strip_quotes('"token"')
|
|
310
|
+
'token'
|
|
311
|
+
>>> _strip_quotes("value # comment")
|
|
312
|
+
'value'
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
316
|
+
return value[1:-1]
|
|
317
|
+
if value.startswith("#"):
|
|
318
|
+
return ""
|
|
319
|
+
if " #" in value:
|
|
320
|
+
return value.split(" #", 1)[0].strip()
|
|
321
|
+
return value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _assign_nested(target: dict[str, object], key: str, value: object) -> None:
|
|
325
|
+
"""Assign ``value`` in ``target`` using case-insensitive dotted syntax.
|
|
326
|
+
|
|
327
|
+
Why
|
|
328
|
+
----
|
|
329
|
+
Ensure dotenv keys with ``__`` delimiters mirror environment variable
|
|
330
|
+
nesting rules.
|
|
331
|
+
|
|
332
|
+
What
|
|
333
|
+
----
|
|
334
|
+
Splits the key on ``__``, ensures each intermediate mapping exists, resolves
|
|
335
|
+
case-insensitive keys, and assigns the final value.
|
|
336
|
+
|
|
337
|
+
Parameters
|
|
338
|
+
----------
|
|
339
|
+
target:
|
|
340
|
+
Mapping being mutated.
|
|
341
|
+
key:
|
|
342
|
+
Dotenv key using ``__`` separators.
|
|
343
|
+
value:
|
|
344
|
+
Parsed string value to assign.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
None
|
|
349
|
+
|
|
350
|
+
Side Effects
|
|
351
|
+
------------
|
|
352
|
+
Mutates ``target``.
|
|
353
|
+
|
|
354
|
+
Examples
|
|
355
|
+
--------
|
|
356
|
+
>>> data: dict[str, object] = {}
|
|
357
|
+
>>> _assign_nested(data, 'SERVICE__TOKEN', 'secret')
|
|
358
|
+
>>> data
|
|
359
|
+
{'service': {'token': 'secret'}}
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
parts = key.split("__")
|
|
363
|
+
cursor = target
|
|
364
|
+
for part in parts[:-1]:
|
|
365
|
+
cursor = _ensure_child_mapping(cursor, part, error_cls=InvalidFormat)
|
|
366
|
+
final_key = _resolve_key(cursor, parts[-1])
|
|
367
|
+
cursor[final_key] = value
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _resolve_key(mapping: dict[str, object], key: str) -> str:
|
|
371
|
+
"""Return an existing key with matching case-insensitive name or create a new lowercase entry.
|
|
372
|
+
|
|
373
|
+
Why
|
|
374
|
+
----
|
|
375
|
+
Preserve original casing when keys repeat while avoiding duplicates that
|
|
376
|
+
differ only by case.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
mapping:
|
|
381
|
+
Mutable mapping being inspected.
|
|
382
|
+
key:
|
|
383
|
+
Raw key from the dotenv file.
|
|
384
|
+
|
|
385
|
+
Returns
|
|
386
|
+
-------
|
|
387
|
+
str
|
|
388
|
+
Existing key or lowercase variant suitable for insertion.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
lower = key.lower()
|
|
392
|
+
for existing in mapping.keys():
|
|
393
|
+
if existing.lower() == lower:
|
|
394
|
+
return existing
|
|
395
|
+
return lower
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _ensure_child_mapping(mapping: dict[str, object], key: str, *, error_cls: type[Exception]) -> dict[str, object]:
|
|
399
|
+
"""Ensure ``mapping[key]`` is a ``dict`` or raise ``error_cls`` when a scalar blocks nesting.
|
|
400
|
+
|
|
401
|
+
Why
|
|
402
|
+
----
|
|
403
|
+
Nested keys should never overwrite scalar values without an explicit error.
|
|
404
|
+
This keeps configuration shapes predictable.
|
|
405
|
+
|
|
406
|
+
What
|
|
407
|
+
----
|
|
408
|
+
Resolves the key, creates an empty mapping when missing, or raises the
|
|
409
|
+
provided error when a scalar is encountered.
|
|
410
|
+
|
|
411
|
+
Parameters
|
|
412
|
+
----------
|
|
413
|
+
mapping:
|
|
414
|
+
Mapping being mutated.
|
|
415
|
+
key:
|
|
416
|
+
Key segment to ensure.
|
|
417
|
+
error_cls:
|
|
418
|
+
Exception type raised on scalar collisions.
|
|
419
|
+
|
|
420
|
+
Returns
|
|
421
|
+
-------
|
|
422
|
+
dict[str, object]
|
|
423
|
+
Child mapping stored at the resolved key.
|
|
424
|
+
|
|
425
|
+
Side Effects
|
|
426
|
+
------------
|
|
427
|
+
Mutates ``mapping`` by inserting a new child mapping when missing.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
resolved = _resolve_key(mapping, key)
|
|
431
|
+
if resolved not in mapping:
|
|
432
|
+
mapping[resolved] = dict[str, object]()
|
|
433
|
+
child = mapping[resolved]
|
|
434
|
+
if not isinstance(child, dict):
|
|
435
|
+
raise error_cls(f"Cannot overwrite scalar with mapping for key {key}")
|
|
436
|
+
typed_child = cast(dict[str, object], child)
|
|
437
|
+
mapping[resolved] = typed_child
|
|
438
|
+
return typed_child
|