1import builtins
2import os.path
3import re
4import token
5
6from thonny import assistance
7from thonny.assistance import (
8    ErrorHelper,
9    Suggestion,
10    add_error_helper,
11    name_similarity,
12    HelperNotSupportedError,
13)
14from thonny.misc_utils import running_on_windows
15
16
17class SyntaxErrorHelper(ErrorHelper):
18    def __init__(self, error_info):
19        import tokenize
20
21        super().__init__(error_info)
22
23        self.tokens = []
24        self.token_error = None
25
26        if self.error_info["message"] == "EOL while scanning string literal":
27            self.intro_text = (
28                "You haven't properly closed the string on line %s." % self.error_info["lineno"]
29                + "\n(If you want a multi-line string, then surround it with"
30                + " `'''` or `\"\"\"` at both ends.)"
31            )
32
33        elif self.error_info["message"] == "EOF while scanning triple-quoted string literal":
34            # lineno is not useful, as it is at the end of the file and user probably
35            # didn't want the string to end there
36            self.intro_text = "You haven't properly closed a triple-quoted string"
37
38        else:
39            if self.error_info["filename"] and os.path.isfile(self.error_info["filename"]):
40                with open(self.error_info["filename"], mode="rb") as fp:
41                    try:
42                        for t in tokenize.tokenize(fp.readline):
43                            self.tokens.append(t)
44                    except tokenize.TokenError as e:
45                        self.token_error = e
46                    except IndentationError as e:
47                        self.indentation_error = e
48
49                if not self.tokens or self.tokens[-1].type not in [
50                    token.ERRORTOKEN,
51                    token.ENDMARKER,
52                ]:
53                    self.tokens.append(tokenize.TokenInfo(token.ERRORTOKEN, "", None, None, ""))
54            else:
55                self.tokens = []
56
57            unbalanced = self._sug_unbalanced_parens()
58            if unbalanced:
59                self.intro_text = (
60                    "Unbalanced parentheses, brackets or braces:\n\n" + unbalanced.body
61                )
62                self.intro_confidence = 5
63            else:
64                self.intro_text = "Python doesn't know how to read your program."
65
66                if "^" in str(self.error_info):
67                    self.intro_text += (
68                        "\n\nSmall `^` in the original error message shows where it gave up,"
69                        + " but the actual mistake can be before this."
70                    )
71
72                self.suggestions = [self._sug_missing_or_misplaced_colon()]
73
74    def _sug_missing_or_misplaced_colon(self):
75        import tokenize
76
77        i = 0
78        title = "Did you forget the colon?"
79        relevance = 0
80        body = ""
81        while i < len(self.tokens) and self.tokens[i].type != token.ENDMARKER:
82            t = self.tokens[i]
83            if t.string in [
84                "if",
85                "elif",
86                "else",
87                "while",
88                "for",
89                "with",
90                "try",
91                "except",
92                "finally",
93                "class",
94                "def",
95            ]:
96                keyword_pos = i
97                while (
98                    self.tokens[i].type
99                    not in [
100                        token.NEWLINE,
101                        token.ENDMARKER,
102                        token.COLON,  # colon may be OP
103                        token.RBRACE,
104                    ]
105                    and self.tokens[i].string != ":"
106                ):
107
108                    old_i = i
109                    if self.tokens[i].string in "([{":
110                        i = self._skip_braced_part(i)
111                        assert i > old_i
112                        if i == len(self.tokens):
113                            return None
114                    else:
115                        i += 1
116
117                if self.tokens[i].string != ":":
118                    relevance = 9
119                    body = "`%s` header must end with a colon." % t.string
120                    break
121
122                # Colon was present, but maybe it should have been right
123                # after the keyword.
124                if (
125                    t.string in ["else", "try", "finally"]
126                    and self.tokens[keyword_pos + 1].string != ":"
127                ):
128                    title = "Incorrect use of `%s`" % t.string
129                    body = "Nothing is allowed between `%s` and colon." % t.string
130                    relevance = 9
131                    if (
132                        self.tokens[keyword_pos + 1].type not in (token.NEWLINE, tokenize.COMMENT)
133                        and t.string == "else"
134                    ):
135                        body = "If you want to specify a condition, then use `elif` or nested `if`."
136                    break
137
138            i += 1
139
140        return Suggestion("missing-or-misplaced-colon", title, body, relevance)
141
142    def _sug_unbalanced_parens(self):
143        problem = self._find_first_braces_problem()
144        if not problem:
145            return None
146
147        return Suggestion("missing-or-misplaced-colon", "Unbalanced brackets", problem[1], 8)
148
149    def _sug_wrong_increment_op(self):
150        pass
151
152    def _sug_wrong_decrement_op(self):
153        pass
154
155    def _sug_wrong_comparison_op(self):
156        pass
157
158    def _sug_switched_assignment_sides(self):
159        pass
160
161    def _skip_braced_part(self, token_index):
162        assert self.tokens[token_index].string in ["(", "[", "{"]
163        level = 1
164        token_index += 1
165        while token_index < len(self.tokens):
166
167            if self.tokens[token_index].string in ["(", "[", "{"]:
168                level += 1
169            elif self.tokens[token_index].string in [")", "]", "}"]:
170                level -= 1
171
172            token_index += 1
173
174            if level <= 0:
175                return token_index
176
177        assert token_index == len(self.tokens)
178        return token_index
179
180    def _find_first_braces_problem(self):
181        # closers = {'(':')', '{':'}', '[':']'}
182        openers = {")": "(", "}": "{", "]": "["}
183
184        brace_stack = []
185        for t in self.tokens:
186            if t.string in ["(", "[", "{"]:
187                brace_stack.append(t)
188            elif t.string in [")", "]", "}"]:
189                if not brace_stack:
190                    return (
191                        t,
192                        "Found '`%s`' at `line %d <%s>`_ without preceding matching '`%s`'"
193                        % (
194                            t.string,
195                            t.start[0],
196                            assistance.format_file_url(
197                                self.error_info["filename"], t.start[0], t.start[1]
198                            ),
199                            openers[t.string],
200                        ),
201                    )
202                elif brace_stack[-1].string != openers[t.string]:
203                    return (
204                        t,
205                        "Found '`%s`' at `line %d <%s>`__ when last unmatched opener was '`%s`' at `line %d <%s>`__"
206                        % (
207                            t.string,
208                            t.start[0],
209                            assistance.format_file_url(
210                                self.error_info["filename"], t.start[0], t.start[1]
211                            ),
212                            brace_stack[-1].string,
213                            brace_stack[-1].start[0],
214                            assistance.format_file_url(
215                                self.error_info["filename"],
216                                brace_stack[-1].start[0],
217                                brace_stack[-1].start[1],
218                            ),
219                        ),
220                    )
221                else:
222                    brace_stack.pop()
223
224        if brace_stack:
225            return (
226                brace_stack[-1],
227                "'`%s`' at `line %d <%s>`_ is not closed by the end of the program"
228                % (
229                    brace_stack[-1].string,
230                    brace_stack[-1].start[0],
231                    assistance.format_file_url(
232                        self.error_info["filename"],
233                        brace_stack[-1].start[0],
234                        brace_stack[-1].start[1],
235                    ),
236                ),
237            )
238
239        return None
240
241
242class NameErrorHelper(ErrorHelper):
243    def __init__(self, error_info):
244        super().__init__(error_info)
245
246        names = re.findall(r"\'.*\'", error_info["message"])
247        assert len(names) == 1
248        self.name = names[0].strip("'")
249
250        self.intro_text = "Python doesn't know what `%s` stands for." % self.name
251        self.suggestions = [
252            self._sug_bad_spelling(),
253            self._sug_missing_quotes(),
254            self._sug_missing_import(),
255            self._sug_local_from_global(),
256            self._sug_not_defined_yet(),
257        ]
258
259    def _sug_missing_quotes(self):
260        if self._is_attribute_value() or self._is_call_function() or self._is_subscript_value():
261            relevance = 0
262        else:
263            relevance = 5
264
265        return Suggestion(
266            "missing-quotes",
267            "Did you actually mean string (text)?",
268            'If you didn\'t mean a variable but literal text "%s", then surround it with quotes.'
269            % self.name,
270            relevance,
271        )
272
273    def _sug_bad_spelling(self):
274
275        # Yes, it would be more proper to consult builtins from the backend,
276        # but it's easier this way...
277        all_names = {name for name in dir(builtins) if not name.startswith("_")}
278        all_names |= {"pass", "break", "continue", "return", "yield"}
279
280        if self.last_frame.globals is not None:
281            all_names |= set(self.last_frame.globals.keys())
282        if self.last_frame.locals is not None:
283            all_names |= set(self.last_frame.locals.keys())
284
285        similar_names = {self.name}
286        if all_names:
287            relevance = 0
288            for name in all_names:
289                sim = name_similarity(name, self.name)
290                if sim > 4:
291                    similar_names.add(name)
292                relevance = max(sim, relevance)
293        else:
294            relevance = 3
295
296        if len(similar_names) > 1:
297            body = "I found similar names. Are all of them spelled correctly?\n\n"
298            for name in sorted(similar_names, key=lambda x: x.lower()):
299                # TODO: add location info
300                body += "* `%s`\n\n" % name
301        else:
302            body = (
303                "Compare the name with corresponding definition / assignment / documentation."
304                + " Don't forget that case of the letters matters!"
305            )
306
307        return Suggestion("bad-spelling-name", "Did you misspell it (somewhere)?", body, relevance)
308
309    def _sug_missing_import(self):
310        likely_importable_functions = {
311            "math": {"ceil", "floor", "sqrt", "sin", "cos", "degrees"},
312            "random": {"randint"},
313            "turtle": {
314                "left",
315                "right",
316                "forward",
317                "fd",
318                "goto",
319                "setpos",
320                "Turtle",
321                "penup",
322                "up",
323                "pendown",
324                "down",
325                "color",
326                "pencolor",
327                "fillcolor",
328                "begin_fill",
329                "end_fill",
330                "pensize",
331                "width",
332            },
333            "re": {"search", "match", "findall"},
334            "datetime": {"date", "time", "datetime", "today"},
335            "statistics": {
336                "mean",
337                "median",
338                "median_low",
339                "median_high",
340                "mode",
341                "pstdev",
342                "pvariance",
343                "stdev",
344                "variance",
345            },
346            "os": {"listdir"},
347            "time": {"time", "sleep"},
348        }
349
350        body = None
351
352        if self._is_call_function():
353            relevance = 5
354            for mod in likely_importable_functions:
355                if self.name in likely_importable_functions[mod]:
356                    relevance += 3
357                    body = (
358                        "If you meant `%s` from module `%s`, then add\n\n`from %s import %s`\n\nto the beginning of your script."
359                        % (self.name, mod, mod, self.name)
360                    )
361                    break
362
363        elif self._is_attribute_value():
364            relevance = 5
365            body = (
366                "If you meant module `%s`, then add `import %s` to the beginning of your script"
367                % (self.name, self.name)
368            )
369
370            if self.name in likely_importable_functions:
371                relevance += 3
372
373        elif self._is_subscript_value() and self.name != "argv":
374            relevance = 0
375        elif self.name == "pi":
376            body = "If you meant the constant π, then add `from math import pi` to the beginning of your script."
377            relevance = 8
378        elif self.name == "argv":
379            body = "If you meant the list with program arguments, then add `from sys import argv` to the beginning of your script."
380            relevance = 8
381        else:
382            relevance = 3
383
384        if body is None:
385            body = "Some functions/variables need to be imported before they can be used."
386
387        return Suggestion("missing-import", "Did you forget to import it?", body, relevance)
388
389    def _sug_local_from_global(self):
390        import ast
391
392        relevance = 0
393        body = None
394
395        if self.last_frame.code_name == "<module>" and self.last_frame_module_ast is not None:
396            function_names = set()
397            for node in ast.walk(self.last_frame_module_ast):
398                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
399                    if self.name in map(lambda x: x.arg, node.args.args):
400                        function_names.add(node.name)
401                    # TODO: varargs, kw, ...
402                    declared_global = False
403                    for localnode in ast.walk(node):
404                        # print(node.name, localnode)
405                        if (
406                            isinstance(localnode, ast.Name)
407                            and localnode.id == self.name
408                            and isinstance(localnode.ctx, ast.Store)
409                        ):
410                            function_names.add(node.name)
411                        elif isinstance(localnode, ast.Global) and self.name in localnode.names:
412                            declared_global = True
413
414                    if node.name in function_names and declared_global:
415                        function_names.remove(node.name)
416
417            if function_names:
418                relevance = 9
419                body = (
420                    (
421                        "Name `%s` defined in `%s` is not accessible in the global/module level."
422                        % (self.name, " and ".join(function_names))
423                    )
424                    + "\n\nIf you need that data at the global level, then consider changing the function so that it `return`-s the value."
425                )
426
427        return Suggestion(
428            "local-from-global",
429            "Are you trying to access a local variable outside of the function?",
430            body,
431            relevance,
432        )
433
434    def _sug_not_defined_yet(self):
435        return Suggestion(
436            "not-defined-yet",
437            "Has Python executed the definition?",
438            (
439                "Don't forget that name becomes defined when corresponding definition ('=', 'def' or 'import') gets executed."
440                + " If the definition comes later in code or is inside an if-statement, Python may not have executed it (yet)."
441                + "\n\n"
442                + "Make sure Python arrives to the definition before it arrives to this line. When in doubt, "
443                + "`use the debugger <debuggers.rst>`_."
444            ),
445            2,
446        )
447
448    def _sug_maybe_attribute(self):
449        "TODO:"
450
451    def _sug_synonym(self):
452        "TODO:"
453
454    def _is_call_function(self):
455        return self.name + "(" in (
456            self.error_info["line"].replace(" ", "").replace("\n", "").replace("\r", "")
457        )
458
459    def _is_subscript_value(self):
460        return self.name + "[" in (
461            self.error_info["line"].replace(" ", "").replace("\n", "").replace("\r", "")
462        )
463
464    def _is_attribute_value(self):
465        return self.name + "." in (
466            self.error_info["line"].replace(" ", "").replace("\n", "").replace("\r", "")
467        )
468
469
470class AttributeErrorHelper(ErrorHelper):
471    def __init__(self, error_info):
472        super().__init__(error_info)
473
474        names = re.findall(r"\'.*?\'", error_info["message"])
475        if len(names) != 2:
476            # Can happen eg. in PGZero
477            # https://github.com/thonny/thonny/issues/1535
478            raise HelperNotSupportedError()
479
480        self.type_name = names[0].strip("'")
481        self.att_name = names[1].strip("'")
482
483        self.intro_text = (
484            "Your program tries to "
485            + ("call method " if self._is_call_function() else "access attribute ")
486            + "`%s` of " % self.att_name
487            + _get_phrase_for_object(self.type_name)
488            + ", but this type doesn't have such "
489            + ("method." if self._is_call_function() else "attribute.")
490        )
491
492        self.suggestions = [
493            self._sug_wrong_attribute_instead_of_len(),
494            self._sug_bad_spelling(),
495            self._sug_bad_type(),
496        ]
497
498    def _sug_wrong_attribute_instead_of_len(self):
499
500        if self.type_name == "str":
501            goal = "length"
502        elif self.type_name == "bytes":
503            goal = "number of bytes"
504        elif self.type_name == "list":
505            goal = "number of elements"
506        elif self.type_name == "tuple":
507            goal = "number of elements"
508        elif self.type_name == "set":
509            goal = "number of elements"
510        elif self.type_name == "dict":
511            goal = "number of entries"
512        else:
513            return None
514
515        return Suggestion(
516            "wrong-attribute-instead-of-len",
517            "Did you mean to ask the %s?" % goal,
518            "This can be done with function `len`, eg:\n\n`len(%s)`"
519            % _get_sample_for_type(self.type_name),
520            (9 if self.att_name.lower() in ("len", "length", "size") else 0),
521        )
522
523    def _sug_bad_spelling(self):
524        # TODO: compare with attributes of known types
525        return Suggestion(
526            "bad-spelling-attribute",
527            "Did you misspell the name?",
528            "Don't forget that case of the letters matters too!",
529            3,
530        )
531
532    def _sug_bad_type(self):
533        if self._is_call_function():
534            action = "call this function on"
535        else:
536            action = "ask this attribute from"
537
538        return Suggestion(
539            "wrong-type-attribute",
540            "Did you expect another type?",
541            "If you didn't mean %s %s, " % (action, _get_phrase_for_object(self.type_name))
542            + "then step through your program to see "
543            + "why this type appears here.",
544            3,
545        )
546
547    def _is_call_function(self):
548        return "." + self.att_name + "(" in (
549            self.error_info["line"].replace(" ", "").replace("\n", "").replace("\r", "")
550        )
551
552
553class OSErrorHelper(ErrorHelper):
554    def __init__(self, error_info):
555        super().__init__(error_info)
556
557        if "Address already in use" in self.error_info["message"]:
558            self.intro_text = "Your programs tries to listen on a port which is already taken."
559            self.suggestions = [
560                Suggestion(
561                    "kill-by-port-type-error",
562                    "Want to close the other process?",
563                    self.get_kill_process_instructions(),
564                    5,
565                ),
566                Suggestion(
567                    "use-another-type-error",
568                    "Can you use another port?",
569                    "If you don't want to mess with the other process, then check whether"
570                    + " you can configure your program to use another port.",
571                    3,
572                ),
573            ]
574
575        else:
576            self.intro_text = "No specific information is available for this error."
577
578    def get_kill_process_instructions(self):
579        s = (
580            "Let's say you need port 5000. If you don't know which process is using it,"
581            + " then enter following system command into Thonny's Shell:\n\n"
582        )
583
584        if running_on_windows():
585            s += (
586                "``!netstat -ano | findstr :5000``\n\n"
587                + "You should see the process ID in the last column.\n\n"
588            )
589        else:
590            s += (
591                "``!lsof -i:5000``\n\n" + "You should see the process ID under the heading PID.\n\n"
592            )
593
594        s += (
595            "Let's pretend the ID is 12345."
596            " You can try hard-killing the process with following command:\n\n"
597        )
598
599        if running_on_windows():
600            s += "``!tskill 12345``\n"
601        else:
602            s += (
603                "``!kill -9 12345``\n\n"
604                + "Both steps can be combined into single command:\n\n"
605                + "``!kill -9 $(lsof -t -i:5000)``\n\n"
606            )
607
608        return s
609
610
611class TypeErrorHelper(ErrorHelper):
612    def __init__(self, error_info):
613        super().__init__(error_info)
614
615        self.intro_text = (
616            "Python was asked to do an operation with an object which " + "doesn't support it."
617        )
618
619        self.suggestions = [
620            Suggestion(
621                "step-to-find-type-error",
622                "Did you expect another type?",
623                "Step through your program to see why this type appears here.",
624                3,
625            ),
626            Suggestion(
627                "look-documentation-type-error",
628                "Maybe you forgot some details about this operation?",
629                "Look up the documentation or perform a web search with the error message.",
630                2,
631            ),
632        ]
633
634        # overwrite / add for special cases
635        # something + str or str + something
636        for r, string_first in [
637            (r"unsupported operand type\(s\) for \+: '(.+?)' and 'str'", False),
638            (r"^Can't convert '(.+?)' object to str implicitly$", True),  # Python 3.5
639            (r"^must be str, not (.+)$", True),  # Python 3.6
640            (r'^can only concatenate str (not "(.+?)") to str$', True),  # Python 3.7
641        ]:
642            m = re.match(r, error_info["message"], re.I)  # @UndefinedVariable
643            if m is not None:
644                self._bad_string_concatenation(m.group(1), string_first)
645                return
646
647        # TODO: other operations, when one side is string
648
649    def _bad_string_concatenation(self, other_type_name, string_first):
650        self.intro_text = "Your program is trying to put together " + (
651            "a string and %s." if string_first else "%s and a string."
652        ) % _get_phrase_for_object(other_type_name)
653
654        self.suggestions.append(
655            Suggestion(
656                "convert-other-operand-to-string",
657                "Did you mean to treat both sides as text and produce a string?",
658                "In this case you should apply function `str` to the %s "
659                % _get_phrase_for_object(other_type_name, False)
660                + "in order to convert it to string first, eg:\n\n"
661                + ("`'abc' + str(%s)`" if string_first else "`str(%s) + 'abc'`")
662                % _get_sample_for_type(other_type_name),
663                8,
664            )
665        )
666
667        if other_type_name in ("float", "int"):
668            self.suggestions.append(
669                Suggestion(
670                    "convert-other-operand-to-number",
671                    "Did you mean to treat both sides as numbers and produce a sum?",
672                    "In this case you should first convert the string to a number "
673                    + "using either function `float` or `int`, eg:\n\n"
674                    + ("`float('3.14') + 22`" if string_first else "`22 + float('3.14')`"),
675                    7,
676                )
677            )
678
679
680def _get_phrase_for_object(type_name, with_article=True):
681    friendly_names = {
682        "str": "a string",
683        "int": "an integer",
684        "float": "a float",
685        "list": "a list",
686        "tuple": "a tuple",
687        "dict": "a dictionary",
688        "set": "a set",
689        "bool": "a boolean",
690    }
691    result = friendly_names.get(type_name, "an object of type '%s'" % type_name)
692
693    if with_article:
694        return result
695    else:
696        _, rest = result.split(" ", maxsplit=1)
697        return rest
698
699
700def _get_sample_for_type(type_name):
701    if type_name == "int":
702        return "42"
703    elif type_name == "float":
704        return "3.14"
705    elif type_name == "str":
706        return "'abc'"
707    elif type_name == "bytes":
708        return "b'abc'"
709    elif type_name == "list":
710        return "[1, 2, 3]"
711    elif type_name == "tuple":
712        return "(1, 2, 3)"
713    elif type_name == "set":
714        return "{1, 2, 3}"
715    elif type_name == "dict":
716        return "{1 : 'one', 2 : 'two'}"
717    else:
718        return "..."
719
720
721def load_plugin():
722    for name in globals():
723        if name.endswith("ErrorHelper") and not name.startswith("_"):
724            type_name = name[: -len("Helper")]
725            add_error_helper(type_name, globals()[name])
726