monoconf 0.1.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.
monoconf/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """monoconf — one schema, one precedence order.
2
+
3
+ A pydantic v2 model produces a CLI parser, an environment reader and a layered
4
+ TOML loader. See PLAN.md for the design and its rationale.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from monoconf.config import Config
10
+ from monoconf.errors import (
11
+ ConfigFileError,
12
+ HelpRequested,
13
+ IgnoredValue,
14
+ MonoconfError,
15
+ MonoconfWarning,
16
+ OutputRequested,
17
+ SchemaError,
18
+ ShowConfigRequested,
19
+ SourceError,
20
+ UnknownConfigKey,
21
+ UnreadableSource,
22
+ ValidationFailed,
23
+ )
24
+ from monoconf.fields import Field
25
+ from monoconf.merge import Origin, SourceKind
26
+ from monoconf.settings import settings
27
+
28
+ __all__ = [
29
+ "Config",
30
+ "ConfigFileError",
31
+ "Field",
32
+ "HelpRequested",
33
+ "IgnoredValue",
34
+ "MonoconfError",
35
+ "MonoconfWarning",
36
+ "Origin",
37
+ "OutputRequested",
38
+ "SchemaError",
39
+ "ShowConfigRequested",
40
+ "SourceError",
41
+ "SourceKind",
42
+ "UnknownConfigKey",
43
+ "UnreadableSource",
44
+ "ValidationFailed",
45
+ "settings",
46
+ ]
monoconf/config.py ADDED
@@ -0,0 +1,281 @@
1
+ """The `Config` base class and the resolver behind it.
2
+
3
+ A subclass is introspected once, at class-definition time: its `Spec` is derived
4
+ and its schema validated, so a malformed schema fails on import rather than at
5
+ `load()`. Resolution itself is a fold over the sources, in one fixed order, with
6
+ one validation pass at the end.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from collections.abc import Mapping, Sequence
14
+ from contextvars import ContextVar
15
+ from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast
16
+ from weakref import WeakKeyDictionary
17
+
18
+ from pydantic import BaseModel, ValidationError, create_model
19
+
20
+ from monoconf import toml
21
+ from monoconf.errors import (
22
+ HelpRequested,
23
+ MonoconfError,
24
+ OutputRequested,
25
+ ShowConfigRequested,
26
+ ValidationFailed,
27
+ render_rejected,
28
+ render_validation_error,
29
+ )
30
+ from monoconf.merge import Layer, Origin, Values, leaves, merge, set_leaf
31
+ from monoconf.schema import Spec, build_spec
32
+ from monoconf.settings import program_name, settings
33
+ from monoconf.sources import Context, Rejected, cli, defaults, files
34
+ from monoconf.sources import env as env_source
35
+
36
+ __all__ = ["Config"]
37
+
38
+ _ORIGIN_ATTR = "__monoconf_origin__"
39
+
40
+ # Set while monoconf derives a model of its own from an application's schema. The
41
+ # derived model is not a schema anyone wrote, so it is neither introspected nor
42
+ # checked: it borrows the spec of the class it came from.
43
+ _DERIVING: ContextVar[bool] = ContextVar("monoconf_deriving", default=False)
44
+
45
+
46
+ class Config(BaseModel):
47
+ """Base class for a monoconf configuration model.
48
+
49
+ A field without a default is required, positional, and settable only from the
50
+ command line. A field with a default is optional everywhere. Instances are
51
+ frozen; `settings(frozen=False)` opts out.
52
+ """
53
+
54
+ model_config = settings()
55
+
56
+ if TYPE_CHECKING:
57
+ __monoconf_spec__: ClassVar[Spec]
58
+
59
+ # Declared as a read-only property for type checkers only. A plain
60
+ # annotation would make pydantic's dataclass_transform treat it as a field
61
+ # and demand it as a constructor argument; at runtime it is attached to the
62
+ # resolved instance with object.__setattr__.
63
+ @property
64
+ def __monoconf_origin__(self) -> Mapping[str, Origin]: ...
65
+
66
+ @classmethod
67
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
68
+ # Runs after pydantic has finished building the model, so model_fields is
69
+ # populated -- which __init_subclass__ cannot promise.
70
+ super().__pydantic_init_subclass__(**kwargs)
71
+ if _DERIVING.get():
72
+ return
73
+ cls.__monoconf_spec__ = build_spec(cls)
74
+
75
+ @classmethod
76
+ def load(
77
+ cls,
78
+ argv: Sequence[str] | None = None,
79
+ env: Mapping[str, str] | None = None,
80
+ ) -> Self:
81
+ """Resolve the configuration with CLI semantics.
82
+
83
+ Bad input writes a message to stderr and raises `SystemExit(2)`; `--help`
84
+ prints and exits 0. Use this in `main()`.
85
+ """
86
+ try:
87
+ return cls.resolve(argv=argv, env=env)
88
+ except OutputRequested as requested:
89
+ _write(sys.stdout, requested.text)
90
+ raise SystemExit(requested.exit_code) from None
91
+ except MonoconfError as error:
92
+ prog = program_name(cls.__monoconf_spec__.settings)
93
+ hint = f"Try '{prog} --help' for usage."
94
+ if isinstance(error, ValidationFailed):
95
+ # Already a titled block naming the program; it gets a blank line
96
+ # before the hint rather than a second "myapp:" in front of it.
97
+ _write(sys.stderr, f"{error}\n\n{hint}")
98
+ else:
99
+ _write(sys.stderr, f"{prog}: {error}\n{hint}")
100
+ raise SystemExit(2) from None
101
+
102
+ @classmethod
103
+ def resolve(
104
+ cls,
105
+ argv: Sequence[str] | None = None,
106
+ env: Mapping[str, str] | None = None,
107
+ ) -> Self:
108
+ """Resolve the configuration with library semantics.
109
+
110
+ Raises `MonoconfError` subclasses rather than exiting, and reports `--help`
111
+ and `--show-config` as `HelpRequested` and `ShowConfigRequested`, carrying
112
+ the rendered text. Use this in tests and when embedding.
113
+
114
+ `argv=None` means `sys.argv[1:]` and `env=None` means `os.environ`; an empty
115
+ sequence or mapping means exactly that, not a default.
116
+ """
117
+ spec = cls.__monoconf_spec__
118
+ command_line = tuple(sys.argv[1:] if argv is None else argv)
119
+ # Pass one, before anything can complain about a missing positional, and
120
+ # before the file layer can be collected: it is what says which files
121
+ # there are.
122
+ meta = cli.parse_meta(spec, command_line)
123
+ ctx = Context(
124
+ argv=command_line,
125
+ env=os.environ if env is None else env,
126
+ overrides=meta.config_override,
127
+ show_config=meta.show_config,
128
+ )
129
+
130
+ if meta.help_requested:
131
+ raise HelpRequested(_help(spec, ctx))
132
+
133
+ layers, rejected = _accepted(_lower_layers(spec, ctx))
134
+ values, origins = merge([merge(layers), cli.collect(spec, ctx)])
135
+ model = _without_positionals(cls) if meta.show_config else cls
136
+ prog = program_name(spec.settings)
137
+ try:
138
+ resolved = model.model_validate(values)
139
+ except ValidationError as error:
140
+ raise ValidationFailed(
141
+ render_validation_error(prog, spec, origins, error, rejected)
142
+ ) from error
143
+ if rejected:
144
+ # Everything that could be read was valid; what could not be read is
145
+ # still the operator's to fix, and is reported the same way.
146
+ raise ValidationFailed(
147
+ render_validation_error(prog, spec, origins, None, rejected)
148
+ )
149
+ if meta.show_config:
150
+ raise ShowConfigRequested(
151
+ toml.dump(prog, spec, resolved.model_dump(), origins)
152
+ )
153
+ # The instance is frozen, and origins are not a field: this is the one way
154
+ # to attach them, and it leaves model_dump, equality and repr untouched.
155
+ object.__setattr__(resolved, _ORIGIN_ATTR, origins)
156
+ return resolved
157
+
158
+ def origin_of(self, path: str) -> Origin:
159
+ """Where the value at a dotted field path came from."""
160
+ origins: Mapping[str, Origin] | None = getattr(self, _ORIGIN_ATTR, None)
161
+ if origins is None:
162
+ raise MonoconfError(
163
+ f"this {type(self).__name__} was constructed directly, so it has no "
164
+ "provenance. Resolve one with load() or resolve() to ask where a "
165
+ "value came from."
166
+ )
167
+ try:
168
+ return origins[path]
169
+ except KeyError:
170
+ raise MonoconfError(
171
+ f"{type(self).__name__} has no field '{path}'."
172
+ ) from None
173
+
174
+
175
+ _C = TypeVar("_C", bound=Config)
176
+
177
+ # Weakly held: a schema class defined and dropped, as tests do by the hundred,
178
+ # should not be kept alive by a copy of itself.
179
+ _derived: WeakKeyDictionary[type[Config], type[Config]] = WeakKeyDictionary()
180
+
181
+
182
+ def _without_positionals(cls: type[_C]) -> type[_C]:
183
+ """The schema with every positional optional, for `--show-config`.
184
+
185
+ Derived from the application's own class, so its validators still run and a
186
+ value that would fail a real run fails this one too. Positionals given on the
187
+ command line validate exactly as they would have; only an absent one is let
188
+ through, as None. Built once per class.
189
+ """
190
+ cached = _derived.get(cls)
191
+ if cached is not None:
192
+ # A subclass of cls, since that is how it was built below.
193
+ return cast(type[_C], cached)
194
+ positionals: dict[str, Any] = {
195
+ field.name: (field.annotation | None, None)
196
+ for field in cls.__monoconf_spec__.positionals
197
+ }
198
+ token = _DERIVING.set(True)
199
+ try:
200
+ derived = create_model(
201
+ cls.__name__, __base__=cls, __module__=cls.__module__, **positionals
202
+ )
203
+ finally:
204
+ _DERIVING.reset(token)
205
+ _derived[cls] = derived
206
+ return derived
207
+
208
+
209
+ def _help(spec: Spec, ctx: Context) -> str:
210
+ """The help text, showing the defaults the lower layers resolved.
211
+
212
+ Guarded, because help is what an operator reaches for when something is
213
+ already wrong: a file that cannot be parsed must not be able to withhold it.
214
+ Where a source raises, the schema's own defaults stand in and the reason is
215
+ printed beneath them, naming the file or the variable. Help always answers.
216
+ """
217
+ prog = program_name(spec.settings)
218
+ try:
219
+ layers, rejected = _accepted(_lower_layers(spec, ctx))
220
+ values, _origins = merge(layers)
221
+ except MonoconfError as error:
222
+ return (
223
+ f"{cli.render_help(spec, {})}\n{prog}: {error}\n{prog}: the defaults "
224
+ "shown above are the schema's own, since that could not be read."
225
+ )
226
+ text = cli.render_help(spec, values)
227
+ if not rejected:
228
+ return text
229
+ fields = spec.by_path()
230
+ notes = [
231
+ f"{prog}: {origin.describe()} is left out of the defaults shown above: "
232
+ f"{render_rejected(fields.get(path), value)}"
233
+ for path, (value, origin) in rejected.items()
234
+ ]
235
+ return f"{text}\n" + "\n".join(notes)
236
+
237
+
238
+ Rejections = dict[str, tuple[Rejected, Origin]]
239
+
240
+
241
+ def _accepted(layers: list[Layer]) -> tuple[list[Layer], Rejections]:
242
+ """The layers without the values their sources rejected, and those values.
243
+
244
+ A rejected value comes out before the merge, so pydantic never sees one and the
245
+ layers beneath decide its field. It is kept, with its origin, to be reported
246
+ with the validation errors: one run names every problem, not only the first.
247
+ """
248
+ kept: list[Layer] = []
249
+ rejected: Rejections = {}
250
+ for values, origins in layers:
251
+ bad = {
252
+ path: value for path, value in leaves(values) if isinstance(value, Rejected)
253
+ }
254
+ if not bad:
255
+ kept.append((values, origins))
256
+ continue
257
+ clean: Values = {}
258
+ for path, value in leaves(values):
259
+ if path not in bad:
260
+ set_leaf(clean, path, value)
261
+ kept.append((clean, {k: v for k, v in origins.items() if k not in bad}))
262
+ rejected.update({path: (value, origins[path]) for path, value in bad.items()})
263
+ return kept, rejected
264
+
265
+
266
+ def _lower_layers(spec: Spec, ctx: Context) -> list[Layer]:
267
+ """Every layer below the command line, lowest priority first.
268
+
269
+ The ordering of the chain lives in this one place and nowhere else. `files`
270
+ covers layers 2 to 5, which it folds internally: they share every code path,
271
+ so they are one source with an order of its own.
272
+ """
273
+ return [
274
+ defaults.collect(spec, ctx),
275
+ files.collect(spec, ctx),
276
+ env_source.collect(spec, ctx),
277
+ ]
278
+
279
+
280
+ def _write(stream: Any, text: str) -> None:
281
+ stream.write(text if text.endswith("\n") else f"{text}\n")
monoconf/errors.py ADDED
@@ -0,0 +1,285 @@
1
+ """Exceptions and warnings raised by monoconf.
2
+
3
+ `SchemaError` fires at class-definition time; everything else fires during
4
+ resolution. `load()` turns the resolution errors into a stderr message plus
5
+ `SystemExit(2)`, while `resolve()` lets them propagate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from pydantic import ValidationError
13
+
14
+ if TYPE_CHECKING: # merge.py and schema.py import this module, so these cannot be
15
+ from collections.abc import Mapping, Sequence
16
+
17
+ from pydantic_core import ErrorDetails
18
+
19
+ from monoconf.merge import Origin
20
+ from monoconf.schema import FieldSpec, Spec
21
+ from monoconf.sources import Rejected
22
+
23
+ __all__ = [
24
+ "ConfigFileError",
25
+ "HelpRequested",
26
+ "IgnoredValue",
27
+ "MonoconfError",
28
+ "MonoconfWarning",
29
+ "OutputRequested",
30
+ "SchemaError",
31
+ "ShowConfigRequested",
32
+ "SourceError",
33
+ "UnknownConfigKey",
34
+ "UnreadableSource",
35
+ "ValidationFailed",
36
+ "render_rejected",
37
+ "render_validation_error",
38
+ ]
39
+
40
+
41
+ class MonoconfError(Exception):
42
+ """Base class for every error monoconf raises."""
43
+
44
+
45
+ class SchemaError(MonoconfError):
46
+ """A `Config` subclass is malformed. Raised at class-definition time."""
47
+
48
+
49
+ class ConfigFileError(MonoconfError):
50
+ """A config file could not be read, parsed or interpreted."""
51
+
52
+
53
+ class SourceError(MonoconfError):
54
+ """The command line could not be parsed: an unknown flag, a missing value.
55
+
56
+ A bad value, from the command line or anywhere else, is a `ValidationFailed`.
57
+ """
58
+
59
+
60
+ class ValidationFailed(MonoconfError):
61
+ """The merged configuration failed validation.
62
+
63
+ Wraps pydantic's `ValidationError`, which stays available as `__cause__`.
64
+ """
65
+
66
+
67
+ class OutputRequested(MonoconfError):
68
+ """The command line asked for output instead of a config object.
69
+
70
+ `resolve()` raises this; `load()` writes `text` to stdout and exits with
71
+ `exit_code`.
72
+ """
73
+
74
+ def __init__(self, text: str, exit_code: int = 0) -> None:
75
+ super().__init__(text)
76
+ self.text = text
77
+ self.exit_code = exit_code
78
+
79
+
80
+ class HelpRequested(OutputRequested):
81
+ """`--help` or `-h` was given. Carries the rendered help text."""
82
+
83
+
84
+ class ShowConfigRequested(OutputRequested):
85
+ """`--show-config` was given. Carries the rendered TOML."""
86
+
87
+
88
+ class MonoconfWarning(UserWarning):
89
+ """Base class for every warning monoconf emits.
90
+
91
+ Filterable as a group: `warnings.filterwarnings("error", category=MonoconfWarning)`.
92
+ """
93
+
94
+
95
+ class UnknownConfigKey(MonoconfWarning):
96
+ """A config file contains a key matching no field."""
97
+
98
+
99
+ class IgnoredValue(MonoconfWarning):
100
+ """A file or environment variable set a required field, which is CLI-only."""
101
+
102
+
103
+ class UnreadableSource(MonoconfWarning):
104
+ """A config path or drop-in directory exists but could not be read."""
105
+
106
+
107
+ def render_validation_error(
108
+ prog: str,
109
+ spec: Spec,
110
+ origins: Mapping[str, Origin],
111
+ error: ValidationError | None,
112
+ rejected: Mapping[str, tuple[Rejected, Origin]],
113
+ ) -> str:
114
+ """Rewrite pydantic's validation errors for whoever has to act on them.
115
+
116
+ Pydantic's own output is addressed to the author of the model. The operator
117
+ reading this never saw it, and needs to know which setting is wrong and which
118
+ file or variable it came from. One block per error; never only the first.
119
+
120
+ `rejected` are the values a source could not read at all, keyed by path. They
121
+ are reported in the same list, in declaration order with the rest: to the
122
+ operator they are bad settings like any other.
123
+ """
124
+ # No "try --help" line here: that is advice for someone at a command line, and
125
+ # load() is the only caller that knows it is talking to one.
126
+ fields = spec.by_path()
127
+ blocks: list[tuple[str, str, str]] = [
128
+ (
129
+ path,
130
+ f"{path}: {render_rejected(fields.get(path), value)}",
131
+ f"from {origin.describe()}",
132
+ )
133
+ for path, (value, origin) in rejected.items()
134
+ ]
135
+ for detail in error.errors() if error is not None else ():
136
+ loc = tuple(detail["loc"])
137
+ field_path, shown = _locate(loc, fields)
138
+ problem = _problem(detail, fields.get(field_path))
139
+ provenance = _provenance(detail, loc, origins, shown)
140
+ blocks.append((field_path, f"{shown or '(root)'}: {problem}", provenance))
141
+ order = [field.path for field in spec.fields]
142
+ blocks.sort(key=lambda block: _position(block[0], order))
143
+ lines = [f"{prog}: invalid configuration", ""]
144
+ for _path, head, provenance in blocks:
145
+ lines.extend((f" {head}", f" {provenance}", ""))
146
+ return "\n".join(lines).rstrip()
147
+
148
+
149
+ def render_rejected(field: FieldSpec | None, rejected: Rejected) -> str:
150
+ """A value a source could not read, said as a problem with that value."""
151
+ shown = "the secret" if field is not None and field.secret else _shown(rejected.raw)
152
+ return f"{shown} {rejected.problem}"
153
+
154
+
155
+ def _position(path: str, order: Sequence[str]) -> int:
156
+ """Where a path's first field is declared; a model-wide error goes last."""
157
+ for index, field_path in enumerate(order):
158
+ if field_path == path or field_path.startswith(f"{path}."):
159
+ return index
160
+ return len(order)
161
+
162
+
163
+ def _locate(
164
+ loc: tuple[int | str, ...], fields: Mapping[str, FieldSpec]
165
+ ) -> tuple[str, str]:
166
+ """The field an error belongs to, and the path to show the operator.
167
+
168
+ Past a leaf field, pydantic's location carries segments of its own -- the
169
+ member of a union it tried, a validator's name -- which mean nothing to the
170
+ person who wrote the file. List indices are kept, as `tags[1]`.
171
+ """
172
+ parts: list[str] = []
173
+ shown = ""
174
+ for part in loc:
175
+ field_path = ".".join(parts)
176
+ if isinstance(part, int):
177
+ shown += f"[{part}]"
178
+ continue
179
+ if field_path in fields:
180
+ continue
181
+ parts.append(part)
182
+ shown = f"{shown}.{part}" if shown else part
183
+ return ".".join(parts), shown
184
+
185
+
186
+ # Pydantic's error types, as the operator would say them. The value itself is
187
+ # named in front of each: "'nine thousand' is not a valid integer".
188
+ _PROBLEMS: dict[str, str] = {
189
+ "int_parsing": "is not a valid integer",
190
+ "int_type": "is not a valid integer",
191
+ "int_from_float": "is not a whole number",
192
+ "float_parsing": "is not a valid number",
193
+ "float_type": "is not a valid number",
194
+ "decimal_parsing": "is not a valid decimal number",
195
+ "decimal_type": "is not a valid decimal number",
196
+ "bool_parsing": "is not a valid boolean; use true or false",
197
+ "bool_type": "is not a valid boolean; use true or false",
198
+ "string_type": "is not a string; in a config file, quote it",
199
+ "list_type": "is not a list; in a config file, write it as an array",
200
+ "set_type": "is not a list; in a config file, write it as an array",
201
+ "model_type": "is not a table of settings",
202
+ "model_attributes_type": "is not a table of settings",
203
+ "dict_type": "is not a table of settings",
204
+ "uuid_parsing": "is not a valid UUID",
205
+ "uuid_type": "is not a valid UUID",
206
+ "date_parsing": "is not a valid date; use YYYY-MM-DD",
207
+ "date_from_datetime_parsing": "is not a valid date; use YYYY-MM-DD",
208
+ "date_type": "is not a valid date; use YYYY-MM-DD",
209
+ "datetime_parsing": "is not a valid date and time; use YYYY-MM-DDTHH:MM:SS",
210
+ "datetime_from_date_parsing": (
211
+ "is not a valid date and time; use YYYY-MM-DDTHH:MM:SS"
212
+ ),
213
+ "datetime_type": "is not a valid date and time; use YYYY-MM-DDTHH:MM:SS",
214
+ "time_parsing": "is not a valid time; use HH:MM:SS",
215
+ "time_type": "is not a valid time; use HH:MM:SS",
216
+ "time_delta_parsing": "is not a valid duration",
217
+ "time_delta_type": "is not a valid duration",
218
+ "url_parsing": "is not a valid URL",
219
+ "url_type": "is not a valid URL",
220
+ }
221
+
222
+ _CHOICE_ERRORS = frozenset({"enum", "literal_error"})
223
+
224
+ # How much of a value to quote back. A whole table dumped into one line of an
225
+ # error message hides the message.
226
+ _MAX_SHOWN = 60
227
+
228
+
229
+ def _problem(detail: ErrorDetails, field: FieldSpec | None) -> str:
230
+ kind = detail["type"]
231
+ message = detail["msg"]
232
+ if kind == "missing":
233
+ return "is required"
234
+ validator = kind in ("value_error", "assertion_error")
235
+ if validator:
236
+ # A validator's own words, without pydantic's "Value error, " label.
237
+ _, _, message = message.partition(", ")
238
+ if field is None:
239
+ # A model-level check: the input is a whole table, and quoting it
240
+ # back would bury the reason.
241
+ return message
242
+ # A rejected secret is usually a near miss of the real one: never quote it.
243
+ shown = (
244
+ "the secret" if field is not None and field.secret else _shown(detail["input"])
245
+ )
246
+ if kind in _CHOICE_ERRORS:
247
+ choices = None if field is None else field.choices
248
+ if choices is None:
249
+ return f"{shown} is not one of {detail.get('ctx', {}).get('expected')}"
250
+ return f"{shown} is not one of: {', '.join(choices)}"
251
+ if kind in _PROBLEMS:
252
+ return f"{shown} {_PROBLEMS[kind]}"
253
+ if validator:
254
+ return f"{shown} was rejected: {message}"
255
+ head, found, rest = message.partition("Input should be ")
256
+ if found and not head:
257
+ return f"{shown} should be {rest}"
258
+ return f"{shown} was rejected: {message[:1].lower()}{message[1:]}"
259
+
260
+
261
+ def _shown(value: Any) -> str:
262
+ text = repr(value)
263
+ return text if len(text) <= _MAX_SHOWN else f"{text[: _MAX_SHOWN - 3]}..."
264
+
265
+
266
+ def _provenance(
267
+ detail: ErrorDetails,
268
+ loc: tuple[int | str, ...],
269
+ origins: Mapping[str, Origin],
270
+ shown: str,
271
+ ) -> str:
272
+ """Where the offending value came from: the longest located prefix's origin.
273
+
274
+ A list is a value one layer set whole, so an error at `tags.1` came from
275
+ wherever `tags` did.
276
+ """
277
+ parts = [str(part) for part in loc]
278
+ for end in range(len(parts), 0, -1):
279
+ origin = origins.get(".".join(parts[:end]))
280
+ if origin is not None:
281
+ return f"from {origin.describe()}"
282
+ if detail["type"] == "missing":
283
+ return "no value from any source"
284
+ subject = f"'{shown}'" if shown else "the configuration"
285
+ return f"from a check on {subject} as a whole"