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 +10 -0
- multicz/changelog.py +196 -0
- multicz/cli.py +1677 -0
- multicz/commits.py +271 -0
- multicz/components.py +62 -0
- multicz/config.py +425 -0
- multicz/debian.py +266 -0
- multicz/discovery.py +652 -0
- multicz/planner.py +652 -0
- multicz/state.py +103 -0
- multicz/validation.py +403 -0
- multicz/writers.py +192 -0
- multicz-0.1.0.dist-info/METADATA +1644 -0
- multicz-0.1.0.dist-info/RECORD +16 -0
- multicz-0.1.0.dist-info/WHEEL +4 -0
- multicz-0.1.0.dist-info/entry_points.txt +3 -0
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")
|