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/_entry.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Changelog entry parsing and loading.
|
|
2
|
+
|
|
3
|
+
An entry file is made of a TOML frontmatter delimited by ``+++`` lines,
|
|
4
|
+
followed by the entry content::
|
|
5
|
+
|
|
6
|
+
+++
|
|
7
|
+
date = 2025-08-01
|
|
8
|
+
nonce = "k3jf9a"
|
|
9
|
+
section = "bugfix"
|
|
10
|
+
|
|
11
|
+
[metadata]
|
|
12
|
+
gh_issue = 123
|
|
13
|
+
+++
|
|
14
|
+
Fix a bug in the documentation format handling.
|
|
15
|
+
|
|
16
|
+
The content is treated as opaque text: it is never parsed, only incorporated
|
|
17
|
+
into the changelog where the documentation engine processes it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime
|
|
23
|
+
import secrets
|
|
24
|
+
import tomllib
|
|
25
|
+
from collections.abc import Collection, Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import tomli_w
|
|
31
|
+
|
|
32
|
+
from rtfc._validation import (
|
|
33
|
+
Field,
|
|
34
|
+
Schema,
|
|
35
|
+
ValidationContext,
|
|
36
|
+
ValidationError,
|
|
37
|
+
Validator,
|
|
38
|
+
dict_of,
|
|
39
|
+
iso_date,
|
|
40
|
+
nullable,
|
|
41
|
+
str_,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = ("ENTRY_SUFFIX", "Entry", "EntryError", "load_entries")
|
|
45
|
+
|
|
46
|
+
ENTRY_SUFFIX = ".rtfc"
|
|
47
|
+
"""File extension of changelog entry files."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _file_name(nonce: str, section: str | None) -> str:
|
|
51
|
+
"""The canonical entry file name: ``{nonce}.{section}.rtfc``, or ``{nonce}.rtfc`` without a section."""
|
|
52
|
+
stem = nonce if section is None else f"{nonce}.{section}"
|
|
53
|
+
return f"{stem}{ENTRY_SUFFIX}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_DELIMITER = "+++"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EntryError(Exception):
|
|
60
|
+
"""Raised when a changelog entry cannot be read or is invalid."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _FlatValue(Validator[Any]):
|
|
64
|
+
"""Accepts any value except tables: metadata fields are top-level only."""
|
|
65
|
+
|
|
66
|
+
def validate(self, value: object, context: ValidationContext) -> Any:
|
|
67
|
+
if isinstance(value, Mapping):
|
|
68
|
+
raise ValidationError.single(context.path, "nested tables are not allowed")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Frontmatter(Schema):
|
|
73
|
+
date = Field(iso_date)
|
|
74
|
+
nonce = Field(str_)
|
|
75
|
+
section = Field(nullable(str_), default=None)
|
|
76
|
+
metadata = Field(dict_of(_FlatValue()), default_factory=dict)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, kw_only=True)
|
|
80
|
+
class Entry:
|
|
81
|
+
"""A changelog entry."""
|
|
82
|
+
|
|
83
|
+
path: Path
|
|
84
|
+
"""The entry file."""
|
|
85
|
+
|
|
86
|
+
date: datetime.date
|
|
87
|
+
"""Date of the change."""
|
|
88
|
+
|
|
89
|
+
nonce: str
|
|
90
|
+
"""Unique identifier of the entry, part of the file name."""
|
|
91
|
+
|
|
92
|
+
section: str | None
|
|
93
|
+
"""Id of the section the entry belongs to, if any."""
|
|
94
|
+
|
|
95
|
+
metadata: dict[str, Any]
|
|
96
|
+
"""Free-form entry metadata."""
|
|
97
|
+
|
|
98
|
+
content: str
|
|
99
|
+
"""Raw entry content, in the documentation format of the project."""
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def create(
|
|
103
|
+
cls,
|
|
104
|
+
directory: Path,
|
|
105
|
+
*,
|
|
106
|
+
section: str | None = None,
|
|
107
|
+
metadata: dict[str, Any] | None = None,
|
|
108
|
+
content: str,
|
|
109
|
+
) -> Entry:
|
|
110
|
+
"""Create a new entry, dated today, with a generated nonce.
|
|
111
|
+
|
|
112
|
+
The entry file is named after the nonce and section.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
directory: The entry directory, from the configuration.
|
|
116
|
+
section: Id of the section the entry belongs to, if any.
|
|
117
|
+
metadata: Entry metadata.
|
|
118
|
+
content: Entry content.
|
|
119
|
+
"""
|
|
120
|
+
nonce = secrets.token_hex(4)
|
|
121
|
+
return cls(
|
|
122
|
+
path=directory / _file_name(nonce, section),
|
|
123
|
+
date=datetime.date.today(),
|
|
124
|
+
nonce=nonce,
|
|
125
|
+
section=section,
|
|
126
|
+
metadata=metadata or {},
|
|
127
|
+
content=content,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_file(
|
|
132
|
+
cls, file: Path, *, sections: Collection[str], metadata_validator: Validator[dict[str, Any]] | None = None
|
|
133
|
+
) -> Entry:
|
|
134
|
+
"""Load an entry from ``file``.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
file: The entry file to load.
|
|
138
|
+
sections: Valid section ids, from the configuration.
|
|
139
|
+
metadata_validator: Validator applied to the entry metadata, built
|
|
140
|
+
from the configured metadata schema. ``None`` leaves metadata
|
|
141
|
+
free-form.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
EntryError: If the entry cannot be read or is invalid.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
text = file.read_text(encoding="utf-8")
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
raise EntryError(f"{file.name}: {exc}") from exc
|
|
150
|
+
try:
|
|
151
|
+
data, body = _split_frontmatter(text)
|
|
152
|
+
try:
|
|
153
|
+
frontmatter = _Frontmatter.validate(data)
|
|
154
|
+
except ValidationError as exc:
|
|
155
|
+
raise EntryError(f"Invalid frontmatter:\n{exc}") from exc
|
|
156
|
+
if frontmatter.section is not None and frontmatter.section not in sections:
|
|
157
|
+
expected = ", ".join(map(repr, sections))
|
|
158
|
+
raise EntryError(f"Unknown section {frontmatter.section!r} (expected one of: {expected})")
|
|
159
|
+
metadata = frontmatter.metadata
|
|
160
|
+
if metadata_validator is not None:
|
|
161
|
+
try:
|
|
162
|
+
metadata = metadata_validator.validate(metadata, ValidationContext(path=("metadata",)))
|
|
163
|
+
except ValidationError as exc:
|
|
164
|
+
raise EntryError(f"Invalid metadata:\n{exc}") from exc
|
|
165
|
+
content = body.strip()
|
|
166
|
+
if not content:
|
|
167
|
+
raise EntryError("Entry has no content")
|
|
168
|
+
except EntryError as exc:
|
|
169
|
+
raise EntryError(f"{file.name}: {exc}") from exc
|
|
170
|
+
expected_name = _file_name(frontmatter.nonce, frontmatter.section)
|
|
171
|
+
if file.name != expected_name:
|
|
172
|
+
raise EntryError(
|
|
173
|
+
f"{file.name}: File name does not match the entry, expected {expected_name!r} "
|
|
174
|
+
"(derived from the 'nonce' and 'section' fields)"
|
|
175
|
+
)
|
|
176
|
+
return cls(
|
|
177
|
+
path=file,
|
|
178
|
+
date=frontmatter.date,
|
|
179
|
+
nonce=frontmatter.nonce,
|
|
180
|
+
section=frontmatter.section,
|
|
181
|
+
metadata=metadata,
|
|
182
|
+
content=content,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def write(self) -> None:
|
|
186
|
+
"""Write the entry to its file, creating the entry directory if needed.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
EntryError: If a metadata value cannot be serialized to TOML.
|
|
190
|
+
"""
|
|
191
|
+
frontmatter: dict[str, Any] = {"date": self.date, "nonce": self.nonce}
|
|
192
|
+
if self.section is not None:
|
|
193
|
+
frontmatter["section"] = self.section
|
|
194
|
+
if self.metadata:
|
|
195
|
+
frontmatter["metadata"] = self.metadata
|
|
196
|
+
try:
|
|
197
|
+
dumped = tomli_w.dumps(frontmatter)
|
|
198
|
+
except TypeError as exc:
|
|
199
|
+
raise EntryError(f"Cannot serialize frontmatter to TOML: {exc}") from exc
|
|
200
|
+
text = f"{_DELIMITER}\n{dumped}{_DELIMITER}\n{self.content}\n"
|
|
201
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
self.path.write_text(text, encoding="utf-8")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
206
|
+
"""Split an entry file into its parsed TOML frontmatter and raw body."""
|
|
207
|
+
lines = text.split("\n")
|
|
208
|
+
if lines[0].rstrip() != _DELIMITER:
|
|
209
|
+
raise EntryError(f"Entry must start with a {_DELIMITER!r} frontmatter delimiter")
|
|
210
|
+
candidates = [i for i, line in enumerate(lines[1:], start=1) if line.rstrip() == _DELIMITER]
|
|
211
|
+
if not candidates:
|
|
212
|
+
raise EntryError(f"Missing closing {_DELIMITER!r} frontmatter delimiter")
|
|
213
|
+
first_error: tomllib.TOMLDecodeError | None = None
|
|
214
|
+
# The closing delimiter is the first `+++` line whose preceding text parses as valid
|
|
215
|
+
# TOML. A bare `+++` line can only appear inside a TOML multiline string, in which
|
|
216
|
+
# case the preceding text is invalid (unterminated string), so the first valid parse
|
|
217
|
+
# is necessarily the true delimiter.
|
|
218
|
+
for i in candidates:
|
|
219
|
+
try:
|
|
220
|
+
frontmatter = tomllib.loads("\n".join(lines[1:i]))
|
|
221
|
+
except tomllib.TOMLDecodeError as exc:
|
|
222
|
+
first_error = first_error or exc
|
|
223
|
+
continue
|
|
224
|
+
return frontmatter, "\n".join(lines[i + 1 :])
|
|
225
|
+
raise EntryError(f"Invalid frontmatter TOML: {first_error}") from first_error
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_entries(
|
|
229
|
+
directory: Path, *, sections: Collection[str], metadata_validator: Validator[dict[str, Any]] | None = None
|
|
230
|
+
) -> list[Entry]:
|
|
231
|
+
"""Load all changelog entries from ``directory``.
|
|
232
|
+
|
|
233
|
+
Only files with the ``.rtfc`` suffix are considered. Entries are returned
|
|
234
|
+
in file name order; all invalid entries are reported together.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
directory: The entry directory, from the configuration.
|
|
238
|
+
sections: Valid section ids, from the configuration.
|
|
239
|
+
metadata_validator: Validator applied to the entry metadata, built from
|
|
240
|
+
the configured metadata schema. ``None`` leaves metadata free-form.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
EntryError: If the directory does not exist or any entry is invalid.
|
|
244
|
+
"""
|
|
245
|
+
if not directory.is_dir():
|
|
246
|
+
raise EntryError(f"Entry directory {str(directory)!r} does not exist")
|
|
247
|
+
entries: list[Entry] = []
|
|
248
|
+
errors: list[str] = []
|
|
249
|
+
for file in sorted(directory.glob(f"*{ENTRY_SUFFIX}")):
|
|
250
|
+
try:
|
|
251
|
+
entries.append(Entry.from_file(file, sections=sections, metadata_validator=metadata_validator))
|
|
252
|
+
except EntryError as exc:
|
|
253
|
+
errors.append(str(exc))
|
|
254
|
+
if errors:
|
|
255
|
+
raise EntryError("\n".join(errors))
|
|
256
|
+
return entries
|
rtfc/_format/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Documentation format abstraction.
|
|
2
|
+
|
|
3
|
+
A :class:`Format` produces the format-specific structure of the changelog
|
|
4
|
+
(headings, list items, comments); it never parses entry content. The rst
|
|
5
|
+
implementation is built in; third-party formats subclass :class:`Format` and
|
|
6
|
+
register under the ``rtfc.formats`` entry point group.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
|
|
13
|
+
from rtfc._format.base import Format, FormatError
|
|
14
|
+
from rtfc._format.rst import RstFormat
|
|
15
|
+
|
|
16
|
+
__all__ = ("Format", "FormatError", "RstFormat", "get_format")
|
|
17
|
+
|
|
18
|
+
_ENTRY_POINT_GROUP = "rtfc.formats"
|
|
19
|
+
|
|
20
|
+
_BUILTIN_FORMATS: dict[str, type[Format]] = {RstFormat.name: RstFormat}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_format(name: str) -> Format:
|
|
24
|
+
"""Resolve a format by name.
|
|
25
|
+
|
|
26
|
+
Built-in formats take priority; other names are looked up in the
|
|
27
|
+
``rtfc.formats`` entry point group.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
FormatError: If the name cannot be resolved to a :class:`Format`.
|
|
31
|
+
"""
|
|
32
|
+
if name in _BUILTIN_FORMATS:
|
|
33
|
+
return _BUILTIN_FORMATS[name]()
|
|
34
|
+
for entry_point in importlib.metadata.entry_points(group=_ENTRY_POINT_GROUP, name=name):
|
|
35
|
+
loaded = entry_point.load()
|
|
36
|
+
if not (isinstance(loaded, type) and issubclass(loaded, Format)):
|
|
37
|
+
raise FormatError(f"Entry point {name!r} in group {_ENTRY_POINT_GROUP!r} is not a Format subclass")
|
|
38
|
+
return loaded()
|
|
39
|
+
raise FormatError(f"Unknown format {name!r}")
|
rtfc/_format/base.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Base documentation format definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import ClassVar, final
|
|
8
|
+
|
|
9
|
+
# The marker identity is format-independent (only the comment syntax around it
|
|
10
|
+
# varies), so the changelog assembly logic can locate it in any format:
|
|
11
|
+
_INSERT_MARKER = "rtfc-insert"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FormatError(Exception):
|
|
15
|
+
"""Raised when a documentation format cannot be resolved."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Format(ABC):
|
|
19
|
+
"""A documentation format, defining the structure of the changelog."""
|
|
20
|
+
|
|
21
|
+
name: ClassVar[str]
|
|
22
|
+
"""Name of the format, as referenced by the ``format`` configuration value."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def heading(self, title: str, level: int) -> str:
|
|
26
|
+
"""Format a heading.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
title: The heading title.
|
|
30
|
+
level: Heading level, relative to the changelog document title:
|
|
31
|
+
``1`` for versions, ``2`` for sections.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def comment(self, text: str) -> str:
|
|
36
|
+
"""Format ``text`` as a comment, invisible in the rendered document."""
|
|
37
|
+
|
|
38
|
+
def version_header(self, version: str, date: datetime.date) -> str:
|
|
39
|
+
"""Format the heading of a released version."""
|
|
40
|
+
return self.heading(f"v{version} ({date.isoformat()})", 1)
|
|
41
|
+
|
|
42
|
+
def unreleased_header(self) -> str:
|
|
43
|
+
"""Format the heading of the unreleased changes."""
|
|
44
|
+
return self.heading("Unreleased", 1)
|
|
45
|
+
|
|
46
|
+
def section_header(self, label: str) -> str:
|
|
47
|
+
"""Format the heading of an entry section."""
|
|
48
|
+
return self.heading(label, 2)
|
|
49
|
+
|
|
50
|
+
def list_item(self, text: str) -> str:
|
|
51
|
+
"""Format already-rendered entry text as a list item."""
|
|
52
|
+
first, *rest = text.splitlines()
|
|
53
|
+
lines = [f"- {first}", *(f" {line}" if line else "" for line in rest)]
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
@final
|
|
57
|
+
def insert_marker(self) -> str:
|
|
58
|
+
"""The comment after which new version blocks are inserted."""
|
|
59
|
+
return self.comment(_INSERT_MARKER)
|
rtfc/_format/rst.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The reStructuredText documentation format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from rtfc._format.base import Format
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RstFormat(Format):
|
|
11
|
+
"""The reStructuredText format, for sphinx documentation."""
|
|
12
|
+
|
|
13
|
+
name: ClassVar[str] = "rst"
|
|
14
|
+
|
|
15
|
+
# Assumes the changelog document title is underlined with '=':
|
|
16
|
+
_underlines: ClassVar[dict[int, str]] = {1: "-", 2: "~"}
|
|
17
|
+
|
|
18
|
+
def heading(self, title: str, level: int) -> str:
|
|
19
|
+
return f"{title}\n{self._underlines[level] * len(title)}"
|
|
20
|
+
|
|
21
|
+
def comment(self, text: str) -> str:
|
|
22
|
+
return f".. {text}"
|
rtfc/_render/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Rendering of changelog entries into version blocks.
|
|
2
|
+
|
|
3
|
+
A version block is the text added to the changelog for one release (or for the
|
|
4
|
+
unreleased changes): a version header followed by the rendered entries, grouped
|
|
5
|
+
by section. :class:`Renderer` orchestrates grouping, sorting and format
|
|
6
|
+
structure; how a single entry is turned into text is left to subclasses, so
|
|
7
|
+
that alternative template engines can be plugged in. :class:`JinjaRenderer` is
|
|
8
|
+
the default, rendering entries through the configured Jinja template. Entry
|
|
9
|
+
content is never parsed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from rtfc._render.base import Renderer, RenderError
|
|
13
|
+
from rtfc._render.jinja import JinjaRenderer
|
|
14
|
+
|
|
15
|
+
__all__ = ("JinjaRenderer", "RenderError", "Renderer")
|
rtfc/_render/base.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Base renderer definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rtfc._config import Config
|
|
11
|
+
from rtfc._entry import Entry
|
|
12
|
+
from rtfc._format import Format
|
|
13
|
+
|
|
14
|
+
_METADATA_SORT_PREFIX = "metadata."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RenderError(Exception):
|
|
18
|
+
"""Raised when entries cannot be rendered."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SectionGroup:
|
|
23
|
+
"""The entries of a changelog section, as exposed to version block templates."""
|
|
24
|
+
|
|
25
|
+
id: str | None
|
|
26
|
+
"""Id of the section, or ``None`` for the unsectioned group."""
|
|
27
|
+
|
|
28
|
+
label: str | None
|
|
29
|
+
"""Label of the section, or ``None`` for the unsectioned group."""
|
|
30
|
+
|
|
31
|
+
entries: list[Entry]
|
|
32
|
+
"""The entries of the section, in file name order."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sort_entries(entries: Sequence[Entry], keys: Sequence[str]) -> list[Entry]:
|
|
36
|
+
"""Sort entries by the given keys: ``date``, ``nonce``, or ``metadata.<field>``.
|
|
37
|
+
|
|
38
|
+
Entries missing a value sort last.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
RenderError: If a key is unknown or entry values cannot be compared.
|
|
42
|
+
"""
|
|
43
|
+
for sort_key in keys:
|
|
44
|
+
if sort_key not in ("date", "nonce") and not sort_key.startswith(_METADATA_SORT_PREFIX):
|
|
45
|
+
raise RenderError(f"Unknown sort key {sort_key!r} (expected 'date', 'nonce' or 'metadata.<key>')")
|
|
46
|
+
|
|
47
|
+
def key(entry: Entry) -> tuple[tuple[int, Any], ...]:
|
|
48
|
+
parts: list[tuple[int, Any]] = []
|
|
49
|
+
for sort_key in keys:
|
|
50
|
+
if sort_key.startswith(_METADATA_SORT_PREFIX):
|
|
51
|
+
# Metadata fields are top-level only, so the rest of the sort key is the literal field name:
|
|
52
|
+
value = entry.metadata.get(sort_key.removeprefix(_METADATA_SORT_PREFIX))
|
|
53
|
+
else:
|
|
54
|
+
value = getattr(entry, sort_key)
|
|
55
|
+
# Entries missing a value sort last, and (1, None) tuples compare equal:
|
|
56
|
+
parts.append((1, None) if value is None else (0, value))
|
|
57
|
+
return tuple(parts)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
return sorted(entries, key=key)
|
|
61
|
+
except TypeError as exc:
|
|
62
|
+
raise RenderError(f"Cannot sort entries by {list(keys)}: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Renderer(ABC):
|
|
66
|
+
"""Renders changelog entries into version blocks."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *, config: Config, fmt: Format) -> None:
|
|
69
|
+
self.config = config
|
|
70
|
+
self.fmt = fmt
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def render_entry(self, entry: Entry) -> str:
|
|
74
|
+
"""Render a single entry into its changelog text, before list item wrapping.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
RenderError: If the entry cannot be rendered.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def render_block(self, entries: Sequence[Entry], *, header: str) -> str:
|
|
82
|
+
"""Render entries into a version block, opened by the already-formatted ``header``.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
RenderError: If the entries cannot be rendered.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def group_entries(self, entries: Sequence[Entry]) -> list[SectionGroup]:
|
|
89
|
+
"""Group entries by section: the unsectioned group first, then the configured sections in order.
|
|
90
|
+
|
|
91
|
+
Empty groups are included.
|
|
92
|
+
"""
|
|
93
|
+
groups: dict[str | None, SectionGroup] = {None: SectionGroup(id=None, label=None, entries=[])}
|
|
94
|
+
for section_id, section in self.config.sections.items():
|
|
95
|
+
groups[section_id] = SectionGroup(id=section_id, label=section.label, entries=[])
|
|
96
|
+
for entry in entries:
|
|
97
|
+
groups[entry.section].entries.append(entry)
|
|
98
|
+
return list(groups.values())
|
rtfc/_render/jinja.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The Jinja template based renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jinja2
|
|
9
|
+
|
|
10
|
+
from rtfc._config import Config
|
|
11
|
+
from rtfc._entry import Entry
|
|
12
|
+
from rtfc._format import Format
|
|
13
|
+
from rtfc._render.base import Renderer, RenderError, sort_entries
|
|
14
|
+
|
|
15
|
+
_env = jinja2.Environment(trim_blocks=True, lstrip_blocks=True)
|
|
16
|
+
_env.filters["sort_entries"] = lambda entries, *keys: sort_entries(entries, keys or ("date",))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JinjaRenderer(Renderer):
|
|
20
|
+
"""The default renderer, driven by the configured Jinja templates.
|
|
21
|
+
|
|
22
|
+
The version block template receives ``header``, ``entries`` (all entries),
|
|
23
|
+
``sections`` (the :class:`~rtfc._render.base.SectionGroup` of each
|
|
24
|
+
section), the ``render_entry()``, ``list_item()`` and ``section_header()``
|
|
25
|
+
functions, and can sort entries with the ``sort_entries()`` filter. The
|
|
26
|
+
entry template receives ``content``, ``date``, ``nonce``, ``section`` and
|
|
27
|
+
``metadata``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, config: Config, fmt: Format) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Args:
|
|
33
|
+
config: The project configuration.
|
|
34
|
+
fmt: The documentation format.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
RenderError: If a template is invalid.
|
|
38
|
+
ConfigError: If a template file cannot be read.
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(config=config, fmt=fmt)
|
|
41
|
+
try:
|
|
42
|
+
self._template = _env.from_string(config.render.resolve_template())
|
|
43
|
+
except jinja2.TemplateSyntaxError as exc:
|
|
44
|
+
raise RenderError(f"Invalid template: {exc}") from exc
|
|
45
|
+
try:
|
|
46
|
+
self._entry_template = _env.from_string(config.render.resolve_entry_template())
|
|
47
|
+
except jinja2.TemplateSyntaxError as exc:
|
|
48
|
+
raise RenderError(f"Invalid entry template: {exc}") from exc
|
|
49
|
+
|
|
50
|
+
def render_entry(self, entry: Entry) -> str:
|
|
51
|
+
try:
|
|
52
|
+
text = self._entry_template.render(
|
|
53
|
+
content=entry.content,
|
|
54
|
+
date=entry.date,
|
|
55
|
+
nonce=entry.nonce,
|
|
56
|
+
section=entry.section,
|
|
57
|
+
metadata=entry.metadata,
|
|
58
|
+
)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
raise RenderError(f"{entry.path.name}: Failed to render entry: {exc}") from exc
|
|
61
|
+
return text.strip()
|
|
62
|
+
|
|
63
|
+
def render_block(self, entries: Sequence[Entry], *, header: str) -> str:
|
|
64
|
+
context: dict[str, Any] = {
|
|
65
|
+
"header": header,
|
|
66
|
+
"entries": list(entries),
|
|
67
|
+
"sections": self.group_entries(entries),
|
|
68
|
+
"render_entry": self.render_entry,
|
|
69
|
+
"list_item": self.fmt.list_item,
|
|
70
|
+
"section_header": self.fmt.section_header,
|
|
71
|
+
}
|
|
72
|
+
try:
|
|
73
|
+
text = self._template.render(context)
|
|
74
|
+
except RenderError:
|
|
75
|
+
raise
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise RenderError(f"Failed to render version block: {exc}") from exc
|
|
78
|
+
return text.strip()
|