diff --git a/CITATION.cff b/CITATION.cff index cdd0f7bf2f..dd2650a0eb 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -185,6 +185,9 @@ authors: - family-names: James given-names: Toby + - family-names: Tom + given-names: Chapman + version: 5.0.0 date-released: 2023-02-08 repository-code: https://github.com/boutproject/BOUT-dev diff --git a/bin/update_citations.py b/bin/update_citations.py new file mode 100755 index 0000000000..ed358f7592 --- /dev/null +++ b/bin/update_citations.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +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() + output = subprocess.run( + ["git", "log", "--format='%aN %aE'"], + capture_output=True, + cwd=main_directory, + check=True, + text=True, + ) + + authors_string = output.stdout + 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: + return yaml.safe_load(stream) + + +def get_authors_from_cff_file(): + filename = get_main_directory() / "CITATION.cff" + file_contents = parse_cff_file(filename) + try: + return file_contents["authors"] + except KeyError as key_error: + raise ValueError(f"Failed to find section:{key_error} in {filename}") + + +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) + ] + + if not unrecognised_authors: + return + + 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("Dudson", "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"), + "j-b-o": KnownAuthor("Omotani", "John"), + "BS": KnownAuthor("Brendan", "Shanahan"), +} + +if __name__ == "__main__": + authors_from_git = get_authors_from_git() + existing_authors = get_authors_from_cff_file() + update_citations() diff --git a/bin/update_version_number_in_files.py b/bin/update_version_number_in_files.py new file mode 100755 index 0000000000..de187c413a --- /dev/null +++ b/bin/update_version_number_in_files.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +from dataclasses import dataclass +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, + ) + + +@dataclass +class VersionNumber: + major_version: int + minor_version: int + patch_version: int + + def __str__(self): + return "%d.%d.%d" % (self.major_version, self.minor_version, self.patch_version) + + +@dataclass +class ShortVersionNumber: + major_version: int + minor_version: int + + def __str__(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], str(new_version_number)) + + modified = re.sub(pattern, get_replacement, source, flags=re.MULTILINE) + + 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( + """\ + Update the software version number to the specified version, + to be given in the form major.minor.patch, + e.g. 5.10.3 + + Where the 3rd ('patch') part of the version is omitted, + only the 'major' and 'minor' parts will be used, + e.g. 5.10.3 -> 5.10 + + For the 'bout-next' version number, + the 'minor' version number of the provided version will be incremented by 1, + e.g. 5.10.3 -> 5.11.3 + + """ + ), + ) + + 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" + ) + parser.add_argument("new_version", help="Specify the new version number") + + args = parser.parse_args() + + if args.force and args.patch_only: + raise ValueError("Incompatible options: --force and --patch") + major, minor, patch = map(int, args.new_version.split(".")) + bump_version_numbers(new_version_number=VersionNumber(major, minor, patch))