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.
@@ -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
@@ -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())