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/_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
+ }