1#!/usr/bin/env python 2import re 3import sys 4import shutil 5import os.path 6import subprocess 7import sysconfig 8 9import reindent 10import untabify 11 12 13# Excluded directories which are copies of external libraries: 14# don't check their coding style 15EXCLUDE_DIRS = [os.path.join('Modules', '_ctypes', 'libffi'), 16 os.path.join('Modules', '_ctypes', 'libffi_osx'), 17 os.path.join('Modules', '_ctypes', 'libffi_msvc'), 18 os.path.join('Modules', 'expat'), 19 os.path.join('Modules', 'zlib')] 20SRCDIR = sysconfig.get_config_var('srcdir') 21 22 23def n_files_str(count): 24 """Return 'N file(s)' with the proper plurality on 'file'.""" 25 return "{} file{}".format(count, "s" if count != 1 else "") 26 27 28def status(message, modal=False, info=None): 29 """Decorator to output status info to stdout.""" 30 def decorated_fxn(fxn): 31 def call_fxn(*args, **kwargs): 32 sys.stdout.write(message + ' ... ') 33 sys.stdout.flush() 34 result = fxn(*args, **kwargs) 35 if not modal and not info: 36 print "done" 37 elif info: 38 print info(result) 39 else: 40 print "yes" if result else "NO" 41 return result 42 return call_fxn 43 return decorated_fxn 44 45 46def get_git_branch(): 47 """Get the symbolic name for the current git branch""" 48 cmd = "git rev-parse --abbrev-ref HEAD".split() 49 try: 50 return subprocess.check_output(cmd, stderr=subprocess.PIPE) 51 except subprocess.CalledProcessError: 52 return None 53 54 55def get_git_upstream_remote(): 56 """Get the remote name to use for upstream branches 57 58 Uses "upstream" if it exists, "origin" otherwise 59 """ 60 cmd = "git remote get-url upstream".split() 61 try: 62 subprocess.check_output(cmd, stderr=subprocess.PIPE) 63 except subprocess.CalledProcessError: 64 return "origin" 65 return "upstream" 66 67 68@status("Getting base branch for PR", 69 info=lambda x: x if x is not None else "not a PR branch") 70def get_base_branch(): 71 if not os.path.exists(os.path.join(SRCDIR, '.git')): 72 # Not a git checkout, so there's no base branch 73 return None 74 version = sys.version_info 75 if version.releaselevel == 'alpha': 76 base_branch = "master" 77 else: 78 base_branch = "{0.major}.{0.minor}".format(version) 79 this_branch = get_git_branch() 80 if this_branch is None or this_branch == base_branch: 81 # Not on a git PR branch, so there's no base branch 82 return None 83 upstream_remote = get_git_upstream_remote() 84 return upstream_remote + "/" + base_branch 85 86 87@status("Getting the list of files that have been added/changed", 88 info=lambda x: n_files_str(len(x))) 89def changed_files(base_branch=None): 90 """Get the list of changed or added files from git.""" 91 if os.path.exists(os.path.join(SRCDIR, '.git')): 92 # We just use an existence check here as: 93 # directory = normal git checkout/clone 94 # file = git worktree directory 95 if base_branch: 96 cmd = 'git diff --name-status ' + base_branch 97 else: 98 cmd = 'git status --porcelain' 99 filenames = [] 100 st = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) 101 try: 102 for line in st.stdout: 103 line = line.decode().rstrip() 104 status_text, filename = line.split(None, 1) 105 status = set(status_text) 106 # modified, added or unmerged files 107 if not status.intersection('MAU'): 108 continue 109 if ' -> ' in filename: 110 # file is renamed 111 filename = filename.split(' -> ', 2)[1].strip() 112 filenames.append(filename) 113 finally: 114 st.stdout.close() 115 else: 116 sys.exit('need a git checkout to get modified files') 117 118 filenames2 = [] 119 for filename in filenames: 120 # Normalize the path to be able to match using .startswith() 121 filename = os.path.normpath(filename) 122 if any(filename.startswith(path) for path in EXCLUDE_DIRS): 123 # Exclude the file 124 continue 125 filenames2.append(filename) 126 127 return filenames2 128 129 130def report_modified_files(file_paths): 131 count = len(file_paths) 132 if count == 0: 133 return n_files_str(count) 134 else: 135 lines = ["{}:".format(n_files_str(count))] 136 for path in file_paths: 137 lines.append(" {}".format(path)) 138 return "\n".join(lines) 139 140 141@status("Fixing whitespace", info=report_modified_files) 142def normalize_whitespace(file_paths): 143 """Make sure that the whitespace for .py files have been normalized.""" 144 reindent.makebackup = False # No need to create backups. 145 fixed = [] 146 for path in (x for x in file_paths if x.endswith('.py')): 147 if reindent.check(os.path.join(SRCDIR, path)): 148 fixed.append(path) 149 return fixed 150 151 152@status("Fixing C file whitespace", info=report_modified_files) 153def normalize_c_whitespace(file_paths): 154 """Report if any C files """ 155 fixed = [] 156 for path in file_paths: 157 abspath = os.path.join(SRCDIR, path) 158 with open(abspath, 'r') as f: 159 if '\t' not in f.read(): 160 continue 161 untabify.process(abspath, 8, verbose=False) 162 fixed.append(path) 163 return fixed 164 165 166ws_re = re.compile(br'\s+(\r?\n)$') 167 168@status("Fixing docs whitespace", info=report_modified_files) 169def normalize_docs_whitespace(file_paths): 170 fixed = [] 171 for path in file_paths: 172 abspath = os.path.join(SRCDIR, path) 173 try: 174 with open(abspath, 'rb') as f: 175 lines = f.readlines() 176 new_lines = [ws_re.sub(br'\1', line) for line in lines] 177 if new_lines != lines: 178 shutil.copyfile(abspath, abspath + '.bak') 179 with open(abspath, 'wb') as f: 180 f.writelines(new_lines) 181 fixed.append(path) 182 except Exception as err: 183 print 'Cannot fix %s: %s' % (path, err) 184 return fixed 185 186 187@status("Docs modified", modal=True) 188def docs_modified(file_paths): 189 """Report if any file in the Doc directory has been changed.""" 190 return bool(file_paths) 191 192 193@status("Misc/ACKS updated", modal=True) 194def credit_given(file_paths): 195 """Check if Misc/ACKS has been changed.""" 196 return os.path.join('Misc', 'ACKS') in file_paths 197 198 199@status("Misc/NEWS.d updated with `blurb`", modal=True) 200def reported_news(file_paths): 201 """Check if Misc/NEWS.d has been changed.""" 202 return any(p.startswith(os.path.join('Misc', 'NEWS.d', 'next')) 203 for p in file_paths) 204 205 206def main(): 207 base_branch = get_base_branch() 208 file_paths = changed_files(base_branch) 209 python_files = [fn for fn in file_paths if fn.endswith('.py')] 210 c_files = [fn for fn in file_paths if fn.endswith(('.c', '.h'))] 211 doc_files = [fn for fn in file_paths if fn.startswith('Doc') and 212 fn.endswith(('.rst', '.inc'))] 213 misc_files = {p for p in file_paths if p.startswith('Misc')} 214 # PEP 8 whitespace rules enforcement. 215 normalize_whitespace(python_files) 216 # C rules enforcement. 217 normalize_c_whitespace(c_files) 218 # Doc whitespace enforcement. 219 normalize_docs_whitespace(doc_files) 220 # Docs updated. 221 docs_modified(doc_files) 222 # Misc/ACKS changed. 223 credit_given(misc_files) 224 # Misc/NEWS changed. 225 reported_news(misc_files) 226 227 # Test suite run and passed. 228 if python_files or c_files: 229 end = " and check for refleaks?" if c_files else "?" 230 print 231 print "Did you run the test suite" + end 232 233 234if __name__ == '__main__': 235 main() 236