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/__init__.py
ADDED
rtfc/__main__.py
ADDED
rtfc/_changelog.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Assembly of version blocks into the changelog document.
|
|
2
|
+
|
|
3
|
+
The changelog document must contain an insert marker (a format comment, e.g.
|
|
4
|
+
``.. rtfc-insert`` for rst): released version blocks are inserted right after
|
|
5
|
+
it, newest first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from rtfc._format import Format
|
|
11
|
+
|
|
12
|
+
__all__ = ("ChangelogError", "insert_version")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChangelogError(Exception):
|
|
16
|
+
"""Raised when the changelog document cannot be updated."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _with_trailing_newline(lines: list[str]) -> str:
|
|
20
|
+
text = "\n".join(lines)
|
|
21
|
+
return text if text.endswith("\n") else text + "\n"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def insert_version(changelog: str, block: str, *, fmt: Format) -> str:
|
|
25
|
+
"""Insert a released version block right after the insert marker.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
changelog: The current changelog text.
|
|
29
|
+
block: The rendered version block.
|
|
30
|
+
fmt: The documentation format.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ChangelogError: If the insert marker is missing.
|
|
34
|
+
"""
|
|
35
|
+
lines = changelog.split("\n")
|
|
36
|
+
marker = fmt.insert_marker()
|
|
37
|
+
for index, line in enumerate(lines):
|
|
38
|
+
if line.strip() == marker:
|
|
39
|
+
rest = lines[index + 1 :]
|
|
40
|
+
while rest and not rest[0].strip():
|
|
41
|
+
rest.pop(0)
|
|
42
|
+
inserted = [*lines[: index + 1], "", *block.split("\n")]
|
|
43
|
+
if rest:
|
|
44
|
+
inserted += ["", *rest]
|
|
45
|
+
return _with_trailing_newline(inserted)
|
|
46
|
+
raise ChangelogError(f"Changelog is missing the {marker!r} insert marker")
|
rtfc/_cli.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Commands operate on the project in the current working directory, where the
|
|
4
|
+
configuration is discovered.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import datetime
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tomllib
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from rtfc._changelog import ChangelogError, insert_version
|
|
20
|
+
from rtfc._config import Config, ConfigError, MetadataFieldConfig, SectionConfig, load_config
|
|
21
|
+
from rtfc._entry import Entry, EntryError, load_entries
|
|
22
|
+
from rtfc._format import Format, FormatError, get_format
|
|
23
|
+
from rtfc._render import JinjaRenderer, RenderError
|
|
24
|
+
from rtfc._validation import ValidationContext, ValidationError
|
|
25
|
+
|
|
26
|
+
__all__ = ("main",)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Args(argparse.Namespace):
|
|
30
|
+
command: str
|
|
31
|
+
# build:
|
|
32
|
+
version: str = ""
|
|
33
|
+
dry_run: bool = False
|
|
34
|
+
# new:
|
|
35
|
+
section: str | None = None
|
|
36
|
+
meta: list[tuple[str, Any]] | None = None
|
|
37
|
+
content: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_value(raw: str) -> Any:
|
|
41
|
+
"""Parse ``raw`` as a TOML value, falling back to a plain string."""
|
|
42
|
+
try:
|
|
43
|
+
return tomllib.loads(f"value = {raw}")["value"]
|
|
44
|
+
except tomllib.TOMLDecodeError:
|
|
45
|
+
return raw
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _meta_field(value: str) -> tuple[str, Any]:
|
|
49
|
+
"""Parse a ``KEY=VALUE`` argument into a metadata item, used as an argparse type.
|
|
50
|
+
|
|
51
|
+
The value is parsed as TOML, falling back to a plain string.
|
|
52
|
+
"""
|
|
53
|
+
key, separator, raw = value.partition("=")
|
|
54
|
+
if not separator:
|
|
55
|
+
raise argparse.ArgumentTypeError(f"{value!r} is not of the form KEY=VALUE")
|
|
56
|
+
return key, _parse_value(raw)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_parser(config: Config) -> argparse.ArgumentParser:
|
|
60
|
+
parser = argparse.ArgumentParser(prog="rtfc", description="Manage changelog entries and build changelogs.")
|
|
61
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
62
|
+
|
|
63
|
+
subparsers.add_parser("check", help="Validate the configuration and all changelog entries.")
|
|
64
|
+
|
|
65
|
+
build = subparsers.add_parser("build", help="Combine the changelog entries into the changelog.")
|
|
66
|
+
build.add_argument("--version", required=True, help="Release version; entry files are deleted after building.")
|
|
67
|
+
build.add_argument(
|
|
68
|
+
"--dry-run", action="store_true", help="Print the version block instead of updating the changelog."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
new = subparsers.add_parser("new", help="Create a changelog entry.", description="Create a changelog entry.")
|
|
72
|
+
new.add_argument("-s", "--section", choices=list(config.sections) or None, help="Section id of the entry.")
|
|
73
|
+
new.add_argument(
|
|
74
|
+
"--meta", action="append", type=_meta_field, metavar="KEY=VALUE", help="Metadata field; can be repeated."
|
|
75
|
+
)
|
|
76
|
+
new.add_argument("--content", help="Entry content; defaults to a placeholder, opened in $EDITOR when set.")
|
|
77
|
+
|
|
78
|
+
return parser
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _docs_parser() -> argparse.ArgumentParser:
|
|
82
|
+
"""Build the parser from a default configuration, for the sphinx-argparse documentation.
|
|
83
|
+
|
|
84
|
+
Running the actual command line interface requires a valid configuration.
|
|
85
|
+
``construct()`` bypasses validation, so the documented ``--section``
|
|
86
|
+
choices are the default sections.
|
|
87
|
+
"""
|
|
88
|
+
return _build_parser(Config.construct(changelog=Path("changelog.rst"), sections=['depends on configuration.']))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load(config: Config) -> tuple[Format, list[Entry]]:
|
|
92
|
+
fmt = get_format(config.format)
|
|
93
|
+
entries = load_entries(config.directory, sections=config.sections, metadata_validator=config.metadata_validator())
|
|
94
|
+
return fmt, entries
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _check(config: Config) -> int:
|
|
98
|
+
"""Handle the ``check`` command: validate the configuration and all entries."""
|
|
99
|
+
fmt, entries = _load(config)
|
|
100
|
+
JinjaRenderer(config=config, fmt=fmt) # Validates the entry template.
|
|
101
|
+
print(f"OK: {len(entries)} valid entries")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build(args: _Args, config: Config) -> int:
|
|
106
|
+
"""Handle the ``build`` command: combine the entries into the changelog."""
|
|
107
|
+
fmt, entries = _load(config)
|
|
108
|
+
if not entries:
|
|
109
|
+
print("No changelog entries found", file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
renderer = JinjaRenderer(config=config, fmt=fmt)
|
|
112
|
+
header = fmt.version_header(args.version, datetime.date.today())
|
|
113
|
+
block = renderer.render_block(entries, header=header)
|
|
114
|
+
if args.dry_run:
|
|
115
|
+
print(block)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
changelog = config.changelog.read_text(encoding="utf-8")
|
|
119
|
+
config.changelog.write_text(insert_version(changelog, block, fmt=fmt), encoding="utf-8")
|
|
120
|
+
for entry in entries:
|
|
121
|
+
entry.path.unlink()
|
|
122
|
+
print(f"Updated {config.changelog}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _prompt_section(sections: dict[str, SectionConfig]) -> str | None:
|
|
127
|
+
"""Prompt for the entry section; an empty answer means no section."""
|
|
128
|
+
ids = ", ".join(sections)
|
|
129
|
+
while True:
|
|
130
|
+
raw = input(f"Section ({ids}) [none]: ").strip()
|
|
131
|
+
if not raw:
|
|
132
|
+
return None
|
|
133
|
+
if raw in sections:
|
|
134
|
+
return raw
|
|
135
|
+
print(f"Unknown section {raw!r}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _prompt_metadata(fields: dict[str, MetadataFieldConfig], metadata: dict[str, Any]) -> None:
|
|
139
|
+
"""Prompt for the metadata fields of the configured schema not already provided.
|
|
140
|
+
|
|
141
|
+
An empty answer skips the field, leaving its schema default to apply;
|
|
142
|
+
invalid values are prompted for again.
|
|
143
|
+
"""
|
|
144
|
+
for name, field in fields.items():
|
|
145
|
+
if name in metadata:
|
|
146
|
+
continue
|
|
147
|
+
if field.required:
|
|
148
|
+
hint = "required"
|
|
149
|
+
elif field.default is not None:
|
|
150
|
+
hint = f"default: {field.default!r}"
|
|
151
|
+
else:
|
|
152
|
+
hint = "optional"
|
|
153
|
+
while True:
|
|
154
|
+
raw = input(f"{name} ({field.type}, {hint}): ").strip()
|
|
155
|
+
if not raw:
|
|
156
|
+
if field.required:
|
|
157
|
+
print(f"{name!r} is required")
|
|
158
|
+
continue
|
|
159
|
+
break
|
|
160
|
+
# For string fields the raw input is the value; anything else goes
|
|
161
|
+
# through TOML parsing (so e.g. arrays can be entered as `["a", "b"]`):
|
|
162
|
+
value = raw if field.type == "string" else _parse_value(raw)
|
|
163
|
+
try:
|
|
164
|
+
field._validator().validate(value, ValidationContext(path=(name,)))
|
|
165
|
+
except ValidationError as exc:
|
|
166
|
+
print(exc)
|
|
167
|
+
continue
|
|
168
|
+
metadata[name] = value
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _new(args: _Args, config: Config) -> int:
|
|
173
|
+
"""Handle the ``new`` command: create a changelog entry.
|
|
174
|
+
|
|
175
|
+
When run from a terminal, prompts for the values not provided as command
|
|
176
|
+
line arguments, before opening ``$EDITOR`` on the created entry.
|
|
177
|
+
"""
|
|
178
|
+
if args.section is not None and args.section not in config.sections:
|
|
179
|
+
expected = ", ".join(map(repr, config.sections))
|
|
180
|
+
print(f"Unknown section {args.section!r} (expected one of: {expected})", file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
|
|
183
|
+
metadata = dict(args.meta or [])
|
|
184
|
+
if sys.stdin.isatty():
|
|
185
|
+
if args.section is None and config.sections:
|
|
186
|
+
args.section = _prompt_section(config.sections)
|
|
187
|
+
_prompt_metadata(config.metadata, metadata)
|
|
188
|
+
|
|
189
|
+
entry = Entry.create(
|
|
190
|
+
config.directory,
|
|
191
|
+
section=args.section,
|
|
192
|
+
metadata=metadata,
|
|
193
|
+
content=args.content if args.content is not None else "Describe the change.",
|
|
194
|
+
)
|
|
195
|
+
entry.write()
|
|
196
|
+
print(f"Created {entry.path}")
|
|
197
|
+
|
|
198
|
+
editor = os.getenv("EDITOR")
|
|
199
|
+
if args.content is None and editor:
|
|
200
|
+
subprocess.run([editor, str(entry.path)], check=False)
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
205
|
+
"""Run the rtfc command line interface.
|
|
206
|
+
|
|
207
|
+
A valid configuration is required for any invocation: it is loaded first,
|
|
208
|
+
as the parser is built from it (e.g. the ``--section`` choices).
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
config = load_config(Path.cwd())
|
|
212
|
+
except ConfigError as exc:
|
|
213
|
+
print(exc, file=sys.stderr)
|
|
214
|
+
return 1
|
|
215
|
+
args = _build_parser(config).parse_args(argv, namespace=_Args())
|
|
216
|
+
try:
|
|
217
|
+
if args.command == "check":
|
|
218
|
+
return _check(config)
|
|
219
|
+
if args.command == "build":
|
|
220
|
+
return _build(args, config)
|
|
221
|
+
return _new(args, config)
|
|
222
|
+
except (ChangelogError, EntryError, FormatError, RenderError) as exc:
|
|
223
|
+
print(exc, file=sys.stderr)
|
|
224
|
+
return 1
|
|
225
|
+
except (EOFError, KeyboardInterrupt):
|
|
226
|
+
print("Aborted", file=sys.stderr)
|
|
227
|
+
return 1
|
rtfc/_config.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Configuration discovery and loading.
|
|
2
|
+
|
|
3
|
+
Configuration is read from the ``[rtfc]`` table of ``rtfc.toml`` or, as a
|
|
4
|
+
fallback, the ``[tool.rtfc]`` table of ``pyproject.toml``. Files are only
|
|
5
|
+
looked up in the invocation directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from rtfc._validation import (
|
|
15
|
+
Field,
|
|
16
|
+
Issue,
|
|
17
|
+
Schema,
|
|
18
|
+
ValidationContext,
|
|
19
|
+
ValidationError,
|
|
20
|
+
Validator,
|
|
21
|
+
any_,
|
|
22
|
+
bool_,
|
|
23
|
+
dict_of,
|
|
24
|
+
dir_path,
|
|
25
|
+
file_path,
|
|
26
|
+
float_,
|
|
27
|
+
int_,
|
|
28
|
+
iso_date,
|
|
29
|
+
list_of,
|
|
30
|
+
nullable,
|
|
31
|
+
one_of,
|
|
32
|
+
record,
|
|
33
|
+
str_,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = ("Config", "ConfigError", "MetadataFieldConfig", "RenderConfig", "SectionConfig", "load_config")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfigError(Exception):
|
|
40
|
+
"""Raised when the configuration is missing or invalid."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_DEFAULT_ENTRY_TEMPLATE = "{{ content }}"
|
|
44
|
+
|
|
45
|
+
_DEFAULT_TEMPLATE = """\
|
|
46
|
+
{{ header }}
|
|
47
|
+
{% for section in sections if section.entries %}
|
|
48
|
+
|
|
49
|
+
{% if section.label %}
|
|
50
|
+
{{ section_header(section.label) }}
|
|
51
|
+
|
|
52
|
+
{% endif %}
|
|
53
|
+
{% for entry in section.entries | sort_entries %}
|
|
54
|
+
{{ list_item(render_entry(entry)) }}
|
|
55
|
+
{% endfor %}
|
|
56
|
+
{% endfor %}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SectionConfig(Schema):
|
|
61
|
+
"""Configuration of a changelog section (an entry category)."""
|
|
62
|
+
|
|
63
|
+
id = Field(str_)
|
|
64
|
+
"""Id of the section, as used in the ``section`` field of entries."""
|
|
65
|
+
|
|
66
|
+
label = Field(str_)
|
|
67
|
+
"""Heading used for the section in the changelog."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _derive_label(section_id: str) -> str:
|
|
71
|
+
"""Derive a section label from its id, e.g. ``'breaking_change'`` gives ``'Breaking change'``."""
|
|
72
|
+
return section_id.replace("_", " ").replace("-", " ").capitalize()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _SectionsValidator(Validator[dict[str, SectionConfig]]):
|
|
76
|
+
"""Validates the ``sections`` list into an id-keyed mapping, in list order."""
|
|
77
|
+
|
|
78
|
+
def validate(self, value: object, context: ValidationContext) -> dict[str, SectionConfig]:
|
|
79
|
+
if isinstance(value, dict) and all(isinstance(section, SectionConfig) for section in value.values()):
|
|
80
|
+
# Already-normalized mappings (e.g. the field default) are passed through:
|
|
81
|
+
return cast("dict[str, SectionConfig]", value)
|
|
82
|
+
if type(value) is not list:
|
|
83
|
+
raise ValidationError.single(context.path, f"expected list, got {type(value).__name__}")
|
|
84
|
+
sections: dict[str, SectionConfig] = {}
|
|
85
|
+
issues: list[Issue] = []
|
|
86
|
+
for i, item in enumerate(value):
|
|
87
|
+
item_context = context.at(i)
|
|
88
|
+
if type(item) is str:
|
|
89
|
+
section = SectionConfig.construct(id=item, label=_derive_label(item))
|
|
90
|
+
else:
|
|
91
|
+
try:
|
|
92
|
+
section = SectionConfig.validate(item, item_context)
|
|
93
|
+
except ValidationError as exc:
|
|
94
|
+
issues.extend(exc.issues)
|
|
95
|
+
continue
|
|
96
|
+
if section.id in sections:
|
|
97
|
+
issues.append(Issue(item_context.path, f"duplicate section id {section.id!r}"))
|
|
98
|
+
else:
|
|
99
|
+
sections[section.id] = section
|
|
100
|
+
if issues:
|
|
101
|
+
raise ValidationError(issues)
|
|
102
|
+
return sections
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_METADATA_TYPES: dict[str, Validator[Any]] = {
|
|
106
|
+
"string": str_,
|
|
107
|
+
"integer": int_,
|
|
108
|
+
"boolean": bool_,
|
|
109
|
+
"number": float_,
|
|
110
|
+
"date": iso_date,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MetadataFieldConfig(Schema):
|
|
115
|
+
"""Configuration of an entry metadata field."""
|
|
116
|
+
|
|
117
|
+
type = Field(one_of(*_METADATA_TYPES, "array"))
|
|
118
|
+
"""Type of the field value: ``'string'``, ``'integer'``, ``'boolean'``, ``'number'``,
|
|
119
|
+
``'date'``, or ``'array'`` (requires ``items``)."""
|
|
120
|
+
|
|
121
|
+
items = Field(nullable(one_of(*_METADATA_TYPES)), default=None)
|
|
122
|
+
"""Type of the array items. Required and only allowed when ``type`` is ``'array'``."""
|
|
123
|
+
|
|
124
|
+
required = Field(bool_, default=False)
|
|
125
|
+
"""Whether the field must be present on every entry. Mutually exclusive with ``default``."""
|
|
126
|
+
|
|
127
|
+
default = Field(any_, default=None)
|
|
128
|
+
"""Value applied when the field is absent. Mutually exclusive with ``required``."""
|
|
129
|
+
|
|
130
|
+
def _validator(self) -> Validator[Any]:
|
|
131
|
+
"""The validator for values of this field."""
|
|
132
|
+
if self.type == "array":
|
|
133
|
+
assert self.items is not None
|
|
134
|
+
return list_of(_METADATA_TYPES[self.items])
|
|
135
|
+
return _METADATA_TYPES[self.type]
|
|
136
|
+
|
|
137
|
+
def __post_validate__(self, context: ValidationContext) -> None:
|
|
138
|
+
if (self.type == "array") != (self.items is not None):
|
|
139
|
+
message = (
|
|
140
|
+
"'items' is required for 'array' fields"
|
|
141
|
+
if self.items is None
|
|
142
|
+
else "'items' is only allowed for 'array' fields"
|
|
143
|
+
)
|
|
144
|
+
raise ValidationError.single(context.path, message)
|
|
145
|
+
if self.default is not None:
|
|
146
|
+
if self.required:
|
|
147
|
+
raise ValidationError.single(context.path, "'required' and 'default' are mutually exclusive")
|
|
148
|
+
self._validator().validate(self.default, context.at("default"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RenderConfig(Schema):
|
|
152
|
+
"""Configuration of how entries are combined into the changelog."""
|
|
153
|
+
|
|
154
|
+
template = Field(nullable(str_), default=None)
|
|
155
|
+
"""Jinja template rendering a version block. Mutually exclusive with ``template_file``."""
|
|
156
|
+
|
|
157
|
+
template_file = Field(nullable(file_path), default=None)
|
|
158
|
+
"""Path to a file containing the version block template, relative to the invocation
|
|
159
|
+
directory (resolved at validation time)."""
|
|
160
|
+
|
|
161
|
+
entry_template = Field(nullable(str_), default=None)
|
|
162
|
+
"""Jinja template rendering a single entry. Mutually exclusive with ``entry_template_file``."""
|
|
163
|
+
|
|
164
|
+
entry_template_file = Field(nullable(file_path), default=None)
|
|
165
|
+
"""Path to a file containing the entry template, relative to the configuration file."""
|
|
166
|
+
|
|
167
|
+
def resolve_template(self) -> str:
|
|
168
|
+
"""Return the effective version block template text.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ConfigError: If the template file cannot be read.
|
|
172
|
+
"""
|
|
173
|
+
if self.template_file is not None:
|
|
174
|
+
try:
|
|
175
|
+
return self.template_file.read_text(encoding="utf-8")
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
raise ConfigError(f"Cannot read template file: {exc}") from exc
|
|
178
|
+
if self.template is not None:
|
|
179
|
+
return self.template
|
|
180
|
+
return _DEFAULT_TEMPLATE
|
|
181
|
+
|
|
182
|
+
def resolve_entry_template(self) -> str:
|
|
183
|
+
"""Return the effective entry template text.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ConfigError: If the template file cannot be read.
|
|
187
|
+
"""
|
|
188
|
+
if self.entry_template_file is not None:
|
|
189
|
+
try:
|
|
190
|
+
return self.entry_template_file.read_text(encoding="utf-8")
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
raise ConfigError(f"Cannot read entry template file: {exc}") from exc
|
|
193
|
+
if self.entry_template is not None:
|
|
194
|
+
return self.entry_template
|
|
195
|
+
return _DEFAULT_ENTRY_TEMPLATE
|
|
196
|
+
|
|
197
|
+
def __post_validate__(self, context: ValidationContext) -> None:
|
|
198
|
+
if self.template is not None and self.template_file is not None:
|
|
199
|
+
raise ValidationError.single(context.path, "'template' and 'template_file' are mutually exclusive")
|
|
200
|
+
if self.entry_template is not None and self.entry_template_file is not None:
|
|
201
|
+
raise ValidationError.single(
|
|
202
|
+
context.path, "'entry_template' and 'entry_template_file' are mutually exclusive"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _default_sections() -> dict[str, SectionConfig]:
|
|
207
|
+
return {
|
|
208
|
+
"change": SectionConfig.construct(id="change", label="Changes"),
|
|
209
|
+
"feature": SectionConfig.construct(id="feature", label="Features"),
|
|
210
|
+
"bugfix": SectionConfig.construct(id="bugfix", label="Bug fixes"),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Config(Schema):
|
|
215
|
+
"""Top-level rtfc configuration."""
|
|
216
|
+
|
|
217
|
+
directory = Field(dir_path, default=Path("changelog"))
|
|
218
|
+
"""Directory holding the changelog entry files, relative to the configuration file."""
|
|
219
|
+
|
|
220
|
+
changelog = Field(file_path)
|
|
221
|
+
"""Path to the changelog file entries are combined into, relative to the configuration file."""
|
|
222
|
+
|
|
223
|
+
format = Field(str_, default="rst")
|
|
224
|
+
"""Name of the documentation format used for entries and the changelog."""
|
|
225
|
+
|
|
226
|
+
sections = Field(_SectionsValidator(), default_factory=_default_sections)
|
|
227
|
+
"""Changelog sections by id."""
|
|
228
|
+
|
|
229
|
+
metadata = Field(dict_of(MetadataFieldConfig), default_factory=dict)
|
|
230
|
+
"""Schema of the entry metadata fields. When empty, metadata is free-form."""
|
|
231
|
+
|
|
232
|
+
render = Field(RenderConfig, default_factory=RenderConfig.construct)
|
|
233
|
+
"""Rendering options."""
|
|
234
|
+
|
|
235
|
+
def metadata_validator(self) -> Validator[dict[str, Any]] | None:
|
|
236
|
+
"""Build a validator for entry metadata from the configured schema.
|
|
237
|
+
|
|
238
|
+
Returns ``None`` when no metadata schema is configured. Optional fields
|
|
239
|
+
without a default take ``None`` when absent.
|
|
240
|
+
"""
|
|
241
|
+
if not self.metadata:
|
|
242
|
+
return None
|
|
243
|
+
fields: dict[str, Field[Any]] = {}
|
|
244
|
+
for name, metadata_field in self.metadata.items():
|
|
245
|
+
type_validator = metadata_field._validator()
|
|
246
|
+
if metadata_field.required:
|
|
247
|
+
fields[name] = Field(type_validator)
|
|
248
|
+
elif metadata_field.default is not None:
|
|
249
|
+
fields[name] = Field(type_validator, default=metadata_field.default)
|
|
250
|
+
else:
|
|
251
|
+
fields[name] = Field(nullable(type_validator), default=None)
|
|
252
|
+
return record(fields)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _load_toml(file: Path) -> dict[str, Any]:
|
|
256
|
+
try:
|
|
257
|
+
with file.open("rb") as f:
|
|
258
|
+
return tomllib.load(f)
|
|
259
|
+
except tomllib.TOMLDecodeError as exc:
|
|
260
|
+
raise ConfigError(f"{file.name}: invalid TOML: {exc}") from exc
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _validate_config(data: object, context: ValidationContext, file_name: str) -> Config:
|
|
264
|
+
try:
|
|
265
|
+
return Config.validate(data, context)
|
|
266
|
+
except ValidationError as exc:
|
|
267
|
+
raise ConfigError(f"{file_name}: invalid configuration:\n{exc}") from exc
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_config(directory: Path) -> Config:
|
|
271
|
+
"""Load the rtfc configuration from ``directory``.
|
|
272
|
+
|
|
273
|
+
The ``[rtfc]`` table of ``rtfc.toml`` takes priority over the
|
|
274
|
+
``[tool.rtfc]`` table of ``pyproject.toml``. Only ``directory`` itself is
|
|
275
|
+
searched.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
directory: Directory containing the configuration file.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
ConfigError: If no configuration is found or it is invalid.
|
|
282
|
+
"""
|
|
283
|
+
rtfc_toml = directory / "rtfc.toml"
|
|
284
|
+
if rtfc_toml.is_file():
|
|
285
|
+
data = _load_toml(rtfc_toml)
|
|
286
|
+
if "rtfc" not in data:
|
|
287
|
+
raise ConfigError(f"{rtfc_toml.name}: missing the [rtfc] table")
|
|
288
|
+
context = ValidationContext(path=("rtfc",), current_directory=directory)
|
|
289
|
+
return _validate_config(data["rtfc"], context, rtfc_toml.name)
|
|
290
|
+
|
|
291
|
+
pyproject = directory / "pyproject.toml"
|
|
292
|
+
if pyproject.is_file():
|
|
293
|
+
tool = _load_toml(pyproject).get("tool")
|
|
294
|
+
if isinstance(tool, dict) and "rtfc" in tool:
|
|
295
|
+
context = ValidationContext(path=("tool", "rtfc"), current_directory=directory)
|
|
296
|
+
return _validate_config(tool["rtfc"], context, pyproject.name)
|
|
297
|
+
|
|
298
|
+
raise ConfigError("No configuration found: define an 'rtfc.toml' file or a '[tool.rtfc]' table in 'pyproject.toml'")
|