multicz 0.2.2__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: multicz
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Multi-component versioning for monorepos: bump apps, charts, and images independently from conventional commits.
5
5
  Keywords: semver,monorepo,helm,conventional-commits,release,versioning
6
6
  Author: Chris
@@ -960,6 +960,28 @@ exec multicz check "$1"
960
960
  * `.properties` — line-based `key=value` substitution (e.g. `gradle.properties`)
961
961
  * anything else — treated as a one-line `VERSION` file (`key = ` omitted)
962
962
 
963
+ ### Regex escape hatch (`key = "regex:..."`)
964
+
965
+ For files no structured parser handles — Python `__version__`,
966
+ TypeScript `export const VERSION`, Cargo `version = "..."` *outside*
967
+ the `[package]` table, Makefile `VERSION := ...`, shell scripts,
968
+ Dockerfile `LABEL version=...` — prefix the key with `regex:`:
969
+
970
+ ```toml
971
+ [components.api]
972
+ bump_files = [
973
+ { file = "pyproject.toml", key = "project.version" },
974
+ { file = "src/api/__init__.py", key = 'regex:^__version__\s*=\s*"([^"]+)"' },
975
+ ]
976
+ ```
977
+
978
+ The pattern needs exactly one capture group locating the version
979
+ literal. Matching uses `re.MULTILINE`, and only the *first* match's
980
+ capture group is rewritten — surrounding text (quotes, indentation,
981
+ comments, the rest of the file) is preserved byte-for-byte. Bad
982
+ patterns (uncompilable, no group, no match in file) surface at
983
+ `multicz validate --strict` rather than at bump time.
984
+
963
985
  ### Debian packages (`format = "debian"`)
964
986
 
965
987
  `multicz` writes a proper `debian/changelog` instead of a markdown
@@ -932,6 +932,28 @@ exec multicz check "$1"
932
932
  * `.properties` — line-based `key=value` substitution (e.g. `gradle.properties`)
933
933
  * anything else — treated as a one-line `VERSION` file (`key = ` omitted)
934
934
 
935
+ ### Regex escape hatch (`key = "regex:..."`)
936
+
937
+ For files no structured parser handles — Python `__version__`,
938
+ TypeScript `export const VERSION`, Cargo `version = "..."` *outside*
939
+ the `[package]` table, Makefile `VERSION := ...`, shell scripts,
940
+ Dockerfile `LABEL version=...` — prefix the key with `regex:`:
941
+
942
+ ```toml
943
+ [components.api]
944
+ bump_files = [
945
+ { file = "pyproject.toml", key = "project.version" },
946
+ { file = "src/api/__init__.py", key = 'regex:^__version__\s*=\s*"([^"]+)"' },
947
+ ]
948
+ ```
949
+
950
+ The pattern needs exactly one capture group locating the version
951
+ literal. Matching uses `re.MULTILINE`, and only the *first* match's
952
+ capture group is rewritten — surrounding text (quotes, indentation,
953
+ comments, the rest of the file) is preserved byte-for-byte. Bad
954
+ patterns (uncompilable, no group, no match in file) surface at
955
+ `multicz validate --strict` rather than at bump time.
956
+
935
957
  ### Debian packages (`format = "debian"`)
936
958
 
937
959
  `multicz` writes a proper `debian/changelog` instead of a markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "multicz"
3
- version = "0.2.2"
3
+ version = "0.4.0"
4
4
  description = "Multi-component versioning for monorepos: bump apps, charts, and images independently from conventional commits."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -29,12 +29,24 @@ from __future__ import annotations
29
29
 
30
30
  import re
31
31
  from collections.abc import Iterable, Sequence
32
+ from dataclasses import dataclass
32
33
  from datetime import date
33
34
  from pathlib import Path
34
35
 
35
36
  from .commits import Commit
36
37
  from .config import ChangelogSection, _default_changelog_sections
37
38
 
39
+
40
+ @dataclass(frozen=True)
41
+ class CascadeEntry:
42
+ """A non-commit reason for a bump (mirror or trigger), surfaced in
43
+ the changelog so cascade-only releases describe what made them
44
+ happen instead of rendering ``_No notable changes._``.
45
+ """
46
+
47
+ upstream: str
48
+ upstream_version: str
49
+
38
50
  _PREAMBLE = (
39
51
  "# Changelog\n"
40
52
  "\n"
@@ -49,16 +61,34 @@ def render_body(
49
61
  sections: Sequence[ChangelogSection] | None = None,
50
62
  breaking_title: str = "Breaking changes",
51
63
  other_title: str = "",
64
+ cascades: Sequence[CascadeEntry] | None = None,
65
+ cascade_title: str = "Dependencies",
66
+ cascade_format: str = "Track `{upstream}` `{upstream_version}`",
52
67
  ) -> str:
53
68
  """Render the section bodies (no leading H2).
54
69
 
55
70
  Empty string ``breaking_title`` disables the breaking bucket (breaking
56
71
  commits then fall through to whichever section claims their type).
57
72
  Empty string ``other_title`` drops unmatched conventional commits.
73
+
74
+ ``cascades`` lists upstream bumps that pulled this component along
75
+ (mirror writes, trigger edges). When present and ``cascade_title``
76
+ is non-empty, they render as a dedicated H3 section; this also
77
+ suppresses the ``_No notable changes._`` placeholder when no
78
+ commits otherwise apply.
58
79
  """
59
80
  sections = list(sections) if sections is not None else _default_changelog_sections()
60
81
  relevant = [c for c in commits if c.is_conventional]
61
- if not relevant:
82
+ cascade_lines: list[str] = []
83
+ if cascades and cascade_title:
84
+ for entry in cascades:
85
+ cascade_lines.append(
86
+ cascade_format.format(
87
+ upstream=entry.upstream,
88
+ upstream_version=entry.upstream_version,
89
+ )
90
+ )
91
+ if not relevant and not cascade_lines:
62
92
  return "_No notable changes._\n"
63
93
 
64
94
  breaking: list[Commit] = []
@@ -94,7 +124,7 @@ def render_body(
94
124
  if other_title and other_title in buckets:
95
125
  ordered.append((other_title, buckets[other_title]))
96
126
 
97
- if not ordered:
127
+ if not ordered and not cascade_lines:
98
128
  return "_No notable changes._\n"
99
129
 
100
130
  lines: list[str] = []
@@ -105,6 +135,12 @@ def render_body(
105
135
  scope = f"**{commit.scope}**: " if commit.scope else ""
106
136
  lines.append(f"- {scope}{commit.subject} (`{commit.sha[:7]}`)")
107
137
  lines.append("")
138
+ if cascade_lines:
139
+ lines.append(f"### {cascade_title}")
140
+ lines.append("")
141
+ for entry in cascade_lines:
142
+ lines.append(f"- {entry}")
143
+ lines.append("")
108
144
  return "\n".join(lines)
109
145
 
110
146
 
@@ -116,6 +152,9 @@ def render_section(
116
152
  sections: Sequence[ChangelogSection] | None = None,
117
153
  breaking_title: str = "Breaking changes",
118
154
  other_title: str = "",
155
+ cascades: Sequence[CascadeEntry] | None = None,
156
+ cascade_title: str = "Dependencies",
157
+ cascade_format: str = "Track `{upstream}` `{upstream_version}`",
119
158
  ) -> str:
120
159
  """Render the markdown for a single release section."""
121
160
  when = (today or date.today()).isoformat()
@@ -124,6 +163,9 @@ def render_section(
124
163
  sections=sections,
125
164
  breaking_title=breaking_title,
126
165
  other_title=other_title,
166
+ cascades=cascades,
167
+ cascade_title=cascade_title,
168
+ cascade_format=cascade_format,
127
169
  )
128
170
  return f"## [{version}] - {when}\n\n" + body
129
171
 
@@ -174,6 +216,9 @@ def update_changelog_file(
174
216
  breaking_title: str = "Breaking changes",
175
217
  other_title: str = "",
176
218
  drop_prereleases: bool = False,
219
+ cascades: Sequence[CascadeEntry] | None = None,
220
+ cascade_title: str = "Dependencies",
221
+ cascade_format: str = "Track `{upstream}` `{upstream_version}`",
177
222
  ) -> None:
178
223
  """Render a new section and merge it into ``path`` (creating the file if needed).
179
224
 
@@ -188,6 +233,9 @@ def update_changelog_file(
188
233
  sections=sections,
189
234
  breaking_title=breaking_title,
190
235
  other_title=other_title,
236
+ cascades=cascades,
237
+ cascade_title=cascade_title,
238
+ cascade_format=cascade_format,
191
239
  )
192
240
  existing = path.read_text(encoding="utf-8") if path.exists() else ""
193
241
  if drop_prereleases:
@@ -14,7 +14,7 @@ from rich.console import Console
14
14
  from rich.table import Table
15
15
 
16
16
  from . import __version__
17
- from .changelog import render_body, update_changelog_file
17
+ from .changelog import CascadeEntry, render_body, update_changelog_file
18
18
  from .commits import (
19
19
  DEFAULT_TYPES,
20
20
  commits_in_range,
@@ -1376,6 +1376,27 @@ def bump(
1376
1376
  planned.component, config, repo, matcher,
1377
1377
  since_stable=use_stable_since,
1378
1378
  )
1379
+ # Surface mirror/trigger cascades as a Dependencies
1380
+ # section: when a release is purely cascade-driven
1381
+ # (e.g. chart bumps because api updated appVersion),
1382
+ # this is the only thing that explains *why* the
1383
+ # release exists.
1384
+ cascade_entries: list[CascadeEntry] = []
1385
+ seen_upstreams: set[str] = set()
1386
+ for reason in planned.reasons:
1387
+ if isinstance(reason, MirrorReason | TriggerReason):
1388
+ if reason.upstream in seen_upstreams:
1389
+ continue
1390
+ upstream_planned = plan.bumps.get(reason.upstream)
1391
+ if upstream_planned is None:
1392
+ continue
1393
+ cascade_entries.append(
1394
+ CascadeEntry(
1395
+ upstream=reason.upstream,
1396
+ upstream_version=upstream_planned.next,
1397
+ )
1398
+ )
1399
+ seen_upstreams.add(reason.upstream)
1379
1400
  changelog_path = repo / comp.changelog
1380
1401
  update_changelog_file(
1381
1402
  changelog_path,
@@ -1385,6 +1406,9 @@ def bump(
1385
1406
  breaking_title=config.project.breaking_section_title,
1386
1407
  other_title=config.project.other_section_title,
1387
1408
  drop_prereleases=is_final and strategy == "promote",
1409
+ cascades=cascade_entries,
1410
+ cascade_title=config.project.cascade_section_title,
1411
+ cascade_format=config.project.cascade_changelog_format,
1388
1412
  )
1389
1413
  if changelog_path not in written:
1390
1414
  written.append(changelog_path)
@@ -231,6 +231,14 @@ class ProjectSettings(BaseModel):
231
231
  )
232
232
  breaking_section_title: str = "Breaking changes"
233
233
  other_section_title: str = ""
234
+ # Section + line format for cascade-only bumps (mirror or trigger).
235
+ # Replaces the legacy ``_No notable changes._`` placeholder when the
236
+ # only reason for a release is an upstream component bumping. Set
237
+ # ``cascade_section_title = ""`` to disable and fall back to the
238
+ # placeholder. ``cascade_changelog_format`` accepts ``{upstream}`` and
239
+ # ``{upstream_version}`` placeholders.
240
+ cascade_section_title: str = "Dependencies"
241
+ cascade_changelog_format: str = "Track `{upstream}` `{upstream_version}`"
234
242
  finalize_strategy: Literal["consolidate", "promote", "annotate"] = "consolidate"
235
243
  overlap_policy: Literal["error", "first-match", "allow", "all"] = "error"
236
244
  ignored_types: list[str] = Field(default_factory=list)
@@ -13,22 +13,54 @@ A ``key`` is a dotted path (``project.version``, ``image.tag``). Passing
13
13
  files the dotted-path interpretation is disabled — the key is taken
14
14
  verbatim, since properties files routinely use dotted keys (``a.b.c``)
15
15
  that are *not* nested.
16
+
17
+ A ``key`` prefixed with ``regex:`` is a language-agnostic escape hatch:
18
+ the rest of the string is a regex with one capture group locating the
19
+ version literal. Useful for ``__version__ = "X"`` in Python,
20
+ ``export const VERSION = "X"`` in TypeScript, ``VERSION := X`` in
21
+ Makefiles, etc. The regex is anchored with :data:`re.MULTILINE`, and
22
+ only the *first* match's capture group is rewritten.
16
23
  """
17
24
 
18
25
  from __future__ import annotations
19
26
 
20
27
  import io
21
28
  import json
29
+ import re
22
30
  from pathlib import Path
23
31
 
24
32
  import tomlkit
25
33
  from ruamel.yaml import YAML
26
34
 
35
+ REGEX_KEY_PREFIX = "regex:"
36
+
27
37
 
28
38
  class WriterError(RuntimeError):
29
39
  """Raised when a value cannot be read or written."""
30
40
 
31
41
 
42
+ def _is_regex_key(key: str | None) -> bool:
43
+ return key is not None and key.startswith(REGEX_KEY_PREFIX)
44
+
45
+
46
+ def _compile_regex_key(key: str) -> re.Pattern[str]:
47
+ pattern = key[len(REGEX_KEY_PREFIX):]
48
+ if not pattern:
49
+ raise WriterError(f"empty regex pattern in key: {key!r}")
50
+ try:
51
+ compiled = re.compile(pattern, re.MULTILINE)
52
+ except re.error as exc:
53
+ raise WriterError(
54
+ f"invalid regex {pattern!r} in key {key!r}: {exc}"
55
+ ) from exc
56
+ if compiled.groups < 1:
57
+ raise WriterError(
58
+ f"regex {pattern!r} must contain exactly one capture "
59
+ "group locating the version literal"
60
+ )
61
+ return compiled
62
+
63
+
32
64
  def _split_key(key: str) -> list[str]:
33
65
  parts = [part for part in key.split(".") if part]
34
66
  if not parts:
@@ -121,6 +153,14 @@ def read_value(file: Path, key: str | None) -> str:
121
153
  text = file.read_text(encoding="utf-8")
122
154
  if key is None:
123
155
  return text.strip()
156
+ if _is_regex_key(key):
157
+ pattern = _compile_regex_key(key)
158
+ match = pattern.search(text)
159
+ if not match:
160
+ raise WriterError(
161
+ f"regex {key!r} matched nothing in {file}"
162
+ )
163
+ return match.group(1)
124
164
  if _is_properties(file):
125
165
  result = _read_property(text, key)
126
166
  if result is None:
@@ -157,6 +197,22 @@ def write_value(file: Path, key: str | None, value: str) -> None:
157
197
  file.write_text(value + "\n", encoding="utf-8")
158
198
  return
159
199
 
200
+ if _is_regex_key(key):
201
+ pattern = _compile_regex_key(key)
202
+ text = file.read_text(encoding="utf-8")
203
+ match = pattern.search(text)
204
+ if not match:
205
+ raise WriterError(
206
+ f"regex {key!r} matched nothing in {file}"
207
+ )
208
+ # Replace only the first match's capture group, preserving the
209
+ # surrounding text byte-for-byte (quotes, indentation, comments).
210
+ g_start, g_end = match.span(1)
211
+ file.write_text(
212
+ text[:g_start] + value + text[g_end:], encoding="utf-8"
213
+ )
214
+ return
215
+
160
216
  if _is_properties(file):
161
217
  text = file.read_text(encoding="utf-8")
162
218
  file.write_text(_write_property(text, key, value), encoding="utf-8")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes