1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3#
4# Copyright 2017, Data61
5# Commonwealth Scientific and Industrial Research Organisation (CSIRO)
6# ABN 41 687 119 230.
7#
8# This software may be distributed and modified according to the terms of
9# the BSD 2-Clause license. Note that NO WARRANTY is provided.
10# See "LICENSE_BSD2.txt" for details.
11#
12# @TAG(DATA61_BSD)
13#
14
15'''
16CAmkES source linter.
17
18This tool is designed to recognise various Python programming idioms used in
19the CAmkES sources and report likely typos in them. It is fairly limited right
20now, but feel free to extend it to detect more idioms.
21'''
22
23import argparse, ast, codecs, sys
24
25def main(argv):
26    parser = argparse.ArgumentParser(
27        description='check source files for mistaken CAmkES idioms')
28    parser.add_argument('filename', help='file to check',
29        type=argparse.FileType('r'))
30    options = parser.parse_args(argv[1:])
31
32    # Parse the input as UTF-8 as all the CAmkES sources are intended to be
33    # UTF-8-clean.
34    with codecs.open(options.filename.name, 'rt', 'utf-8') as f:
35        st = ast.parse(f.read().encode('utf-8'))
36
37    result = 0
38
39    # Walk the AST of the input file looking for functions.
40    for node in (x for x in ast.walk(st) if isinstance(x, ast.FunctionDef)):
41
42        # Extract the names of the arguments to this function.
43        args = [x.id for x in node.args.args]
44
45        # Scan any leading assertions in the function's body.
46        for stmt in node.body:
47            if not isinstance(stmt, ast.Assert):
48                break
49
50            # Extract the body of the assertion.
51            test = stmt.test
52
53            # Now try to recognise two idioms:
54            #  1. `assert foo is None or isinstance(foo, ...)`; and
55            #  2. `assert isinstance(foo, ...)`.
56            # These idioms are used in the CAmkES sources to perform runtime
57            # type-checking of function parameters. Any deviation from the
58            # above templates likely indicates a typo.
59
60            # In the following variable, we'll store the node corresponding to
61            # the `isinstance` call in one of the two above idioms.
62            insttest = None
63
64            # Try to recognise case (1).
65            if isinstance(test, ast.BoolOp) and \
66               isinstance(test.op, ast.Or) and \
67               isinstance(test.values[0], ast.Compare) and \
68               isinstance(test.values[0].left, ast.Name) and \
69               len(test.values[0].ops) == 1 and \
70               isinstance(test.values[0].ops[0], ast.Is) and \
71               len(test.values[0].comparators) == 1 and \
72               isinstance(test.values[0].comparators[0], ast.Name):
73
74                if test.values[0].left.id not in args:
75                    sys.stderr.write('%s:%d: leading assertion references '
76                        '`%s` that is not a function argument\n' %
77                        (options.filename.name, test.values[0].left.lineno,
78                        test.values[0].left.id))
79                    result |= -1
80
81                if test.values[0].comparators[0].id != 'None':
82                    sys.stderr.write('%s:%d: leading `is` assertion against '
83                        '`%s` instead of `None` as expected\n' %
84                        (options.filename.name,
85                        test.values[0].comparators[0].lineno,
86                        test.values[0].comparators[0].id))
87                    result |= -1
88
89                if isinstance(test.values[1], ast.Call) and \
90                   test.values[1].func.id == 'isinstance':
91                    insttest = test.values[1]
92
93            # Try to recognise case (2).
94            if isinstance(test, ast.Call) and \
95               isinstance(test.func, ast.Name) and \
96               test.func.id == 'isinstance':
97                insttest = test
98
99            # Check the `isinstance` contents.
100            if insttest is not None:
101                if len(insttest.args) != 2:
102                    sys.stderr.write('%s:%d: %d arguments to `isinstance` '
103                        'instead of 2 as expected\n' % (options.filename.name,
104                        insttest.lineno, len(insttest.args)))
105                    result |= -1
106
107                elif not isinstance(insttest.args[0], (ast.Name, ast.Attribute)):
108                    sys.stderr.write('%s:%d: unexpected first argument to '
109                        '`isinstance`\n' % (options.filename.name,
110                        insttest.args[0].lineno))
111                    result |= -1
112
113                elif isinstance(insttest.args[0], ast.Name) and \
114                     insttest.args[0].id not in args:
115                    sys.stderr.write('%s:%d: leading assertion references '
116                        '`%s` that is not a function argument\n' %
117                        (options.filename.name, insttest.args[0].lineno,
118                        insttest.args[0].id))
119                    result |= -1
120
121                elif isinstance(insttest.args[0], ast.Attribute) and \
122                     (not isinstance(insttest.args[0].value, ast.Name) or
123                     insttest.args[0].value.id != 'self'):
124                    sys.stderr.write('%s:%d: leading assertion references '
125                        '`%s.%s` that is not a function argument\n' %
126                        (options.filename.name, insttest.args[0].lineno,
127                        insttest.args[0].value.id, insttest.args[0].attr))
128                    result |= -1
129
130    return result
131
132if __name__ == '__main__':
133    sys.exit(main(sys.argv))
134