multicz 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.
multicz/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """multicz — multi-component versioning for monorepos."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("multicz")
7
+ except PackageNotFoundError: # editable install before metadata is built
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
multicz/changelog.py ADDED
@@ -0,0 +1,196 @@
1
+ """Render and insert per-component CHANGELOG.md sections.
2
+
3
+ A *section* is the chunk of markdown describing a single release for one
4
+ component:
5
+
6
+ ## [1.3.0] - 2026-04-30
7
+
8
+ ### Breaking changes
9
+
10
+ - **api**: drop py3.11 (`abc1234`)
11
+
12
+ ### Features
13
+
14
+ - **api**: add login (`def5678`)
15
+
16
+ The list of section buckets and their titles is configurable via
17
+ ``ProjectSettings.changelog_sections`` so each project can pick its own
18
+ vocabulary (Features/Fixes vs. keep-a-changelog's Added/Changed/Fixed,
19
+ etc.). Commits whose type matches no section are silently dropped — keep
20
+ the changelog focused on user-visible changes — unless
21
+ ``other_section_title`` is set, which buckets them under that title.
22
+
23
+ When written into an existing file the new section lands directly after
24
+ the preamble (anything before the first ``## `` heading) and before any
25
+ older release section.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from collections.abc import Iterable, Sequence
32
+ from datetime import date
33
+ from pathlib import Path
34
+
35
+ from .commits import Commit
36
+ from .config import ChangelogSection, _default_changelog_sections
37
+
38
+ _PREAMBLE = (
39
+ "# Changelog\n"
40
+ "\n"
41
+ "All notable changes to this component are documented here.\n"
42
+ "\n"
43
+ )
44
+
45
+
46
+ def render_body(
47
+ commits: Iterable[Commit],
48
+ *,
49
+ sections: Sequence[ChangelogSection] | None = None,
50
+ breaking_title: str = "Breaking changes",
51
+ other_title: str = "",
52
+ ) -> str:
53
+ """Render the section bodies (no leading H2).
54
+
55
+ Empty string ``breaking_title`` disables the breaking bucket (breaking
56
+ commits then fall through to whichever section claims their type).
57
+ Empty string ``other_title`` drops unmatched conventional commits.
58
+ """
59
+ sections = list(sections) if sections is not None else _default_changelog_sections()
60
+ relevant = [c for c in commits if c.is_conventional]
61
+ if not relevant:
62
+ return "_No notable changes._\n"
63
+
64
+ breaking: list[Commit] = []
65
+ if breaking_title:
66
+ breaking = [c for c in relevant if c.breaking]
67
+
68
+ buckets: dict[str, list[Commit]] = {}
69
+ breaking_set = {id(c) for c in breaking}
70
+ for section in sections:
71
+ type_set = {t.lower() for t in section.types}
72
+ items = [
73
+ c for c in relevant
74
+ if id(c) not in breaking_set and c.type.lower() in type_set
75
+ ]
76
+ if items:
77
+ buckets[section.title] = items
78
+
79
+ if other_title:
80
+ claimed = {t.lower() for s in sections for t in s.types}
81
+ leftovers = [
82
+ c for c in relevant
83
+ if id(c) not in breaking_set and c.type.lower() not in claimed
84
+ ]
85
+ if leftovers:
86
+ buckets[other_title] = leftovers
87
+
88
+ ordered: list[tuple[str, list[Commit]]] = (
89
+ [(breaking_title, breaking)] if breaking else []
90
+ )
91
+ for section in sections:
92
+ if section.title in buckets:
93
+ ordered.append((section.title, buckets[section.title]))
94
+ if other_title and other_title in buckets:
95
+ ordered.append((other_title, buckets[other_title]))
96
+
97
+ if not ordered:
98
+ return "_No notable changes._\n"
99
+
100
+ lines: list[str] = []
101
+ for title, items in ordered:
102
+ lines.append(f"### {title}")
103
+ lines.append("")
104
+ for commit in items:
105
+ scope = f"**{commit.scope}**: " if commit.scope else ""
106
+ lines.append(f"- {scope}{commit.subject} (`{commit.sha[:7]}`)")
107
+ lines.append("")
108
+ return "\n".join(lines)
109
+
110
+
111
+ def render_section(
112
+ version: str,
113
+ commits: Iterable[Commit],
114
+ *,
115
+ today: date | None = None,
116
+ sections: Sequence[ChangelogSection] | None = None,
117
+ breaking_title: str = "Breaking changes",
118
+ other_title: str = "",
119
+ ) -> str:
120
+ """Render the markdown for a single release section."""
121
+ when = (today or date.today()).isoformat()
122
+ body = render_body(
123
+ commits,
124
+ sections=sections,
125
+ breaking_title=breaking_title,
126
+ other_title=other_title,
127
+ )
128
+ return f"## [{version}] - {when}\n\n" + body
129
+
130
+
131
+ def insert_section(existing: str, section: str) -> str:
132
+ """Insert ``section`` into ``existing``, separating it from neighbouring
133
+ sections with a blank line.
134
+ """
135
+ if not existing.strip():
136
+ return _PREAMBLE + section.rstrip() + "\n"
137
+
138
+ lines = existing.splitlines(keepends=True)
139
+ for index, line in enumerate(lines):
140
+ if line.startswith("## "):
141
+ block = section.rstrip("\n") + "\n\n"
142
+ return "".join(lines[:index]) + block + "".join(lines[index:])
143
+
144
+ return existing.rstrip("\n") + "\n\n" + section.rstrip() + "\n"
145
+
146
+
147
+ def drop_prerelease_sections(text: str, base_version: str) -> str:
148
+ """Remove markdown sections whose H2 heading is ``[<base_version>-<pre>.<n>]``.
149
+
150
+ Used by the ``promote`` finalize strategy so that once ``[1.3.0]`` is
151
+ written, the now-superseded ``[1.3.0-rc.1]``, ``[1.3.0-rc.2]``, …
152
+ sections are removed from the file.
153
+ """
154
+ pre_re = re.compile(
155
+ rf"^## \[{re.escape(base_version)}-[A-Za-z]+\.\d+\]"
156
+ )
157
+ out: list[str] = []
158
+ skip = False
159
+ for line in text.splitlines(keepends=True):
160
+ if line.startswith("## "):
161
+ skip = bool(pre_re.match(line))
162
+ if not skip:
163
+ out.append(line)
164
+ return "".join(out)
165
+
166
+
167
+ def update_changelog_file(
168
+ path: Path,
169
+ version: str,
170
+ commits: Iterable[Commit],
171
+ *,
172
+ today: date | None = None,
173
+ sections: Sequence[ChangelogSection] | None = None,
174
+ breaking_title: str = "Breaking changes",
175
+ other_title: str = "",
176
+ drop_prereleases: bool = False,
177
+ ) -> None:
178
+ """Render a new section and merge it into ``path`` (creating the file if needed).
179
+
180
+ ``drop_prereleases=True`` removes any prior ``## [<version>-<pre>.<n>]``
181
+ sections from the file before inserting the new release section —
182
+ used by the ``promote`` finalize strategy.
183
+ """
184
+ section = render_section(
185
+ version,
186
+ commits,
187
+ today=today,
188
+ sections=sections,
189
+ breaking_title=breaking_title,
190
+ other_title=other_title,
191
+ )
192
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
193
+ if drop_prereleases:
194
+ existing = drop_prerelease_sections(existing, version)
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ path.write_text(insert_section(existing, section), encoding="utf-8")