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
13SRCDIR = sysconfig.get_config_var('srcdir')
14
15
16def n_files_str(count):
17    """Return 'N file(s)' with the proper plurality on 'file'."""
18    return "{} file{}".format(count, "s" if count != 1 else "")
19
20
21def status(message, modal=False, info=None):
22    """Decorator to output status info to stdout."""
23    def decorated_fxn(fxn):
24        def call_fxn(*args, **kwargs):
25            sys.stdout.write(message + ' ... ')
26            sys.stdout.flush()
27            result = fxn(*args, **kwargs)
28            if not modal and not info:
29                print "done"
30            elif info:
31                print info(result)
32            else:
33                print "yes" if result else "NO"
34            return result
35        return call_fxn
36    return decorated_fxn
37
38
39def mq_patches_applied():
40    """Check if there are any applied MQ patches."""
41    cmd = 'hg qapplied'
42    st = subprocess.Popen(cmd.split(),
43                          stdout=subprocess.PIPE,
44                          stderr=subprocess.PIPE)
45    try:
46        bstdout, _ = st.communicate()
47        return st.returncode == 0 and bstdout
48    finally:
49        st.stdout.close()
50        st.stderr.close()
51
52
53@status("Getting the list of files that have been added/changed",
54        info=lambda x: n_files_str(len(x)))
55def changed_files():
56    """Get the list of changed or added files from the VCS."""
57    if os.path.isdir(os.path.join(SRCDIR, '.hg')):
58        vcs = 'hg'
59        cmd = 'hg status --added --modified --no-status'
60        if mq_patches_applied():
61            cmd += ' --rev qparent'
62    elif os.path.isdir('.svn'):
63        vcs = 'svn'
64        cmd = 'svn status --quiet --non-interactive --ignore-externals'
65    else:
66        sys.exit('need a checkout to get modified files')
67
68    st = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
69    try:
70        st.wait()
71        if vcs == 'hg':
72            return [x.decode().rstrip() for x in st.stdout]
73        else:
74            output = (x.decode().rstrip().rsplit(None, 1)[-1]
75                      for x in st.stdout if x[0] in 'AM')
76        return set(path for path in output if os.path.isfile(path))
77    finally:
78        st.stdout.close()
79
80
81def report_modified_files(file_paths):
82    count = len(file_paths)
83    if count == 0:
84        return n_files_str(count)
85    else:
86        lines = ["{}:".format(n_files_str(count))]
87        for path in file_paths:
88            lines.append("  {}".format(path))
89        return "\n".join(lines)
90
91
92@status("Fixing whitespace", info=report_modified_files)
93def normalize_whitespace(file_paths):
94    """Make sure that the whitespace for .py files have been normalized."""
95    reindent.makebackup = False  # No need to create backups.
96    fixed = []
97    for path in (x for x in file_paths if x.endswith('.py')):
98        if reindent.check(os.path.join(SRCDIR, path)):
99            fixed.append(path)
100    return fixed
101
102
103@status("Fixing C file whitespace", info=report_modified_files)
104def normalize_c_whitespace(file_paths):
105    """Report if any C files """
106    fixed = []
107    for path in file_paths:
108        abspath = os.path.join(SRCDIR, path)
109        with open(abspath, 'r') as f:
110            if '\t' not in f.read():
111                continue
112        untabify.process(abspath, 8, verbose=False)
113        fixed.append(path)
114    return fixed
115
116
117ws_re = re.compile(br'\s+(\r?\n)$')
118
119@status("Fixing docs whitespace", info=report_modified_files)
120def normalize_docs_whitespace(file_paths):
121    fixed = []
122    for path in file_paths:
123        abspath = os.path.join(SRCDIR, path)
124        try:
125            with open(abspath, 'rb') as f:
126                lines = f.readlines()
127            new_lines = [ws_re.sub(br'\1', line) for line in lines]
128            if new_lines != lines:
129                shutil.copyfile(abspath, abspath + '.bak')
130                with open(abspath, 'wb') as f:
131                    f.writelines(new_lines)
132                fixed.append(path)
133        except Exception as err:
134            print 'Cannot fix %s: %s' % (path, err)
135    return fixed
136
137
138@status("Docs modified", modal=True)
139def docs_modified(file_paths):
140    """Report if any file in the Doc directory has been changed."""
141    return bool(file_paths)
142
143
144@status("Misc/ACKS updated", modal=True)
145def credit_given(file_paths):
146    """Check if Misc/ACKS has been changed."""
147    return os.path.join('Misc', 'ACKS') in file_paths
148
149
150@status("Misc/NEWS updated", modal=True)
151def reported_news(file_paths):
152    """Check if Misc/NEWS has been changed."""
153    return os.path.join('Misc', 'NEWS') in file_paths
154
155
156def main():
157    file_paths = changed_files()
158    python_files = [fn for fn in file_paths if fn.endswith('.py')]
159    c_files = [fn for fn in file_paths if fn.endswith(('.c', '.h'))]
160    doc_files = [fn for fn in file_paths if fn.startswith('Doc') and
161                 fn.endswith(('.rst', '.inc'))]
162    misc_files = {os.path.join('Misc', 'ACKS'), os.path.join('Misc', 'NEWS')}\
163            & set(file_paths)
164    # PEP 8 whitespace rules enforcement.
165    normalize_whitespace(python_files)
166    # C rules enforcement.
167    normalize_c_whitespace(c_files)
168    # Doc whitespace enforcement.
169    normalize_docs_whitespace(doc_files)
170    # Docs updated.
171    docs_modified(doc_files)
172    # Misc/ACKS changed.
173    credit_given(misc_files)
174    # Misc/NEWS changed.
175    reported_news(misc_files)
176
177    # Test suite run and passed.
178    if python_files or c_files:
179        end = " and check for refleaks?" if c_files else "?"
180        print
181        print "Did you run the test suite" + end
182
183
184if __name__ == '__main__':
185    main()
186