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