From a908c4b9c67181cee1a7d6472b316e481734a817 Mon Sep 17 00:00:00 2001 From: f4exb Date: Sun, 19 Nov 2023 13:42:07 +0100 Subject: [PATCH] Added script to update copyright notices from git history. Part of #1893 --- sdrgui/copygen.py | 298 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 sdrgui/copygen.py diff --git a/sdrgui/copygen.py b/sdrgui/copygen.py new file mode 100644 index 000000000..62dadd791 --- /dev/null +++ b/sdrgui/copygen.py @@ -0,0 +1,298 @@ +#!/usr/bin/python +""" +Utility to generate copyright notices from the git history of the source file +Inspired by: https://0pointer.net/blog/projects/copyright.html +""" +import sys +import os +import functools +from subprocess import * +from datetime import * +from optparse import OptionParser + +AUTHOR_SUBSTITUTES = { + "ZigaS": "Ziga S", + "hexameron": "John Greb", + "srcejon": "Jon Beniston, M7RCE", + "Jon Beniston": "Jon Beniston, M7RCE", + "f4exb": "Edouard Griffiths, F4EXB", + "Edouard Griffiths": "Edouard Griffiths, F4EXB" +} + +# Commits rewriting the copyright notices +EXCLUDE_HASHES = { + "c9e13c336", + "e61317ef0", + "65842d9b5", + "00b041d76", + "3a944fa20", + "b6c4d10b6", + "869f1a419", + "743260db9", + "9ce0810a2", + "3596fe431" +} + +# ====================================================================== +def getInputOptions(): + parser = OptionParser(usage="usage: %%prog options\n\n%s") + parser.add_option("-f", "--file", dest="file", help="File to process", metavar="FILE", type="str") + parser.add_option("-d", "--directory", dest="directory", help="Directory to process", metavar="DIRECTORY", type="str") + parser.add_option("-e", "--extension", dest="extensions", help="Filter by this extension (includes dot)", metavar="EXTENSION", type="str", action="append") + parser.add_option("-l", "--list-authors", dest="list_authors", help="List authors", metavar="LIST", action="store_true", default=False) + parser.add_option("-r", "--remove-original", dest="remove_original", help="Remove original copyright notices", metavar="REMOVE", action="store_true", default=False) + parser.add_option("-n", "--dry-run", dest="dry_run", help="Print new headers instead of overwriting the files", metavar="DRY_RUN", action="store_true", default=False) + (options, args) = parser.parse_args() + return options + +# ====================================================================== +def validate_options(options): + if options.file is None and options.directory is None: + print("At least a file (-f) or a directory (-d) must be specified") + return False + elif options.file is not None and options.directory is not None: + print("Specify either a file (-f) or a directory (-d) but not both") + return False + if not options.extensions: + options.extensions = [".h", ".cpp"] + return True + +# ====================================================================== +def pretty_years(s): + + l = list(s) + l.sort() + + start = None + prev = None + r = [] + + for x in l: + if prev is None: + start = x + prev = x + continue + + if x == prev + 1: + prev = x + continue + + if prev == start: + r.append("%i" % prev) + else: + r.append("%i-%i" % (start, prev)) + + start = x + prev = x + + if not prev is None: + if prev == start: + r.append("%i" % prev) + else: + r.append("%i-%i" % (start, prev)) + + return ", ".join(r) + +# ====================================================================== +def order_by_year(a, b): + + la = list(a[2]) + la.sort() + + lb = list(b[2]) + lb.sort() + + if la[0] < lb[0]: + return -1 + elif la[0] > lb[0]: + return 1 + else: + return 0 + +# ====================================================================== +def analyze(f): + print(f"File: {f}") + + commits = [] + data = {} + + for ln in Popen(["git", "log", "--follow", "--all", "--date=format:'%Y-%m-%d %H:%M:%S,%z'", "--pretty=format:'%an,%ae,%ad,%h'", f], stdout=PIPE).stdout: + ls = ln.decode().strip() + le = ls.split(',') # Line elements (comma separated) + lh = le[4].rstrip("\'") + if lh in EXCLUDE_HASHES: + continue + dt = datetime.strptime(le[2].lstrip("\'"), '%Y-%m-%d %H:%M:%S') + tz = le[3].rstrip("\'") + data = { + "author": le[0].lstrip("\'"), + "author-mail": le[1], + "author-time": int(datetime.timestamp(dt)), + "author-tz": tz + } + if data["author"] == "Hexameron": + data["author-time"] = int(datetime.timestamp(datetime(2012, 1, 1))) + if data["author"] in AUTHOR_SUBSTITUTES: + data["author"] = AUTHOR_SUBSTITUTES[data["author"]] + commits.append(data) + + by_author = {} + + for c in commits: + try: + n = by_author[c["author"]] + except KeyError: + n = (c["author"], c["author-mail"], set()) + by_author[c["author"]] = n + + # FIXME: Handle time zones properly + year = datetime.fromtimestamp(int(c["author-time"])).year + + n[2].add(year) + + for an, a in list(by_author.items()): + for bn, b in list(by_author.items()): + if a is b: + continue + + if a[1] == b[1]: + a[2].update(b[2]) + + if an in by_author and bn in by_author: + del by_author[bn] + + copyrite = list(by_author.values()) + copyrite.sort(key=functools.cmp_to_key(order_by_year)) + return copyrite + +# ====================================================================== +def get_files(options): + files = [] + dirs = os.walk(options.directory) + for dirspec in dirs: + for f in dirspec[2]: + filepath = os.path.join(dirspec[0], f) + ext = os.path.splitext(filepath)[1] + if ext in options.extensions: + files.append(filepath) + return files + +# ====================================================================== +def list_authors(options): + authors = set() + if options.directory is not None: + files = get_files(options) + for f in files: + copyrite = analyze(f) + for c in copyrite: + authors.add(c[0]) + for author in authors: + print(author) + return + copyrite = analyze(options.file) + for c in copyrite: + print(c[0]) + +# ====================================================================== +def remove_line(line): + if "Copyright (C)" in line: + return True + if "Copyright (c)" in line: + return True + if line.startswith("// written by"): + return True + +# ====================================================================== +def get_header_lines(): + return [ + "", + "This program is free software; you can redistribute it and/or modify", + "it under the terms of the GNU General Public License as published by", + "the Free Software Foundation as version 3 of the License, or", + "(at your option) any later version.", + "", + "This program is distributed in the hope that it will be useful,", + "but WITHOUT ANY WARRANTY; without even the implied warranty of", + "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the", + "GNU General Public License V3 for more details.", + "", + "You should have received a copy of the GNU General Public License", + "along with this program. If not, see ." + ] + +# ====================================================================== +def process_file(f, options): + with open(f) as ff: + lines_in = [line.rstrip() for line in ff] + lines_out = [] + cr = analyze(f) + header = False + header_start = 0 + for iline, line in enumerate(lines_in): + if line.startswith("////////"): + header= True + header_start = iline + break + if header: + lines_out = lines_in[:header_start+1] + else: + lines_out = ["///////////////////////////////////////////////////////////////////////////////////////"] + width = len(lines_out[header_start]) - 6 + for name, mail, years in cr: + if name == "Hexameron": + lines_out.append("// {0:{1}} //".format("Copyright (C) 2012 maintech GmbH, Otto-Hahn-Str. 15, 97204 Hoechberg, Germany", width)) + lines_out.append("// {0:{1}} //".format("written by Christian Daniel", width)) + else: + cr_string = f"Copyright (C) {pretty_years(years)} {name} <{mail}>" + lines_out.append(f"// {cr_string:{width}} //") + if not header: + for hline in get_header_lines(): + lines_out.append("// {0:{1}} //".format(hline, width)) + lines_out.append(lines_out[0]) + elif not options.remove_original: + lines_out.append("") + in_header = header + header_stop = len(lines_out) + iread = header_start+1 if header else 0 + for iline, line in enumerate(lines_in[iread:]): + if in_header and options.remove_original and remove_line(line): + continue + if line.startswith("////////"): + in_header = False + header_stop += iline + lines_out.append(line) + if options.dry_run: + for line_out in lines_out[header_start:header_stop]: + print(line_out) + else: + with open(f, "w") as ff: + for line in lines_out: + ff.write(f"{line}\n") + + +# ====================================================================== +def process_directory(options): + files = get_files(options) + for f in files: + process_file(f, options) + + +# ====================================================================== +def main(): + try: + options = getInputOptions() + if not validate_options(options): + sys.exit(-1) + if options.list_authors: + list_authors(options) + elif options.file: + process_file(options.file, options) + else: + process_directory(options) + except KeyboardInterrupt: + print("Keyboard interrupt. Exiting") + + +# ====================================================================== +if __name__ == '__main__': + main()