diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000000000000000000000000000000000..487138dac4a3219731512d04a042aa2739a7c759 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,28 @@ +cff-version: 1.2.0 +title: Gaudi +message: >- + If you use this software, please cite it using the + metadata from this file. +type: software +authors: + - name: LHCb Collaboration + - name: ATLAS Collaboration +identifiers: + - type: doi + value: 10.5281/zenodo.3660963 +repository-code: 'https://gitlab.cern.ch/gaudi/Gaudi' +url: 'https://cern.ch/gaudi' +abstract: >- + Gaudi is a software architecture and framework that can be + used to facilitate the development of data processing + applications for High Energy Physics experiments. +keywords: + - Gaudi + - LHCb + - ATLAS + - CERN + - HEP + - Software Framework +license: Apache-2.0 +version: v37r0 +date-released: '2023-09-14' diff --git a/utils/update_version.py b/utils/update_version.py new file mode 100755 index 0000000000000000000000000000000000000000..b891cf785244d1ac6d2a5675c4cc1caf3af5c8b4 --- /dev/null +++ b/utils/update_version.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +##################################################################################### +# (c) Copyright 2023 CERN for the benefit of the LHCb and ATLAS collaborations # +# # +# This software is distributed under the terms of the Apache version 2 licence, # +# copied verbatim in the file "LICENSE". # +# # +# In applying this licence, CERN does not waive the privileges and immunities # +# granted to it by virtue of its status as an Intergovernmental Organization # +# or submit itself to any jurisdiction. # +##################################################################################### +import datetime +import re +import sys +from collections.abc import Callable, Iterable +from difflib import unified_diff +from subprocess import run +from typing import Union + +import click + + +def normalize_version(version: str) -> tuple[str, str]: + """ + Convert a version in format "vXrY" or "X.Y" in the pair ("X.Y", "vXrY"). + + >>> normalize_version("v37r0") + ('37.0', 'v37r0') + >>> normalize_version("37.0.1") + ('37.0.1', 'v37r0p1') + """ + # extract the digits + numbers = re.findall(r"\d+", version) + return ( + ".".join(numbers), + "".join("{}{}".format(*pair) for pair in zip("vrpt", numbers)), + ) + + +class Fields: + """ + Helper to carry the allowed fields for formatting replacement strings. + + >>> f = Fields("v37r1", datetime.date(2023, 9, 25)) + >>> f + Fields('37.1', datetime.date(2023, 9, 25)) + >>> f.data + {'cmake_version': '37.1', 'tag_version': 'v37r1', 'date': datetime.date(2023, 9, 25)} + """ + + def __init__(self, version: str, date: datetime.date): + cmake_version, tag_version = normalize_version(version) + self._data = dict( + cmake_version=cmake_version, + tag_version=tag_version, + date=date, + ) + + def __repr__(self): + return ( + f"Fields({repr(self._data['cmake_version'])}, {repr(self._data['date'])})" + ) + + @property + def data(self): + return self._data + + +class ReplacementRule: + """ + Helper to replace lines with patterns or applying functions. + + >>> r = ReplacementRule(r"^version: ", "version: {cmake_version}") + >>> f = Fields("v1r1", datetime.date(2023, 9, 25)) + >>> r("nothing to change\\n", f) + 'nothing to change\\n' + >>> r("version: 1.0\\n", f) + 'version: 1.1\\n' + """ + + def __init__( + self, + pattern: Union[str, re.Pattern], + replace: Union[str, Callable[[str, Fields], str]], + ): + self.pattern = re.compile(pattern) + if isinstance(replace, str): + replace = f"{replace.rstrip()}\n" + self.replace = lambda _line, fields: replace.format(**fields.data) + else: + self.replace = replace + + def __call__(self, line: str, fields: Fields) -> str: + if self.pattern.match(line): + return self.replace(line, fields) + return line + + +class FileUpdater: + def __init__( + self, filename: str, rules: Iterable[Union[ReplacementRule, tuple[str, str]]] + ): + self.filename = filename + self.rules = [ + r if isinstance(r, ReplacementRule) else ReplacementRule(*r) for r in rules + ] + + def _apply_rules(self, line: str, fields: Fields) -> str: + for rule in self.rules: + line = rule(line, fields) + return line + + def __call__(self, fields: Fields) -> tuple[str, list[str], list[str]]: + with open(self.filename) as f: + old = f.readlines() + return self.filename, old, [self._apply_rules(line, fields) for line in old] + + +def update_changelog(fields: Fields) -> tuple[str, list[str], list[str]]: + """ + Special updater to fill draft changelog entry. + """ + latest_tag = run( + ["git", "describe", "--tags", "--abbrev=0"], capture_output=True, text=True + ).stdout.strip() + # This formats the git log as a rough markdown list + # - collect the log formatting it such that we can machine parse it + changes_txt = run( + ["git", "log", "--first-parent", "--format=%s<=>%b|", f"{latest_tag}.."], + capture_output=True, + text=True, + ).stdout + # - removing trailing separator and make it a single line + changes_txt = " ".join(changes_txt.strip().rstrip("|").splitlines()) + # - normalize issues and merge requests links + changes = ( + changes_txt.replace("Closes #", "gaudi/Gaudi#") + .replace("See merge request ", "") + .split("|") + ) + # - split the messages and format the list + changes = [ + f"- {msg.strip()} ({', '.join(refs.split())})\n" + if refs.strip() + else f"- {msg.strip()}\n" + for change in changes + for msg, refs in [change.split("<=>", 1)] + ] + + filename = "CHANGELOG.md" + with open(filename) as f: + old = f.readlines() + for idx, line in enumerate(old): + if line.startswith("## ["): + break + + data = old[:idx] + data.extend( + [ + "## [{tag_version}](https://gitlab.cern.ch/gaudi/Gaudi/-/releases/{tag_version}) - {date}\n".format( + **fields.data + ), + "\n", + "### Changed\n", + "### Added\n", + "### Fixed\n", + "\n", + ] + ) + data.extend(changes) + data.extend(["\n", "\n"]) + data.extend(old[idx:]) + + return filename, old, data + + +@click.command() +@click.argument("version", type=str) +@click.argument( + "date", + type=click.DateTime(("%Y-%m-%d",)), + metavar="[DATE]", + default=datetime.datetime.now(), +) +@click.option( + "--dry-run", + "-n", + default=False, + is_flag=True, + help="only show what would change, but do not modify the files", +) +def update_version(version: str, date: datetime.datetime, dry_run: bool): + """ + Helper to easily update the project version number in all needed files. + """ + fields = Fields(version, date.date()) + click.echo( + "Bumping version to {cmake_version} (tag: {tag_version})".format(**fields.data) + ) + + for updater in [ + FileUpdater( + "CMakeLists.txt", + [(r"^project\(Gaudi VERSION", "project(Gaudi VERSION {cmake_version}")], + ), + FileUpdater( + "CITATION.cff", + [ + (r"^version: ", "version: {tag_version}"), + (r"^date-released: ", "date-released: '{date}'"), + ], + ), + FileUpdater( + "docs/source/conf.py", + [ + (r"^version = ", "version = {cmake_version}"), + (r"^release = ", "release = {tag_version}"), + ], + ), + update_changelog, + ]: + filename, old, new = updater(fields) + + if old != new: + if dry_run: + sys.stdout.writelines( + unified_diff( + old, + new, + fromfile=f"a/{filename}", + tofile=f"b/{filename}", + ) + ) + else: + click.echo(f"updated {filename}") + with open(filename, "w") as f: + f.writelines(new) + + +if __name__ == "__main__": + update_version()