reflex-release 0.1.0a1__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.
@@ -0,0 +1,15 @@
1
+ """Changelog-driven release automation for Python packages and monorepos.
2
+
3
+ The ``CHANGELOG.md`` files are the source of truth for publishing: a package is
4
+ published exactly when the newest version heading in its changelog has no
5
+ corresponding git tag. Tags are created only *after* a successful upload, so a
6
+ failed build or publish is retried by pushing a fix on top of the changelog
7
+ bump — no tag or release ever needs to be deleted.
8
+
9
+ The public entry point is the ``reflex-release`` command line interface; see
10
+ :mod:`reflex_release.cli` and the package README for the workflows it drives.
11
+ """
12
+
13
+ from .config import Config, LockstepGroup, load_config
14
+
15
+ __all__ = ["Config", "LockstepGroup", "load_config"]
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m reflex_release``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """GitHub Actions plumbing: annotations, step outputs and job summaries.
2
+
3
+ Every helper degrades gracefully outside Actions, so the commands can be run
4
+ locally: annotations print as plain lines and outputs/summaries are written only
5
+ when the corresponding environment variable is set.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import NoReturn
14
+
15
+
16
+ class ReleaseError(Exception):
17
+ """A user-facing failure that should end the command with exit code 1."""
18
+
19
+
20
+ def fail(message: str) -> NoReturn:
21
+ """Raise a :class:`ReleaseError` carrying a user-facing message.
22
+
23
+ Args:
24
+ message: Human-readable description of the failure.
25
+
26
+ Raises:
27
+ ReleaseError: Always.
28
+ """
29
+ raise ReleaseError(message)
30
+
31
+
32
+ def echo(message: str) -> None:
33
+ """Write a line of human-readable output to stdout.
34
+
35
+ Args:
36
+ message: The text to write.
37
+ """
38
+ sys.stdout.write(f"{message}\n")
39
+
40
+
41
+ def notice(message: str) -> None:
42
+ """Emit a GitHub Actions notice annotation (plain line outside Actions).
43
+
44
+ Args:
45
+ message: The notice text.
46
+ """
47
+ sys.stdout.write(f"::notice::{message}\n")
48
+
49
+
50
+ def error(message: str) -> None:
51
+ """Emit a GitHub Actions error annotation (plain line outside Actions).
52
+
53
+ Args:
54
+ message: The error text.
55
+ """
56
+ sys.stdout.write(f"::error::{message}\n")
57
+
58
+
59
+ def repo_url() -> str:
60
+ """Return the web URL of the repository the workflow is running against.
61
+
62
+ Returns:
63
+ ``https://<server>/<owner>/<repo>``, or an empty string outside GitHub
64
+ Actions — callers fall back to plain text when there is no URL to link.
65
+ """
66
+ repository = os.environ.get("GITHUB_REPOSITORY")
67
+ if not repository:
68
+ return ""
69
+ server = os.environ.get("GITHUB_SERVER_URL") or "https://github.com"
70
+ return f"{server.rstrip('/')}/{repository}"
71
+
72
+
73
+ def link(label: str, url: str) -> str:
74
+ """Render a markdown link, degrading to the label alone without a URL.
75
+
76
+ Args:
77
+ label: The link text (already escaped/quoted as it should appear).
78
+ url: The target, or an empty string.
79
+
80
+ Returns:
81
+ ``[label](url)``, or just ``label``.
82
+ """
83
+ return f"[{label}]({url})" if url else label
84
+
85
+
86
+ def _append_lines(env_var: str, lines: list[str]) -> None:
87
+ """Append lines to the file named by an environment variable, if it is set.
88
+
89
+ Args:
90
+ env_var: ``GITHUB_OUTPUT`` or ``GITHUB_STEP_SUMMARY``.
91
+ lines: The lines to append.
92
+ """
93
+ path = os.environ.get(env_var)
94
+ if not path:
95
+ sys.stdout.write("\n".join(lines) + "\n")
96
+ return
97
+ with Path(path).open("a", encoding="utf-8") as f:
98
+ f.write("\n".join(lines) + "\n")
99
+
100
+
101
+ def write_outputs(**outputs: str) -> None:
102
+ """Append ``key=value`` pairs to ``$GITHUB_OUTPUT``.
103
+
104
+ Args:
105
+ **outputs: Output names and single-line values.
106
+ """
107
+ _append_lines("GITHUB_OUTPUT", [f"{key}={value}" for key, value in outputs.items()])
108
+
109
+
110
+ def write_summary(lines: list[str]) -> None:
111
+ """Append markdown lines to ``$GITHUB_STEP_SUMMARY``.
112
+
113
+ Args:
114
+ lines: The markdown lines.
115
+ """
116
+ _append_lines("GITHUB_STEP_SUMMARY", lines)
@@ -0,0 +1,251 @@
1
+ """Parsing and rewriting of towncrier-generated ``CHANGELOG.md`` files.
2
+
3
+ The changelogs are the source of truth for publishing, so exactly one parser
4
+ recognizes version headings: the pull-request guard, the publish validation and
5
+ the release-notes extraction all go through this module and therefore cannot
6
+ disagree about what counts as a release.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import re
13
+ import sys
14
+
15
+ from packaging.version import InvalidVersion, Version
16
+
17
+ from .actions import fail
18
+
19
+ NO_SIGNIFICANT_CHANGES = "No significant changes"
20
+
21
+ DEFAULT_TITLE_FORMAT = "## {version} ({project_date})"
22
+
23
+ _HEADING_RE = re.compile(r"^(?P<label>.*?)(?:\s+\((?P<date>[^()]*)\))?\s*$")
24
+
25
+
26
+ @dataclasses.dataclass(frozen=True)
27
+ class Section:
28
+ """One ``## <version> (<date>)`` section of a towncrier changelog.
29
+
30
+ Attributes:
31
+ label: The heading text without the ``## `` prefix or date suffix.
32
+ date: The parenthesized date portion of the heading, if any.
33
+ version: The parsed version, or None for unversioned headings.
34
+ body: Everything below the heading, up to the next one.
35
+ raw: The heading and body verbatim.
36
+ """
37
+
38
+ label: str
39
+ date: str | None
40
+ version: Version | None
41
+ body: str
42
+ raw: str
43
+
44
+
45
+ def heading_version(label: str, date: str | None) -> Version | None:
46
+ """Parse the version out of a section heading label.
47
+
48
+ Args:
49
+ label: The heading text without the ``## `` prefix or date suffix.
50
+ date: The parenthesized date portion of the heading, if any.
51
+
52
+ Returns:
53
+ The parsed version, or None for unparsable or "Unreleased" headings.
54
+ """
55
+ if "unreleased" in label.lower() or "unreleased" in (date or "").lower():
56
+ return None
57
+ try:
58
+ return Version(label.removeprefix("v"))
59
+ except InvalidVersion:
60
+ return None
61
+
62
+
63
+ def parse_sections(text: str) -> tuple[str, list[Section]]:
64
+ """Split changelog markdown into a preamble and its ``## `` sections.
65
+
66
+ Args:
67
+ text: The changelog markdown.
68
+
69
+ Returns:
70
+ A ``(preamble, sections)`` tuple; the preamble is everything before the
71
+ first ``## `` heading.
72
+ """
73
+ lines = text.splitlines(keepends=True)
74
+ starts = [i for i, line in enumerate(lines) if line.startswith("## ")]
75
+ if not starts:
76
+ return text, []
77
+ preamble = "".join(lines[: starts[0]])
78
+ sections: list[Section] = []
79
+ for pos, start in enumerate(starts):
80
+ end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
81
+ match = _HEADING_RE.match(lines[start][3:].strip())
82
+ label = match["label"] if match else ""
83
+ date = match["date"] if match else None
84
+ sections.append(
85
+ Section(
86
+ label=label,
87
+ date=date,
88
+ version=heading_version(label, date),
89
+ body="".join(lines[start + 1 : end]),
90
+ raw="".join(lines[start:end]),
91
+ )
92
+ )
93
+ return preamble, sections
94
+
95
+
96
+ def latest_version(text: str) -> Version | None:
97
+ """Return the newest version in a changelog, skipping "Unreleased".
98
+
99
+ The newest version is the first section heading that parses as a version;
100
+ towncrier prepends new sections, so document order is version order.
101
+
102
+ Args:
103
+ text: The changelog markdown.
104
+
105
+ Returns:
106
+ The version of the first versioned section, or None.
107
+ """
108
+ _, sections = parse_sections(text)
109
+ for section in sections:
110
+ if section.version is not None:
111
+ return section.version
112
+ return None
113
+
114
+
115
+ def extract_notes(text: str, version: Version) -> str | None:
116
+ """Return the body of the changelog section for a version.
117
+
118
+ Args:
119
+ text: The changelog markdown.
120
+ version: The version whose section to extract.
121
+
122
+ Returns:
123
+ The section body without the heading, stripped, or None if the version
124
+ has no section.
125
+ """
126
+ _, sections = parse_sections(text)
127
+ for section in sections:
128
+ if section.version == version:
129
+ return section.body.strip()
130
+ return None
131
+
132
+
133
+ def split_categories(body: str) -> tuple[str, list[tuple[str, str]]]:
134
+ """Split a section body into leading text and its ``### `` category blocks.
135
+
136
+ Args:
137
+ body: The section body (text below a ``## `` heading).
138
+
139
+ Returns:
140
+ A ``(lead, categories)`` tuple where lead is the text before the first
141
+ category heading and categories is a list of ``(name, block)`` pairs.
142
+ """
143
+ lines = body.splitlines(keepends=True)
144
+ starts = [i for i, line in enumerate(lines) if line.startswith("### ")]
145
+ if not starts:
146
+ return body, []
147
+ lead = "".join(lines[: starts[0]])
148
+ categories: list[tuple[str, str]] = []
149
+ for pos, start in enumerate(starts):
150
+ end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
151
+ categories.append((lines[start][4:].strip(), "".join(lines[start + 1 : end])))
152
+ return lead, categories
153
+
154
+
155
+ def render_heading(title_format: str, version: str, date_str: str) -> str:
156
+ """Render a section heading the way towncrier would.
157
+
158
+ Args:
159
+ title_format: The configured ``title_format``.
160
+ version: The version as it appears in headings (``v1.2.3``).
161
+ date_str: The release date (YYYY-MM-DD).
162
+
163
+ Returns:
164
+ The heading line, without a trailing newline.
165
+ """
166
+ return title_format.format(name="", version=version, project_date=date_str).strip()
167
+
168
+
169
+ def collapse_prereleases(
170
+ text: str,
171
+ final_version: Version,
172
+ date_str: str,
173
+ category_order: list[str],
174
+ title_format: str = DEFAULT_TITLE_FORMAT,
175
+ ) -> str:
176
+ """Collapse the top prerelease sections into a single final section.
177
+
178
+ Merges the section for ``final_version`` (freshly written by towncrier from
179
+ any remaining fragments) together with every consecutive prerelease section
180
+ at the top of the changelog into one section, concatenating each category's
181
+ entries oldest-first. "No significant changes." placeholders are dropped
182
+ unless nothing else remains.
183
+
184
+ Args:
185
+ text: The changelog markdown.
186
+ final_version: The final version being released.
187
+ date_str: The date for the new section heading (YYYY-MM-DD).
188
+ category_order: Category names in canonical display order.
189
+ title_format: The towncrier ``title_format`` to render the heading with.
190
+
191
+ Returns:
192
+ The rewritten changelog markdown.
193
+
194
+ Raises:
195
+ ReleaseError: When the changelog has no prerelease sections to collapse.
196
+ """
197
+ preamble, sections = parse_sections(text)
198
+ run: list[Section] = []
199
+ for section in sections:
200
+ if section.version is not None and (
201
+ section.version == final_version or section.version.is_prerelease
202
+ ):
203
+ run.append(section)
204
+ else:
205
+ break
206
+ if not run:
207
+ fail(f"no prerelease sections to collapse into v{final_version}")
208
+
209
+ strays = [
210
+ section
211
+ for section in sections[len(run) :]
212
+ if section.version is not None and section.version.is_prerelease
213
+ ]
214
+ for stray in strays:
215
+ sys.stderr.write(
216
+ f"Warning: prerelease section '{stray.label}' is not contiguous "
217
+ "with the top of the changelog and was left in place.\n"
218
+ )
219
+
220
+ merged: dict[str, list[str]] = {}
221
+ extra_categories: list[str] = []
222
+ leads: list[str] = []
223
+ for section in reversed(run):
224
+ lead, categories = split_categories(section.body)
225
+ lead = lead.strip()
226
+ if lead and not lead.startswith(NO_SIGNIFICANT_CHANGES):
227
+ leads.append(lead)
228
+ for name, block in categories:
229
+ block = block.strip()
230
+ if not block:
231
+ continue
232
+ if name not in merged:
233
+ merged[name] = []
234
+ if name not in category_order:
235
+ extra_categories.append(name)
236
+ merged[name].append(block)
237
+
238
+ parts: list[str] = [render_heading(title_format, f"v{final_version}", date_str), ""]
239
+ if leads:
240
+ parts.extend(["\n\n".join(leads), ""])
241
+ for name in [*category_order, *extra_categories]:
242
+ if name in merged:
243
+ parts.extend([f"### {name}", "", "\n".join(merged[name]), ""])
244
+ if not leads and not merged:
245
+ parts.extend([f"{NO_SIGNIFICANT_CHANGES}.", ""])
246
+
247
+ new_section = "\n".join(parts).rstrip("\n") + "\n"
248
+ remainder = "".join(section.raw for section in sections[len(run) :])
249
+ if remainder:
250
+ return f"{preamble}{new_section}\n\n{remainder}"
251
+ return preamble + new_section