1#!/usr/bin/env python3 2"""Check proposed changes for common issues.""" 3import re 4import sys 5import shutil 6import os.path 7import subprocess 8import sysconfig 9 10import reindent 11import untabify 12 13 14# Excluded directories which are copies of external libraries: 15# don't check their coding style 16EXCLUDE_DIRS = [os.path.join('Modules', '_ctypes', 'libffi_osx'), 17 os.path.join('Modules', '_ctypes', 'libffi_msvc'), 18 os.path.join('Modules', '_decimal', 'libmpdec'), 19 os.path.join('Modules', 'expat'), 20 os.path.join('Modules', 'zlib')] 21SRCDIR = sysconfig.get_config_var('srcdir') 22 23 24def n_files_str(count): 25 """Return 'N file(s)' with the proper plurality on 'file'.""" 26 return "{} file{}".format(count, "s" if count != 1 else "") 27 28 29def status(message, modal=False, info=None): 30 """Decorator to output status info to stdout.""" 31 def decorated_fxn(fxn): 32 def call_fxn(*args, **kwargs): 33 sys.stdout.write(message + ' ... ') 34 sys.stdout.flush() 35 result = fxn(*args, **kwargs) 36 if not modal and not info: 37 print("done") 38 elif info: 39 print(info(result)) 40 else: 41 print("yes" if result else "NO") 42 return result 43 return call_fxn 44 return decorated_fxn 45 46 47def get_git_branch(): 48 """Get the symbolic name for the current git branch""" 49 cmd = "git rev-parse --abbrev-ref HEAD".split() 50 try: 51 return subprocess.check_output(cmd, 52 stderr=subprocess.DEVNULL, 53 cwd=SRCDIR, 54 encoding='UTF-8') 55 except subprocess.CalledProcessError: 56 return None 57 58 59def get_git_upstream_remote(): 60 """Get the remote name to use for upstream branches 61 62 Uses "upstream" if it exists, "origin" otherwise 63 """ 64 cmd = "git remote get-url upstream".split() 65 try: 66 subprocess.check_output(cmd, 67 stderr=subprocess.DEVNULL, 68 cwd=SRCDIR, 69 encoding='UTF-8') 70 except subprocess.CalledProcessError: 71 return "origin" 72 return "upstream" 73 74 75def get_git_remote_default_branch(remote_name): 76 """Get the name of the default branch for the given remote 77 78 It is typically called 'main', but may differ 79 """ 80 cmd = "git remote show {}".format(remote_name).split() 81 env = os.environ.copy() 82 env['LANG'] = 'C' 83 try: 84 remote_info = subprocess.check_output(cmd, 85 stderr=subprocess.DEVNULL, 86 cwd=SRCDIR, 87 encoding='UTF-8', 88 env=env) 89 except subprocess.CalledProcessError: 90 return None 91 for line in remote_info.splitlines(): 92 if "HEAD branch:" in line: 93 base_branch = line.split(":")[1].strip() 94 return base_branch 95 return None 96 97 98@status("Getting base branch for PR", 99 info=lambda x: x if x is not None else "not a PR branch") 100def get_base_branch(): 101 if not os.path.exists(os.path.join(SRCDIR, '.git')): 102 # Not a git checkout, so there's no base branch 103 return None 104 upstream_remote = get_git_upstream_remote() 105 version = sys.version_info 106 if version.releaselevel == 'alpha': 107 base_branch = get_git_remote_default_branch(upstream_remote) 108 else: 109 base_branch = "{0.major}.{0.minor}".format(version) 110 this_branch = get_git_branch() 111 if this_branch is None or this_branch == base_branch: 112 # Not on a git PR branch, so there's no base branch 113 return None 114 return upstream_remote + "/" + base_branch 115 116 117@status("Getting the list of files that have been added/changed", 118 info=lambda x: n_files_str(len(x))) 119def changed_files(base_branch=None): 120 """Get the list of changed or added files from git.""" 121 if os.path.exists(os.path.join(SRCDIR, '.git')): 122 # We just use an existence check here as: 123 # directory = normal git checkout/clone 124 # file = git worktree directory 125 if base_branch: 126 cmd = 'git diff --name-status ' + base_branch 127 else: 128 cmd = 'git status --porcelain' 129 filenames = [] 130 with subprocess.Popen(cmd.split(), 131 stdout=subprocess.PIPE, 132 cwd=SRCDIR) as st: 133 for line in st.stdout: 134 line = line.decode().rstrip() 135 status_text, filename = line.split(maxsplit=1) 136 status = set(status_text) 137 # modified, added or unmerged files 138 if not status.intersection('MAU'): 139 continue 140 if ' -> ' in filename: 141 # file is renamed 142 filename = filename.split(' -> ', 2)[1].strip() 143 filenames.append(filename) 144 else: 145 sys.exit('need a git checkout to get modified files') 146 147 filenames2 = [] 148 for filename in filenames: 149 # Normalize the path to be able to match using .startswith() 150 filename = os.path.normpath(filename) 151 if any(filename.startswith(path) for path in EXCLUDE_DIRS): 152 # Exclude the file 153 continue 154 filenames2.append(filename) 155 156 return filenames2 157 158 159def report_modified_files(file_paths): 160 count = len(file_paths) 161 if count == 0: 162 return n_files_str(count) 163 else: 164 lines = ["{}:".format(n_files_str(count))] 165 for path in file_paths: 166 lines.append(" {}".format(path)) 167 return "\n".join(lines) 168 169 170@status("Fixing Python file whitespace", info=report_modified_files) 171def normalize_whitespace(file_paths): 172 """Make sure that the whitespace for .py files have been normalized.""" 173 reindent.makebackup = False # No need to create backups. 174 fixed = [path for path in file_paths if path.endswith('.py') and 175 reindent.check(os.path.join(SRCDIR, path))] 176 return fixed 177 178 179@status("Fixing C file whitespace", info=report_modified_files) 180def normalize_c_whitespace(file_paths): 181 """Report if any C files """ 182 fixed = [] 183 for path in file_paths: 184 abspath = os.path.join(SRCDIR, path) 185 with open(abspath, 'r') as f: 186 if '\t' not in f.read(): 187 continue 188 untabify.process(abspath, 8, verbose=False) 189 fixed.append(path) 190 return fixed 191 192 193ws_re = re.compile(br'\s+(\r?\n)$') 194 195@status("Fixing docs whitespace", info=report_modified_files) 196def normalize_docs_whitespace(file_paths): 197 fixed = [] 198 for path in file_paths: 199 abspath = os.path.join(SRCDIR, path) 200 try: 201 with open(abspath, 'rb') as f: 202 lines = f.readlines() 203 new_lines = [ws_re.sub(br'\1', line) for line in lines] 204 if new_lines != lines: 205 shutil.copyfile(abspath, abspath + '.bak') 206 with open(abspath, 'wb') as f: 207 f.writelines(new_lines) 208 fixed.append(path) 209 except Exception as err: 210 print('Cannot fix %s: %s' % (path, err)) 211 return fixed 212 213 214@status("Docs modified", modal=True) 215def docs_modified(file_paths): 216 """Report if any file in the Doc directory has been changed.""" 217 return bool(file_paths) 218 219 220@status("Misc/ACKS updated", modal=True) 221def credit_given(file_paths): 222 """Check if Misc/ACKS has been changed.""" 223 return os.path.join('Misc', 'ACKS') in file_paths 224 225 226@status("Misc/NEWS.d updated with `blurb`", modal=True) 227def reported_news(file_paths): 228 """Check if Misc/NEWS.d has been changed.""" 229 return any(p.startswith(os.path.join('Misc', 'NEWS.d', 'next')) 230 for p in file_paths) 231 232@status("configure regenerated", modal=True, info=str) 233def regenerated_configure(file_paths): 234 """Check if configure has been regenerated.""" 235 if 'configure.ac' in file_paths: 236 return "yes" if 'configure' in file_paths else "no" 237 else: 238 return "not needed" 239 240@status("pyconfig.h.in regenerated", modal=True, info=str) 241def regenerated_pyconfig_h_in(file_paths): 242 """Check if pyconfig.h.in has been regenerated.""" 243 if 'configure.ac' in file_paths: 244 return "yes" if 'pyconfig.h.in' in file_paths else "no" 245 else: 246 return "not needed" 247 248def travis(pull_request): 249 if pull_request == 'false': 250 print('Not a pull request; skipping') 251 return 252 base_branch = get_base_branch() 253 file_paths = changed_files(base_branch) 254 python_files = [fn for fn in file_paths if fn.endswith('.py')] 255 c_files = [fn for fn in file_paths if fn.endswith(('.c', '.h'))] 256 doc_files = [fn for fn in file_paths if fn.startswith('Doc') and 257 fn.endswith(('.rst', '.inc'))] 258 fixed = [] 259 fixed.extend(normalize_whitespace(python_files)) 260 fixed.extend(normalize_c_whitespace(c_files)) 261 fixed.extend(normalize_docs_whitespace(doc_files)) 262 if not fixed: 263 print('No whitespace issues found') 264 else: 265 print(f'Please fix the {len(fixed)} file(s) with whitespace issues') 266 print('(on UNIX you can run `make patchcheck` to make the fixes)') 267 sys.exit(1) 268 269def main(): 270 base_branch = get_base_branch() 271 file_paths = changed_files(base_branch) 272 python_files = [fn for fn in file_paths if fn.endswith('.py')] 273 c_files = [fn for fn in file_paths if fn.endswith(('.c', '.h'))] 274 doc_files = [fn for fn in file_paths if fn.startswith('Doc') and 275 fn.endswith(('.rst', '.inc'))] 276 misc_files = {p for p in file_paths if p.startswith('Misc')} 277 # PEP 8 whitespace rules enforcement. 278 normalize_whitespace(python_files) 279 # C rules enforcement. 280 normalize_c_whitespace(c_files) 281 # Doc whitespace enforcement. 282 normalize_docs_whitespace(doc_files) 283 # Docs updated. 284 docs_modified(doc_files) 285 # Misc/ACKS changed. 286 credit_given(misc_files) 287 # Misc/NEWS changed. 288 reported_news(misc_files) 289 # Regenerated configure, if necessary. 290 regenerated_configure(file_paths) 291 # Regenerated pyconfig.h.in, if necessary. 292 regenerated_pyconfig_h_in(file_paths) 293 294 # Test suite run and passed. 295 if python_files or c_files: 296 end = " and check for refleaks?" if c_files else "?" 297 print() 298 print("Did you run the test suite" + end) 299 300 301if __name__ == '__main__': 302 import argparse 303 parser = argparse.ArgumentParser(description=__doc__) 304 parser.add_argument('--travis', 305 help='Perform pass/fail checks') 306 args = parser.parse_args() 307 if args.travis: 308 travis(args.travis) 309 else: 310 main() 311