The Gaudi Framework  master (181af51f)
Loading...
Searching...
No Matches
update_version.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2
12import datetime
13import os
14import re
15import sys
16from collections.abc import Callable, Iterable
17from difflib import unified_diff
18from subprocess import run
19from typing import Union
20
21import click
22
23GITLAB_TOKEN = os.environ.get("GITLAB_TOKEN")
24
25
26def normalize_version(version: str) -> tuple[str, str]:
27 """
28 Convert a version in format "vXrY" or "X.Y" in the pair ("X.Y", "vXrY").
29
30 >>> normalize_version("v37r0")
31 ('37.0', 'v37r0')
32 >>> normalize_version("37.0.1")
33 ('37.0.1', 'v37r0p1')
34 """
35 # extract the digits
36 numbers = re.findall(r"\d+", version)
37 return (
38 ".".join(numbers),
39 "".join("{}{}".format(*pair) for pair in zip("vrpt", numbers)),
40 )
41
42
43class Fields:
44 """
45 Helper to carry the allowed fields for formatting replacement strings.
46
47 >>> f = Fields("v37r1", datetime.date(2023, 9, 25))
48 >>> f
49 Fields('37.1', datetime.date(2023, 9, 25))
50 >>> f.data
51 {'cmake_version': '37.1', 'tag_version': 'v37r1', 'date': datetime.date(2023, 9, 25)}
52 """
53
54 def __init__(self, version: str, date: datetime.date):
55 cmake_version, tag_version = normalize_version(version)
56 self._data = dict(
57 cmake_version=cmake_version,
58 tag_version=tag_version,
59 date=date,
60 )
61
62 def __repr__(self):
63 return (
64 f"Fields({repr(self._data['cmake_version'])}, {repr(self._data['date'])})"
65 )
66
67 @property
68 def data(self):
69 return self._data
70
71
73 """
74 Helper to replace lines with patterns or applying functions.
75
76 >>> r = ReplacementRule(r"^version: ", "version: {cmake_version}")
77 >>> f = Fields("v1r1", datetime.date(2023, 9, 25))
78 >>> r("nothing to change\\n", f)
79 'nothing to change\\n'
80 >>> r("version: 1.0\\n", f)
81 'version: 1.1\\n'
82 """
83
85 self,
86 pattern: Union[str, re.Pattern],
87 replace: Union[str, Callable[[str, Fields], str]],
88 ):
89 self.pattern = re.compile(pattern)
90 if isinstance(replace, str):
91 replace = f"{replace.rstrip()}\n"
92 self.replace = lambda _line, fields: replace.format(**fields.data)
93 else:
94 self.replace = replace
95
96 def __call__(self, line: str, fields: Fields) -> str:
97 if self.pattern.match(line):
98 return self.replace(line, fields)
99 return line
100
101
104 self, filename: str, rules: Iterable[Union[ReplacementRule, tuple[str, str]]]
105 ):
106 self.filename = filename
107 self.rules = [
108 r if isinstance(r, ReplacementRule) else ReplacementRule(*r) for r in rules
109 ]
110
111 def _apply_rules(self, line: str, fields: Fields) -> str:
112 for rule in self.rules:
113 line = rule(line, fields)
114 return line
115
116 def __call__(self, fields: Fields) -> tuple[str, list[str], list[str]]:
117 with open(self.filename) as f:
118 old = f.readlines()
119 return self.filename, old, [self._apply_rules(line, fields) for line in old]
120
121
122def update_changelog(fields: Fields) -> tuple[str, list[str], list[str]]:
123 """
124 Special updater to fill draft changelog entry.
125 """
126 latest_tag = run(
127 ["git", "describe", "--tags", "--abbrev=0"], capture_output=True, text=True
128 ).stdout.strip()
129 # This formats the git log as a rough markdown list
130 # - collect the log formatting it such that we can machine parse it
131 changes_txt = run(
132 ["git", "log", "--first-parent", "--format=%s<=>%b|", f"{latest_tag}.."],
133 capture_output=True,
134 text=True,
135 ).stdout
136 # - removing trailing separator and make it a single line
137 changes_txt = " ".join(changes_txt.strip().rstrip("|").splitlines())
138 # - normalize issues and merge requests links
139 changes = (
140 changes_txt.replace("Closes #", "gaudi/Gaudi#")
141 .replace("See merge request ", "")
142 .split("|")
143 )
144 # - split the messages and format the list
145 changes = [
146 f"- {msg.strip()} ({', '.join(refs.split())})\n"
147 if refs.strip()
148 else f"- {msg.strip()}\n"
149 for change in changes
150 for msg, refs in ([change.split("<=>", 1)] if "<=>" in change else [])
151 ]
152 # Now we get the list of contributors
153 contributors = sorted(
155 for name in set(
156 run(
157 ["git", "log", "--format=%an", f"{latest_tag}...HEAD"],
158 capture_output=True,
159 text=True,
160 ).stdout.splitlines()
161 )
162 )
163
164 filename = "CHANGELOG.md"
165 with open(filename) as f:
166 old = f.readlines()
167 for idx, line in enumerate(old):
168 if line.startswith("## ["):
169 break
170
171 data = old[:idx]
172 data.extend(
173 [
174 "## [{tag_version}](https://gitlab.cern.ch/gaudi/Gaudi/-/releases/{tag_version}) - {date}\n".format(
175 **fields.data
176 ),
177 "\nA special thanks to all the people that contributed to this release:\n",
178 ",\n".join(contributors),
179 ".\n\n",
180 "### Changed\n",
181 "### Added\n",
182 "### Fixed\n",
183 "\n",
184 ]
185 )
186 data.extend(changes)
187 data.extend(["\n", "\n"])
188 data.extend(old[idx:])
189
190 return filename, old, data
191
192
193def contributor_handle(name: str) -> str:
194 if GITLAB_TOKEN:
195 from requests import get
196
197 users = get(
198 "https://gitlab.cern.ch/api/v4/users",
199 headers={"PRIVATE-TOKEN": GITLAB_TOKEN},
200 params={"search": name},
201 ).json()
202 if users:
203 return f'@{users[0]["username"]}'
204 return name
205
206
207@click.command()
208@click.argument("version", type=str)
209@click.argument(
210 "date",
211 type=click.DateTime(("%Y-%m-%d",)),
212 metavar="[DATE]",
213 default=datetime.datetime.now(),
214)
215@click.option(
216 "--dry-run",
217 "-n",
218 default=False,
219 is_flag=True,
220 help="only show what would change, but do not modify the files",
221)
222def update_version(version: str, date: datetime.datetime, dry_run: bool):
223 """
224 Helper to easily update the project version number in all needed files.
225 """
226 fields = Fields(version, date.date())
227 click.echo(
228 "Bumping version to {cmake_version} (tag: {tag_version})".format(**fields.data)
229 )
230
231 for updater in [
233 "CMakeLists.txt",
234 [(r"^project\‍(Gaudi VERSION", "project(Gaudi VERSION {cmake_version}")],
235 ),
237 "CITATION.cff",
238 [
239 (r"^version: ", "version: {tag_version}"),
240 (r"^date-released: ", "date-released: '{date}'"),
241 ],
242 ),
244 "docs/source/conf.py",
245 [
246 (r"^version = ", 'version = "{cmake_version}"'),
247 (r"^release = ", 'release = "{tag_version}"'),
248 ],
249 ),
250 update_changelog,
251 ]:
252 filename, old, new = updater(fields)
253
254 if old != new:
255 if dry_run:
256 sys.stdout.writelines(
257 unified_diff(
258 old,
259 new,
260 fromfile=f"a/{filename}",
261 tofile=f"b/{filename}",
262 )
263 )
264 else:
265 click.echo(f"updated {filename}")
266 with open(filename, "w") as f:
267 f.writelines(new)
268
269
270if __name__ == "__main__":
GAUDI_API std::string format(const char *,...)
MsgStream format utility "a la sprintf(...)".
Definition MsgStream.cpp:93
__init__(self, str version, datetime.date date)
str _apply_rules(self, str line, Fields fields)
__init__(self, str filename, Iterable[Union[ReplacementRule, tuple[str, str]]] rules)
tuple[str, list[str], list[str]] __call__(self, Fields fields)
__init__(self, Union[str, re.Pattern] pattern, Union[str, Callable[[str, Fields], str]] replace)
str __call__(self, str line, Fields fields)
str contributor_handle(str name)
tuple[str, str] normalize_version(str version)
tuple[str, list[str], list[str]] update_changelog(Fields fields)