patchcheck.py 9.6 KB

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