~ruby/pythoscope/better-exception-handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
import inspect
import sys
import types

from pythoscope.util import compact


IGNORED_NAMES = ["<module>", "<genexpr>"]

def find_variable(frame, varname):
    """Find variable named varname in the scope of a frame.

    Raise a KeyError when the varname cannot be found.
    """
    try:
        return frame.f_locals[varname]
    except KeyError:
        return frame.f_globals[varname]

def callable_type(frame):
    """Return a type of a called frame or raise a KeyError if it can't be
    retrieved.

    The latter is the case for class definitions and method calls, which are
    not refrenced neither in local nor global scope.
    """
    return type(find_variable(frame.f_back, frame.f_code.co_name))

def is_class_definition(frame):
    "Return True if given frame represents a class definition."
    try:
        # Old-style classes are of type "ClassType", while new-style
        # classes or of type "type".
        return callable_type(frame) in [types.ClassType, type]
    except KeyError:
        return frame.f_code.co_names[:2] == ('__name__', '__module__')

class NotMethodFrame(Exception):
    pass

def get_method_information(frame):
    """Analyze the frame and return relevant information about the method
    call it presumably represents.

    Returns a tuple: (self_object, input_dictionary).

    If the frame doesn't represent a method call, raises NotMethodFrame
    exception.
    """
    try:
        args, varargs, varkw, locals = inspect.getargvalues(frame)
        if args:
            # Will raise TypeError if args[0] is a list.
            self = locals[args[0]]
        else:
            # Will raise an IndexError if no arguments were passed.
            self = locals[varargs][0]

        methodname = frame.f_code.co_name
        # Will raise AttributeError when the self is None or doesn't
        # have method with given name.
        method = getattr(self, methodname)

        # This isn't a call on the first argument's method.
        if not method.im_func.func_code == frame.f_code:
            raise NotMethodFrame

        # Remove the "self" argument.
        if args:
            args.pop(0)
        elif varargs and locals[varargs]:
            # No pop(), because locals[varargs] is a tuple.
            locals[varargs] = locals[varargs][1:]
        else:
            raise NotMethodFrame

        return (self, input_from_argvalues(args, varargs, varkw, locals))
    except (AttributeError, KeyError, TypeError, IndexError):
        raise NotMethodFrame

def resolve_args(names, locals):
    """Returns a list of tuples representing argument names and values.

    Handles nested arguments lists well.
        >>> resolve_args([['a', 'b'], 'c'], {'.0': (1, 2), 'c': 3})
        [('a', 1), ('b', 2), ('c', 3)]

        >>> resolve_args(['a', ['b', 'c']], {'.1': (8, 7), 'a': 9})
        [('a', 9), ('b', 8), ('c', 7)]
    """
    result = []
    for i, name in enumerate(names):
        if isinstance(name, list):
            result.extend(zip(name, locals['.%d' % i]))
        else:
            result.append((name, locals[name]))
    return result

def input_from_argvalues(args, varargs, varkw, locals):
    return dict(resolve_args(args + compact([varargs, varkw]), locals))

def make_callable(code):
    if isinstance(code, str):
        def function():
            exec code in {}
        return function
    return code

class Tracer(object):
    """Wrapper around basic C{sys.settrace} mechanism that maps 'call', 'return'
    and 'exception' events into more meaningful callbacks.

    See L{ICallback} for details on events that Tracer reports.
    """
    def __init__(self, callback):
        self.callback = callback

        self.top_level_function = None
        self.sys_modules = None

    # :: function | str -> None
    def trace(self, code):
        """Trace execution of given code. Code may be either a function
        or a string with Python code.

        This method may be invoked many times for a single Tracer instance.
        """
        self.setup(code)
        sys.settrace(self.tracer)
        try:
            self.top_level_function()
        finally:
            sys.settrace(None)
            self.teardown()

    def setup(self, code):
        self.top_level_function = make_callable(code)
        self.sys_modules = sys.modules.keys()

    def teardown(self):
        # Revert any changes to sys.modules.
        # This unfortunatelly doesn't include changes to the modules' state itself.
        # Replaced module instances in sys.modules are also not reverted.
        modnames = [m for m in sys.modules.keys() if m not in self.sys_modules]
        for modname in modnames:
            del sys.modules[modname]

        self.top_level_function = None
        self.sys_modules = None

    def tracer(self, frame, event, arg):
        if event == 'call':
            if not self.should_ignore_frame(frame):
                if self.record_call(frame):
                    return self.tracer
        elif event == 'return':
            self.callback.returned(arg)
        elif event == 'exception':
            if arg[0] is not GeneratorExit:
                # There are three cases here, each requiring different handling
                # of values in arg[0] and arg[1]. First, we may get a regular
                # exception generated by the `raise` statement. Second, we may
                # get an exception generated inside the interpreter, like an
                # IndexError or NameError. Finally, code in Python < 2.6 can
                # raise a string exception.
                #
                # In each case, arg[0] and arg[1] have different values,
                # described in the table below.
                #
                #               +------------------+---------------------------+
                #               |      arg[0]      |          arg[1]           |
                # +-------------+------------------+---------------------------+
                # | regular     | exception type   | exception instance        |
                # |  exceptions |  (e.g. TypeError)|                           |
                # +-------------+------------------+---------------------------+
                # | interpreter | exception type   | message (a string) or     |
                # |  exceptions |  (e.g. NameError)|  exception initialization |
                # |             |                  |  arguments (a tuple)      |
                # +-------------+------------------+---------------------------+
                # | string      | string itself    | value or None             |
                # |  exceptions |  (e.g. "Error")  |                           |
                # +-------------+------------------+---------------------------+
                #
                # arg[2] in all cases contains an exception traceback.
                if isinstance(arg[0], str):
                    # Return the string itself as an exception and ignore
                    # the value, as it's not used during test generation,
                    # at least for now.
                    exception = arg[0]
                elif isinstance(arg[1], str):
                    # Recreate instance of a single-argument interpreter
                    # exception.
                    exception = arg[0](arg[1])
                elif isinstance(arg[1], tuple):
                    # Recreate instance of a multi-argument interpreter
                    # exception.
                    exception = arg[0](*arg[1])
                else:
                    exception = arg[1]
                self.callback.raised(exception, arg[2])

    def should_ignore_frame(self, frame):
        return is_class_definition(frame) or self.is_ignored_code(frame.f_code)

    def is_ignored_code(self, code):
        if code.co_name in IGNORED_NAMES:
            return True
        if code in [self.top_level_function.func_code]:
            return True
        return False

    def record_call(self, frame):
        code = frame.f_code
        name = code.co_name

        try:
            obj, input = get_method_information(frame)
            return self.callback.method_called(name, obj, input, code, frame)
        except NotMethodFrame:
            input = input_from_argvalues(*inspect.getargvalues(frame))
            return self.callback.function_called(name, input, code, frame)

class ICallback(object):
    """Interface that Tracer's callback object should adhere to.
    """
    # :: (str, object, dict, code, frame) -> bool
    def method_called(self, name, obj, args, code, frame):
        """Reported when a method with given name is called on a given object.
        'args' represent rest of method arguments (i.e. without bounded object).

        Return value of this method decides whether tracer should simply ignore
        execution of this method, or should it continue tracing its contents.
        True value means 'continue', anything else means 'ignore'.
        """
        raise NotImplementedError("Method method_called() not defined.")

    # :: (str, dict, code, frame) -> bool
    def function_called(self, name, args, code, frame):
        """Reported when a function with given name is called.

        Return value of this method decides whether tracer should simply ignore
        execution of this function, or should it continue tracing its contents.
        True value means 'continue', anything else means 'ignore'.
        """
        raise NotImplementedError("Method function_called() not defined.")

    # :: object -> None
    def returned(self, output):
        """Reported when function or method returns.

        Return value is ignored.
        """
        raise NotImplementedError("Method returned() not defined.")

    # :: (exception|str, traceback) -> None
    def raised(self, exception, traceback):
        """Reported when exception is raised.

        Return value is ignored.
        """
        raise NotImplementedError("Method raised() not defined.")