Completed
Push — master ( ae1382...0d2e01 )
by De
01:12
created

partial_app()   A

Complexity

Conditions 2

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 4
rs 10
cc 2
1
# -*- coding: utf-8
2
"""Logic to add suggestions to exceptions."""
3
import keyword
4
import difflib
5
import didyoumean_re as re
6
import itertools
7
import inspect
8
import errno
9
import os
10
from collections import namedtuple
11
12
13
#: Standard modules we'll consider while searching for undefined values
14
# To be completed
15
# Potential candidates :
16
#  - sys.builtin_module_names
17
# https://docs.python.org/2/library/sys.html#sys.builtin_module_names
18
#  - sys.modules
19
# https://docs.python.org/2/library/sys.html#sys.modules
20
#  - pkgutil.iter_modules
21
# https://docs.python.org/2/library/pkgutil.html#pkgutil.iter_modules
22
STAND_MODULES = set(['string', 'os', 'sys', 're', 'math', 'random',
23
                     'datetime', 'timeit', 'unittest', 'itertools',
24
                     'functools', 'collections', '__future__'])
25
26
#: Almost synonyms methods that can be confused from one type to another
27
# To be completed
28
SYNONYMS_SETS = [set(['add', 'append']), set(['extend', 'update'])]
29
30
#: Maximum number of files suggested
31
MAX_NB_FILES = 4
32
33
34
# Helper function for string manipulation
35
def quote(string):
36
    """Surround string with single quotes."""
37
    return "'{0}'".format(string)
38
39
40
def get_close_matches(word, possibilities):
41
    """
42
    Return a list of the best "good enough" matches.
43
44
    Wrapper around difflib.get_close_matches() to be able to
45
    change default values or implementation details easily.
46
    """
47
    return difflib.get_close_matches(word, possibilities, 3, 0.7)
48
49
50
def get_suggestion_string(sugg):
51
    """Return the suggestion list as a string."""
52
    sugg = list(sugg)
53
    return ". Did you mean " + ", ".join(sugg) + "?" if sugg else ""
54
55
56
# Helper functions for code introspection
57
def get_subclasses(klass):
58
    """Get the subclasses of a class.
59
60
    Get the set of direct/indirect subclasses of a class including itself.
61
    """
62
    if hasattr(klass, '__subclasses__'):
63
        try:
64
            subclasses = set(klass.__subclasses__())
65
        except TypeError:
66
            try:
67
                subclasses = set(klass.__subclasses__(klass))
68
            except TypeError:
69
                subclasses = set()
70
    else:
71
        subclasses = set()
72
    for derived in set(subclasses):
73
        subclasses.update(get_subclasses(derived))
74
    subclasses.add(klass)
75
    return subclasses
76
77
78
def get_types_for_str_using_inheritance(name):
79
    """Get types corresponding to a string name.
80
81
    This goes through all defined classes. Therefore, it :
82
    - does not include old style classes on Python 2.x
83
    - is to be called as late as possible to ensure wanted type is defined.
84
    """
85
    return set(c for c in get_subclasses(object) if c.__name__ == name)
86
87
88
def get_types_for_str_using_names(name, frame):
89
    """Get types corresponding to a string name using names in frame.
90
91
    This does not find everything as builtin types for instance may not
92
    be in the names.
93
    """
94
    return set(obj
95
               for obj, _ in get_objects_in_frame(frame).get(name, [])
96
               if inspect.isclass(obj) and obj.__name__ == name)
97
98
99
def get_types_for_str(tp_name, frame):
100
    """Get a list of candidate types from a string.
101
102
    String corresponds to the tp_name as described in :
103
    https://docs.python.org/2/c-api/typeobj.html#c.PyTypeObject.tp_name
104
    as it is the name used in exception messages. It may include full path
105
    with module, subpackage, package but this is just removed in current
106
    implementation to search only based on the type name.
107
108
    Lookup uses both class hierarchy and name lookup as the first may miss
109
    old style classes on Python 2 and second does find them.
110
    Just like get_types_for_str_using_inheritance, this needs to be called
111
    as late as possible but because it requires a frame, there is not much
112
    choice anyway.
113
    """
114
    name = tp_name.split('.')[-1]
115
    res = set.union(
116
        get_types_for_str_using_inheritance(name),
117
        get_types_for_str_using_names(name, frame))
118
    assert all(inspect.isclass(t) and t.__name__ == name for t in res)
119
    return res
120
121
122
def merge_dict(*dicts):
123
    """Merge dicts and return a dictionnary mapping key to list of values.
124
125
    Order of the values corresponds to the order of the original dicts.
126
    """
127
    ret = dict()
128
    for dict_ in dicts:
129
        for key, val in dict_.items():
130
            ret.setdefault(key, []).append(val)
131
    return ret
132
133
ScopedObj = namedtuple('ScopedObj', 'obj scope')
134
135
136
def add_scope_to_dict(dict_, scope):
137
    """Convert name:obj dict to name:ScopedObj(obj,scope) dict."""
138
    return dict((k, ScopedObj(v, scope)) for k, v in dict_.items())
139
140
141
def get_objects_in_frame(frame):
142
    """Get objects defined in a given frame.
143
144
    This includes variable, types, builtins, etc.
145
    """
146
    # https://www.python.org/dev/peps/pep-0227/ PEP227 Statically Nested Scopes
147
    # "Under this proposal, it will not be possible to gain dictionary-style
148
    #      access to all visible scopes."
149
    # https://www.python.org/dev/peps/pep-3104/ PEP 3104 Access to Names in
150
    #      Outer Scopes
151
    return merge_dict(  # LEGB Rule (missing E atm - not sure if a problem)
152
        add_scope_to_dict(frame.f_locals, 'local'),
153
        add_scope_to_dict(frame.f_globals, 'global'),
154
        add_scope_to_dict(frame.f_builtins, 'builtin'),
155
    )
156
157
158
def import_from_frame(module_name, frame):
159
    """Wrapper around import to use information from frame."""
160
    if frame is None:
161
        return None
162
    return __import__(
163
        module_name,
164
        frame.f_globals,
165
        frame.f_locals)
166
167
168
# To be used in `get_suggestions_for_exception`.
169
SUGGESTION_FUNCTIONS = dict()
170
171
172
def register_suggestion_for(error_type, regex):
173
    """Decorator to register a function to be called to get suggestions
174
    for a specific error type and if the error message matches a given
175
    regex.
176
177
    The decorated function is expected to yield any number (0 included) of
178
    suggestions (as string).
179
    The parameters are: (value, frame, groups):
180
     - value: Exception object
181
     - frame: Last frame of the traceback (may be None when the traceback is
182
        None which happens only in edge cases)
183
     - groups: Groups from the error message matched by the error message.
184
    """
185
    def internal_decorator(func):
186
        def registered_function(value, frame):
187
            if regex is None:
188
                return func(value, frame, [])
189
            error_msg = value.args[0]
190
            match = re.match(regex, error_msg)
191
            if match:
192
                return func(value, frame, match.groups())
193
            return []
194
        SUGGESTION_FUNCTIONS.setdefault(error_type, []) \
195
            .append(registered_function)
196
        return func  # return original function
197
    return internal_decorator
198
199
200
# Functions related to NameError
201
@register_suggestion_for(NameError, re.VARREFBEFOREASSIGN_RE)
202
@register_suggestion_for(NameError, re.NAMENOTDEFINED_RE)
203
def suggest_name_not_defined(value, frame, groups):
204
    """Get the suggestions for name in case of NameError."""
205
    del value  # unused param
206
    name, = groups
207
    objs = get_objects_in_frame(frame)
208
    return itertools.chain(
209
        suggest_name_as_attribute(name, objs),
210
        suggest_name_as_standard_module(name),
211
        suggest_name_as_name_typo(name, objs),
212
        suggest_name_as_keyword_typo(name),
213
        suggest_name_as_missing_import(name, objs, frame),
214
        suggest_name_as_special_case(name))
215
216
217
def suggest_name_as_attribute(name, objdict):
218
    """Suggest that name could be an attribute of an object.
219
220
    Example: 'do_stuff()' -> 'self.do_stuff()'.
221
    """
222
    for nameobj, objs in objdict.items():
223
        prev_scope = None
224
        for obj, scope in objs:
225
            if hasattr(obj, name):
226
                yield quote(nameobj + '.' + name) + \
227
                    ('' if prev_scope is None else
228
                     ' ({0} hidden by {1})'.format(scope, prev_scope))
229
                break
230
            prev_scope = scope
231
232
233
def suggest_name_as_missing_import(name, objdict, frame):
234
    """Suggest that name could come from missing import.
235
236
    Example: 'foo' -> 'import mod, mod.foo'.
237
    """
238
    for mod in STAND_MODULES:
239
        if mod not in objdict and name in dir(import_from_frame(mod, frame)):
240
            yield "'{0}' from {1} (not imported)".format(name, mod)
241
242
243
def suggest_name_as_standard_module(name):
244
    """Suggest that name could be a non-imported standard module.
245
246
    Example: 'os.whatever' -> 'import os' and then 'os.whatever'.
247
    """
248
    if name in STAND_MODULES:
249
        yield 'to import {0} first'.format(name)
250
251
252
def suggest_name_as_name_typo(name, objdict):
253
    """Suggest that name could be a typo (misspelled existing name).
254
255
    Example: 'foobaf' -> 'foobar'.
256
    """
257
    for name in get_close_matches(name, objdict.keys()):
258
        yield quote(name) + ' (' + objdict[name][0].scope + ')'
259
260
261
def suggest_name_as_keyword_typo(name):
262
    """Suggest that name could be a typo (misspelled keyword).
263
264
    Example: 'yieldd' -> 'yield'.
265
    """
266
    for name in get_close_matches(name, keyword.kwlist):
267
        yield quote(name) + " (keyword)"
268
269
270
def suggest_name_as_special_case(name):
271
    """Suggest that name could correspond to a typo with special handling."""
272
    special_cases = {
273
        # Imaginary unit is '1j' in Python
274
        'i': quote('1j') + " (imaginary unit)",
275
        'j': quote('1j') + " (imaginary unit)",
276
        # Shell commands entered in interpreter
277
        'pwd': quote('os.getcwd()'),
278
        'ls': quote('os.listdir(os.getcwd())'),
279
        'cd': quote('os.chdir(path)'),
280
        'rm': "'os.remove(filename)', 'shutil.rmtree(dir)' for recursive",
281
    }
282
    result = special_cases.get(name)
283
    if result is not None:
284
        yield result
285
286
287
# Functions related to AttributeError
288
@register_suggestion_for(AttributeError, re.ATTRIBUTEERROR_RE)
289
def suggest_attribute_error(value, frame, groups):
290
    """Get suggestions in case of ATTRIBUTEERROR."""
291
    del value  # unused param
292
    type_str, attr = groups
293
    return get_attribute_suggestions(type_str, attr, frame)
294
295
296
@register_suggestion_for(AttributeError, re.MODULEHASNOATTRIBUTE_RE)
297
def suggest_module_has_no_attr(value, frame, groups):
298
    """Get suggestions in case of MODULEHASNOATTRIBUTE."""
299
    del value  # unused param
300
    _, attr = groups  # name ignored for the time being
301
    return get_attribute_suggestions('module', attr, frame)
302
303
304
def get_attribute_suggestions(type_str, attribute, frame):
305
    """Get the suggestions closest to the attribute name for a given type."""
306
    types = get_types_for_str(type_str, frame)
307
    attributes = set(a for t in types for a in dir(t))
308
    if type_str == 'module':
309
        # For module, we manage to get the corresponding 'module' type
310
        # but the type doesn't bring much information about its content.
311
        # A hacky way to do so is to assume that the exception was something
312
        # like 'module_name.attribute' so that we can actually find the module
313
        # based on the name. Eventually, we check that the found object is a
314
        # module indeed. This is not failproof but it brings a whole lot of
315
        # interesting suggestions and the (minimal) risk is to have invalid
316
        # suggestions.
317
        module_name = frame.f_code.co_names[0]
318
        objs = get_objects_in_frame(frame)
319
        mod = objs[module_name][0].obj
320
        attributes = set(dir(mod))
321
322
    return itertools.chain(
323
        suggest_attribute_as_builtin(attribute, type_str, frame),
324
        suggest_attribute_as_removed(attribute, type_str, attributes),
325
        suggest_attribute_synonyms(attribute, attributes),
326
        suggest_attribute_as_typo(attribute, attributes))
327
328
329
def suggest_attribute_as_builtin(attribute, type_str, frame):
330
    """Suggest that a builtin was used as an attribute.
331
332
    Example: 'lst.len()' -> 'len(lst)'.
333
    """
334
    if attribute in frame.f_builtins:
335
        yield quote(attribute + '(' + type_str + ')')
336
337
338
def suggest_attribute_as_removed(attribute, type_str, attributes):
339
    """Suggest that attribute is removed (and give an alternative)."""
340
    if attribute == 'has_key' and '__contains__' in attributes:
341
        yield quote('key in ' + type_str)
342
343
344
def suggest_attribute_synonyms(attribute, attributes):
345
    """Suggest that a method with a similar meaning was used.
346
347
    Example: 'lst.add(e)' -> 'lst.append(e)'.
348
    """
349
    for set_sub in SYNONYMS_SETS:
350
        if attribute in set_sub:
351
            for syn in set_sub & attributes:
352
                yield quote(syn)
353
354
355
def suggest_attribute_as_typo(attribute, attributes):
356
    """Suggest the attribute could be a typo.
357
358
    Example: 'a.do_baf()' -> 'a.do_bar()'.
359
    """
360
    for name in get_close_matches(attribute, attributes):
361
        # Handle Private name mangling
362
        if name.startswith('_') and '__' in name and not name.endswith('__'):
363
            yield quote(name) + ' (but it is supposed to be private)'
364
        else:
365
            yield quote(name)
366
367
368
# Functions related to ImportError
369
@register_suggestion_for(ImportError, re.NOMODULE_RE)
370
def suggest_no_module(value, frame, groups):
371
    """Get the suggestions closest to the failing module import.
372
373
    Example: 'import maths' -> 'import math'.
374
    """
375
    del value, frame  # unused param
376
    module_str, = groups
377
    for name in get_close_matches(module_str, STAND_MODULES):
378
        yield quote(name)
379
380
381
@register_suggestion_for(ImportError, re.CANNOTIMPORT_RE)
382
def suggest_cannot_import(value, frame, groups):
383
    """Get the suggestions closest to the failing import."""
384
    del value  # unused param
385
    imported_name, = groups
386
    module_name = frame.f_code.co_names[0]
387
    return itertools.chain(
388
        suggest_imported_name_as_typo(imported_name, module_name, frame),
389
        suggest_import_from_module(imported_name, frame))
390
391
392
def suggest_imported_name_as_typo(imported_name, module_name, frame):
393
    """Suggest that imported name could be a typo from actual name in module.
394
395
    Example: 'from math import pie' -> 'from math import pi'.
396
    """
397
    dir_mod = dir(import_from_frame(module_name, frame))
398
    for name in get_close_matches(imported_name, dir_mod):
399
        yield quote(name)
400
401
402
def suggest_import_from_module(imported_name, frame):
403
    """Suggest than name could be found in a standard module.
404
405
    Example: 'from itertools import pi' -> 'from math import pi'.
406
    """
407
    for mod in STAND_MODULES:
408
        if imported_name in dir(import_from_frame(mod, frame)):
409
            yield quote('from {0} import {1}'.format(mod, imported_name))
410
411
412
# Functions related to TypeError
413
@register_suggestion_for(TypeError, re.UNSUBSCRIBTABLE_RE)
414
def suggest_unsubscriptable(value, frame, groups):
415
    """Get suggestions in case of UNSUBSCRIBTABLE error."""
416
    del value  # unused param
417
    type_str, = groups
418
    types = get_types_for_str(type_str, frame)
419
    if any(hasattr(t, '__call__') for t in types):
420
        yield quote(type_str + '(value)')
421
422
423
@register_suggestion_for(TypeError, re.UNEXPECTED_KEYWORDARG_RE)
424
def suggest_unexpected_keywordarg(value, frame, groups):
425
    """Get suggestions in case of UNEXPECTED_KEYWORDARG error."""
426
    del value  # unused param
427
    func_name, kw_arg = groups
428
    objs = get_objects_in_frame(frame)
429
    if func_name in objs:
430
        func = objs[func_name][0].obj
431
        args = func.__code__.co_varnames
432
        for name in get_close_matches(kw_arg, args):
433
            yield quote(name)
434
435
436
@register_suggestion_for(TypeError, re.UNEXPECTED_KEYWORDARG2_RE)
437
def suggest_unexpected_keywordarg2(value, frame, groups):
438
    """Get suggestions in case of UNEXPECTED_KEYWORDARG2 error."""
439
    del value
440
    del frame  # unused param
441
    del groups  # unused param
442
    return []  # no implementation so far
443
444
445
@register_suggestion_for(TypeError, re.NOT_CALLABLE_RE)
446
def suggest_not_callable(value, frame, groups):
447
    """Get suggestions in case of NOT_CALLABLE error."""
448
    del value  # unused param
449
    type_str, = groups
450
    types = get_types_for_str(type_str, frame)
451
    if any(hasattr(t, '__getitem__') for t in types):
452
        yield quote(type_str + '[value]')
453
454
455
# Functions related to ValueError
456
@register_suggestion_for(ValueError, re.ZERO_LEN_FIELD_RE)
457
def suggest_zero_len_field(value, frame, groups):
458
    """Get suggestions in case of ZERO_LEN_FIELD."""
459
    del value, frame, groups  # unused param
460
    yield '{0}'
461
462
463
@register_suggestion_for(ValueError, re.TIME_DATA_DOES_NOT_MATCH_FORMAT_RE)
464
def suggest_time_data_is_wrong(value, frame, groups):
465
    """Get suggestions in case of TIME_DATA_DOES_NOT_MATCH_FORMAT_RE."""
466
    del value, frame
467
    timedata, timeformat = groups
468
    if timedata.count('%') > timeformat.count('%%'):
469
        yield "to swap value and format parameters"
470
471
472
# Functions related to SyntaxError
473
@register_suggestion_for(SyntaxError, re.OUTSIDE_FUNCTION_RE)
474
def suggest_outside_func_error(value, frame, groups):
475
    """Get suggestions in case of OUTSIDE_FUNCTION error."""
476
    del value, frame  # unused param
477
    yield "to indent it"
478
    word, = groups
479
    if word == 'return':
480
        yield "'sys.exit([arg])'"
481
482
483
@register_suggestion_for(SyntaxError, re.FUTURE_FEATURE_NOT_DEF_RE)
484
def suggest_future_feature(value, frame, groups):
485
    """Get suggestions in case of FUTURE_FEATURE_NOT_DEF error."""
486
    del value  # unused param
487
    feature, = groups
488
    return suggest_imported_name_as_typo(feature, '__future__', frame)
489
490
491
@register_suggestion_for(SyntaxError, re.INVALID_COMP_RE)
492
def suggest_invalid_comp(value, frame, groups):
493
    """Get suggestions in case of INVALID_COMP error."""
494
    del value, frame, groups  # unused param
495
    yield quote('!=')
496
497
498
@register_suggestion_for(SyntaxError, re.INVALID_SYNTAX_RE)
499
def suggest_invalid_syntax(value, frame, groups):
500
    """Get suggestions in case of INVALID_SYNTAX error."""
501
    del frame, groups  # unused param
502
    offset = value.offset
503
    if offset is not None and offset > 2:
504
        two_last = value.text[offset - 2:offset]
505
        if two_last == '<>':
506
            yield quote('!=')
507
508
509
# Functions related to MemoryError
510
@register_suggestion_for(MemoryError, None)
511
def get_memory_error_sugg(value, frame, groups):
512
    """Get suggestions for MemoryError exception."""
513
    del groups  # unused param
514
    objs = get_objects_in_frame(frame)
515
    return itertools.chain.from_iterable(
516
        suggest_memory_friendly_equi(name, objs)
517
        for name in frame.f_code.co_names)
518
519
520
# Functions related to OverflowError
521
@register_suggestion_for(OverflowError, re.RESULT_TOO_MANY_ITEMS_RE)
522
def suggest_too_many_items(value, frame, groups):
523
    """Suggest for TOO_MANY_ITEMS error."""
524
    del value  # unused param
525
    func, = groups
526
    objs = get_objects_in_frame(frame)
527
    return suggest_memory_friendly_equi(func, objs)
528
529
530
def suggest_memory_friendly_equi(name, objs):
531
    """Suggest name of a memory friendly equivalent for a function."""
532
    suggs = {'range': ['xrange']}
533
    return [quote(s) for s in suggs.get(name, []) if s in objs]
534
535
536
# Functions related to IOError/OSError
537
@register_suggestion_for((IOError, OSError), None)
538
def get_io_os_error_sugg(value, frame, groups):
539
    """Get suggestions for IOError/OSError exception."""
540
    # https://www.python.org/dev/peps/pep-3151/
541
    del frame, groups  # unused param
542
    err, _ = value.args
543
    errnos = {
544
        errno.ENOENT: suggest_if_file_does_not_exist,
545
        errno.ENOTDIR: suggest_if_file_is_not_dir,
546
        errno.EISDIR: suggest_if_file_is_dir,
547
    }
548
    return errnos.get(err, lambda x: [])(value)
549
550
551
def suggest_if_file_does_not_exist(value):
552
    """Get suggestions when a file does not exist."""
553
    # TODO: Add fuzzy match
554
    filename = value.filename
555
    for func, name in (
556
            (os.path.expanduser, 'os.path.expanduser'),
557
            (os.path.expandvars, 'os.path.expandvars')):
558
        expanded = func(filename)
559
        if os.path.exists(expanded) and filename != expanded:
560
            yield quote(expanded) + " (calling " + name + ")"
561
562
563
def suggest_if_file_is_not_dir(value):
564
    """Get suggestions when a file should have been a dir and is not."""
565
    filename = value.filename
566
    yield quote(os.path.dirname(filename)) + " (calling os.path.dirname)"
567
568
569
def suggest_if_file_is_dir(value):
570
    """Get suggestions when a file is a dir and should not."""
571
    filename = value.filename
572
    listdir = sorted(os.listdir(filename))
573
    if listdir:
574
        trunc_l = listdir[:MAX_NB_FILES]
575
        truncated = listdir != trunc_l
576
        filelist = [quote(f) for f in trunc_l] + (["etc"] if truncated else [])
577
        yield "any of the {0} files in directory ({1})".format(
578
            len(listdir), ", ".join(filelist))
579
    else:
580
        yield "to add content to {0} first".format(filename)
581
582
583
def get_suggestions_for_exception(value, traceback):
584
    """Get suggestions for an exception."""
585
    frame = get_last_frame(traceback)
586
    return itertools.chain.from_iterable(
587
            func(value, frame)
588
            for error_type, functions in SUGGESTION_FUNCTIONS.items()
589
            if isinstance(value, error_type)
590
            for func in functions)
591
592
593
def add_string_to_exception(value, string):
594
    """Add string to the exception parameter."""
595
    # The point is to have the string visible when the exception is printed
596
    # or converted to string - may it be via `str()`, `repr()` or when the
597
    # exception is uncaught and displayed (which seems to use `str()`).
598
    # In an ideal world, one just needs to update `args` but apparently it
599
    # is not enough for SyntaxError, IOError, etc where other
600
    # attributes (`msg`, `strerror`, `reason`, etc) are to be updated too
601
    # (for `str()`, not for `repr()`).
602
    # Also, elements in args might not be strings or args might me empty
603
    # so we add to the first string and add the element otherwise.
604
    assert type(value.args) == tuple
605
    if string:
606
        lst_args = list(value.args)
607
        for i, arg in enumerate(lst_args):
608
            if isinstance(arg, str):
609
                lst_args[i] = arg + string
610
                break
611
        else:
612
            # if no string arg, add the string anyway
613
            lst_args.append(string)
614
        value.args = tuple(lst_args)
615
        for attr in ['msg', 'strerror', 'reason']:
616
            attrval = getattr(value, attr, None)
617
            if attrval is not None:
618
                setattr(value, attr, attrval + string)
619
620
621
def get_last_frame(traceback):
622
    """Extract last frame from a traceback."""
623
    # In some rare case, the give traceback might be None
624
    if traceback is None:
625
        return None
626
    while traceback.tb_next:
627
        traceback = traceback.tb_next
628
    return traceback.tb_frame
629
630
631
def add_suggestions_to_exception(type_, value, traceback):
632
    """Add suggestion to an exception.
633
634
    Arguments are such as provided by sys.exc_info().
635
    """
636
    assert isinstance(value, type_)
637
    add_string_to_exception(
638
        value,
639
        get_suggestion_string(
640
            get_suggestions_for_exception(
641
                value,
642
                traceback)))
643