#!/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()