Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automate release process #2734

Open
wants to merge 33 commits into
base: v5.1.0-rc
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4d9ddd5
Add script to bump version numbers. (Initial revision).
tomchapman Jul 11, 2023
988753d
Add script to check for new authors. (Initial revision).
tomchapman Jul 11, 2023
88f709e
Move scripts to bin directory
tomchapman Jul 12, 2023
80d5a4f
Remove hard-coded paths.
tomchapman Jul 12, 2023
7df3104
Make KNOWN_AUTHORS a dictionary, holding more information to help ide…
tomchapman Jul 12, 2023
80129ae
Handle version numbers with multiple digits.
tomchapman Jul 12, 2023
0111768
Add classes VersionNumber and ShortVersionNumber.
tomchapman Jul 12, 2023
f329b47
Run git log command to get the list of authors.
tomchapman Jul 13, 2023
7ec28f4
Extracted function get_main_directory().
tomchapman Jul 13, 2023
b4db274
Also list emails for unrecognised authors.
tomchapman Jul 13, 2023
048207d
Copy functions from other scripts:
tomchapman Aug 10, 2023
db5d413
Apply copied functions to this script.
tomchapman Aug 10, 2023
696da06
Black formatting
tomchapman Aug 21, 2023
9b8bc24
No need for ShortVersionNumber to inherit from VersionNumber
tomchapman Aug 21, 2023
2d514f0
Use cwd argument to subprocess.run() to change directory (subprocess.…
tomchapman Aug 21, 2023
5329cf4
Use subprocess check=True argument to check for error.
tomchapman Aug 21, 2023
c7051f2
Use subprocess text=True argument (then no need to decode() stdout).
tomchapman Aug 21, 2023
8a27bfa
No need to catch real errors.
tomchapman Aug 21, 2023
f85ad2a
When catching KeyError, raise a new exception instead. Use an f-string.
tomchapman Aug 21, 2023
19357aa
Use get_main_directory() function.
tomchapman Aug 21, 2023
80b914c
Make VersionNumber a dataclass.
tomchapman Aug 21, 2023
cbb9823
Use str dunder method.
tomchapman Aug 21, 2023
80eced1
Remove commented-out copied code.
tomchapman Aug 21, 2023
e9686f2
Add command line argument for the new version number.
tomchapman Aug 21, 2023
6de3826
Corrected regex.
tomchapman Aug 21, 2023
a932ba0
Add command line description to update_version_number_in_file script.
tomchapman Aug 22, 2023
d52d622
Make ShortVersionNumber a dataclass.
tomchapman Aug 22, 2023
19fe7ed
Maint: Make update version/citation scripts executable
ZedThree Sep 20, 2023
5a8375c
Maint: Fix typo in next version regex
ZedThree Sep 20, 2023
a7f057e
Maint: Update list of known authors in citation script
ZedThree Sep 20, 2023
bc1b436
Maint: Don't print anything if no unrecognised authors
ZedThree Sep 20, 2023
a3fa61c
Add Tom Chapman to authors list
ZedThree Sep 20, 2023
33632ad
Fix typo in author list
ZedThree Sep 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
184 changes: 184 additions & 0 deletions bin/update_citations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import subprocess
from pathlib import Path
import os
import yaml
from unidecode import unidecode
from typing import NamedTuple


def get_main_directory():
return Path(os.path.abspath(__file__)).parent.parent


def get_authors_from_git():

main_directory = get_main_directory()
subprocess.run(["cd", main_directory], shell=True)
tomc271 marked this conversation as resolved.
Show resolved Hide resolved

output = subprocess.run(["git", "log", "--format='%aN %aE'"], capture_output=True)
if output.stderr:
return output.stderr
tomc271 marked this conversation as resolved.
Show resolved Hide resolved

authors_string = output.stdout.decode()
tomc271 marked this conversation as resolved.
Show resolved Hide resolved
authors_list = authors_string.split('\n')
authors_without_quotes = [a.strip("'") for a in authors_list]

distinct_authors = set(authors_without_quotes)
distinct_authors_list_without_empty_strings = [a for a in distinct_authors if a]
authors_with_emails = [a.rsplit(maxsplit=1) for a in distinct_authors_list_without_empty_strings]
return authors_with_emails


def parse_cff_file(filename):
with open(filename, "r", encoding='UTF-8') as stream:
try:
return yaml.safe_load(stream)
except yaml.YAMLError as exc:
print(exc)
tomc271 marked this conversation as resolved.
Show resolved Hide resolved


def get_authors_from_cff_file():
main_directory = Path(os.path.abspath(__file__)).parent.parent
filename = Path(main_directory) / "CITATION.cff"
tomc271 marked this conversation as resolved.
Show resolved Hide resolved
file_contents = parse_cff_file(filename)
try:
return file_contents["authors"]
except KeyError as key_error:
print("Failed to find section:", key_error, "in", filename)
tomc271 marked this conversation as resolved.
Show resolved Hide resolved


class ExistingAuthorNames:

def __init__(self, existing_authors):
self._existing_author_names = \
[(unidecode(a.get("given-names")), unidecode(a.get("family-names"))) for a in existing_authors]

def last_name_matches_surname_and_first_name_or_first_letter_matches_given_name(self, last_name, first_name):
matches = [n for n in self._existing_author_names if
n[1].casefold() == last_name.casefold()] # Last name matches surname

for match in matches:
if match[0].casefold() == first_name.casefold(): # The given name also matches author first name
return True
if match[0][0].casefold() == first_name[0].casefold(): # The first initial matches author first name
return True

def first_name_matches_surname_and_last_name_matches_given_name(self, first_name, last_name):
matches = [n for n in self._existing_author_names if
n[1].casefold() == first_name.casefold()] # First name matches surname

for match in matches:
if match[0].casefold() == last_name.casefold(): # The given name also matches author last name
return True

def surname_matches_whole_author_name(self, author):

surname_matches = [n for n in self._existing_author_names if n[1].casefold() == author.casefold()]
if len(surname_matches) > 0:
return True

def given_name_matches_matches_whole_author_name(self, author):
given_name_matches = [n for n in self._existing_author_names if n[0].casefold() == author.casefold()]
if len(given_name_matches) > 0:
return True

def combined_name_matches_whole_author_name(self, author):

combined_name_matches = [n for n in self._existing_author_names if
(n[0] + n[1]).casefold() == author.casefold()]
if len(combined_name_matches) > 0:
return True

def combined_name_reversed_matches(self, author):

combined_name_reversed_matches = [n for n in self._existing_author_names if
(n[1] + n[0]).casefold() == author.casefold()]
if len(combined_name_reversed_matches) > 0:
return True

def author_name_is_first_initial_and_surname_concatenated(self, author):
first_character = author[0]
remaining_characters = author[1:]
matches = [n for n in self._existing_author_names if
n[1].casefold() == remaining_characters.casefold()] # Second part of name matches surname
for match in matches:
if match[0][0].casefold() == first_character.casefold(): # The first initial matches author first name
return True


def author_found_in_existing_authors(author, existing_authors):

existing_author_names = ExistingAuthorNames(existing_authors)

names = author.split()
first_name = unidecode(names[0].replace(",", ""))
last_name = unidecode(names[-1])

if existing_author_names.last_name_matches_surname_and_first_name_or_first_letter_matches_given_name(
last_name, first_name):
return True

if existing_author_names.first_name_matches_surname_and_last_name_matches_given_name(first_name, last_name):
return True

if existing_author_names.surname_matches_whole_author_name(author):
return True

if existing_author_names.given_name_matches_matches_whole_author_name(author):
return True

if existing_author_names.combined_name_matches_whole_author_name(author):
return True

if existing_author_names.combined_name_reversed_matches(author):
return True

if existing_author_names.author_name_is_first_initial_and_surname_concatenated(author):
return True

return False


def update_citations():

nonhuman_authors = [a for a in authors_from_git if "github" in a[0].casefold() or "dependabot" in a[0].casefold()]

known_authors = [a for a in authors_from_git if a[0] in KNOWN_AUTHORS]

human_authors = [a for a in authors_from_git if a not in nonhuman_authors]

authors_to_search_for = [a for a in human_authors if a not in known_authors]

unrecognised_authors = [a for a in authors_to_search_for if
not author_found_in_existing_authors(a[0], existing_authors)]

print("The following authors were not recognised. Add to citations?")
for author in unrecognised_authors:
print(author)


class KnownAuthor(NamedTuple):
family_names: str
given_names: str


KNOWN_AUTHORS = {"bendudson": KnownAuthor("Dodson", "Benjamin"),
"brey": KnownAuthor("Breyiannis", "George"),
"David Schwörer": KnownAuthor("Bold", "David"),
"dschwoerer": KnownAuthor("Bold", "David"),
"hahahasan": KnownAuthor("Muhammed", "Hasan"),
"Ilon Joseph - x31405": KnownAuthor("Joseph", "Ilon"),
"kangkabseok": KnownAuthor("Kang", "Kab Seok"),
"loeiten": KnownAuthor("Løiten", "Michael"),
"Michael Loiten Magnussen": KnownAuthor("Løiten", "Michael"),
"Maxim Umansky - x26041": KnownAuthor("Umansky", "Maxim"),
"nick-walkden": KnownAuthor("Walkden", "Nicholas"),
"ZedThree": KnownAuthor("Hill", "Peter")
# "tomc271": KnownAuthor("Chapman", "Tom")
}

if __name__ == '__main__':

authors_from_git = get_authors_from_git()
existing_authors = get_authors_from_cff_file()
update_citations()
206 changes: 206 additions & 0 deletions bin/update_version_number_in_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
from pathlib import Path
import argparse
import difflib
import copy
import textwrap
import os
import re


def get_full_filepath(filepath):

main_directory = Path(os.path.abspath(__file__)).parent.parent
return Path(main_directory) / filepath


def update_version_number_in_file(relative_filepath, pattern, new_version_number):

full_filepath = get_full_filepath(relative_filepath)

with open(full_filepath, "r", encoding='UTF-8') as file:

file_contents = file.read()
original = copy.deepcopy(file_contents)

modified = apply_fixes(pattern, new_version_number, file_contents)
patch = create_patch(str(full_filepath), original, modified)

if args.patch_only:
print(patch)
return

if not patch:
if not args.quiet:
print("No changes to make to {}".format(full_filepath))
return

if not args.quiet:
print("\n******************************************")
print("Changes to {}\n".format(full_filepath))
print(patch)
print("\n******************************************")

if args.force:
make_change = True
else:
make_change = yes_or_no("Make changes to {}?".format(full_filepath))

if make_change:
with open(full_filepath, "w", encoding='UTF-8') as file:
file.write(modified)


def bump_version_numbers(new_version_number):

short_version_number = ShortVersionNumber(new_version_number.major_version, new_version_number.minor_version)
bout_next_version_number = VersionNumber(new_version_number.major_version,
new_version_number.minor_version + 1,
new_version_number.patch_version)

update_version_number_in_file("configure.ac",
r"^AC_INIT\(\[BOUT\+\+\],\[(\d+\.\d+\.\d+)\]", new_version_number)
update_version_number_in_file("CITATION.cff",
r"^version: (\d+\.\d+\.\d+)", new_version_number)
update_version_number_in_file("manual/sphinx/conf.py",
r"^version = \"(\d+\.\d+)\"", short_version_number)
update_version_number_in_file("manual/sphinx/conf.py",
r"^release = \"(\d+\.\d+\.\d+)\"", new_version_number)
update_version_number_in_file("manual/doxygen/Doxyfile_readthedocs",
r"^PROJECT_NUMBER = (\d+\.\d+\.\d+)", new_version_number)
update_version_number_in_file("manual/doxygen/Doxyfile",
r"^PROJECT_NUMBER = (\d+\.\d+\.\d+)", new_version_number)
update_version_number_in_file("CMakeLists.txt",
r"^set\(_bout_previous_version \"v(\d+\.\d+\.\d+)\"\)", new_version_number)
update_version_number_in_file("CMakeLists.txt",
r"^set\(_bout_next_version \"(\d+\.\d+\.\d+)\"\)", bout_next_version_number)
update_version_number_in_file("tools/pylib/_boutpp_build/backend.py",
r"_bout_previous_version = \"v(\d+\.\d+\.\d+)\"", new_version_number)
update_version_number_in_file("tools/pylib/_boutpp_build/backend.py",
r"_bout_next_version = \"v(\d+\.\d+\.\d+)\"", bout_next_version_number)


class VersionNumber:
tomc271 marked this conversation as resolved.
Show resolved Hide resolved

major_version: int
minor_version: int
patch_version: int

def __init__(self, major_version, minor_version, patch_version):
self.major_version = major_version
self.minor_version = minor_version
self.patch_version = patch_version
tomc271 marked this conversation as resolved.
Show resolved Hide resolved

def as_string(self):
tomc271 marked this conversation as resolved.
Show resolved Hide resolved
return "%d.%d.%d" % (self.major_version, self.minor_version, self.patch_version)


class ShortVersionNumber(VersionNumber):
tomc271 marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, major_version, minor_version):
self.major_version = major_version
self.minor_version = minor_version
self.patch_version = None

def as_string(self):
return "%d.%d" % (self.major_version, self.minor_version)


def apply_fixes(pattern, new_version_number, source):
"""Apply the various fixes for each factory to source. Returns
modified source

Parameters
----------
pattern
Regex pattern to apply for replacement
new_version_number
New version number to use in replacement
source
Text to update
"""

def get_replacement(match):
return match[0].replace(match[1], new_version_number.as_string())
tomc271 marked this conversation as resolved.
Show resolved Hide resolved

modified = re.sub(pattern, get_replacement, source, flags=re.MULTILINE)

return modified

# """Apply all fixes in this module"""
# modified = copy.deepcopy(source)
#
# for replacement in replacements:
# if replacement["new"] is None:
# print(
# "'%s' has been removed, please delete from your code"
# % replacement["old"]
# )
# continue
#
# modified = fix_include_version_header(
# replacement["old"], replacement["headers"], modified
# )
# if replacement["macro"] and replacement["always_defined"]:
# modified = fix_always_defined_macros(
# replacement["old"], replacement["new"], modified
# )
# elif replacement["always_defined"]:
# modified = fix_ifdefs(replacement["old"], modified)
# modified = fix_replacement(replacement["old"], replacement["new"], modified)
#
# return modified


def yes_or_no(question):
"""Convert user input from yes/no variations to True/False"""
while True:
reply = input(question + " [y/N] ").lower().strip()
if not reply or reply[0] == "n":
return False
if reply[0] == "y":
return True


def create_patch(filename, original, modified):
"""Create a unified diff between original and modified"""

patch = "\n".join(
difflib.unified_diff(
original.splitlines(),
modified.splitlines(),
fromfile=filename,
tofile=filename,
lineterm="",
)
)

return patch


if __name__ == '__main__':

parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=textwrap.dedent(
"""\
TODO: Description here...
"""
),
)

parser.add_argument(
"--force", "-f", action="store_true", help="Make changes without asking"
)
parser.add_argument(
"--quiet", "-q", action="store_true", help="Don't print patches"
)
parser.add_argument(
"--patch-only", "-p", action="store_true", help="Print the patches and exit"
)

args = parser.parse_args()

if args.force and args.patch_only:
raise ValueError("Incompatible options: --force and --patch")

bump_version_numbers(new_version_number=VersionNumber(63, 15, 12))