whence 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.
- whence/__init__.py +121 -0
- whence/_platform.py +196 -0
- whence/binding/__init__.py +223 -0
- whence/binding/_dataclasses.py +144 -0
- whence/binding/_pydantic.py +120 -0
- whence/binding/coerce.py +220 -0
- whence/chain.py +176 -0
- whence/cli.py +83 -0
- whence/config.py +405 -0
- whence/decorators.py +371 -0
- whence/discovery.py +426 -0
- whence/errors.py +68 -0
- whence/formats/__init__.py +90 -0
- whence/formats/json.py +40 -0
- whence/formats/properties.py +105 -0
- whence/formats/toml.py +37 -0
- whence/formats/xml.py +90 -0
- whence/formats/yaml.py +73 -0
- whence/interpolate.py +180 -0
- whence/keys.py +184 -0
- whence/origin.py +166 -0
- whence/profiles.py +93 -0
- whence/py.typed +0 -0
- whence/secret.py +148 -0
- whence/sources/__init__.py +48 -0
- whence/sources/argv.py +84 -0
- whence/sources/dotenv.py +175 -0
- whence/sources/env.py +128 -0
- whence/sources/files.py +68 -0
- whence/sources/mapping.py +51 -0
- whence/sources/secrets.py +60 -0
- whence/tree.py +122 -0
- whence-1.0.0.dist-info/METADATA +201 -0
- whence-1.0.0.dist-info/RECORD +37 -0
- whence-1.0.0.dist-info/WHEEL +4 -0
- whence-1.0.0.dist-info/entry_points.txt +2 -0
- whence-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Binding to pydantic models, when pydantic is installed.
|
|
2
|
+
|
|
3
|
+
whence never imports pydantic at module scope and never declares it as a
|
|
4
|
+
dependency. The binder is selected only when ``pydantic`` is importable, which
|
|
5
|
+
is what lets the same library serve a zero-dependency project and a
|
|
6
|
+
pydantic-native one.
|
|
7
|
+
|
|
8
|
+
The interesting work is the last step: pydantic reports errors with a ``loc``
|
|
9
|
+
tuple, and whence holds a map from key path to origin keyed identically. Joining
|
|
10
|
+
them at the key is what produces an error that names the file and line, which no
|
|
11
|
+
Python library does today.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from importlib.util import find_spec
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..keys import KeyPath, join
|
|
20
|
+
from ..origin import Tracked
|
|
21
|
+
from ..tree import unflatten
|
|
22
|
+
|
|
23
|
+
__all__ = ["PydanticBinder", "pydantic_available"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@functools.cache
|
|
27
|
+
def pydantic_available() -> bool:
|
|
28
|
+
"""Report whether pydantic can be imported.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True when the package is installed.
|
|
32
|
+
"""
|
|
33
|
+
return find_spec("pydantic") is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PydanticBinder:
|
|
37
|
+
"""Binds a subtree of configuration onto a pydantic model."""
|
|
38
|
+
|
|
39
|
+
def supports(self, target: type) -> bool:
|
|
40
|
+
"""Report whether this binder handles the target.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
target: The schema class.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True for a ``pydantic.BaseModel`` subclass.
|
|
47
|
+
"""
|
|
48
|
+
if not pydantic_available():
|
|
49
|
+
return False
|
|
50
|
+
import pydantic
|
|
51
|
+
|
|
52
|
+
return issubclass(target, pydantic.BaseModel)
|
|
53
|
+
|
|
54
|
+
def declared(self, target: type, prefix: KeyPath = ()) -> set[KeyPath]:
|
|
55
|
+
"""List the key paths the model declares, recursing into sub-models.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
target: The model.
|
|
59
|
+
prefix: The path it sits at.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Every declared key path.
|
|
63
|
+
"""
|
|
64
|
+
import pydantic
|
|
65
|
+
|
|
66
|
+
out: set[KeyPath] = set()
|
|
67
|
+
for name, info in target.model_fields.items(): # type: ignore[attr-defined]
|
|
68
|
+
annotation = info.annotation
|
|
69
|
+
path = (*prefix, name)
|
|
70
|
+
if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel):
|
|
71
|
+
out |= self.declared(annotation, path)
|
|
72
|
+
else:
|
|
73
|
+
out.add(path)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def bind(
|
|
77
|
+
self,
|
|
78
|
+
target: type,
|
|
79
|
+
values: Mapping[KeyPath, Tracked],
|
|
80
|
+
prefix: KeyPath = (),
|
|
81
|
+
problems: list[Any] | None = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Validate the subtree into a model instance.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
target: The model.
|
|
87
|
+
values: Flat resolved values.
|
|
88
|
+
prefix: The subtree to read.
|
|
89
|
+
problems: A list to append problems to.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A model instance, or ``None`` when validation failed.
|
|
93
|
+
"""
|
|
94
|
+
import pydantic
|
|
95
|
+
|
|
96
|
+
from . import Problem
|
|
97
|
+
|
|
98
|
+
collected = problems if problems is not None else []
|
|
99
|
+
depth = len(prefix)
|
|
100
|
+
subtree = {
|
|
101
|
+
path[depth:]: tracked.value
|
|
102
|
+
for path, tracked in values.items()
|
|
103
|
+
if path[:depth] == prefix and len(path) > depth
|
|
104
|
+
}
|
|
105
|
+
try:
|
|
106
|
+
return target.model_validate(unflatten(subtree)) # type: ignore[attr-defined]
|
|
107
|
+
except pydantic.ValidationError as exc:
|
|
108
|
+
for error in exc.errors():
|
|
109
|
+
loc = tuple(str(part) for part in error["loc"])
|
|
110
|
+
path = (*prefix, *loc)
|
|
111
|
+
tracked = values.get(path)
|
|
112
|
+
collected.append(
|
|
113
|
+
Problem(
|
|
114
|
+
join(path),
|
|
115
|
+
None if tracked is None else tracked.value,
|
|
116
|
+
None if tracked is None else tracked.origin,
|
|
117
|
+
error["msg"],
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return None
|
whence/binding/coerce.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Turning strings into the types a schema asks for.
|
|
2
|
+
|
|
3
|
+
No message in this module ever contains the value it rejected. A rejected value
|
|
4
|
+
may be a secret, and an exception is the most widely logged object in a program;
|
|
5
|
+
the standard library's own conversion errors quote the input, so they are caught
|
|
6
|
+
and replaced rather than passed through.
|
|
7
|
+
|
|
8
|
+
Environment variables and ``.properties`` files carry only strings, so some
|
|
9
|
+
coercion is unavoidable. The rule whence follows is that coercion is **never a
|
|
10
|
+
global guess**: a list is JSON when the string opens with ``[`` and
|
|
11
|
+
comma-separated otherwise, and everything else is driven by the declared field
|
|
12
|
+
type. The three conventions in the wild -- JSON, comma-separated and indexed
|
|
13
|
+
keys -- are irreconcilable, so guessing between them by inspection is how a
|
|
14
|
+
value silently becomes the wrong shape.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import datetime as dt
|
|
18
|
+
import json
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from types import UnionType
|
|
24
|
+
from typing import Annotated, Any, Literal, Union, get_args, get_origin
|
|
25
|
+
|
|
26
|
+
from ..secret import Secret
|
|
27
|
+
|
|
28
|
+
__all__ = ["TRUE", "coerce", "is_optional", "strip_annotated"]
|
|
29
|
+
|
|
30
|
+
TRUE = frozenset({"1", "true", "yes", "on", "y", "t"})
|
|
31
|
+
"""Strings that mean ``True``; everything else that parses means ``False``."""
|
|
32
|
+
|
|
33
|
+
FALSE = frozenset({"0", "false", "no", "off", "n", "f", ""})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CoercionError(ValueError):
|
|
37
|
+
"""A value could not be converted to the declared type."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def strip_annotated(annotation: Any) -> Any:
|
|
41
|
+
"""Return the type an annotation declares, without its metadata.
|
|
42
|
+
|
|
43
|
+
``Annotated[int, Value("x")]`` declares an ``int``; the metadata is for
|
|
44
|
+
whoever put it there. Stripping is recursive because nesting is legal.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
annotation: A type annotation, annotated or not.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The underlying type.
|
|
51
|
+
"""
|
|
52
|
+
while get_origin(annotation) is Annotated:
|
|
53
|
+
annotation = get_args(annotation)[0]
|
|
54
|
+
return annotation
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_optional(annotation: Any) -> bool:
|
|
58
|
+
"""Report whether an annotation admits ``None``.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
annotation: A type annotation.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True for ``X | None`` and ``Optional[X]``.
|
|
65
|
+
"""
|
|
66
|
+
annotation = strip_annotated(annotation)
|
|
67
|
+
return get_origin(annotation) in (Union, UnionType) and type(None) in get_args(annotation)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _non_none(annotation: Any) -> Any:
|
|
71
|
+
"""Strip ``None`` from an optional annotation."""
|
|
72
|
+
args = [a for a in get_args(annotation) if a is not type(None)]
|
|
73
|
+
return args[0] if len(args) == 1 else annotation
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _to_bool(value: Any) -> bool:
|
|
77
|
+
"""Parse a boolean from a string or a number."""
|
|
78
|
+
if isinstance(value, bool):
|
|
79
|
+
return value
|
|
80
|
+
if isinstance(value, (int, float)):
|
|
81
|
+
return bool(value)
|
|
82
|
+
text = str(value).strip().lower()
|
|
83
|
+
if text in TRUE:
|
|
84
|
+
return True
|
|
85
|
+
if text in FALSE:
|
|
86
|
+
return False
|
|
87
|
+
msg = f"expected a boolean; accepted spellings are {sorted(TRUE | FALSE - {''})}"
|
|
88
|
+
raise CoercionError(msg)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _to_timedelta(value: Any) -> dt.timedelta:
|
|
92
|
+
"""Parse a duration from seconds or a ``1h30m`` style string."""
|
|
93
|
+
if isinstance(value, dt.timedelta):
|
|
94
|
+
return value
|
|
95
|
+
if isinstance(value, (int, float)):
|
|
96
|
+
return dt.timedelta(seconds=float(value))
|
|
97
|
+
text = str(value).strip().lower()
|
|
98
|
+
try:
|
|
99
|
+
return dt.timedelta(seconds=float(text))
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
units = {"d": 86400.0, "h": 3600.0, "m": 60.0, "s": 1.0, "ms": 0.001}
|
|
103
|
+
total, number = 0.0, ""
|
|
104
|
+
i = 0
|
|
105
|
+
while i < len(text):
|
|
106
|
+
if text[i].isdigit() or text[i] == ".":
|
|
107
|
+
number += text[i]
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
unit = text[i : i + 2] if text[i : i + 2] in units else text[i]
|
|
111
|
+
if unit not in units or not number:
|
|
112
|
+
msg = "expected a duration such as '30s', '1h30m' or a number of seconds"
|
|
113
|
+
raise CoercionError(msg)
|
|
114
|
+
total += float(number) * units[unit]
|
|
115
|
+
number = ""
|
|
116
|
+
i += len(unit)
|
|
117
|
+
if number:
|
|
118
|
+
total += float(number)
|
|
119
|
+
return dt.timedelta(seconds=total)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _split_list(text: str) -> list[Any]:
|
|
123
|
+
"""Split a list from JSON or from commas."""
|
|
124
|
+
stripped = text.strip()
|
|
125
|
+
if stripped.startswith("["):
|
|
126
|
+
loaded = json.loads(stripped)
|
|
127
|
+
if isinstance(loaded, list):
|
|
128
|
+
return loaded
|
|
129
|
+
return [part.strip() for part in stripped.split(",") if part.strip()]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_SCALARS: Mapping[Any, Callable[[Any], Any]] = {
|
|
133
|
+
bool: _to_bool,
|
|
134
|
+
int: lambda v: v if isinstance(v, int) and not isinstance(v, bool) else int(str(v).strip()),
|
|
135
|
+
float: lambda v: float(v) if isinstance(v, (int, float)) else float(str(v).strip()),
|
|
136
|
+
str: lambda v: v if isinstance(v, str) else str(v),
|
|
137
|
+
Path: lambda v: v if isinstance(v, Path) else Path(str(v)),
|
|
138
|
+
uuid.UUID: lambda v: v if isinstance(v, uuid.UUID) else uuid.UUID(str(v)),
|
|
139
|
+
dt.timedelta: _to_timedelta,
|
|
140
|
+
dt.datetime: lambda v: v if isinstance(v, dt.datetime) else dt.datetime.fromisoformat(str(v)),
|
|
141
|
+
dt.date: lambda v: v if isinstance(v, dt.date) else dt.date.fromisoformat(str(v)),
|
|
142
|
+
Secret: lambda v: v if isinstance(v, Secret) else Secret(str(v)),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def coerce(value: Any, annotation: Any) -> Any:
|
|
147
|
+
"""Convert a loaded value to the type a field declares.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
value: The value as a source produced it.
|
|
151
|
+
annotation: The declared type.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The converted value.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
CoercionError: If the value cannot be converted.
|
|
158
|
+
"""
|
|
159
|
+
annotation = strip_annotated(annotation)
|
|
160
|
+
if annotation is Any or annotation is None:
|
|
161
|
+
return value
|
|
162
|
+
if is_optional(annotation):
|
|
163
|
+
if value is None or (isinstance(value, str) and value.strip() == ""):
|
|
164
|
+
return None
|
|
165
|
+
return coerce(value, _non_none(annotation))
|
|
166
|
+
|
|
167
|
+
origin = get_origin(annotation)
|
|
168
|
+
if origin is Literal:
|
|
169
|
+
allowed = get_args(annotation)
|
|
170
|
+
for option in allowed:
|
|
171
|
+
if value == option or str(value) == str(option):
|
|
172
|
+
return option
|
|
173
|
+
msg = f"expected one of {list(allowed)}"
|
|
174
|
+
raise CoercionError(msg)
|
|
175
|
+
|
|
176
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
177
|
+
for member in annotation:
|
|
178
|
+
if value is member or value == member.value or str(value) == str(member.value):
|
|
179
|
+
return member
|
|
180
|
+
try:
|
|
181
|
+
return annotation[str(value)]
|
|
182
|
+
except KeyError:
|
|
183
|
+
msg = f"expected one of {[m.value for m in annotation]}"
|
|
184
|
+
raise CoercionError(msg) from None
|
|
185
|
+
|
|
186
|
+
if origin in (list, tuple, set, frozenset):
|
|
187
|
+
items = _split_list(value) if isinstance(value, str) else list(value)
|
|
188
|
+
args = get_args(annotation)
|
|
189
|
+
inner = args[0] if args and args[0] is not Ellipsis else Any
|
|
190
|
+
converted = [coerce(item, inner) for item in items]
|
|
191
|
+
return origin(converted) if origin is not list else converted
|
|
192
|
+
|
|
193
|
+
if origin is dict:
|
|
194
|
+
raw = json.loads(value) if isinstance(value, str) else value
|
|
195
|
+
if not isinstance(raw, Mapping):
|
|
196
|
+
msg = "expected a mapping"
|
|
197
|
+
raise CoercionError(msg)
|
|
198
|
+
args = get_args(annotation)
|
|
199
|
+
kt, vt = (*args, Any, Any)[:2]
|
|
200
|
+
return {coerce(k, kt): coerce(v, vt) for k, v in raw.items()}
|
|
201
|
+
|
|
202
|
+
convert = _SCALARS.get(annotation)
|
|
203
|
+
if convert is not None:
|
|
204
|
+
try:
|
|
205
|
+
return convert(value)
|
|
206
|
+
except CoercionError:
|
|
207
|
+
raise
|
|
208
|
+
except (TypeError, ValueError) as exc:
|
|
209
|
+
# Deliberately not `{exc}`: the standard library embeds the rejected
|
|
210
|
+
# input in its message ("invalid literal for int() ... 'hunter2'"),
|
|
211
|
+
# and that value may be a secret.
|
|
212
|
+
name = getattr(annotation, "__name__", str(annotation))
|
|
213
|
+
msg = f"could not convert to {name}"
|
|
214
|
+
raise CoercionError(msg) from exc
|
|
215
|
+
|
|
216
|
+
if isinstance(annotation, type) and isinstance(value, annotation):
|
|
217
|
+
return value
|
|
218
|
+
if isinstance(value, Sequence) and not isinstance(value, str):
|
|
219
|
+
return value
|
|
220
|
+
return value
|
whence/chain.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The ordered, named chain of sources.
|
|
2
|
+
|
|
3
|
+
Precedence is position in a list whose entries have names, not an integer
|
|
4
|
+
ordinal. Both models exist in the wild -- Spring uses a name-addressable list,
|
|
5
|
+
SmallRye uses integer ordinals -- and the list wins for the primary model: a
|
|
6
|
+
third-party source can say "immediately above the ``.env`` file" without knowing
|
|
7
|
+
what everything else chose, and two sources can never tie.
|
|
8
|
+
|
|
9
|
+
Ordinals remain available as sugar through :meth:`SourceChain.insert_by_ordinal`,
|
|
10
|
+
because letting an operator drop in a file that outranks the environment without
|
|
11
|
+
touching code is genuinely useful. They are converted to a position on insert,
|
|
12
|
+
so the ambiguity stays at the edge.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
from .sources import Source
|
|
19
|
+
from .tree import Resolved, resolve
|
|
20
|
+
|
|
21
|
+
__all__ = ["SourceChain"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SourceChain:
|
|
25
|
+
"""A mutable, ordered collection of named sources, highest precedence first."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, sources: Sequence[Source] = ()) -> None:
|
|
28
|
+
"""Build a chain.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
sources: Sources in precedence order, highest first.
|
|
32
|
+
"""
|
|
33
|
+
self._sources: list[Source] = list(sources)
|
|
34
|
+
|
|
35
|
+
def __iter__(self) -> Iterator[Source]:
|
|
36
|
+
"""Iterate sources highest precedence first."""
|
|
37
|
+
return iter(self._sources)
|
|
38
|
+
|
|
39
|
+
def __len__(self) -> int:
|
|
40
|
+
"""Return the number of sources."""
|
|
41
|
+
return len(self._sources)
|
|
42
|
+
|
|
43
|
+
def __contains__(self, name: object) -> bool:
|
|
44
|
+
"""Report whether a source with this name is present."""
|
|
45
|
+
return any(self._name(s) == name for s in self._sources)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _name(source: Source) -> str:
|
|
49
|
+
"""Read a source's name."""
|
|
50
|
+
return str(getattr(source, "name", type(source).__name__))
|
|
51
|
+
|
|
52
|
+
def names(self) -> tuple[str, ...]:
|
|
53
|
+
"""List source names, highest precedence first.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The names.
|
|
57
|
+
"""
|
|
58
|
+
return tuple(self._name(s) for s in self._sources)
|
|
59
|
+
|
|
60
|
+
def _index(self, name: str) -> int:
|
|
61
|
+
"""Find a source by name.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ConfigError: If no source carries that name.
|
|
65
|
+
"""
|
|
66
|
+
for i, source in enumerate(self._sources):
|
|
67
|
+
if self._name(source) == name:
|
|
68
|
+
return i
|
|
69
|
+
msg = f"no source named {name!r}; the chain holds {', '.join(self.names()) or '<nothing>'}"
|
|
70
|
+
raise ConfigError(msg)
|
|
71
|
+
|
|
72
|
+
def add_first(self, source: Source) -> "SourceChain":
|
|
73
|
+
"""Insert a source at the highest precedence.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
source: The source.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
This chain, for chaining.
|
|
80
|
+
"""
|
|
81
|
+
self._sources.insert(0, source)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def add_last(self, source: Source) -> "SourceChain":
|
|
85
|
+
"""Append a source at the lowest precedence.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
source: The source.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
This chain, for chaining.
|
|
92
|
+
"""
|
|
93
|
+
self._sources.append(source)
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def add_before(self, relative: str, source: Source) -> "SourceChain":
|
|
97
|
+
"""Insert a source immediately above a named one.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
relative: The name to insert above.
|
|
101
|
+
source: The source.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
This chain, for chaining.
|
|
105
|
+
"""
|
|
106
|
+
self._sources.insert(self._index(relative), source)
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def add_after(self, relative: str, source: Source) -> "SourceChain":
|
|
110
|
+
"""Insert a source immediately below a named one.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
relative: The name to insert below.
|
|
114
|
+
source: The source.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
This chain, for chaining.
|
|
118
|
+
"""
|
|
119
|
+
self._sources.insert(self._index(relative) + 1, source)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def replace(self, name: str, source: Source) -> Source:
|
|
123
|
+
"""Swap a source in place, keeping its precedence.
|
|
124
|
+
|
|
125
|
+
This is how a source is decorated rather than displaced -- wrapping the
|
|
126
|
+
environment source to decrypt values, for instance.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
name: The source to replace.
|
|
130
|
+
source: The replacement.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The source that was removed.
|
|
134
|
+
"""
|
|
135
|
+
i = self._index(name)
|
|
136
|
+
previous = self._sources[i]
|
|
137
|
+
self._sources[i] = source
|
|
138
|
+
return previous
|
|
139
|
+
|
|
140
|
+
def remove(self, name: str) -> Source:
|
|
141
|
+
"""Drop a source by name.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: The source to remove.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The source that was removed.
|
|
148
|
+
"""
|
|
149
|
+
return self._sources.pop(self._index(name))
|
|
150
|
+
|
|
151
|
+
def insert_by_ordinal(self, source: Source, ordinal: int) -> "SourceChain":
|
|
152
|
+
"""Insert a source by integer rank, higher winning.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
source: The source, which must carry an ``ordinal`` attribute for
|
|
156
|
+
comparison against existing entries.
|
|
157
|
+
ordinal: The rank.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
This chain, for chaining.
|
|
161
|
+
"""
|
|
162
|
+
for i, existing in enumerate(self._sources):
|
|
163
|
+
if int(getattr(existing, "ordinal", 0)) < ordinal:
|
|
164
|
+
self._sources.insert(i, source)
|
|
165
|
+
return self
|
|
166
|
+
self._sources.append(source)
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def load(self) -> Resolved:
|
|
170
|
+
"""Load every source and merge the layers.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The winning values, the shadow chain, and every layer consulted.
|
|
174
|
+
"""
|
|
175
|
+
layers = [source.load() for source in self._sources]
|
|
176
|
+
return resolve(layers)
|
whence/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The ``whence`` command: explain, dump, discovery.
|
|
2
|
+
|
|
3
|
+
One discipline, learned from someone else's outage: **data goes to stdout,
|
|
4
|
+
diagnostics go to stderr, and the library itself never prints at all.** Node's
|
|
5
|
+
dotenv added a single ``console.log`` on stdout and broke a JSON-RPC transport
|
|
6
|
+
in the wild, because a library that writes to stdout is a library that corrupts
|
|
7
|
+
whatever pipeline it is embedded in.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .errors import WhenceError
|
|
17
|
+
|
|
18
|
+
__all__ = ["main"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
"""Build the argument parser."""
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="whence",
|
|
25
|
+
description="Inspect layered configuration and where each value came from.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument("app", help="application name, e.g. myapp")
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"command",
|
|
30
|
+
choices=("explain", "dump", "discovery"),
|
|
31
|
+
help="explain one key, dump every value, or show how files were searched for",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("key", nargs="?", help="dotted key, for `explain`")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-p",
|
|
36
|
+
"--profile",
|
|
37
|
+
action="append",
|
|
38
|
+
default=None,
|
|
39
|
+
dest="profiles",
|
|
40
|
+
help="activate a profile; repeat for several, last wins",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--json", action="store_true", help="emit JSON, for `dump`")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--reveal",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="do not redact secrets; pass this only deliberately",
|
|
47
|
+
)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
52
|
+
"""Run the command-line interface.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
argv: Arguments, defaulting to ``sys.argv[1:]``.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A process exit code.
|
|
59
|
+
"""
|
|
60
|
+
args = _parser().parse_args(argv)
|
|
61
|
+
try:
|
|
62
|
+
config = Config.load(args.app, profiles=args.profiles)
|
|
63
|
+
if args.command == "discovery":
|
|
64
|
+
print(config.discovery_report())
|
|
65
|
+
elif args.command == "dump":
|
|
66
|
+
data = config.dump(reveal=args.reveal)
|
|
67
|
+
if args.json:
|
|
68
|
+
print(json.dumps(data, indent=2, default=str))
|
|
69
|
+
else:
|
|
70
|
+
print("\n".join(f"{key} = {value!r}" for key, value in data.items()))
|
|
71
|
+
else:
|
|
72
|
+
if not args.key:
|
|
73
|
+
print("explain needs a key, e.g. `whence myapp explain db.host`", file=sys.stderr)
|
|
74
|
+
return 2
|
|
75
|
+
print(config.explain(args.key))
|
|
76
|
+
except WhenceError as exc:
|
|
77
|
+
print(f"whence: {exc}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__": # pragma: no cover
|
|
83
|
+
raise SystemExit(main())
|