The Gaudi Framework  master (37c0b60a)
update_version.py
Go to the documentation of this file.
1 #!/usr/bin/env python3
2 
12 import datetime
13 import os
14 import re
15 import sys
16 from collections.abc import Callable, Iterable
17 from difflib import unified_diff
18 from subprocess import run
19 from typing import Union
20 
21 import click
22 
23 GITLAB_TOKEN = os.environ.get("GITLAB_TOKEN")
24 
25 
26 def 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 
43 class 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 
84  def __init__(
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 
103  def __init__(
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 
122 def 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(
154  contributor_handle(name)
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 
193 def 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 )
222 def 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 [
232  FileUpdater(
233  "CMakeLists.txt",
234  [(r"^project\‍(Gaudi VERSION", "project(Gaudi VERSION {cmake_version}")],
235  ),
236  FileUpdater(
237  "CITATION.cff",
238  [
239  (r"^version: ", "version: {tag_version}"),
240  (r"^date-released: ", "date-released: '{date}'"),
241  ],
242  ),
243  FileUpdater(
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 
270 if __name__ == "__main__":
update_version.ReplacementRule.replace
replace
Definition: update_version.py:88
jsonFromLHCbLog.json
json
Definition: jsonFromLHCbLog.py:86
update_version.ReplacementRule
Definition: update_version.py:72
GaudiPartProp.decorators.get
get
decorate the vector of properties
Definition: decorators.py:283
update_version.contributor_handle
str contributor_handle(str name)
Definition: update_version.py:193
update_version.ReplacementRule.pattern
pattern
Definition: update_version.py:85
update_version.ReplacementRule.__init__
def __init__(self, Union[str, re.Pattern] pattern, Union[str, Callable[[str, Fields], str]] replace)
Definition: update_version.py:84
update_version.FileUpdater
Definition: update_version.py:102
update_version.FileUpdater.filename
filename
Definition: update_version.py:104
update_version
Definition: update_version.py:1
update_version.Fields
Definition: update_version.py:43
update_version.update_version
def update_version(str version, datetime.datetime date, bool dry_run)
Definition: update_version.py:222
update_version.FileUpdater._apply_rules
str _apply_rules(self, str line, Fields fields)
Definition: update_version.py:111
format
GAUDI_API std::string format(const char *,...)
MsgStream format utility "a la sprintf(...)".
Definition: MsgStream.cpp:119
update_version.FileUpdater.__call__
tuple[str, list[str], list[str]] __call__(self, Fields fields)
Definition: update_version.py:116
update_version.Fields._data
_data
Definition: update_version.py:56
update_version.Fields.data
def data(self)
Definition: update_version.py:68
update_version.FileUpdater.__init__
def __init__(self, str filename, Iterable[Union[ReplacementRule, tuple[str, str]]] rules)
Definition: update_version.py:103
update_version.Fields.__init__
def __init__(self, str version, datetime.date date)
Definition: update_version.py:54
update_version.ReplacementRule.__call__
str __call__(self, str line, Fields fields)
Definition: update_version.py:96
update_version.normalize_version
tuple[str, str] normalize_version(str version)
Definition: update_version.py:26
update_version.update_changelog
tuple[str, list[str], list[str]] update_changelog(Fields fields)
Definition: update_version.py:122
update_version.Fields.__repr__
def __repr__(self)
Definition: update_version.py:62
update_version.FileUpdater.rules
rules
Definition: update_version.py:105