Created
September 5, 2018 21:40
-
-
Save dangunter/ec5300250fac7863d291ce6adf5cb565 to your computer and use it in GitHub Desktop.
annotate source code with a copyright header
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
""" | |
Annotate source code with a notice (see NOTICE_TEXT). | |
An existing notice will be replaced, and if there is no notice | |
encountered then one will be inserted. Detection of the notice | |
is exceedingly simple: if any line without a comment is encountered, from | |
the top of the file, before the standard "separator" of a long string | |
of comment characters, then the notice will be inserted. Likewise, the | |
"end" of the notice is either the same separator used for the beginning or | |
a line that is not commented. | |
For example, in the following he notice will be inserted between the | |
second and third lines:: | |
#!/usr/bin/env python | |
# hello | |
import sys | |
In this file he notice will be inserted before the first line:: | |
''' | |
Top of the file comment | |
''' | |
import logging | |
Finally, if the notice is already there then the entire notice will be | |
replaced with the current text:: | |
############################################################ | |
# Copyright (C) 2099 Nobody | |
# You cannot have this code. Ever. It's too cool. | |
############################################################ | |
import asyncio | |
""" | |
import argparse | |
from collections import deque | |
from glob import glob, fnmatch | |
import logging | |
import os | |
import re | |
import shutil | |
import sys | |
_log = logging.getLogger('annotate_source') | |
_h = logging.StreamHandler() | |
_h.setFormatter(logging.Formatter( | |
fmt='%(asctime)s [%(levelname)s] %(message)s')) | |
_log.addHandler(_h) | |
NOTICE_TEXT = '''My Project Name (MPN) Copyright (c) 2018, by the | |
software owners: The Regents of the University of Wonderland, through | |
Down the Rabbit Hole, et al. All rights reserved. | |
Please see the files COPYRIGHT.txt and LICENSE.txt for full copyright and | |
license information, respectively. Both files are also available online | |
at the URL "https://github.com/MPN/myproject".''' | |
def modify_files(finder, modifier, **flags): | |
while True: | |
try: | |
f = finder.get() | |
except IndexError: | |
break | |
modifier.modify(f, **flags) | |
def print_files(finder): | |
while True: | |
try: | |
f = finder.get() | |
except IndexError: | |
break | |
print(f) | |
class FileFinder(object): | |
def __init__(self, root: str, glob_pat=None): | |
if not os.path.isdir(root): | |
raise FileNotFoundError('Root directory "{}"'.format(root)) | |
glob_pat = ['*.py'] if glob_pat is None else glob_pat | |
neg_pat, pos_pat = [], [] | |
for p in glob_pat: | |
if not p: | |
pass | |
if p[0] == '~': | |
neg_pat.append(p[1:]) | |
_log.info('Negative pattern: {}'.format(p[1:])) | |
else: | |
pos_pat.append(p) | |
_log.info('Positive pattern: {}'.format(p[1:])) | |
self._root = root | |
self._q = deque() | |
for pat in pos_pat: | |
self._find(pat, neg_pat) | |
def __len__(self): | |
return len(self._q) | |
def _find(self, glob_pat, neg_pat): | |
pat = os.path.join(self._root, '**', glob_pat) | |
if neg_pat: | |
# need to check each file, to eliminate bad ones | |
for fpath in glob(pat, recursive=True): | |
f, ok = os.path.basename(fpath), True | |
# eliminate any that match a negative pattern | |
for np in neg_pat: | |
_log.debug('Match file {} to pattern {}'.format( | |
f, np)) | |
if fnmatch.fnmatchcase(f, np): | |
ok = False | |
break | |
if ok: | |
self._q.append(fpath) | |
else: | |
# just grab all files | |
self._q.extend(glob(pat, recursive=True)) | |
def get(self) -> str: | |
item = self._q.pop() | |
return item | |
class FileModifier(object): | |
comment_pfx = '#' | |
comment_sep = comment_pfx * 78 | |
comment_minsep = comment_pfx * 10 | |
def __init__(self, text: str): | |
lines = [l.strip() for l in text.split('\n')] | |
self._txt = '\n'.join(['{} {}'.format(self.comment_pfx, l) | |
for l in lines]) | |
def modify(self, fname: str, remove=False): | |
_log.info('file={}'.format(fname)) | |
# move input file to <name>.orig | |
wfname = fname + '.orig' | |
shutil.move(fname, wfname) | |
# re-open input filename as the output file | |
f = open(wfname, 'r') | |
out = open(fname, 'w') | |
# re-create the file, modified | |
state = 'head' | |
if remove: | |
for line in f: | |
if state == 'head': | |
if line.strip().startswith(self.comment_minsep): | |
state = 'copyright' | |
continue | |
else: | |
out.write(line) | |
elif state == 'copyright': | |
if line.strip().startswith(self.comment_minsep): | |
state = 'code' | |
else: | |
out.write(line) | |
else: | |
lineno = 0 | |
ex = re.compile(r'^[ \t\f]*#' | |
'(.*?coding[:=][ \t]*[-_.a-zA-Z0-9]+|' | |
'!/.*)') | |
def write_copyright(): | |
out.write('{}\n'.format(self.comment_sep)) | |
out.write(self._txt) | |
out.write('\n{}\n'.format(self.comment_sep)) | |
for line in f: | |
lineno += 1 | |
sline = line.strip() | |
if state == 'head': | |
if sline.startswith(self.comment_minsep): | |
state = 'copyright' # skip past this | |
elif lineno < 3 and ex.match(sline): | |
out.write(line) | |
else: | |
state = 'text' | |
write_copyright() | |
out.write(line) | |
elif state == 'copyright': | |
if sline.startswith(self.comment_minsep): | |
state = 'text' | |
write_copyright() | |
elif state == 'text': | |
out.write(line) | |
# finalize the output | |
out.close() | |
# remove moved <name>.orig, the original input file | |
os.unlink(wfname) | |
def main() -> int: | |
p = argparse.ArgumentParser() | |
p.add_argument('root', help='Root path from which to find files') | |
p.add_argument('pattern', nargs='*', default=[], | |
help='UNIX glob-style pattern of files to match' | |
' (default=*.py) Prefix with "~" to take complement') | |
p.add_argument('-n', '--dry-run', action='store_true', dest='dry', | |
help='Do not modify files, just show which files would ' | |
'be affected.') | |
p.add_argument('-r', '--remove', action='store_true', dest='remove', | |
help='Remove any existing headers') | |
p.add_argument('-v', '--verbose', action='count', dest='vb', | |
default=0, help='More verbose logging') | |
args = p.parse_args() | |
if args.vb > 1: | |
_log.setLevel(logging.DEBUG) | |
elif args.vb > 0: | |
_log.setLevel(logging.INFO) | |
else: | |
_log.setLevel(logging.WARN) | |
if len(args.pattern) == 0: | |
patterns = None | |
else: | |
# sanity-check the input patterns | |
for pat in args.pattern: | |
if os.path.sep in pat: | |
p.error('bad pattern "{}": must be a filename, not a path' | |
.format(pat)) | |
patterns = args.pattern | |
finder = FileFinder(args.root, glob_pat=patterns) | |
if len(finder) == 0: | |
_log.warning('No files found from "{}" matching {}' | |
.format(args.root, '|'.join(patterns))) | |
return 1 | |
if args.dry: | |
print_files(finder) | |
else: | |
modifier = FileModifier(NOTICE_TEXT) | |
modify_files(finder, modifier, remove=args.remove) | |
return 0 | |
if __name__ == '__main__': | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment