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
whence/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""whence -- typed configuration that remembers where it came from.
|
|
2
|
+
|
|
3
|
+
Every configuration library can tell you a value. whence can tell you *why* it
|
|
4
|
+
has that value: which file, which line, which profile, and what it overrode.
|
|
5
|
+
|
|
6
|
+
>>> from whence import Config
|
|
7
|
+
>>> cfg = Config.from_mapping({"db": {"host": "localhost"}})
|
|
8
|
+
>>> cfg.get("db.host")
|
|
9
|
+
'localhost'
|
|
10
|
+
|
|
11
|
+
Loading is synchronous and happens once, before the application runs: there is
|
|
12
|
+
no event loop to protect at that point, so there is nothing for an ``await`` to
|
|
13
|
+
yield to. A source that reaches the network simply blocks the startup it is
|
|
14
|
+
already part of.
|
|
15
|
+
|
|
16
|
+
The public API is everything listed in ``__all__``; anything else is internal
|
|
17
|
+
and may change without a major version bump.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
21
|
+
|
|
22
|
+
from .binding import Binder, Problem, bind, render_problems
|
|
23
|
+
from .chain import SourceChain
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .decorators import (
|
|
26
|
+
Injected,
|
|
27
|
+
Value,
|
|
28
|
+
configure,
|
|
29
|
+
current_config,
|
|
30
|
+
from_config,
|
|
31
|
+
load_settings,
|
|
32
|
+
settings,
|
|
33
|
+
)
|
|
34
|
+
from .discovery import Discovery, DiscoveryPlan, PyProjectSource, Step
|
|
35
|
+
from .errors import (
|
|
36
|
+
AmbiguousConfigError,
|
|
37
|
+
BindError,
|
|
38
|
+
ConfigError,
|
|
39
|
+
FormatError,
|
|
40
|
+
InterpolationError,
|
|
41
|
+
MissingConfigError,
|
|
42
|
+
MissingKeyError,
|
|
43
|
+
SecretError,
|
|
44
|
+
UnboundKeyError,
|
|
45
|
+
WhenceError,
|
|
46
|
+
)
|
|
47
|
+
from .keys import KeyPath, canonical, env_name, key_from_env
|
|
48
|
+
from .origin import Origin, RelativePath, Tracked, origin_of, unwrap
|
|
49
|
+
from .profiles import active_profiles, expand_groups
|
|
50
|
+
from .secret import MASK, Secret, is_sensitive, sanitize, unlock_secrets
|
|
51
|
+
from .sources import (
|
|
52
|
+
ArgvSource,
|
|
53
|
+
DotEnvSource,
|
|
54
|
+
EnvSource,
|
|
55
|
+
FileSource,
|
|
56
|
+
MappingSource,
|
|
57
|
+
SecretsDirSource,
|
|
58
|
+
Source,
|
|
59
|
+
)
|
|
60
|
+
from .tree import Layer, Resolved
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"MASK",
|
|
64
|
+
"AmbiguousConfigError",
|
|
65
|
+
"ArgvSource",
|
|
66
|
+
"BindError",
|
|
67
|
+
"Binder",
|
|
68
|
+
"Config",
|
|
69
|
+
"ConfigError",
|
|
70
|
+
"Discovery",
|
|
71
|
+
"DiscoveryPlan",
|
|
72
|
+
"DotEnvSource",
|
|
73
|
+
"EnvSource",
|
|
74
|
+
"FileSource",
|
|
75
|
+
"FormatError",
|
|
76
|
+
"Injected",
|
|
77
|
+
"InterpolationError",
|
|
78
|
+
"KeyPath",
|
|
79
|
+
"Layer",
|
|
80
|
+
"MappingSource",
|
|
81
|
+
"MissingConfigError",
|
|
82
|
+
"MissingKeyError",
|
|
83
|
+
"Origin",
|
|
84
|
+
"Problem",
|
|
85
|
+
"PyProjectSource",
|
|
86
|
+
"RelativePath",
|
|
87
|
+
"Resolved",
|
|
88
|
+
"Secret",
|
|
89
|
+
"SecretError",
|
|
90
|
+
"SecretsDirSource",
|
|
91
|
+
"Source",
|
|
92
|
+
"SourceChain",
|
|
93
|
+
"Step",
|
|
94
|
+
"Tracked",
|
|
95
|
+
"UnboundKeyError",
|
|
96
|
+
"Value",
|
|
97
|
+
"WhenceError",
|
|
98
|
+
"__version__",
|
|
99
|
+
"active_profiles",
|
|
100
|
+
"bind",
|
|
101
|
+
"canonical",
|
|
102
|
+
"configure",
|
|
103
|
+
"current_config",
|
|
104
|
+
"env_name",
|
|
105
|
+
"expand_groups",
|
|
106
|
+
"from_config",
|
|
107
|
+
"is_sensitive",
|
|
108
|
+
"key_from_env",
|
|
109
|
+
"load_settings",
|
|
110
|
+
"origin_of",
|
|
111
|
+
"render_problems",
|
|
112
|
+
"sanitize",
|
|
113
|
+
"settings",
|
|
114
|
+
"unlock_secrets",
|
|
115
|
+
"unwrap",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
__version__ = version("whence")
|
|
120
|
+
except PackageNotFoundError: # pragma: no cover - source tree without an install
|
|
121
|
+
__version__ = "0.0.0.dev0"
|
whence/_platform.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Platform conventions, behind a seam so tests do not need three machines.
|
|
2
|
+
|
|
3
|
+
Every function takes the platform and the environment as arguments rather than
|
|
4
|
+
reading ``sys.platform`` and ``os.environ`` directly. That is what lets one test
|
|
5
|
+
run assert the Windows, macOS and Linux answers on whichever machine CI happens
|
|
6
|
+
to schedule, and it is the same seam figment's ``Jail`` and viper's ``afero``
|
|
7
|
+
exist to provide.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
|
14
|
+
|
|
15
|
+
__all__ = ["Platform", "current_platform", "flavour", "same_file_key", "user_config_dirs"]
|
|
16
|
+
|
|
17
|
+
type Platform = str
|
|
18
|
+
"""A ``sys.platform`` value: ``"linux"``, ``"darwin"``, ``"win32"``, ..."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def current_platform() -> Platform:
|
|
22
|
+
"""Return the running platform.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The value of ``sys.platform``.
|
|
26
|
+
"""
|
|
27
|
+
return sys.platform
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def flavour(platform: Platform) -> type[PurePath]:
|
|
31
|
+
r"""Return the path flavour a platform uses.
|
|
32
|
+
|
|
33
|
+
``PureWindowsPath`` and ``PurePosixPath`` are fully functional on every
|
|
34
|
+
platform, which is what makes the whole platform matrix testable in one
|
|
35
|
+
process. Overriding ``sys.platform`` alone is not enough and is actively
|
|
36
|
+
misleading: ``PurePath(r"C:\\Users\\u").is_absolute()`` is ``False`` on POSIX,
|
|
37
|
+
so a Windows test would silently exercise POSIX semantics and pass against
|
|
38
|
+
the wrong expectation.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
platform: A ``sys.platform`` value.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
``PureWindowsPath`` on Windows, ``PurePosixPath`` elsewhere.
|
|
45
|
+
"""
|
|
46
|
+
return PureWindowsPath if platform == "win32" else PurePosixPath
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_abs(value: str | None, platform: Platform) -> bool:
|
|
50
|
+
"""Report whether a value is a non-empty absolute path for this platform."""
|
|
51
|
+
if not value:
|
|
52
|
+
return False
|
|
53
|
+
return flavour(platform)(value).is_absolute()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _home(environ: Mapping[str, str], platform: Platform) -> PurePath | None:
|
|
57
|
+
"""Resolve the user's home directory, or ``None`` if it is unusable.
|
|
58
|
+
|
|
59
|
+
Deliberately not ``os.path.expanduser("~")`` or ``Path.home()``: with
|
|
60
|
+
``HOME=""`` the former returns ``"/"`` and with ``HOME="rel/dir"`` it returns
|
|
61
|
+
that string verbatim. Neither raises, so both turn a broken environment into
|
|
62
|
+
a plausible-looking wrong answer -- a config directory of ``/.config/myapp``,
|
|
63
|
+
or a directory literally named ``~`` in the working directory.
|
|
64
|
+
"""
|
|
65
|
+
if platform == "win32":
|
|
66
|
+
value = environ.get("USERPROFILE")
|
|
67
|
+
if not value:
|
|
68
|
+
drive, tail = environ.get("HOMEDRIVE", ""), environ.get("HOMEPATH")
|
|
69
|
+
value = (drive + tail) if tail else None
|
|
70
|
+
else:
|
|
71
|
+
value = environ.get("HOME")
|
|
72
|
+
if not value:
|
|
73
|
+
try:
|
|
74
|
+
import pwd
|
|
75
|
+
|
|
76
|
+
value = pwd.getpwuid(os.getuid()).pw_dir
|
|
77
|
+
except (ImportError, KeyError, AttributeError): # pragma: no cover - POSIX only
|
|
78
|
+
value = None
|
|
79
|
+
if value is None or not _is_abs(value, platform):
|
|
80
|
+
return None
|
|
81
|
+
return flavour(platform)(value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _xdg_dir(
|
|
85
|
+
environ: Mapping[str, str], var: str, fallback: PurePath | None, platform: Platform
|
|
86
|
+
) -> PurePath | None:
|
|
87
|
+
"""Read an XDG single-directory variable.
|
|
88
|
+
|
|
89
|
+
The specification is explicit that a value which is unset, empty, or not an
|
|
90
|
+
absolute path must be treated as unset, which is the part most
|
|
91
|
+
implementations skip.
|
|
92
|
+
"""
|
|
93
|
+
value = environ.get(var, "")
|
|
94
|
+
if _is_abs(value, platform):
|
|
95
|
+
return flavour(platform)(value)
|
|
96
|
+
return fallback
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def user_config_dirs(
|
|
100
|
+
app: str,
|
|
101
|
+
*,
|
|
102
|
+
platform: Platform | None = None,
|
|
103
|
+
environ: Mapping[str, str] | None = None,
|
|
104
|
+
) -> tuple[PurePath, ...]:
|
|
105
|
+
"""Return the per-user configuration directories for an application.
|
|
106
|
+
|
|
107
|
+
Ordered most specific first. The directories are not required to exist; the
|
|
108
|
+
caller decides what to do about that.
|
|
109
|
+
|
|
110
|
+
The conventions differ per platform and there is genuine disagreement in the
|
|
111
|
+
ecosystem about macOS, where the Apple answer is ``Application Support`` but
|
|
112
|
+
a large share of command-line tools follow XDG. whence returns both, Apple's
|
|
113
|
+
first, and additionally honours ``XDG_CONFIG_HOME`` there when the user has
|
|
114
|
+
set it explicitly -- which is the only signal that they meant it.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
app: The application name.
|
|
118
|
+
platform: Override the platform; defaults to the running one.
|
|
119
|
+
environ: Override the environment; defaults to ``os.environ``.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Directories to search, most specific first, deduplicated.
|
|
123
|
+
"""
|
|
124
|
+
system = current_platform() if platform is None else platform
|
|
125
|
+
env = os.environ if environ is None else environ
|
|
126
|
+
home = _home(env, system)
|
|
127
|
+
pure = flavour(system)
|
|
128
|
+
out: list[PurePath] = []
|
|
129
|
+
|
|
130
|
+
if system == "win32":
|
|
131
|
+
# Local before Roaming. Roaming is copied over the network and merged
|
|
132
|
+
# last-writer-wins across machines, Microsoft has been retreating from
|
|
133
|
+
# it since 1909, and Package State Roaming is gone in Windows 11.
|
|
134
|
+
# platformdirs defaults to Local for the same reasons.
|
|
135
|
+
for var in ("LOCALAPPDATA", "APPDATA"):
|
|
136
|
+
value = env.get(var)
|
|
137
|
+
if value and _is_abs(value, system):
|
|
138
|
+
out.append(pure(value) / app)
|
|
139
|
+
if not out and home is not None:
|
|
140
|
+
out.append(home / "AppData" / "Local" / app)
|
|
141
|
+
elif system == "darwin":
|
|
142
|
+
# Apple says Application Support; a large share of command-line tools
|
|
143
|
+
# follow XDG. Both are served, Apple's first, and an explicitly set
|
|
144
|
+
# XDG_CONFIG_HOME is honoured because setting it is the only signal a
|
|
145
|
+
# user can give. platformdirs reached the same position in 4.6.0.
|
|
146
|
+
if home is not None:
|
|
147
|
+
out.append(home / "Library" / "Application Support" / app)
|
|
148
|
+
explicit = _xdg_dir(env, "XDG_CONFIG_HOME", None, system)
|
|
149
|
+
if explicit is not None:
|
|
150
|
+
out.append(explicit / app)
|
|
151
|
+
if home is not None:
|
|
152
|
+
out.append(home / ".config" / app)
|
|
153
|
+
else:
|
|
154
|
+
base = _xdg_dir(env, "XDG_CONFIG_HOME", None if home is None else home / ".config", system)
|
|
155
|
+
if base is not None:
|
|
156
|
+
out.append(base / app)
|
|
157
|
+
# A value of ":" or "::" leaves nothing after filtering, which must mean
|
|
158
|
+
# the default rather than nothing at all.
|
|
159
|
+
site = [
|
|
160
|
+
pure(part)
|
|
161
|
+
for part in env.get("XDG_CONFIG_DIRS", "").split(":")
|
|
162
|
+
if _is_abs(part, system)
|
|
163
|
+
] or [pure("/etc/xdg")]
|
|
164
|
+
out.extend(path / app for path in site)
|
|
165
|
+
|
|
166
|
+
# setdefault keeps the first path per normalised key, matching the order
|
|
167
|
+
# these directories are searched in.
|
|
168
|
+
unique: dict[str, PurePath] = {}
|
|
169
|
+
for path in out:
|
|
170
|
+
unique.setdefault(os.path.normcase(str(path)), path)
|
|
171
|
+
return tuple(unique.values())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def same_file_key(path: Path) -> object:
|
|
175
|
+
"""Return a key that is equal for two paths naming the same file.
|
|
176
|
+
|
|
177
|
+
macOS (APFS, HFS+) and Windows (NTFS) are case-insensitive but
|
|
178
|
+
case-preserving, so ``app.toml`` and ``App.TOML`` are one file there and two
|
|
179
|
+
on Linux. Discovery deduplicates candidates through this so a
|
|
180
|
+
case-insensitive filesystem does not look like an ambiguity.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
path: The path to key.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The file's ``(device, inode)`` identity when it exists, which is exact on
|
|
187
|
+
every platform, and a normalised path string when it does not.
|
|
188
|
+
|
|
189
|
+
``os.path.normcase`` alone is not enough: it is the identity function on
|
|
190
|
+
macOS, which is precisely a platform where the filesystem folds case.
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
stat = path.stat()
|
|
194
|
+
except OSError:
|
|
195
|
+
return os.path.normcase(str(path))
|
|
196
|
+
return (stat.st_dev, stat.st_ino)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Binding resolved configuration onto a typed schema.
|
|
2
|
+
|
|
3
|
+
Three behaviours here are not negotiable, and each one is a lesson from a
|
|
4
|
+
framework that got it wrong.
|
|
5
|
+
|
|
6
|
+
**Every error at once.** A configuration file with four mistakes should take one
|
|
7
|
+
run to fix, not four. environs added ``seal()`` for this; .NET's binder was
|
|
8
|
+
still silently swallowing enum failures as late as its own 8.0 breaking-change
|
|
9
|
+
note, which is headed "previously, the following code silently swallowed the
|
|
10
|
+
exceptions".
|
|
11
|
+
|
|
12
|
+
**Unknown keys are errors, by default.** A typo in a key name is the single most
|
|
13
|
+
common configuration bug, and the usual behaviour -- bind what matches, ignore
|
|
14
|
+
the rest -- makes it invisible. Spring ships a handler for this but does not
|
|
15
|
+
turn it on. whence turns it on and adds a suggestion.
|
|
16
|
+
|
|
17
|
+
**Nothing configured means ``None``.** An all-defaults object is
|
|
18
|
+
indistinguishable from a section that was never written, which is how a typo in
|
|
19
|
+
a section name goes unnoticed. Spring's ``BindResult`` makes the distinction and
|
|
20
|
+
so does this.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from collections.abc import Mapping, Sequence
|
|
24
|
+
from dataclasses import dataclass, replace
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
from ..errors import BindError
|
|
28
|
+
from ..keys import KeyPath, canonical, join, suggest
|
|
29
|
+
from ..origin import Origin, Tracked
|
|
30
|
+
from ..secret import sanitize
|
|
31
|
+
from ._dataclasses import DataclassBinder
|
|
32
|
+
from ._pydantic import PydanticBinder, pydantic_available
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Binder",
|
|
36
|
+
"DataclassBinder",
|
|
37
|
+
"Problem",
|
|
38
|
+
"PydanticBinder",
|
|
39
|
+
"bind",
|
|
40
|
+
"choose_binder",
|
|
41
|
+
"pydantic_available",
|
|
42
|
+
"render_problems",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_BINDERS: "tuple[Binder, ...]" = (PydanticBinder(), DataclassBinder())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Problem:
|
|
51
|
+
"""One thing wrong with the configuration.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
key: The dotted key.
|
|
55
|
+
value: The offending value, redacted before rendering.
|
|
56
|
+
origin: Where the value came from.
|
|
57
|
+
reason: What is wrong with it.
|
|
58
|
+
shadowed: Entries this value overrode, for context.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
key: str
|
|
62
|
+
value: Any
|
|
63
|
+
origin: Origin | None
|
|
64
|
+
reason: str
|
|
65
|
+
shadowed: tuple[str, ...] = ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def render_problems(problems: Sequence[Problem]) -> str:
|
|
69
|
+
"""Format problems the way whence reports them.
|
|
70
|
+
|
|
71
|
+
The four-field ``Property / Value / Origin / Reason`` block plus an
|
|
72
|
+
``Action`` line is Spring Boot's failure-analysis layout, which is the best
|
|
73
|
+
in the field and costs nothing to adopt.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
problems: The problems to render.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A multi-line message.
|
|
80
|
+
"""
|
|
81
|
+
count = len(problems)
|
|
82
|
+
lines = [f"{count} error{'s' if count != 1 else ''}", ""]
|
|
83
|
+
for problem in problems:
|
|
84
|
+
lines.append(f" Property: {problem.key}")
|
|
85
|
+
if problem.value is not None:
|
|
86
|
+
lines.append(f" Value: {sanitize(problem.key, problem.value)!r}")
|
|
87
|
+
if problem.origin is not None:
|
|
88
|
+
lines.append(f" Origin: {problem.origin}")
|
|
89
|
+
lines.append(f" Reason: {problem.reason}")
|
|
90
|
+
lines.extend(f" Shadowed: {entry}" for entry in problem.shadowed)
|
|
91
|
+
lines.append("")
|
|
92
|
+
lines.append("Action: correct the configuration, or run `whence explain <key>`.")
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Binder(Protocol):
|
|
97
|
+
"""Turns a subtree of resolved configuration into a typed object."""
|
|
98
|
+
|
|
99
|
+
def supports(self, target: type) -> bool:
|
|
100
|
+
"""Report whether this binder handles the target.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
target: The schema class.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True when it does.
|
|
107
|
+
"""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
def declared(self, target: type, prefix: KeyPath = ()) -> set[KeyPath]:
|
|
111
|
+
"""List the key paths the target declares.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
target: The schema class.
|
|
115
|
+
prefix: The path it sits at.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Every declared key path.
|
|
119
|
+
"""
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
def bind(
|
|
123
|
+
self,
|
|
124
|
+
target: type,
|
|
125
|
+
values: Mapping[KeyPath, Tracked],
|
|
126
|
+
prefix: KeyPath = (),
|
|
127
|
+
problems: list[Any] | None = None,
|
|
128
|
+
) -> Any:
|
|
129
|
+
"""Construct the target.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
target: The schema class.
|
|
133
|
+
values: Flat resolved values.
|
|
134
|
+
prefix: The subtree to read.
|
|
135
|
+
problems: A list to append problems to.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The instance, or ``None`` on failure.
|
|
139
|
+
"""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def choose_binder(target: type) -> Binder:
|
|
144
|
+
"""Pick the binder for a schema class.
|
|
145
|
+
|
|
146
|
+
pydantic is preferred when the target is one of its models and the package
|
|
147
|
+
is installed; otherwise the standard-library dataclass binder is used.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
target: The schema class.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
A binder.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
BindError: If nothing can bind the target.
|
|
157
|
+
"""
|
|
158
|
+
for binder in _BINDERS:
|
|
159
|
+
if binder.supports(target):
|
|
160
|
+
return binder
|
|
161
|
+
name = getattr(target, "__name__", str(target))
|
|
162
|
+
msg = (
|
|
163
|
+
f"cannot bind to {name}: whence binds dataclasses, and pydantic models "
|
|
164
|
+
"when pydantic is installed"
|
|
165
|
+
)
|
|
166
|
+
raise BindError(msg)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def bind(
|
|
170
|
+
target: type,
|
|
171
|
+
values: Mapping[KeyPath, Tracked],
|
|
172
|
+
*,
|
|
173
|
+
prefix: KeyPath = (),
|
|
174
|
+
shadowed: Mapping[KeyPath, tuple[tuple[str, Tracked], ...]] | None = None,
|
|
175
|
+
strict: bool = True,
|
|
176
|
+
) -> Any:
|
|
177
|
+
"""Bind resolved configuration onto a schema, reporting every problem.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
target: The schema class.
|
|
181
|
+
values: Flat resolved values.
|
|
182
|
+
prefix: The subtree to bind.
|
|
183
|
+
shadowed: Shadow records, used to enrich error messages.
|
|
184
|
+
strict: Whether a key nothing declared is an error.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
An instance of ``target``.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
BindError: If anything failed, with every problem in one message.
|
|
191
|
+
"""
|
|
192
|
+
binder = choose_binder(target)
|
|
193
|
+
problems: list[Problem] = []
|
|
194
|
+
instance = binder.bind(target, values, prefix, problems)
|
|
195
|
+
|
|
196
|
+
if strict:
|
|
197
|
+
declared = binder.declared(target, prefix)
|
|
198
|
+
depth = len(prefix)
|
|
199
|
+
for path, tracked in values.items():
|
|
200
|
+
if path[:depth] != prefix or len(path) <= depth or path in declared:
|
|
201
|
+
continue
|
|
202
|
+
hint = suggest(path, declared)
|
|
203
|
+
reason = "no such setting"
|
|
204
|
+
if hint:
|
|
205
|
+
reason = f"{reason} - did you mean {hint[0]!r}?"
|
|
206
|
+
problems.append(Problem(join(path), tracked.value, tracked.origin, reason))
|
|
207
|
+
|
|
208
|
+
if problems:
|
|
209
|
+
if shadowed:
|
|
210
|
+
problems = [replace(p, shadowed=_shadow_text(p.key, shadowed)) for p in problems]
|
|
211
|
+
raise BindError(render_problems(problems))
|
|
212
|
+
return instance
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _shadow_text(
|
|
216
|
+
key: str,
|
|
217
|
+
shadowed: Mapping[KeyPath, tuple[tuple[str, Tracked], ...]] | None,
|
|
218
|
+
) -> tuple[str, ...]:
|
|
219
|
+
"""Render the shadow chain for one key."""
|
|
220
|
+
if not shadowed:
|
|
221
|
+
return ()
|
|
222
|
+
entries = shadowed.get(canonical(key), ())
|
|
223
|
+
return tuple(f"{tracked.origin} = {sanitize(key, tracked.value)!r}" for _, tracked in entries)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Binding to frozen dataclasses, on the standard library alone.
|
|
2
|
+
|
|
3
|
+
This is what keeps whence's zero-dependency claim honest: a project that does
|
|
4
|
+
not want pydantic still gets typed, validated configuration. When pydantic *is*
|
|
5
|
+
installed the other binder takes over automatically and you get its error
|
|
6
|
+
quality for free.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, get_type_hints
|
|
12
|
+
|
|
13
|
+
from ..keys import KeyPath, join
|
|
14
|
+
from ..origin import RelativePath, Tracked
|
|
15
|
+
from .coerce import CoercionError, coerce, is_optional
|
|
16
|
+
|
|
17
|
+
__all__ = ["DataclassBinder", "declared_paths"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_dataclass_type(annotation: Any) -> bool:
|
|
21
|
+
"""Report whether an annotation is a nested dataclass to recurse into."""
|
|
22
|
+
return isinstance(annotation, type) and dataclasses.is_dataclass(annotation)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def declared_paths(target: type, prefix: KeyPath = ()) -> set[KeyPath]:
|
|
26
|
+
"""List every key path a dataclass declares, recursing into nested ones.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
target: The dataclass.
|
|
30
|
+
prefix: The path it sits at.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Every declared key path.
|
|
34
|
+
"""
|
|
35
|
+
hints = get_type_hints(target)
|
|
36
|
+
out: set[KeyPath] = set()
|
|
37
|
+
for field in dataclasses.fields(target):
|
|
38
|
+
annotation = hints.get(field.name, Any)
|
|
39
|
+
path = (*prefix, field.name)
|
|
40
|
+
if _is_dataclass_type(annotation):
|
|
41
|
+
out |= declared_paths(annotation, path)
|
|
42
|
+
else:
|
|
43
|
+
out.add(path)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DataclassBinder:
|
|
48
|
+
"""Binds a subtree of configuration onto a dataclass."""
|
|
49
|
+
|
|
50
|
+
def supports(self, target: type) -> bool:
|
|
51
|
+
"""Report whether this binder handles the target.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
target: The schema class.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True for any dataclass.
|
|
58
|
+
"""
|
|
59
|
+
return dataclasses.is_dataclass(target)
|
|
60
|
+
|
|
61
|
+
def declared(self, target: type, prefix: KeyPath = ()) -> set[KeyPath]:
|
|
62
|
+
"""List the key paths the target declares.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
target: The dataclass.
|
|
66
|
+
prefix: The path it sits at.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Every declared key path.
|
|
70
|
+
"""
|
|
71
|
+
return declared_paths(target, prefix)
|
|
72
|
+
|
|
73
|
+
def bind(
|
|
74
|
+
self,
|
|
75
|
+
target: type,
|
|
76
|
+
values: Mapping[KeyPath, Tracked],
|
|
77
|
+
prefix: KeyPath = (),
|
|
78
|
+
problems: list[Any] | None = None,
|
|
79
|
+
) -> Any:
|
|
80
|
+
"""Construct the target from the resolved values.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
target: The dataclass.
|
|
84
|
+
values: Flat resolved values.
|
|
85
|
+
prefix: The subtree to read.
|
|
86
|
+
problems: A list to append problems to. Errors are accumulated
|
|
87
|
+
rather than raised, so one run reports every mistake in the file
|
|
88
|
+
instead of the first.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
An instance of ``target``, or ``None`` if construction failed.
|
|
92
|
+
"""
|
|
93
|
+
from . import Problem
|
|
94
|
+
|
|
95
|
+
collected = problems if problems is not None else []
|
|
96
|
+
# Scoped to this target: a sibling's problem recorded earlier in the
|
|
97
|
+
# shared list must not suppress an unrelated construction.
|
|
98
|
+
before = len(collected)
|
|
99
|
+
hints = get_type_hints(target)
|
|
100
|
+
kwargs: dict[str, Any] = {}
|
|
101
|
+
for field in dataclasses.fields(target):
|
|
102
|
+
if not field.init:
|
|
103
|
+
continue
|
|
104
|
+
annotation = hints.get(field.name, Any)
|
|
105
|
+
path = (*prefix, field.name)
|
|
106
|
+
if _is_dataclass_type(annotation):
|
|
107
|
+
nested = self.bind(annotation, values, path, collected)
|
|
108
|
+
if nested is not None:
|
|
109
|
+
kwargs[field.name] = nested
|
|
110
|
+
continue
|
|
111
|
+
tracked = values.get(path)
|
|
112
|
+
if tracked is None:
|
|
113
|
+
if _has_default(field):
|
|
114
|
+
continue
|
|
115
|
+
if is_optional(annotation):
|
|
116
|
+
kwargs[field.name] = None
|
|
117
|
+
continue
|
|
118
|
+
collected.append(
|
|
119
|
+
Problem(join(path), None, None, "required, but nothing supplies it")
|
|
120
|
+
)
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
if annotation is RelativePath:
|
|
124
|
+
kwargs[field.name] = RelativePath.resolve_against(tracked.value, tracked.origin)
|
|
125
|
+
continue
|
|
126
|
+
kwargs[field.name] = coerce(tracked.value, annotation)
|
|
127
|
+
except (CoercionError, ValueError, TypeError) as exc:
|
|
128
|
+
collected.append(Problem(join(path), tracked.value, tracked.origin, str(exc)))
|
|
129
|
+
if len(collected) > before:
|
|
130
|
+
# Constructing anyway would raise TypeError for the very field
|
|
131
|
+
# already reported, and append a second `<root>` problem saying so.
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
return target(**kwargs)
|
|
135
|
+
except TypeError as exc: # pragma: no cover - defensive
|
|
136
|
+
collected.append(Problem(join(prefix) or "<root>", None, None, str(exc)))
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _has_default(field: "dataclasses.Field[Any]") -> bool:
|
|
141
|
+
"""Report whether a dataclass field can be left unset."""
|
|
142
|
+
return (
|
|
143
|
+
field.default is not dataclasses.MISSING or field.default_factory is not dataclasses.MISSING
|
|
144
|
+
)
|