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 ADDED
@@ -0,0 +1,5 @@
1
+ """*Read the freaking changelog* is a tool to manage changelogs and versioning."""
2
+
3
+ from rtfc._format import Format, RstFormat
4
+
5
+ __all__ = ("Format", "RstFormat")
rtfc/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support running the command line interface with ``python -m rtfc``."""
2
+
3
+ from rtfc._cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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'")