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 +46 -0
- monoconf/config.py +281 -0
- monoconf/errors.py +285 -0
- monoconf/fields.py +171 -0
- monoconf/merge.py +151 -0
- monoconf/py.typed +0 -0
- monoconf/schema.py +643 -0
- monoconf/settings.py +196 -0
- monoconf/sources/__init__.py +72 -0
- monoconf/sources/cli.py +435 -0
- monoconf/sources/defaults.py +30 -0
- monoconf/sources/env.py +95 -0
- monoconf/sources/files.py +422 -0
- monoconf/toml.py +195 -0
- monoconf-0.1.0.dist-info/METADATA +389 -0
- monoconf-0.1.0.dist-info/RECORD +18 -0
- monoconf-0.1.0.dist-info/WHEEL +4 -0
- monoconf-0.1.0.dist-info/licenses/LICENSE +21 -0
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"
|