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