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/_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
@@ -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}"
@@ -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()