rtfc 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.
- rtfc/__init__.py +5 -0
- rtfc/__main__.py +6 -0
- rtfc/_changelog.py +46 -0
- rtfc/_cli.py +227 -0
- rtfc/_config.py +298 -0
- rtfc/_entry.py +256 -0
- rtfc/_format/__init__.py +39 -0
- rtfc/_format/base.py +59 -0
- rtfc/_format/rst.py +22 -0
- rtfc/_render/__init__.py +15 -0
- rtfc/_render/base.py +98 -0
- rtfc/_render/jinja.py +78 -0
- rtfc/_validation.py +529 -0
- rtfc/py.typed +0 -0
- rtfc/sphinx.py +105 -0
- rtfc-0.1.0.dist-info/METADATA +122 -0
- rtfc-0.1.0.dist-info/RECORD +20 -0
- rtfc-0.1.0.dist-info/WHEEL +4 -0
- rtfc-0.1.0.dist-info/entry_points.txt +2 -0
- rtfc-0.1.0.dist-info/licenses/LICENSE +21 -0
rtfc/_validation.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Standalone validation framework for TOML-sourced data (config and frontmatter).
|
|
2
|
+
|
|
3
|
+
Validators are composable objects implementing the :class:`Validator` protocol.
|
|
4
|
+
Schemas declare fields as :class:`Field` descriptors, making validated attribute
|
|
5
|
+
access fully type-checked without any annotation introspection::
|
|
6
|
+
|
|
7
|
+
class Section(Schema):
|
|
8
|
+
label = Field(str_)
|
|
9
|
+
|
|
10
|
+
section = Section.validate({'label': 'Features'})
|
|
11
|
+
section.label # inferred as ``str``
|
|
12
|
+
|
|
13
|
+
Scalar validators use exact type checks: a ``bool`` is not accepted where an
|
|
14
|
+
``int`` is expected, nor a ``datetime`` where a ``date`` is expected.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime
|
|
20
|
+
import inspect
|
|
21
|
+
import pathlib
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from dataclasses import field as dataclass_field
|
|
25
|
+
from typing import (
|
|
26
|
+
Any,
|
|
27
|
+
ClassVar,
|
|
28
|
+
Generic,
|
|
29
|
+
Literal,
|
|
30
|
+
Never,
|
|
31
|
+
Protocol,
|
|
32
|
+
Self,
|
|
33
|
+
TypeAlias,
|
|
34
|
+
TypeVar,
|
|
35
|
+
cast,
|
|
36
|
+
overload,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from typing_extensions import Sentinel
|
|
40
|
+
|
|
41
|
+
__all__ = (
|
|
42
|
+
"Field",
|
|
43
|
+
"Issue",
|
|
44
|
+
"KeyPath",
|
|
45
|
+
"Schema",
|
|
46
|
+
"ValidationContext",
|
|
47
|
+
"ValidationError",
|
|
48
|
+
"Validator",
|
|
49
|
+
"any_",
|
|
50
|
+
"bool_",
|
|
51
|
+
"dict_of",
|
|
52
|
+
"dir_path",
|
|
53
|
+
"file_path",
|
|
54
|
+
"float_",
|
|
55
|
+
"int_",
|
|
56
|
+
"iso_date",
|
|
57
|
+
"list_of",
|
|
58
|
+
"nullable",
|
|
59
|
+
"one_of",
|
|
60
|
+
"record",
|
|
61
|
+
"str_",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
T = TypeVar("T")
|
|
65
|
+
|
|
66
|
+
KeyPath: TypeAlias = tuple[str | int, ...]
|
|
67
|
+
"""Location of a value relative to the document root, e.g. ``('sections', 'feature', 'label')``."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ValidationContext:
|
|
72
|
+
"""Contextual information carried through validators during validation."""
|
|
73
|
+
|
|
74
|
+
path: KeyPath = ()
|
|
75
|
+
"""Location of the value being validated in the document it is part of."""
|
|
76
|
+
|
|
77
|
+
current_directory: pathlib.Path = dataclass_field(default_factory=pathlib.Path.cwd)
|
|
78
|
+
"""Directory against which relative paths are resolved."""
|
|
79
|
+
|
|
80
|
+
def at(self, part: str | int) -> ValidationContext:
|
|
81
|
+
"""Return a copy with ``part`` appended to the location path."""
|
|
82
|
+
return ValidationContext(path=(*self.path, part), current_directory=self.current_directory)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_path(path: KeyPath) -> str:
|
|
86
|
+
formatted = ""
|
|
87
|
+
for part in path:
|
|
88
|
+
if isinstance(part, int):
|
|
89
|
+
formatted += f"[{part}]"
|
|
90
|
+
else:
|
|
91
|
+
formatted += f".{part}" if formatted else part
|
|
92
|
+
return formatted or "<root>"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Issue:
|
|
97
|
+
"""A single validation failure at a specific location."""
|
|
98
|
+
|
|
99
|
+
path: KeyPath
|
|
100
|
+
message: str
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
return f"{_format_path(self.path)}: {self.message}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ValidationError(Exception):
|
|
107
|
+
"""Raised when validation fails, aggregating one or more issues."""
|
|
108
|
+
|
|
109
|
+
issues: list[Issue]
|
|
110
|
+
|
|
111
|
+
def __init__(self, issues: list[Issue]) -> None:
|
|
112
|
+
super().__init__("\n".join(str(issue) for issue in issues))
|
|
113
|
+
self.issues = issues
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def single(cls, path: KeyPath, message: str) -> ValidationError:
|
|
117
|
+
"""Create an error holding a single issue."""
|
|
118
|
+
return cls([Issue(path, message)])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Invariant and not covariant to ensure the inference of `T` in `Field(validator, default=...)`
|
|
122
|
+
# matches exactly the validator's type (otherwise `Field(str_, default=3)` widens to `str | int`):
|
|
123
|
+
class Validator(Protocol[T]): # pyright: ignore[reportInvalidTypeVarUse]
|
|
124
|
+
"""A composable validator producing values of a fixed type."""
|
|
125
|
+
|
|
126
|
+
def validate(self, value: object, context: ValidationContext) -> T:
|
|
127
|
+
"""Validate ``value``, returning it (possibly converted) on success.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
value: The value to validate.
|
|
131
|
+
context: The validation context, carrying the location of
|
|
132
|
+
``value`` in the document it is part of. Container validators
|
|
133
|
+
extend the location as they recurse (e.g. with the key of each
|
|
134
|
+
table value), so that reported issues carry the full location
|
|
135
|
+
of the offending value, like ``sections.feature.label``.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ValidationError: If ``value`` is invalid.
|
|
139
|
+
"""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _Exact(Validator[T]):
|
|
144
|
+
def __init__(self, typ: type[T], name: str) -> None:
|
|
145
|
+
self._typ = typ
|
|
146
|
+
self._name = name
|
|
147
|
+
|
|
148
|
+
def validate(self, value: object, context: ValidationContext) -> T:
|
|
149
|
+
# An exact type check avoids `bool` passing as `int` (and similar subclass leaks):
|
|
150
|
+
if type(value) is not self._typ:
|
|
151
|
+
raise ValidationError.single(context.path, f"expected {self._name}, got {type(value).__name__}")
|
|
152
|
+
return cast(T, value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _Float(Validator[float]):
|
|
156
|
+
def validate(self, value: object, context: ValidationContext) -> float:
|
|
157
|
+
if type(value) is float:
|
|
158
|
+
return value
|
|
159
|
+
if type(value) is int:
|
|
160
|
+
return float(value)
|
|
161
|
+
raise ValidationError.single(context.path, f"expected float, got {type(value).__name__}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _IsoDate(Validator[datetime.date]):
|
|
165
|
+
def validate(self, value: object, context: ValidationContext) -> datetime.date:
|
|
166
|
+
# Exact check: `datetime` is a `date` subclass but is not accepted here.
|
|
167
|
+
if type(value) is datetime.date:
|
|
168
|
+
return value
|
|
169
|
+
if type(value) is str:
|
|
170
|
+
try:
|
|
171
|
+
return datetime.date.fromisoformat(value)
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise ValidationError.single(context.path, f"invalid ISO date: {value!r}") from None
|
|
174
|
+
raise ValidationError.single(context.path, f"expected ISO date, got {type(value).__name__}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _PathValidator(Validator[pathlib.Path]):
|
|
178
|
+
def __init__(self, kind: Literal["file", "directory"] | None = None) -> None:
|
|
179
|
+
self._kind: Literal["file", "directory"] | None = kind
|
|
180
|
+
|
|
181
|
+
def validate(self, value: object, context: ValidationContext) -> pathlib.Path:
|
|
182
|
+
if not isinstance(value, str | pathlib.Path):
|
|
183
|
+
raise ValidationError.single(context.path, f"expected path, got {type(value).__name__}")
|
|
184
|
+
resolved = context.current_directory / value
|
|
185
|
+
if not resolved.exists():
|
|
186
|
+
raise ValidationError.single(context.path, f"path {str(value)!r} does not exist")
|
|
187
|
+
if self._kind == "file" and not resolved.is_file():
|
|
188
|
+
raise ValidationError.single(context.path, f"path {str(value)!r} is not a file")
|
|
189
|
+
if self._kind == "directory" and not resolved.is_dir():
|
|
190
|
+
raise ValidationError.single(context.path, f"path {str(value)!r} is not a directory")
|
|
191
|
+
return resolved
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _Anything(Validator[Any]):
|
|
195
|
+
def validate(self, value: object, context: ValidationContext) -> Any:
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
bool_: Validator[bool] = _Exact(bool, "bool")
|
|
200
|
+
"""Validator accepting exactly :class:`bool` values."""
|
|
201
|
+
|
|
202
|
+
int_: Validator[int] = _Exact(int, "int")
|
|
203
|
+
"""Validator accepting exactly :class:`int` values."""
|
|
204
|
+
|
|
205
|
+
float_: Validator[float] = _Float()
|
|
206
|
+
"""Validator accepting :class:`float` values, converting from :class:`int`."""
|
|
207
|
+
|
|
208
|
+
str_: Validator[str] = _Exact(str, "str")
|
|
209
|
+
"""Validator accepting exactly :class:`str` values."""
|
|
210
|
+
|
|
211
|
+
iso_date: Validator[datetime.date] = _IsoDate()
|
|
212
|
+
"""Validator accepting :class:`datetime.date` values or ISO format strings."""
|
|
213
|
+
|
|
214
|
+
file_path: Validator[pathlib.Path] = _PathValidator("file")
|
|
215
|
+
"""Validator accepting :class:`str` or :class:`~pathlib.Path` values pointing to an existing file.
|
|
216
|
+
|
|
217
|
+
Relative paths are resolved against the context's current directory, and stored resolved.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
dir_path: Validator[pathlib.Path] = _PathValidator("directory")
|
|
221
|
+
"""Validator accepting :class:`str` or :class:`~pathlib.Path` values pointing to an existing directory.
|
|
222
|
+
|
|
223
|
+
Relative paths are resolved against the context's current directory, and stored resolved.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
any_: Validator[Any] = _Anything()
|
|
227
|
+
"""Validator accepting any value unchanged."""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _Nullable(Validator[T | None]):
|
|
231
|
+
def __init__(self, inner: Validator[T]) -> None:
|
|
232
|
+
self._inner = inner
|
|
233
|
+
|
|
234
|
+
def validate(self, value: object, context: ValidationContext) -> T | None:
|
|
235
|
+
if value is None:
|
|
236
|
+
return None
|
|
237
|
+
return self._inner.validate(value, context)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def nullable(validator: Validator[T]) -> Validator[T | None]:
|
|
241
|
+
"""Wrap ``validator`` to also accept ``None``."""
|
|
242
|
+
return _Nullable(validator)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _OneOf(Validator[T]):
|
|
246
|
+
def __init__(self, values: tuple[T, ...]) -> None:
|
|
247
|
+
self._values = values
|
|
248
|
+
|
|
249
|
+
def validate(self, value: object, context: ValidationContext) -> T:
|
|
250
|
+
for allowed in self._values:
|
|
251
|
+
# An exact type check avoids e.g. `True` matching an allowed `1`:
|
|
252
|
+
if type(value) is type(allowed) and value == allowed:
|
|
253
|
+
return cast(T, value)
|
|
254
|
+
expected = ", ".join(map(repr, self._values))
|
|
255
|
+
raise ValidationError.single(context.path, f"expected one of {expected}, got {value!r}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def one_of(*values: T) -> Validator[T]:
|
|
259
|
+
"""Create a validator accepting only the given values."""
|
|
260
|
+
if not values:
|
|
261
|
+
raise TypeError("one_of() requires at least one value")
|
|
262
|
+
return _OneOf(values)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class _List(Validator[list[T]]):
|
|
266
|
+
def __init__(self, item: Validator[T]) -> None:
|
|
267
|
+
self._item = item
|
|
268
|
+
|
|
269
|
+
def validate(self, value: object, context: ValidationContext) -> list[T]:
|
|
270
|
+
if type(value) is not list:
|
|
271
|
+
raise ValidationError.single(context.path, f"expected list, got {type(value).__name__}")
|
|
272
|
+
items: list[T] = []
|
|
273
|
+
issues: list[Issue] = []
|
|
274
|
+
for i, item in enumerate(value):
|
|
275
|
+
try:
|
|
276
|
+
items.append(self._item.validate(item, context.at(i)))
|
|
277
|
+
except ValidationError as exc:
|
|
278
|
+
issues.extend(exc.issues)
|
|
279
|
+
if issues:
|
|
280
|
+
raise ValidationError(issues)
|
|
281
|
+
return items
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def list_of(item: Validator[T]) -> Validator[list[T]]:
|
|
285
|
+
"""Create a validator for lists with items validated by ``item``."""
|
|
286
|
+
return _List(item)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _Dict(Validator[dict[str, T]]):
|
|
290
|
+
def __init__(self, item: Validator[T]) -> None:
|
|
291
|
+
self._item = item
|
|
292
|
+
|
|
293
|
+
def validate(self, value: object, context: ValidationContext) -> dict[str, T]:
|
|
294
|
+
if not isinstance(value, Mapping):
|
|
295
|
+
raise ValidationError.single(context.path, f"expected table, got {type(value).__name__}")
|
|
296
|
+
items: dict[str, T] = {}
|
|
297
|
+
issues: list[Issue] = []
|
|
298
|
+
for key, item in value.items():
|
|
299
|
+
if type(key) is not str:
|
|
300
|
+
issues.append(Issue(context.path, f"expected string key, got {key!r}"))
|
|
301
|
+
continue
|
|
302
|
+
try:
|
|
303
|
+
items[key] = self._item.validate(item, context.at(key))
|
|
304
|
+
except ValidationError as exc:
|
|
305
|
+
issues.extend(exc.issues)
|
|
306
|
+
if issues:
|
|
307
|
+
raise ValidationError(issues)
|
|
308
|
+
return items
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def dict_of(item: Validator[T]) -> Validator[dict[str, T]]:
|
|
312
|
+
"""Create a validator for string-keyed tables with values validated by ``item``."""
|
|
313
|
+
return _Dict(item)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_UNSET = Sentinel("_UNSET")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _takes_context(factory: Callable[..., Any]) -> bool:
|
|
320
|
+
"""Whether a default factory takes the validation context as an argument."""
|
|
321
|
+
try:
|
|
322
|
+
parameters = inspect.signature(factory).parameters.values()
|
|
323
|
+
except (TypeError, ValueError):
|
|
324
|
+
# Some builtin callables (e.g. `dict`) expose no signature; they are
|
|
325
|
+
# only ever useful as zero-argument factories here:
|
|
326
|
+
return False
|
|
327
|
+
return any(
|
|
328
|
+
parameter.default is parameter.empty
|
|
329
|
+
and parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD)
|
|
330
|
+
for parameter in parameters
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class Field(Generic[T]):
|
|
335
|
+
"""Descriptor declaring a validated :class:`Schema` field.
|
|
336
|
+
|
|
337
|
+
The attribute type is inferred from the validator, e.g. ``Field(str_)``
|
|
338
|
+
produces a ``str`` attribute. A field with no default is required.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
validator: Validator applied to the raw value.
|
|
342
|
+
default: Value used when the key is absent.
|
|
343
|
+
default_factory: A callable producing the default, for mutable or
|
|
344
|
+
context-dependent defaults. Either takes no argument, or the
|
|
345
|
+
:class:`ValidationContext` (e.g. to resolve a default path against
|
|
346
|
+
the context's current directory). Mutually exclusive with
|
|
347
|
+
``default``.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __init__(
|
|
351
|
+
self,
|
|
352
|
+
validator: Validator[T],
|
|
353
|
+
*,
|
|
354
|
+
default: T | _UNSET = _UNSET,
|
|
355
|
+
default_factory: Callable[[], T] | Callable[[ValidationContext], T] | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
if default is not _UNSET and default_factory is not None:
|
|
358
|
+
raise TypeError("'default' and 'default_factory' are mutually exclusive")
|
|
359
|
+
self._validator = validator
|
|
360
|
+
self._default = default
|
|
361
|
+
self._default_factory: Callable[[ValidationContext], T] | None
|
|
362
|
+
if default_factory is not None and not _takes_context(default_factory):
|
|
363
|
+
zero_arg_factory = cast("Callable[[], T]", default_factory)
|
|
364
|
+
self._default_factory = lambda context: zero_arg_factory()
|
|
365
|
+
else:
|
|
366
|
+
self._default_factory = cast("Callable[[ValidationContext], T] | None", default_factory)
|
|
367
|
+
self._name = "<unbound>"
|
|
368
|
+
|
|
369
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
370
|
+
self._name = name
|
|
371
|
+
|
|
372
|
+
@overload
|
|
373
|
+
def __get__(self, obj: None, owner: type) -> Self: ...
|
|
374
|
+
@overload
|
|
375
|
+
def __get__(self, obj: object, owner: type | None = None) -> T: ...
|
|
376
|
+
def __get__(self, obj: object | None, owner: type | None = None) -> Self | T:
|
|
377
|
+
if obj is None:
|
|
378
|
+
return self
|
|
379
|
+
return cast(T, obj.__dict__[self._name])
|
|
380
|
+
|
|
381
|
+
def __set__(self, obj: object, value: Never) -> None:
|
|
382
|
+
raise AttributeError(f"{type(obj).__name__!r} instances are frozen")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _validate_fields(fields: Mapping[str, Field[Any]], value: object, context: ValidationContext) -> dict[str, Any]:
|
|
386
|
+
"""Validate a mapping against a fixed set of fields, collecting all issues.
|
|
387
|
+
|
|
388
|
+
Missing fields take their default, which is validated like a provided
|
|
389
|
+
value; fields without a default are required. Unknown keys are rejected.
|
|
390
|
+
"""
|
|
391
|
+
if not isinstance(value, Mapping):
|
|
392
|
+
raise ValidationError.single(context.path, f"expected table, got {type(value).__name__}")
|
|
393
|
+
values: dict[str, Any] = {}
|
|
394
|
+
issues: list[Issue] = []
|
|
395
|
+
for name, field in fields.items():
|
|
396
|
+
if name in value:
|
|
397
|
+
candidate = value[name]
|
|
398
|
+
elif field._default is not _UNSET:
|
|
399
|
+
candidate = field._default
|
|
400
|
+
elif field._default_factory is not None:
|
|
401
|
+
candidate = field._default_factory(context)
|
|
402
|
+
else:
|
|
403
|
+
issues.append(Issue((*context.path, name), "missing required key"))
|
|
404
|
+
continue
|
|
405
|
+
try:
|
|
406
|
+
values[name] = field._validator.validate(candidate, context.at(name))
|
|
407
|
+
except ValidationError as exc:
|
|
408
|
+
issues.extend(exc.issues)
|
|
409
|
+
for key in value:
|
|
410
|
+
if key not in fields:
|
|
411
|
+
issues.append(Issue((*context.path, str(key)), "unknown key"))
|
|
412
|
+
if issues:
|
|
413
|
+
raise ValidationError(issues)
|
|
414
|
+
return values
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class _Record(Validator[dict[str, Any]]):
|
|
418
|
+
def __init__(self, fields: dict[str, Field[Any]]) -> None:
|
|
419
|
+
self._fields = fields
|
|
420
|
+
|
|
421
|
+
def validate(self, value: object, context: ValidationContext) -> dict[str, Any]:
|
|
422
|
+
return _validate_fields(self._fields, value, context)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def record(fields: Mapping[str, Field[Any]]) -> Validator[dict[str, Any]]:
|
|
426
|
+
"""Create a validator for a table with a fixed set of fields, returning a plain dict.
|
|
427
|
+
|
|
428
|
+
Missing fields take their default; fields without a default are required.
|
|
429
|
+
Unknown keys are rejected.
|
|
430
|
+
"""
|
|
431
|
+
return _Record(dict(fields))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class Schema: # noqa: PLW1641
|
|
435
|
+
"""Base class for validated mappings.
|
|
436
|
+
|
|
437
|
+
Subclasses declare fields as class attributes::
|
|
438
|
+
|
|
439
|
+
class Config(Schema):
|
|
440
|
+
directory = Field(str_, default='changelog.d')
|
|
441
|
+
|
|
442
|
+
Instances are frozen and cannot be created directly: use :meth:`validate`
|
|
443
|
+
for untrusted data or :meth:`construct` for known-valid values. Schema
|
|
444
|
+
classes themselves satisfy the :class:`Validator` protocol, so they can be
|
|
445
|
+
nested: ``Field(Config)``, ``dict_of(Config)``.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
__fields__: ClassVar[dict[str, Field[Any]]] = {}
|
|
449
|
+
|
|
450
|
+
def __init__(self, *args: Never, **kwargs: Never) -> None:
|
|
451
|
+
raise TypeError(
|
|
452
|
+
f"{type(self).__name__!r} cannot be instantiated directly, use 'validate()' or 'construct()' instead"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
456
|
+
super().__init_subclass__(**kwargs)
|
|
457
|
+
cls.__fields__ = cls.__fields__ | {name: field for name, field in vars(cls).items() if isinstance(field, Field)}
|
|
458
|
+
|
|
459
|
+
@classmethod
|
|
460
|
+
def validate(cls, value: object, context: ValidationContext | None = None) -> Self:
|
|
461
|
+
"""Validate a mapping against the schema.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
value: The value to validate. Must be a mapping.
|
|
465
|
+
context: The validation context: the location of ``value`` in the
|
|
466
|
+
document it is part of, used to report issues with their full
|
|
467
|
+
location, and the directory relative paths are resolved
|
|
468
|
+
against. Defaults to an empty location and the current working
|
|
469
|
+
directory.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
A frozen instance with validated field values.
|
|
473
|
+
|
|
474
|
+
Raises:
|
|
475
|
+
ValidationError: If ``value`` does not conform to the schema. All
|
|
476
|
+
issues are collected and reported together.
|
|
477
|
+
"""
|
|
478
|
+
if isinstance(value, cls):
|
|
479
|
+
# Already-validated instances (e.g. used as field defaults) are passed through:
|
|
480
|
+
return value
|
|
481
|
+
context = ValidationContext() if context is None else context
|
|
482
|
+
values = _validate_fields(cls.__fields__, value, context)
|
|
483
|
+
self = cls.__new__(cls)
|
|
484
|
+
self.__dict__.update(values)
|
|
485
|
+
self.__post_validate__(context)
|
|
486
|
+
return self
|
|
487
|
+
|
|
488
|
+
def __post_validate__(self, context: ValidationContext) -> None:
|
|
489
|
+
"""Validate constraints spanning multiple fields.
|
|
490
|
+
|
|
491
|
+
Called by :meth:`validate` on the constructed instance, once all fields
|
|
492
|
+
validated successfully. Not called by :meth:`construct`.
|
|
493
|
+
|
|
494
|
+
Raises:
|
|
495
|
+
ValidationError: If the constraints are not met.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def construct(cls, **values: Any) -> Self:
|
|
500
|
+
"""Create an instance from known-valid field values, without validation.
|
|
501
|
+
|
|
502
|
+
Missing fields take their default; fields without a default are
|
|
503
|
+
required.
|
|
504
|
+
|
|
505
|
+
Raises:
|
|
506
|
+
TypeError: If a required field is missing or an unknown field is provided.
|
|
507
|
+
"""
|
|
508
|
+
self = cls.__new__(cls)
|
|
509
|
+
for name, field in cls.__fields__.items():
|
|
510
|
+
if name in values:
|
|
511
|
+
self.__dict__[name] = values.pop(name)
|
|
512
|
+
elif field._default is not _UNSET:
|
|
513
|
+
self.__dict__[name] = field._default
|
|
514
|
+
elif field._default_factory is not None:
|
|
515
|
+
self.__dict__[name] = field._default_factory(ValidationContext())
|
|
516
|
+
else:
|
|
517
|
+
raise TypeError(f"missing value for field {name!r}")
|
|
518
|
+
if values:
|
|
519
|
+
raise TypeError(f"unknown fields: {', '.join(values)}")
|
|
520
|
+
return self
|
|
521
|
+
|
|
522
|
+
def __repr__(self) -> str:
|
|
523
|
+
args = ", ".join(f"{name}={self.__dict__[name]!r}" for name in type(self).__fields__)
|
|
524
|
+
return f"{type(self).__name__}({args})"
|
|
525
|
+
|
|
526
|
+
def __eq__(self, other: object) -> bool:
|
|
527
|
+
if type(other) is not type(self):
|
|
528
|
+
return NotImplemented
|
|
529
|
+
return self.__dict__ == other.__dict__
|
rtfc/py.typed
ADDED
|
File without changes
|
rtfc/sphinx.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Sphinx extension injecting the unreleased changelog entries at build time.
|
|
2
|
+
|
|
3
|
+
Add the extension to ``conf.py`` and point it at the directory holding the
|
|
4
|
+
rtfc configuration (relative paths are resolved against the ``conf.py``
|
|
5
|
+
directory)::
|
|
6
|
+
|
|
7
|
+
extensions = ["rtfc.sphinx"]
|
|
8
|
+
rtfc_config_directory = "../.."
|
|
9
|
+
|
|
10
|
+
Then use the directive where the unreleased changes should appear, typically
|
|
11
|
+
right above the insert marker::
|
|
12
|
+
|
|
13
|
+
Changelog
|
|
14
|
+
=========
|
|
15
|
+
|
|
16
|
+
.. rtfc-unreleased::
|
|
17
|
+
|
|
18
|
+
.. rtfc-insert
|
|
19
|
+
|
|
20
|
+
The entries are injected in the built documentation only. Released versions are
|
|
21
|
+
inserted into it by ``rtfc build``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib.metadata
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from textwrap import indent
|
|
29
|
+
|
|
30
|
+
from docutils import nodes
|
|
31
|
+
from sphinx.application import Sphinx
|
|
32
|
+
from sphinx.util.docutils import SphinxDirective
|
|
33
|
+
from sphinx.util.typing import ExtensionMetadata
|
|
34
|
+
|
|
35
|
+
from rtfc._config import ConfigError, load_config
|
|
36
|
+
from rtfc._entry import ENTRY_SUFFIX, EntryError, load_entries
|
|
37
|
+
from rtfc._format import FormatError, get_format
|
|
38
|
+
from rtfc._render import JinjaRenderer, RenderError
|
|
39
|
+
|
|
40
|
+
__all__ = ("UnreleasedDirective", "setup")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UnreleasedDirective(SphinxDirective):
|
|
44
|
+
"""Render the unreleased changelog entries under an ``Unreleased`` heading.
|
|
45
|
+
|
|
46
|
+
The directive content, when given, is rendered as a note admonition below
|
|
47
|
+
the heading::
|
|
48
|
+
|
|
49
|
+
.. rtfc-unreleased::
|
|
50
|
+
|
|
51
|
+
This version is not yet released and is under active development.
|
|
52
|
+
|
|
53
|
+
Renders nothing when there are no entries.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
has_content = True
|
|
57
|
+
|
|
58
|
+
def run(self) -> list[nodes.Node]:
|
|
59
|
+
directory = Path(self.config.rtfc_config_directory)
|
|
60
|
+
if not directory.is_absolute():
|
|
61
|
+
directory = (Path(self.env.app.confdir) / directory).resolve()
|
|
62
|
+
try:
|
|
63
|
+
config = load_config(directory)
|
|
64
|
+
self._note_dependencies(directory, config.directory)
|
|
65
|
+
fmt = get_format(config.format)
|
|
66
|
+
entries = load_entries(
|
|
67
|
+
config.directory, sections=config.sections, metadata_validator=config.metadata_validator()
|
|
68
|
+
)
|
|
69
|
+
if not entries:
|
|
70
|
+
return []
|
|
71
|
+
header = fmt.unreleased_header()
|
|
72
|
+
if self.content:
|
|
73
|
+
note = indent("\n".join(self.content), " ")
|
|
74
|
+
header = f"{header}\n\n.. note::\n\n{note}"
|
|
75
|
+
renderer = JinjaRenderer(config=config, fmt=fmt)
|
|
76
|
+
block = renderer.render_block(entries, header=header)
|
|
77
|
+
except (ConfigError, EntryError, FormatError, RenderError) as exc:
|
|
78
|
+
raise self.error(f"rtfc: {exc}") from exc
|
|
79
|
+
return self.parse_text_to_nodes(block, allow_section_headings=True)
|
|
80
|
+
|
|
81
|
+
def _note_dependencies(self, config_directory: Path, entry_directory: Path) -> None:
|
|
82
|
+
"""Mark the configuration and entry files as dependencies of the document.
|
|
83
|
+
|
|
84
|
+
This triggers a rebuild of the document when they change; new entry
|
|
85
|
+
files are picked up through the entry directory modification time.
|
|
86
|
+
"""
|
|
87
|
+
for path in (
|
|
88
|
+
config_directory / "rtfc.toml",
|
|
89
|
+
config_directory / "pyproject.toml",
|
|
90
|
+
entry_directory,
|
|
91
|
+
*entry_directory.glob(f"*{ENTRY_SUFFIX}"),
|
|
92
|
+
):
|
|
93
|
+
if path.exists():
|
|
94
|
+
self.env.note_dependency(str(path))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def setup(app: Sphinx) -> ExtensionMetadata:
|
|
98
|
+
"""Set up the extension."""
|
|
99
|
+
app.add_config_value("rtfc_config_directory", default=".", rebuild="env", types=frozenset({str}))
|
|
100
|
+
app.add_directive("rtfc-unreleased", UnreleasedDirective)
|
|
101
|
+
return {
|
|
102
|
+
"version": importlib.metadata.version("rtfc"),
|
|
103
|
+
"parallel_read_safe": True,
|
|
104
|
+
"parallel_write_safe": True,
|
|
105
|
+
}
|