usrbinenv python3 check proposed changes for common issues import re import sys import shutil import os path import subprocess import sysconfig import reindent import untabify def getpythonsourcedir srcdir sysconfig getconfigvar abssrcdir if not srcdir srcdir sysconfig getconfigvar srcdir return os path abspathsrcdir excluded directories which are copies of external libraries don t check their coding style excludedirs os path join modules decimal libmpdec os path join modules expat os path join modules zlib srcdir getpythonsourcedir def nfilesstrcount decorator to output status info to stdout def decoratedfxnfxn def callfxnargs kwargs sys stdout writemessage sys stdout flush result fxnargs kwargs if not modal and not info printdone elif info printinforesult else printyes if result else no return result return callfxn return decoratedfxn def getgitbranch get the remote name to use for upstream branches uses upstream if it exists origin otherwise get the name of the default branch for the given remote it is typically called main but may differ not a git checkout so there s no base branch not on a git pr branch so there s no base branch get the list of changed or added files from git if os path existsos path joinsrcdir git we just use an existence check here as directory normal git checkoutclone file git worktree directory if basebranch cmd git diff namestatus basebranch else cmd git status porcelain filenames with subprocess popencmd split stdoutsubprocess pipe cwdsrcdir as st gitfilestatus st communicate if st returncode 0 sys exitf error running cmd for line in gitfilestatus splitlines line line decode rstrip statustext filename line splitmaxsplit1 status setstatustext modified added or unmerged files if not status intersection mau continue if in filename file is renamed filename filename split 21 strip filenames appendfilename else sys exit need a git checkout to get modified files filenames2 for filename in filenames normalize the path to be able to match using startswith filename os path normpathfilename if anyfilename startswithpath for path in excludedirs exclude the file continue filenames2 appendfilename return filenames2 def reportmodifiedfilesfilepaths count lenfilepaths if count 0 return nfilesstrcount else lines fnfilesstrcount for path in filepaths lines appendf path return n joinlines python files that have tabs by design pythonfileswithtabs frozenset toolscanalyzercpythonparser py statusfixing python file whitespace inforeportmodifiedfiles def normalizewhitespacefilepaths report if any c files fixed for path in filepaths abspath os path joinsrcdir path with openabspath r as f if t not in f read continue untabify processabspath 8 verbosefalse fixed appendpath return fixed statusdocs modified modaltrue def docsmodifiedfilepaths check if miscacks has been changed return os path join misc acks in filepaths statusmiscnews d updated with blurb modaltrue def reportednewsfilepaths check if configure has been regenerated if configure ac in filepaths return yes if configure in filepaths else no else return not needed statuspyconfig h in regenerated modaltrue infostr def regeneratedpyconfighinfilepaths pep 8 whitespace rules enforcement c rules enforcement docs updated miscacks changed miscnews changed regenerated configure if necessary regenerated pyconfig h in if necessary test suite run and passed usr bin env python3 check proposed changes for common issues excluded directories which are copies of external libraries don t check their coding style return n file s with the proper plurality on file decorator to output status info to stdout get the symbolic name for the current git branch get the remote name to use for upstream branches uses upstream if it exists origin otherwise get the name of the default branch for the given remote it is typically called main but may differ not a git checkout so there s no base branch not on a git pr branch so there s no base branch get the list of changed or added files from git we just use an existence check here as directory normal git checkout clone file git worktree directory modified added or unmerged files file is renamed normalize the path to be able to match using startswith exclude the file python files that have tabs by design make sure that the whitespace for py files have been normalized no need to create backups report if any c files report if any file in the doc directory has been changed check if misc acks has been changed check if misc news d has been changed check if configure has been regenerated check if pyconfig h in has been regenerated pep 8 whitespace rules enforcement c rules enforcement docs updated misc acks changed misc news changed regenerated configure if necessary regenerated pyconfig h in if necessary test suite run and passed
import re
import sys
import shutil
import os.path
import subprocess
import sysconfig

import reindent
import untabify


def get_python_source_dir():
    src_dir = sysconfig.get_config_var('abs_srcdir')
    if not src_dir:
        src_dir = sysconfig.get_config_var('srcdir')
    return os.path.abspath(src_dir)




EXCLUDE_DIRS = [
    os.path.join('Modules', '_decimal', 'libmpdec'),
    os.path.join('Modules', 'expat'),
    os.path.join('Modules', 'zlib'),
    ]
SRCDIR = get_python_source_dir()


def n_files_str(count):
    
    s = "s" if count != 1 else ""
    return f"{count} file{s}"


def status(message, modal=False, info=None):
    
    def decorated_fxn(fxn):
        def call_fxn(*args, **kwargs):
            sys.stdout.write(message + ' ... ')
            sys.stdout.flush()
            result = fxn(*args, **kwargs)
            if not modal and not info:
                print("done")
            elif info:
                print(info(result))
            else:
                print("yes" if result else "NO")
            return result
        return call_fxn
    return decorated_fxn


def get_git_branch():
    
    cmd = "git rev-parse --abbrev-ref HEAD".split()
    try:
        return subprocess.check_output(cmd,
                                       stderr=subprocess.DEVNULL,
                                       cwd=SRCDIR,
                                       encoding='UTF-8')
    except subprocess.CalledProcessError:
        return None


def get_git_upstream_remote():
    
    cmd = "git remote get-url upstream".split()
    try:
        subprocess.check_output(cmd,
                                stderr=subprocess.DEVNULL,
                                cwd=SRCDIR,
                                encoding='UTF-8')
    except subprocess.CalledProcessError:
        return "origin"
    return "upstream"


def get_git_remote_default_branch(remote_name):
    
    cmd = f"git remote show {remote_name}".split()
    env = os.environ.copy()
    env['LANG'] = 'C'
    try:
        remote_info = subprocess.check_output(cmd,
                                              stderr=subprocess.DEVNULL,
                                              cwd=SRCDIR,
                                              encoding='UTF-8',
                                              env=env)
    except subprocess.CalledProcessError:
        return None
    for line in remote_info.splitlines():
        if "HEAD branch:" in line:
            base_branch = line.split(":")[1].strip()
            return base_branch
    return None


@status("Getting base branch for PR",
        info=lambda x: x if x is not None else "not a PR branch")
def get_base_branch():
    if not os.path.exists(os.path.join(SRCDIR, '.git')):
        
        return None
    upstream_remote = get_git_upstream_remote()
    version = sys.version_info
    if version.releaselevel == 'alpha':
        base_branch = get_git_remote_default_branch(upstream_remote)
    else:
        base_branch = "{0.major}.{0.minor}".format(version)
    this_branch = get_git_branch()
    if this_branch is None or this_branch == base_branch:
        
        return None
    return upstream_remote + "/" + base_branch


@status("Getting the list of files that have been added/changed",
        info=lambda x: n_files_str(len(x)))
def changed_files(base_branch=None):
    
    if os.path.exists(os.path.join(SRCDIR, '.git')):
        
        
        
        if base_branch:
            cmd = 'git diff --name-status ' + base_branch
        else:
            cmd = 'git status --porcelain'
        filenames = []
        with subprocess.Popen(cmd.split(),
                              stdout=subprocess.PIPE,
                              cwd=SRCDIR) as st:
            git_file_status, _ = st.communicate()
            if st.returncode != 0:
                sys.exit(f'error running {cmd}')
            for line in git_file_status.splitlines():
                line = line.decode().rstrip()
                status_text, filename = line.split(maxsplit=1)
                status = set(status_text)
                
                if not status.intersection('MAU'):
                    continue
                if ' -> ' in filename:
                    
                    filename = filename.split(' -> ', 2)[1].strip()
                filenames.append(filename)
    else:
        sys.exit('need a git checkout to get modified files')

    filenames2 = []
    for filename in filenames:
        
        filename = os.path.normpath(filename)
        if any(filename.startswith(path) for path in EXCLUDE_DIRS):
            
            continue
        filenames2.append(filename)

    return filenames2


def report_modified_files(file_paths):
    count = len(file_paths)
    if count == 0:
        return n_files_str(count)
    else:
        lines = [f"{n_files_str(count)}:"]
        for path in file_paths:
            lines.append(f"  {path}")
        return "\n".join(lines)



_PYTHON_FILES_WITH_TABS = frozenset({
    'Tools/c-analyzer/cpython/_parser.py',
})


@status("Fixing Python file whitespace", info=report_modified_files)
def normalize_whitespace(file_paths):
    
    reindent.makebackup = False  
    fixed = [
        path for path in file_paths
        if (
            path.endswith('.py')
            and path not in _PYTHON_FILES_WITH_TABS
            and reindent.check(os.path.join(SRCDIR, path))
        )
    ]
    return fixed


@status("Fixing C file whitespace", info=report_modified_files)
def normalize_c_whitespace(file_paths):
    
    fixed = []
    for path in file_paths:
        abspath = os.path.join(SRCDIR, path)
        with open(abspath, 'r') as f:
            if '\t' not in f.read():
                continue
        untabify.process(abspath, 8, verbose=False)
        fixed.append(path)
    return fixed


@status("Docs modified", modal=True)
def docs_modified(file_paths):
    
    return bool(file_paths)


@status("Misc/ACKS updated", modal=True)
def credit_given(file_paths):
    
    return os.path.join('Misc', 'ACKS') in file_paths


@status("Misc/NEWS.d updated with `blurb`", modal=True)
def reported_news(file_paths):
    
    return any(p.startswith(os.path.join('Misc', 'NEWS.d', 'next'))
               for p in file_paths)


@status("configure regenerated", modal=True, info=str)
def regenerated_configure(file_paths):
    
    if 'configure.ac' in file_paths:
        return "yes" if 'configure' in file_paths else "no"
    else:
        return "not needed"


@status("pyconfig.h.in regenerated", modal=True, info=str)
def regenerated_pyconfig_h_in(file_paths):
    
    if 'configure.ac' in file_paths:
        return "yes" if 'pyconfig.h.in' in file_paths else "no"
    else:
        return "not needed"


def ci(pull_request):
    if pull_request == 'false':
        print('Not a pull request; skipping')
        return
    base_branch = get_base_branch()
    file_paths = changed_files(base_branch)
    python_files = [fn for fn in file_paths if fn.endswith('.py')]
    c_files = [fn for fn in file_paths if fn.endswith(('.c', '.h'))]
    fixed = []
    fixed.extend(normalize_whitespace(python_files))
    fixed.extend(normalize_c_whitespace(c_files))
    if not fixed:
        print('No whitespace issues found')
    else:
        count = len(fixed)
        print(f'Please fix the {n_files_str(count)} with whitespace issues')
        print('(on Unix you can run `make patchcheck` to make the fixes)')
        sys.exit(1)


def main():
    base_branch = get_base_branch()
    file_paths = changed_files(base_branch)
    python_files = [fn for fn in file_paths if fn.endswith('.py')]
    c_files = [fn for fn in file_paths if fn.endswith(('.c', '.h'))]
    doc_files = [fn for fn in file_paths if fn.startswith('Doc') and
                 fn.endswith(('.rst', '.inc'))]
    misc_files = {p for p in file_paths if p.startswith('Misc')}
    
    normalize_whitespace(python_files)
    
    normalize_c_whitespace(c_files)
    
    docs_modified(doc_files)
    
    credit_given(misc_files)
    
    reported_news(misc_files)
    
    regenerated_configure(file_paths)
    
    regenerated_pyconfig_h_in(file_paths)

    
    if python_files or c_files:
        end = " and check for refleaks?" if c_files else "?"
        print()
        print("Did you run the test suite" + end)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--ci',
                        help='Perform pass/fail checks')
    args = parser.parse_args()
    if args.ci:
        ci(args.ci)
    else:
        main()