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 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
+ )