diff --git a/Lib/codeop.py b/Lib/codeop.py index adf000ba29f..8cac00442d9 100644 --- a/Lib/codeop.py +++ b/Lib/codeop.py @@ -47,7 +47,7 @@ PyCF_ONLY_AST = 0x400 PyCF_ALLOW_INCOMPLETE_INPUT = 0x4000 -def _maybe_compile(compiler, source, filename, symbol): +def _maybe_compile(compiler, source, filename, symbol, flags): # Check for source consisting of only blank lines and comments. for line in source.split("\n"): line = line.strip() @@ -61,10 +61,10 @@ def _maybe_compile(compiler, source, filename, symbol): with warnings.catch_warnings(): warnings.simplefilter("ignore", (SyntaxWarning, DeprecationWarning)) try: - compiler(source, filename, symbol) + compiler(source, filename, symbol, flags=flags) except SyntaxError: # Let other compile() errors propagate. try: - compiler(source + "\n", filename, symbol) + compiler(source + "\n", filename, symbol, flags=flags) return None except _IncompleteInputError as e: return None @@ -74,14 +74,13 @@ def _maybe_compile(compiler, source, filename, symbol): return compiler(source, filename, symbol, incomplete_input=False) -def _compile(source, filename, symbol, incomplete_input=True): - flags = 0 +def _compile(source, filename, symbol, incomplete_input=True, *, flags=0): if incomplete_input: flags |= PyCF_ALLOW_INCOMPLETE_INPUT flags |= PyCF_DONT_IMPLY_DEDENT return compile(source, filename, symbol, flags) -def compile_command(source, filename="", symbol="single"): +def compile_command(source, filename="", symbol="single", flags=0): r"""Compile a command and determine whether it is incomplete. Arguments: @@ -100,7 +99,7 @@ def compile_command(source, filename="", symbol="single"): syntax error (OverflowError and ValueError can be produced by malformed literals). """ - return _maybe_compile(_compile, source, filename, symbol) + return _maybe_compile(_compile, source, filename, symbol, flags) class Compile: """Instances of this class behave much like the built-in compile @@ -152,4 +151,4 @@ def __call__(self, source, filename="", symbol="single"): syntax error (OverflowError and ValueError can be produced by malformed literals). """ - return _maybe_compile(self.compiler, source, filename, symbol) + return _maybe_compile(self.compiler, source, filename, symbol, flags=self.compiler.flags) diff --git a/Lib/test/test_asyncio/test_tasks.py b/Lib/test/test_asyncio/test_tasks.py index b0ca67d6716..8a291f1cb7e 100644 --- a/Lib/test/test_asyncio/test_tasks.py +++ b/Lib/test/test_asyncio/test_tasks.py @@ -2947,6 +2947,7 @@ def test_log_destroyed_pending_task(self): return super().test_log_destroyed_pending_task() + @unittest.skipUnless(hasattr(futures, '_CFuture') and hasattr(tasks, '_CTask'), 'requires the C _asyncio module') @@ -3007,6 +3008,7 @@ def test_log_destroyed_pending_task(self): return super().test_log_destroyed_pending_task() + @unittest.skipUnless(hasattr(futures, '_CFuture'), 'requires the C _asyncio module') class PyTask_CFuture_Tests(BaseTaskTests, test_utils.TestCase): diff --git a/Lib/test/test_cmd_line_script.py b/Lib/test/test_cmd_line_script.py index 3c417d07af6..f977b97bcd3 100644 --- a/Lib/test/test_cmd_line_script.py +++ b/Lib/test/test_cmd_line_script.py @@ -548,8 +548,6 @@ def test_dash_m_main_traceback(self): self.assertIn(b'Exception in __main__ module', err) self.assertIn(b'Traceback', err) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_pep_409_verbiage(self): # Make sure PEP 409 syntax properly suppresses # the context of an exception diff --git a/Lib/test/test_code_module.py b/Lib/test/test_code_module.py index 0ed17b390c4..39d85d46274 100644 --- a/Lib/test/test_code_module.py +++ b/Lib/test/test_code_module.py @@ -91,7 +91,6 @@ def test_console_stderr(self): else: raise AssertionError("no console stdout") - @unittest.expectedFailure # TODO: RUSTPYTHON; + 'SyntaxError: invalid syntax'] def test_syntax_error(self): self.infunc.side_effect = ["def f():", " x = ?", @@ -166,7 +165,6 @@ def test_sysexcepthook(self): ' File "", line 2, in f\n', 'ValueError: BOOM!\n']) - @unittest.expectedFailure # TODO: RUSTPYTHON; + 'SyntaxError: invalid syntax\n'] def test_sysexcepthook_syntax_error(self): self.infunc.side_effect = ["def f():", " x = ?", @@ -285,7 +283,6 @@ def test_exit_msg(self): self.assertEqual(err_msg, ['write', (expected,), {}]) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: '\nAttributeError\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File "", line 1, in \nValueError\n' not found in 'Python on \nType "help", "copyright", "credits" or "license" for more information.\n(InteractiveConsole)\nAttributeError\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File "", line 1, in \nValueError: \n\nnow exiting InteractiveConsole...\n' def test_cause_tb(self): self.infunc.side_effect = ["raise ValueError('') from AttributeError", EOFError('Finished')] diff --git a/Lib/test/test_codeop.py b/Lib/test/test_codeop.py index c62e3748e6a..bbc46021406 100644 --- a/Lib/test/test_codeop.py +++ b/Lib/test/test_codeop.py @@ -30,8 +30,7 @@ def assertInvalid(self, str, symbol='single', is_syntax=1): except OverflowError: self.assertTrue(not is_syntax) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: at 0xc99532080 file "", line 1> != at 0xc99532f80 file "", line 1> def test_valid(self): av = self.assertValid @@ -94,8 +93,7 @@ def test_valid(self): av("def f():\n pass\n#foo\n") av("@a.b.c\ndef f():\n pass\n") - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: at 0xc99532080 file "", line 1> != None def test_incomplete(self): ai = self.assertIncomplete @@ -282,13 +280,12 @@ def test_filename(self): self.assertNotEqual(compile_command("a = 1\n", "abc").co_filename, compile("a = 1\n", "def", 'single').co_filename) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 0 != 2 def test_warning(self): # Test that the warning is only returned once. with warnings_helper.check_warnings( ('"is" with \'str\' literal', SyntaxWarning), - ("invalid escape sequence", SyntaxWarning), + ('"\\\\e" is an invalid escape sequence', SyntaxWarning), ) as w: compile_command(r"'\e' is 0") self.assertEqual(len(w.warnings), 2) @@ -309,8 +306,7 @@ def test_incomplete_warning(self): self.assertIncomplete("'\\e' + (") self.assertEqual(w, []) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 0 != 1 def test_invalid_warning(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') @@ -325,8 +321,6 @@ def assertSyntaxErrorMatches(self, code, message): with self.assertRaisesRegex(SyntaxError, message): compile_command(code, symbol='exec') - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_syntax_errors(self): self.assertSyntaxErrorMatches( dedent("""\ diff --git a/Lib/test/test_contextlib.py b/Lib/test/test_contextlib.py index 420cc2510a8..fabe0c971c1 100644 --- a/Lib/test/test_contextlib.py +++ b/Lib/test/test_contextlib.py @@ -924,8 +924,6 @@ def __exit__(self, *exc_details): self.assertIsInstance(inner_exc, ValueError) self.assertIsInstance(inner_exc.__context__, ZeroDivisionError) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_exit_exception_chaining(self): # Ensure exception chaining matches the reference behaviour def raise_exc(exc): @@ -957,8 +955,6 @@ def suppress_exc(*exc_details): self.assertIsInstance(inner_exc, ValueError) self.assertIsInstance(inner_exc.__context__, ZeroDivisionError) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_exit_exception_explicit_none_context(self): # Ensure ExitStack chaining matches actual nested `with` statements # regarding explicit __context__ = None. @@ -1053,8 +1049,6 @@ def gets_the_context_right(exc): self.assertIsNone( exc.__context__.__context__.__context__.__context__) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_exit_exception_with_existing_context(self): # Addresses a lack of test coverage discovered after checking in a # fix for issue 20317 that still contained debugging code. diff --git a/Lib/test/test_contextlib_async.py b/Lib/test/test_contextlib_async.py index a224bc68329..c3c303ad9a7 100644 --- a/Lib/test/test_contextlib_async.py +++ b/Lib/test/test_contextlib_async.py @@ -650,7 +650,6 @@ async def __aenter__(self): await stack.enter_async_context(LacksExit()) self.assertFalse(stack._exit_callbacks) - @unittest.expectedFailure # TODO: RUSTPYTHON async def test_async_exit_exception_chaining(self): # Ensure exception chaining matches the reference behaviour async def raise_exc(exc): @@ -682,7 +681,6 @@ async def suppress_exc(*exc_details): self.assertIsInstance(inner_exc, ValueError) self.assertIsInstance(inner_exc.__context__, ZeroDivisionError) - @unittest.expectedFailure # TODO: RUSTPYTHON async def test_async_exit_exception_explicit_none_context(self): # Ensure AsyncExitStack chaining matches actual nested `with` statements # regarding explicit __context__ = None. diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 10c2eb4c3c4..e7b0e8850a1 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -1745,7 +1745,6 @@ def __del__(self): f"deallocator {obj_repr}") self.assertIsNotNone(cm.unraisable.exc_traceback) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_unhandled(self): # Check for sensible reporting of unhandled exceptions for exc_type in (ValueError, BrokenStrException): @@ -2283,7 +2282,6 @@ def test_multiline_not_highlighted(self): class SyntaxErrorTests(unittest.TestCase): maxDiff = None - @unittest.expectedFailure # TODO: RUSTPYTHON @force_not_colorized def test_range_of_offsets(self): cases = [ diff --git a/Lib/test/test_future_stmt/test_future.py b/Lib/test/test_future_stmt/test_future.py index e57c7227cec..f137b2650b8 100644 --- a/Lib/test/test_future_stmt/test_future.py +++ b/Lib/test/test_future_stmt/test_future.py @@ -188,6 +188,8 @@ def test_unicode_literals_exec(self): exec("from __future__ import unicode_literals; x = ''", {}, scope) self.assertIsInstance(scope["x"], str) + # TODO: RUSTPYTHON; barry_as_FLUFL (<> operator) not supported + @unittest.expectedFailure def test_syntactical_future_repl(self): p = spawn_python('-i') p.stdin.write(b"from __future__ import barry_as_FLUFL\n") diff --git a/Lib/test/test_grammar.py b/Lib/test/test_grammar.py index aae5c2b1ce3..77bd5a163ce 100644 --- a/Lib/test/test_grammar.py +++ b/Lib/test/test_grammar.py @@ -221,7 +221,6 @@ def test_ellipsis(self): self.assertTrue(x is Ellipsis) self.assertRaises(SyntaxError, eval, ".. .") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_eof_error(self): samples = ("def foo(", "\ndef foo(", "def foo(\n") for s in samples: diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index c2d64813ad7..cfb287279d8 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -568,7 +568,6 @@ def test_stack(self): self.assertIn('inspect.stack()', record.code_context[0]) self.assertEqual(record.index, 0) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_trace(self): self.assertEqual(len(git.tr), 3) frame1, frame2, frame3, = git.tr diff --git a/Lib/test/test_named_expressions.py b/Lib/test/test_named_expressions.py index 68e4a7704f4..fea86fe4308 100644 --- a/Lib/test/test_named_expressions.py +++ b/Lib/test/test_named_expressions.py @@ -44,32 +44,24 @@ def test_named_expression_invalid_06(self): with self.assertRaisesRegex(SyntaxError, "cannot use assignment expressions with tuple"): exec(code, {}, {}) - # TODO: RUSTPYTHON: wrong error message - @unittest.expectedFailure def test_named_expression_invalid_07(self): code = """def spam(a = b := 42): pass""" with self.assertRaisesRegex(SyntaxError, "invalid syntax"): exec(code, {}, {}) - # TODO: RUSTPYTHON: wrong error message - @unittest.expectedFailure def test_named_expression_invalid_08(self): code = """def spam(a: b := 42 = 5): pass""" with self.assertRaisesRegex(SyntaxError, "invalid syntax"): exec(code, {}, {}) - # TODO: RUSTPYTHON: wrong error message - @unittest.expectedFailure def test_named_expression_invalid_09(self): code = """spam(a=b := 'c')""" with self.assertRaisesRegex(SyntaxError, "invalid syntax"): exec(code, {}, {}) - # TODO: RUSTPYTHON: wrong error message - @unittest.expectedFailure def test_named_expression_invalid_10(self): code = """spam(x = y := f(x))""" @@ -103,8 +95,6 @@ def test_named_expression_invalid_13(self): "positional argument follows keyword argument"): exec(code, {}, {}) - # TODO: RUSTPYTHON: wrong error message - @unittest.expectedFailure def test_named_expression_invalid_14(self): code = """(x := lambda: y := 1)""" @@ -120,8 +110,6 @@ def test_named_expression_invalid_15(self): "cannot use assignment expressions with lambda"): exec(code, {}, {}) - # TODO: RUSTPYTHON: wrong error message - @unittest.expectedFailure def test_named_expression_invalid_16(self): code = "[i + 1 for i in i := [1,2]]" diff --git a/Lib/test/test_syntax.py b/Lib/test/test_syntax.py index 5085798b81e..08e69caae1f 100644 --- a/Lib/test/test_syntax.py +++ b/Lib/test/test_syntax.py @@ -2293,7 +2293,6 @@ def test_expression_with_assignment(self): offset=7 ) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_curly_brace_after_primary_raises_immediately(self): self._check_error("f{}", "invalid syntax", mode="single") diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index 00c2a9b937b..b72d09865d8 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -176,7 +176,6 @@ def test_original_excepthook(self): self.assertRaises(TypeError, sys.__excepthook__) - @unittest.expectedFailure # TODO: RUSTPYTHON; SyntaxError formatting in arbitrary tracebacks @force_not_colorized def test_excepthook_bytes_filename(self): # bpo-37467: sys.excepthook() must not crash if a filename diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 04b77829c1c..8b7938e3283 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -88,7 +88,6 @@ def syntax_error_bad_indentation2(self): def tokenizer_error_with_caret_range(self): compile("blech ( ", "?", "exec") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 11 != 14 def test_caret(self): err = self.get_exception_format(self.syntax_error_with_caret, SyntaxError) @@ -201,7 +200,6 @@ def f(): finally: unlink(TESTFN) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 3 != 4 def test_bad_indentation(self): err = self.get_exception_format(self.syntax_error_bad_indentation, IndentationError) @@ -1797,7 +1795,6 @@ class TestKeywordTypoSuggestions(unittest.TestCase): ("for x im n:\n pass", "in"), ] - @unittest.expectedFailure # TODO: RUSTPYTHON def test_keyword_suggestions_from_file(self): with tempfile.TemporaryDirectory() as script_dir: for i, (code, expected_kw) in enumerate(self.TYPO_CASES): @@ -1808,7 +1805,6 @@ def test_keyword_suggestions_from_file(self): stderr_text = stderr.decode('utf-8') self.assertIn(f"Did you mean '{expected_kw}'", stderr_text) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_keyword_suggestions_from_command_string(self): for code, expected_kw in self.TYPO_CASES: with self.subTest(typo=expected_kw): @@ -3352,7 +3348,6 @@ class MiscTracebackCases(unittest.TestCase): # Check non-printing functions in traceback module # - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 1 != 0 def test_clear(self): def outer(): middle() @@ -3574,7 +3569,6 @@ def format_frame_summary(self, frame_summary, colorize=False): f' File "{__file__}", line {lno}, in f\n 1/0\n' ) - @unittest.expectedFailure # TODO: RUSTPYTHON; Actual: _should_show_carets(13, 14, ['# this line will be used during rendering'], None) def test_summary_should_show_carets(self): # See: https://github.com/python/cpython/issues/122353 @@ -3731,7 +3725,6 @@ def test_context(self): self.assertEqual(type(exc_obj).__name__, exc.exc_type_str) self.assertEqual(str(exc_obj), str(exc)) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 11 not greater than 1000 def test_long_context_chain(self): def f(): try: @@ -4059,7 +4052,6 @@ def test_exception_group_format_exception_onlyi_recursive(self): self.assertEqual(formatted, expected) - @unittest.expectedFailure # TODO: RUSTPYTHON; Diff is 2265 characters long. Set self.maxDiff to None to see it. def test_exception_group_format(self): teg = traceback.TracebackException.from_exception(self.eg) @@ -4841,22 +4833,6 @@ class PurePythonSuggestionFormattingTests( traceback printing in traceback.py. """ - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "'bluch'" not found in "ImportError: cannot import name 'blach'" - def test_import_from_suggestions_underscored(self): - return super().test_import_from_suggestions_underscored() - - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "'bluch'" not found in "ImportError: cannot import name 'blech'" - def test_import_from_suggestions_non_string(self): - return super().test_import_from_suggestions_non_string() - - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "'bluchin'?" not found in "ImportError: cannot import name 'bluch'" - def test_import_from_suggestions(self): - return super().test_import_from_suggestions() - - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 'Did you mean' not found in "AttributeError: 'A' object has no attribute 'blich'" - def test_attribute_error_inside_nested_getattr(self): - return super().test_attribute_error_inside_nested_getattr() - @cpython_only class CPythonSuggestionFormattingTests( @@ -4969,7 +4945,6 @@ class MyList(list): class TestColorizedTraceback(unittest.TestCase): maxDiff = None - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "y = \x1b[31mx['a']['b']\x1b[0m\x1b[1;31m['c']\x1b[0m" not found in 'Traceback (most recent call last):\n File \x1b[35m"/Users/al03219714/Projects/RustPython/crates/pylib/Lib/test/test_traceback.py"\x1b[0m, line \x1b[35m4764\x1b[0m, in \x1b[35mtest_colorized_traceback\x1b[0m\n \x1b[31mbar\x1b[0m\x1b[1;31m()\x1b[0m\n \x1b[31m~~~\x1b[0m\x1b[1;31m^^\x1b[0m\n bar = .bar at 0xb57b09180>\n baz1 = .baz1 at 0xb57b09e00>\n baz2 = .baz2 at 0xb57b09cc0>\n e = TypeError("\'NoneType\' object is not subscriptable")\n foo = .foo at 0xb57b08140>\n self = \n File \x1b[35m"/Users/al03219714/Projects/RustPython/crates/pylib/Lib/test/test_traceback.py"\x1b[0m, line \x1b[35m4760\x1b[0m, in \x1b[35mbar\x1b[0m\n return baz1(1,\n 2,3\n ,4)\n baz1 = .baz1 at 0xb57b09e00>\n File \x1b[35m"/Users/al03219714/Projects/RustPython/crates/pylib/Lib/test/test_traceback.py"\x1b[0m, line \x1b[35m4757\x1b[0m, in \x1b[35mbaz1\x1b[0m\n return baz2(1,2,3,4)\n args = (1, 2, 3, 4)\n baz2 = .baz2 at 0xb57b09cc0>\n File \x1b[35m"/Users/al03219714/Projects/RustPython/crates/pylib/Lib/test/test_traceback.py"\x1b[0m, line \x1b[35m4754\x1b[0m, in \x1b[35mbaz2\x1b[0m\n return \x1b[31m(lambda *args: foo(*args))\x1b[0m\x1b[1;31m(1,2,3,4)\x1b[0m\n \x1b[31m~~~~~~~~~~~~~~~~~~~~~~~~~~\x1b[0m\x1b[1;31m^^^^^^^^^\x1b[0m\n args = (1, 2, 3, 4)\n foo = .foo at 0xb57b08140>\n File \x1b[35m"/Users/al03219714/Projects/RustPython/crates/pylib/Lib/test/test_traceback.py"\x1b[0m, line \x1b[35m4754\x1b[0m, in \x1b[35m\x1b[0m\n return (lambda *args: \x1b[31mfoo\x1b[0m\x1b[1;31m(*args)\x1b[0m)(1,2,3,4)\n \x1b[31m~~~\x1b[0m\x1b[1;31m^^^^^^^\x1b[0m\n args = (1, 2, 3, 4)\n foo = .foo at 0xb57b08140>\n File \x1b[35m"/Users/al03219714/Projects/RustPython/crates/pylib/Lib/test/test_traceback.py"\x1b[0m, line \x1b[35m4751\x1b[0m, in \x1b[35mfoo\x1b[0m\n y = x[\'a\'][\'b\'][\x1b[1;31m\'c\'\x1b[0m]\n \x1b[1;31m^^^\x1b[0m\n args = (1, 2, 3, 4)\n x = {\'a\': {\'b\': None}}\n\x1b[1;35mTypeError\x1b[0m: \x1b[35m\'NoneType\' object is not subscriptable\x1b[0m\n' def test_colorized_traceback(self): def foo(*args): x = {'a':{'b': None}} @@ -5002,7 +4977,6 @@ def bar(): self.assertIn("return baz1(1,\n 2,3\n ,4)", lines) self.assertIn(red + "bar" + reset + boldr + "()" + reset, lines) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ' File \x1b[35m""\x1b[0m, line \x1b[35m1\x1b[0m\n a \x1b[1;31m$\x1b[0m b\n \x1b[1;31m^\x1b[0m\n\x1b[1;35mSyntaxError\x1b[0m: \x1b[35minvalid syntax\x1b[0m\n' not found in 'Traceback (most recent call last):\n File \x1b[35m"/Users/al03219714/Projects/RustPython/crates/pylib/Lib/test/test_traceback.py"\x1b[0m, line \x1b[35m4782\x1b[0m, in \x1b[35mtest_colorized_syntax_error\x1b[0m\n \x1b[31mcompile\x1b[0m\x1b[1;31m("a $ b", "", "exec")\x1b[0m\n \x1b[31m~~~~~~~\x1b[0m\x1b[1;31m^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\x1b[0m\n e = SyntaxError(\'got unexpected token $\')\n self = \n File \x1b[35m""\x1b[0m, line \x1b[35m1\x1b[0m\n a \x1b[1;31m$\x1b[0m b\n \x1b[1;31m^\x1b[0m\n\x1b[1;35mSyntaxError\x1b[0m: \x1b[35mgot unexpected token $\x1b[0m\n' def test_colorized_syntax_error(self): try: compile("a $ b", "", "exec") @@ -5053,7 +5027,6 @@ def expected(t, m, fn, l, f, E, e, z): ] self.assertEqual(actual, expected(**colors)) - @unittest.expectedFailure # TODO: RUSTPYTHON; Diff is 1795 characters long. Set self.maxDiff to None to see it. def test_colorized_traceback_from_exception_group(self): def foo(): exceptions = [] diff --git a/Lib/test/test_unpack_ex.py b/Lib/test/test_unpack_ex.py index 2cadb9c70ba..91ff1121741 100644 --- a/Lib/test/test_unpack_ex.py +++ b/Lib/test/test_unpack_ex.py @@ -183,7 +183,7 @@ # ^ # SyntaxError: invalid syntax - >>> dict(**x for x in [{1:2}]) # TODO: RUSTPYTHON # doctest:+EXPECTED_FAILURE + >>> dict(**x for x in [{1:2}]) Traceback (most recent call last): ... dict(**x for x in [{1:2}]) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 7058a258a44..f76ccd152e7 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -493,56 +493,46 @@ impl Compiler { slice: &ast::Expr, ctx: ast::ExprContext, ) -> CompileResult<()> { - // 1. Check subscripter and index for Load context - // 2. VISIT value - // 3. Handle two-element slice specially - // 4. Otherwise VISIT slice and emit appropriate instruction - - // For Load context, some checks are skipped for now - // if ctx == ast::ExprContext::Load { - // check_subscripter(value); - // check_index(value, slice); - // } + // Save full subscript expression range (set by compile_expression before this call) + let subscript_range = self.current_source_range; // VISIT(c, expr, e->v.Subscript.value) self.compile_expression(value)?; // Handle two-element non-constant slice with BINARY_SLICE/STORE_SLICE - if slice.should_use_slice_optimization() && !matches!(ctx, ast::ExprContext::Del) { + let use_slice_opt = matches!(ctx, ast::ExprContext::Load | ast::ExprContext::Store) + && slice.should_use_slice_optimization(); + if use_slice_opt { match slice { ast::Expr::Slice(s) => self.compile_slice_two_parts(s)?, _ => unreachable!( "should_use_slice_optimization should only return true for ast::Expr::Slice" ), }; - match ctx { - ast::ExprContext::Load => { - emit!(self, Instruction::BinarySlice); - } - ast::ExprContext::Store => { - emit!(self, Instruction::StoreSlice); - } - _ => unreachable!(), - } } else { // VISIT(c, expr, e->v.Subscript.slice) self.compile_expression(slice)?; + } - // Emit appropriate instruction based on context - match ctx { - ast::ExprContext::Load => emit!( - self, - Instruction::BinaryOp { - op: BinaryOperator::Subscr - } - ), - ast::ExprContext::Store => emit!(self, Instruction::StoreSubscr), - ast::ExprContext::Del => emit!(self, Instruction::DeleteSubscr), - ast::ExprContext::Invalid => { - return Err(self.error(CodegenErrorType::SyntaxError( - "Invalid expression context".to_owned(), - ))); + // Restore full subscript expression range before emitting + self.set_source_range(subscript_range); + + match (use_slice_opt, ctx) { + (true, ast::ExprContext::Load) => emit!(self, Instruction::BinarySlice), + (true, ast::ExprContext::Store) => emit!(self, Instruction::StoreSlice), + (true, _) => unreachable!(), + (false, ast::ExprContext::Load) => emit!( + self, + Instruction::BinaryOp { + op: BinaryOperator::Subscr } + ), + (false, ast::ExprContext::Store) => emit!(self, Instruction::StoreSubscr), + (false, ast::ExprContext::Del) => emit!(self, Instruction::DeleteSubscr), + (false, ast::ExprContext::Invalid) => { + return Err(self.error(CodegenErrorType::SyntaxError( + "Invalid expression context".to_owned(), + ))); } } @@ -6603,7 +6593,8 @@ impl Compiler { self.compile_expression(left)?; self.compile_expression(right)?; - // Perform operation: + // Restore full expression range before emitting the operation + self.set_source_range(range); self.compile_op(op, false); } ast::Expr::Subscript(ast::ExprSubscript { @@ -6614,7 +6605,8 @@ impl Compiler { ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => { self.compile_expression(operand)?; - // Perform operation: + // Restore full expression range before emitting the operation + self.set_source_range(range); match op { ast::UnaryOp::UAdd => emit!( self, diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index 2b243262b3c..8daa90360e6 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -1012,7 +1012,7 @@ impl SymbolTableBuilder { if table.symbols.contains_key(parameter.name.as_str()) { return Err(SymbolTableError { error: format!( - "duplicate parameter '{}' in function definition", + "duplicate argument '{}' in function definition", parameter.name ), location: Some( diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 11e3bf0d07a..815d45f2cd8 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -27,6 +27,8 @@ pub struct ParseError { pub location: SourceLocation, pub end_location: SourceLocation, pub source_path: String, + /// Set when the error is an unclosed bracket (converted from EOF). + pub is_unclosed_bracket: bool, } impl ::core::fmt::Display for ParseError { @@ -46,26 +48,71 @@ pub enum CompileError { impl CompileError { pub fn from_ruff_parse_error(error: parser::ParseError, source_file: &SourceFile) -> Self { let source_code = source_file.to_source_code(); - let location = source_code.source_location(error.location.start(), PositionEncoding::Utf8); - let mut end_location = - source_code.source_location(error.location.end(), PositionEncoding::Utf8); - - // If the error range ends at the start of a new line (column 1), - // adjust it to the end of the previous line - if end_location.character_offset.get() == 1 && end_location.line > location.line { - // Get the end of the previous line - let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1); - end_location = source_code.source_location(prev_line_end, PositionEncoding::Utf8); - // Adjust column to be after the last character - end_location.character_offset = end_location.character_offset.saturating_add(1); - } + let source_text = source_file.source_text(); + + // For EOF errors (unclosed brackets), find the unclosed bracket position + // and adjust both the error location and message + let mut is_unclosed_bracket = false; + let (error_type, location, end_location) = if matches!( + &error.error, + parser::ParseErrorType::Lexical(parser::LexicalErrorType::Eof) + ) { + if let Some((bracket_char, bracket_offset)) = find_unclosed_bracket(source_text) { + let bracket_text_size = ruff_text_size::TextSize::new(bracket_offset as u32); + let loc = source_code.source_location(bracket_text_size, PositionEncoding::Utf8); + let end_loc = SourceLocation { + line: loc.line, + character_offset: loc.character_offset.saturating_add(1), + }; + let msg = format!("'{}' was never closed", bracket_char); + is_unclosed_bracket = true; + (parser::ParseErrorType::OtherError(msg), loc, end_loc) + } else { + let loc = + source_code.source_location(error.location.start(), PositionEncoding::Utf8); + let end_loc = + source_code.source_location(error.location.end(), PositionEncoding::Utf8); + (error.error, loc, end_loc) + } + } else if matches!( + &error.error, + parser::ParseErrorType::Lexical(parser::LexicalErrorType::IndentationError) + ) { + // For IndentationError, point the offset to the end of the line content + // instead of the beginning + let loc = source_code.source_location(error.location.start(), PositionEncoding::Utf8); + let line_idx = loc.line.to_zero_indexed(); + let line = source_text.split('\n').nth(line_idx).unwrap_or(""); + let line_end_col = line.chars().count() + 1; // 1-indexed, past last char + let end_loc = SourceLocation { + line: loc.line, + character_offset: ruff_source_file::OneIndexed::new(line_end_col) + .unwrap_or(loc.character_offset), + }; + (error.error, end_loc, end_loc) + } else { + let loc = source_code.source_location(error.location.start(), PositionEncoding::Utf8); + let mut end_loc = + source_code.source_location(error.location.end(), PositionEncoding::Utf8); + + // If the error range ends at the start of a new line (column 1), + // adjust it to the end of the previous line + if end_loc.character_offset.get() == 1 && end_loc.line > loc.line { + let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1); + end_loc = source_code.source_location(prev_line_end, PositionEncoding::Utf8); + end_loc.character_offset = end_loc.character_offset.saturating_add(1); + } + + (error.error, loc, end_loc) + }; Self::Parse(ParseError { - error: error.error, + error: error_type, raw_location: error.location, location, end_location, source_path: source_file.name().to_owned(), + is_unclosed_bracket, }) } @@ -102,6 +149,106 @@ impl CompileError { } } +/// Find the last unclosed opening bracket in source code. +/// Returns the bracket character and its byte offset, or None if all brackets are balanced. +fn find_unclosed_bracket(source: &str) -> Option<(char, usize)> { + let mut stack: Vec<(char, usize)> = Vec::new(); + let mut in_string = false; + let mut string_quote = '\0'; + let mut triple_quote = false; + let mut escape_next = false; + let mut is_raw_string = false; + + let chars: Vec<(usize, char)> = source.char_indices().collect(); + let mut i = 0; + + while i < chars.len() { + let (byte_offset, ch) = chars[i]; + + if escape_next { + escape_next = false; + i += 1; + continue; + } + + if in_string { + if ch == '\\' && !is_raw_string { + escape_next = true; + } else if triple_quote { + if ch == string_quote + && i + 2 < chars.len() + && chars[i + 1].1 == string_quote + && chars[i + 2].1 == string_quote + { + in_string = false; + i += 3; + continue; + } + } else if ch == string_quote { + in_string = false; + } + i += 1; + continue; + } + + // Check for comments + if ch == '#' { + // Skip to end of line + while i < chars.len() && chars[i].1 != '\n' { + i += 1; + } + continue; + } + + // Check for string start (with optional prefix like r, b, f, u, rb, br, etc.) + if ch == '\'' || ch == '"' { + // Check up to 2 characters before the quote for string prefix + is_raw_string = false; + for look_back in 1..=2.min(i) { + let prev = chars[i - look_back].1; + if matches!(prev, 'r' | 'R') { + is_raw_string = true; + break; + } + if !matches!(prev, 'b' | 'B' | 'f' | 'F' | 'u' | 'U') { + break; + } + } + string_quote = ch; + if i + 2 < chars.len() && chars[i + 1].1 == ch && chars[i + 2].1 == ch { + triple_quote = true; + in_string = true; + i += 3; + continue; + } + triple_quote = false; + in_string = true; + i += 1; + continue; + } + + match ch { + '(' | '[' | '{' => stack.push((ch, byte_offset)), + ')' | ']' | '}' => { + let expected = match ch { + ')' => '(', + ']' => '[', + '}' => '{', + _ => unreachable!(), + }; + if stack.last().is_some_and(|&(open, _)| open == expected) { + stack.pop(); + } + } + _ => {} + } + + i += 1; + } + + stack.last().copied() +} + /// Compile a given source code into a bytecode object. pub fn compile( source: &str, diff --git a/crates/vm/src/builtins/asyncgenerator.rs b/crates/vm/src/builtins/asyncgenerator.rs index 7523714a3d0..f5b85410eef 100644 --- a/crates/vm/src/builtins/asyncgenerator.rs +++ b/crates/vm/src/builtins/asyncgenerator.rs @@ -678,29 +678,19 @@ impl PyAnextAwaitable { let awaitable = if wrapped.class().is(vm.ctx.types.coroutine_type) { // Coroutine - get __await__ later wrapped.clone() - } else if let Some(generator) = wrapped.downcast_ref::() { - // Generator with CO_ITERABLE_COROUTINE flag can be awaited - // (e.g., generators decorated with @types.coroutine) - if generator - .as_coro() - .frame() - .code - .flags - .contains(crate::bytecode::CodeFlags::ITERABLE_COROUTINE) + } else { + // Check for generator with CO_ITERABLE_COROUTINE flag + if let Some(generator) = wrapped.downcast_ref::() + && generator + .as_coro() + .frame() + .code + .flags + .contains(crate::bytecode::CodeFlags::ITERABLE_COROUTINE) { // Return the generator itself as the iterator return Ok(wrapped.clone()); } - // Fall through: try to get __await__ method for generator subclasses - if let Some(await_method) = vm.get_method(wrapped.clone(), identifier!(vm, __await__)) { - await_method?.call((), vm)? - } else { - return Err(vm.new_type_error(format!( - "'{}' object can't be awaited", - wrapped.class().name() - ))); - } - } else { // Try to get __await__ method if let Some(await_method) = vm.get_method(wrapped.clone(), identifier!(vm, __await__)) { await_method?.call((), vm)? diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index 838265d62c9..d6bbbf82754 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -6,7 +6,7 @@ use super::{PyCode, PyDictRef, PyIntRef, PyStrRef}; use crate::{ AsObject, Context, Py, PyObjectRef, PyRef, PyResult, VirtualMachine, class::PyClassImpl, - frame::{Frame, FrameRef}, + frame::{Frame, FrameOwner, FrameRef}, function::PySetterValue, types::Representable, }; @@ -31,11 +31,6 @@ impl Representable for Frame { #[pyclass(flags(DISALLOW_INSTANTIATION), with(Py))] impl Frame { - #[pymethod] - const fn clear(&self) { - // TODO - } - #[pygetset] fn f_globals(&self) -> PyDictRef { self.globals.clone() @@ -151,6 +146,45 @@ impl Frame { #[pyclass] impl Py { + #[pymethod] + // = frame_clear_impl + fn clear(&self, vm: &VirtualMachine) -> PyResult<()> { + let owner = FrameOwner::from_i8(self.owner.load(core::sync::atomic::Ordering::Acquire)); + match owner { + FrameOwner::Generator => { + // Generator frame: check if suspended (lasti > 0 means + // FRAME_SUSPENDED). lasti == 0 means FRAME_CREATED and + // can be cleared. + if self.lasti() != 0 { + return Err(vm.new_runtime_error("cannot clear a suspended frame".to_owned())); + } + } + FrameOwner::Thread => { + // Thread-owned frame: always executing, cannot clear. + return Err(vm.new_runtime_error("cannot clear an executing frame".to_owned())); + } + FrameOwner::FrameObject => { + // Detached frame: safe to clear. + } + } + + // Clear fastlocals + { + let mut fastlocals = self.fastlocals.lock(); + for slot in fastlocals.iter_mut() { + *slot = None; + } + } + + // Clear the evaluation stack + self.clear_value_stack(); + + // Clear temporary refs + self.temporary_refs.lock().clear(); + + Ok(()) + } + #[pygetset] fn f_generator(&self) -> Option { self.generator.to_owned() diff --git a/crates/vm/src/builtins/traceback.rs b/crates/vm/src/builtins/traceback.rs index 675025cd6b6..ff88a03f7de 100644 --- a/crates/vm/src/builtins/traceback.rs +++ b/crates/vm/src/builtins/traceback.rs @@ -1,7 +1,7 @@ -use super::PyType; +use super::{PyList, PyType}; use crate::{ AsObject, Context, Py, PyPayload, PyRef, PyResult, VirtualMachine, class::PyClassImpl, - frame::FrameRef, types::Constructor, + frame::FrameRef, function::PySetterValue, types::Constructor, }; use rustpython_common::lock::PyMutex; use rustpython_compiler_core::OneIndexed; @@ -62,12 +62,28 @@ impl PyTraceback { self.next.lock().as_ref().cloned() } + #[pymethod] + fn __dir__(&self, vm: &VirtualMachine) -> PyList { + PyList::from( + ["tb_frame", "tb_next", "tb_lasti", "tb_lineno"] + .iter() + .map(|&s| vm.ctx.new_str(s).into()) + .collect::>(), + ) + } + #[pygetset(setter)] fn set_tb_next( zelf: &Py, - value: Option>, + value: PySetterValue>>, vm: &VirtualMachine, ) -> PyResult<()> { + let value = match value { + PySetterValue::Assign(v) => v, + PySetterValue::Delete => { + return Err(vm.new_type_error("can't delete tb_next attribute".to_owned())); + } + }; if let Some(ref new_next) = value { let mut cursor = new_next.clone(); loop { diff --git a/crates/vm/src/coroutine.rs b/crates/vm/src/coroutine.rs index e2dd849c161..0ee48d959a7 100644 --- a/crates/vm/src/coroutine.rs +++ b/crates/vm/src/coroutine.rs @@ -3,7 +3,7 @@ use crate::{ builtins::{PyBaseExceptionRef, PyStrRef}, common::lock::PyMutex, exceptions::types::PyBaseException, - frame::{ExecutionResult, FrameRef}, + frame::{ExecutionResult, FrameOwner, FrameRef}, function::OptionalArg, object::{Traverse, TraverseFn}, protocol::PyIterReturn, @@ -73,7 +73,14 @@ impl Coro { fn maybe_close(&self, res: &PyResult) { match res { - Ok(ExecutionResult::Return(_)) | Err(_) => self.closed.store(true), + Ok(ExecutionResult::Return(_)) | Err(_) => { + self.closed.store(true); + // Frame is no longer suspended; allow frame.clear() to succeed. + self.frame.owner.store( + FrameOwner::FrameObject as i8, + core::sync::atomic::Ordering::Release, + ); + } Ok(ExecutionResult::Yield(_)) => {} } } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 034dd8ce7f0..e1f67f4e521 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -58,6 +58,30 @@ struct FrameState { lasti: u32, } +/// Tracks who owns a frame. +// = `_PyFrameOwner` +#[repr(i8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FrameOwner { + /// Being executed by a thread (FRAME_OWNED_BY_THREAD). + Thread = 0, + /// Owned by a generator/coroutine (FRAME_OWNED_BY_GENERATOR). + Generator = 1, + /// Not executing; held only by a frame object or traceback + /// (FRAME_OWNED_BY_FRAME_OBJECT). + FrameObject = 2, +} + +impl FrameOwner { + pub(crate) fn from_i8(v: i8) -> Self { + match v { + 0 => Self::Thread, + 1 => Self::Generator, + _ => Self::FrameObject, + } + } +} + #[cfg(feature = "threading")] type Lasti = atomic::AtomicU32; #[cfg(not(feature = "threading"))] @@ -93,6 +117,10 @@ pub struct Frame { /// Previous frame in the call chain for signal-safe traceback walking. /// Mirrors `_PyInterpreterFrame.previous`. pub(crate) previous: AtomicPtr, + /// Who owns this frame. Mirrors `_PyInterpreterFrame.owner`. + /// Used by `frame.clear()` to reject clearing an executing frame, + /// even when called from a different thread. + pub(crate) owner: atomic::AtomicI8, } impl PyPayload for Frame { @@ -183,18 +211,28 @@ impl Frame { temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), previous: AtomicPtr::new(core::ptr::null_mut()), + owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), } } + /// Clear the evaluation stack. Used by frame.clear() to break reference cycles. + pub(crate) fn clear_value_stack(&self) { + self.state.lock().stack.clear(); + } + /// Store a borrowed back-reference to the owning generator/coroutine. /// The caller must ensure the generator outlives the frame. pub fn set_generator(&self, generator: &PyObject) { self.generator.store(generator); + self.owner + .store(FrameOwner::Generator as i8, atomic::Ordering::Release); } /// Clear the generator back-reference. Called when the generator is finalized. pub fn clear_generator(&self) { self.generator.clear(); + self.owner + .store(FrameOwner::FrameObject as i8, atomic::Ordering::Release); } pub fn current_location(&self) -> SourceLocation { @@ -420,6 +458,7 @@ impl ExecutingFrame<'_> { exception: PyBaseExceptionRef, idx: usize, is_reraise: bool, + is_new_raise: bool, vm: &VirtualMachine, ) -> FrameResult { // 1. Extract traceback from exception's '__traceback__' attr. @@ -429,6 +468,11 @@ impl ExecutingFrame<'_> { // RERAISE instructions should not add traceback entries - they're just // re-raising an already-processed exception if !is_reraise { + // Check if the exception already has traceback entries before + // we add ours. If it does, it was propagated from a callee + // function and we should not re-contextualize it. + let had_prior_traceback = exception.__traceback__().is_some(); + // PyTraceBack_Here always adds a new entry without // checking for duplicates. Each time an exception passes through // a frame (e.g., in a loop with repeated raise statements), @@ -444,13 +488,15 @@ impl ExecutingFrame<'_> { ); vm_trace!("Adding to traceback: {:?} {:?}", new_traceback, loc.line); exception.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); - } - // Only contextualize exception for new raises, not re-raises - // CPython only calls _PyErr_SetObject (which does chaining) on initial raise - // RERAISE just propagates the exception without modifying __context__ - if !is_reraise { - vm.contextualize_exception(&exception); + // _PyErr_SetObject sets __context__ only when the exception + // is first raised. When an exception propagates through frames, + // __context__ must not be overwritten. We contextualize when: + // - It's an explicit raise (raise/raise from) + // - The exception had no prior traceback (originated here) + if is_new_raise || !had_prior_traceback { + vm.contextualize_exception(&exception); + } } // Use exception table for zero-cost exception handling @@ -470,7 +516,18 @@ impl ExecutingFrame<'_> { _ => false, }; - match handle_exception(self, exception, idx, is_reraise, vm) { + // Explicit raise instructions (raise/raise from) - these always + // need contextualization even if the exception has prior traceback + let is_new_raise = matches!( + op, + Instruction::RaiseVarargs { kind } + if matches!( + kind.get(arg), + bytecode::RaiseKind::Raise | bytecode::RaiseKind::RaiseCause + ) + ); + + match handle_exception(self, exception, idx, is_reraise, is_new_raise, vm) { Ok(None) => {} Ok(Some(result)) => break Ok(result), Err(exception) => { @@ -564,6 +621,9 @@ impl ExecutingFrame<'_> { // CLEANUP_THROW expects: [sub_iter, last_sent_val, exc] self.push_value(vm.ctx.none()); + // Set __context__ on the exception (_PyErr_ChainStackItem) + vm.contextualize_exception(&err); + // Use unwind_blocks to let exception table route to CLEANUP_THROW match self.unwind_blocks(vm, UnwindReason::Raising { exception: err }) { Ok(None) => self.run(vm), @@ -2203,20 +2263,57 @@ impl ExecutingFrame<'_> { return Ok(sub_module); } - if is_module_initializing(module, vm) { - let module_name = module - .get_attr(identifier!(vm, __name__), vm) - .ok() - .and_then(|n| n.downcast_ref::().map(|s| s.as_str().to_owned())) - .unwrap_or_else(|| "".to_owned()); - - let msg = format!( - "cannot import name '{name}' from partially initialized module '{module_name}' (most likely due to a circular import)", - ); - Err(vm.new_import_error(msg, name.to_owned())) + // Get module name for the error message and ImportError attributes + let mod_name_obj = module.get_attr(identifier!(vm, __name__), vm).ok(); + let mod_name_str = mod_name_obj + .as_ref() + .and_then(|n| n.downcast_ref::().map(|s| s.as_str().to_owned())); + let module_name = mod_name_str.as_deref().unwrap_or(""); + + // Get module path/location for the error message + let mod_path = module + .get_attr("__spec__", vm) + .ok() + .and_then(|spec| spec.get_attr("origin", vm).ok()) + .and_then(|origin| { + if vm.is_none(&origin) { + None + } else { + origin + .downcast_ref::() + .map(|s| s.as_str().to_owned()) + } + }) + .or_else(|| { + module + .get_attr(identifier!(vm, __file__), vm) + .ok() + .and_then(|f| f.downcast_ref::().map(|s| s.as_str().to_owned())) + }); + + let msg = if is_module_initializing(module, vm) { + if let Some(ref path) = mod_path { + format!( + "cannot import name '{name}' from partially initialized module \ + '{module_name}' (most likely due to a circular import) ({path})", + ) + } else { + format!( + "cannot import name '{name}' from partially initialized module \ + '{module_name}' (most likely due to a circular import)", + ) + } + } else if let Some(ref path) = mod_path { + format!("cannot import name '{name}' from '{module_name}' ({path})") } else { - Err(vm.new_import_error(format!("cannot import name '{name}'"), name.to_owned())) - } + format!("cannot import name '{name}' from '{module_name}' (unknown location)") + }; + let err = vm.new_import_error(msg, vm.ctx.new_str(module_name)); + + // name_from = the attribute name that failed to import (best-effort metadata) + let _ignore = err.as_object().set_attr("name_from", name.to_owned(), vm); + + Err(err) } #[cfg_attr(feature = "flame-it", flame("Frame"))] @@ -2294,42 +2391,7 @@ impl ExecutingFrame<'_> { Err(exception) } } - UnwindReason::Returning { value } => { - // Clear tracebacks of exceptions in fastlocals to break reference cycles. - // This is needed because when returning from inside an except block, - // the exception cleanup code (e = None; del e) is skipped, leaving the - // exception with a traceback that references this frame, which references - // the exception in fastlocals, creating a cycle that can't be collected - // since RustPython doesn't have a tracing GC. - // - // We only clear tracebacks of exceptions that: - // 1. Are not the return value itself (will be needed by caller) - // 2. Are not the current active exception (still being handled) - // 3. Have a traceback whose top frame is THIS frame (we created it) - let current_exc = vm.current_exception(); - let fastlocals = self.fastlocals.lock(); - for obj in fastlocals.iter().flatten() { - // Skip if this object is the return value - if obj.is(&value) { - continue; - } - if let Ok(exc) = obj.clone().downcast::() { - // Skip if this is the current active exception - if current_exc.as_ref().is_some_and(|cur| exc.is(cur)) { - continue; - } - // Only clear if traceback's top frame is this frame - if exc - .__traceback__() - .is_some_and(|tb| core::ptr::eq::>(&*tb.frame, self.object)) - { - exc.set_traceback_typed(None); - } - } - } - drop(fastlocals); - Ok(Some(ExecutionResult::Return(value))) - } + UnwindReason::Returning { value } => Ok(Some(ExecutionResult::Return(value))), } } diff --git a/crates/vm/src/stdlib/ast.rs b/crates/vm/src/stdlib/ast.rs index c2d7b8c29ff..92366b6e8e3 100644 --- a/crates/vm/src/stdlib/ast.rs +++ b/crates/vm/src/stdlib/ast.rs @@ -357,6 +357,7 @@ pub(crate) fn parse( location: range.start.to_source_location(), end_location: range.end.to_source_location(), source_path: "".to_string(), + is_unclosed_bracket: false, } })?; @@ -368,6 +369,7 @@ pub(crate) fn parse( location: range.start.to_source_location(), end_location: range.end.to_source_location(), source_path: "".to_string(), + is_unclosed_bracket: false, } .into()); } @@ -441,6 +443,7 @@ pub(crate) fn parse_func_type( location: SourceLocation::default(), end_location: SourceLocation::default(), source_path: "".to_owned(), + is_unclosed_bracket: false, } .into()); }; @@ -458,6 +461,7 @@ pub(crate) fn parse_func_type( location: range.start.to_source_location(), end_location: range.end.to_source_location(), source_path: "".to_string(), + is_unclosed_bracket: false, } })?; Ok(*parsed.into_syntax().body) diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 69ff6a17649..417a0ccb87a 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -698,14 +698,21 @@ mod sys { vm: &VirtualMachine, ) -> PyResult<()> { let stderr = super::get_stderr(vm)?; - - // Try to normalize the exception. If it fails, print error to stderr like CPython match vm.normalize_exception(exc_type.clone(), exc_val.clone(), exc_tb) { - Ok(exc) => vm.write_exception(&mut crate::py_io::PyWriter(stderr, vm), &exc), + Ok(exc) => { + // Try Python traceback module first for richer output + // (enables features like keyword typo suggestions in SyntaxError) + if let Ok(tb_mod) = vm.import("traceback", 0) + && let Ok(print_exc) = tb_mod.get_attr("print_exception", vm) + && print_exc.call((exc.as_object().to_owned(),), vm).is_ok() + { + return Ok(()); + } + // Fallback to Rust-level exception printing + vm.write_exception(&mut crate::py_io::PyWriter(stderr, vm), &exc) + } Err(_) => { - // CPython prints error message to stderr instead of raising exception let type_name = exc_val.class().name(); - // TODO: fix error message let msg = format!( "TypeError: print_exception(): Exception expected for value, {type_name} found\n" ); diff --git a/crates/vm/src/suggestion.rs b/crates/vm/src/suggestion.rs index c23e56d2126..2d732160f07 100644 --- a/crates/vm/src/suggestion.rs +++ b/crates/vm/src/suggestion.rs @@ -48,13 +48,25 @@ pub fn calculate_suggestions<'a>( } pub fn offer_suggestions(exc: &Py, vm: &VirtualMachine) -> Option { - if exc.class().is(vm.ctx.exceptions.attribute_error) { - let name = exc.as_object().get_attr("name", vm).unwrap(); - let obj = exc.as_object().get_attr("obj", vm).unwrap(); + if exc + .class() + .fast_issubclass(vm.ctx.exceptions.attribute_error) + { + let name = exc.as_object().get_attr("name", vm).ok()?; + if vm.is_none(&name) { + return None; + } + let obj = exc.as_object().get_attr("obj", vm).ok()?; + if vm.is_none(&obj) { + return None; + } calculate_suggestions(vm.dir(Some(obj)).ok()?.borrow_vec().iter(), &name) - } else if exc.class().is(vm.ctx.exceptions.name_error) { - let name = exc.as_object().get_attr("name", vm).unwrap(); + } else if exc.class().fast_issubclass(vm.ctx.exceptions.name_error) { + let name = exc.as_object().get_attr("name", vm).ok()?; + if vm.is_none(&name) { + return None; + } let tb = exc.__traceback__()?; let tb = tb.iter().last().unwrap_or(tb); @@ -70,6 +82,16 @@ pub fn offer_suggestions(exc: &Py, vm: &VirtualMachine) -> Opti let builtins: Vec<_> = tb.frame.builtins.try_to_value(vm).ok()?; calculate_suggestions(builtins.iter(), &name) + } else if exc.class().fast_issubclass(vm.ctx.exceptions.import_error) { + let mod_name = exc.as_object().get_attr("name", vm).ok()?; + let wrong_name = exc.as_object().get_attr("name_from", vm).ok()?; + let mod_name_str = mod_name.downcast_ref::()?; + + // Look up the module in sys.modules + let sys_modules = vm.sys_module.get_attr("modules", vm).ok()?; + let module = sys_modules.get_item(mod_name_str.as_str(), vm).ok()?; + + calculate_suggestions(vm.dir(Some(module)).ok()?.borrow_vec().iter(), &wrong_name) } else { None } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 1abfa20054b..6c507f1b701 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -971,7 +971,16 @@ impl VirtualMachine { // Each frame starts with no active exception (None) // This prevents exceptions from leaking between function calls self.push_exception(None); + let old_owner = frame.owner.swap( + crate::frame::FrameOwner::Thread as i8, + core::sync::atomic::Ordering::AcqRel, + ); let result = f(frame); + // SAFETY: frame_ptr is valid because self.frames holds a clone + // of the frame, keeping the underlying allocation alive. + unsafe { &*frame_ptr } + .owner + .store(old_owner, core::sync::atomic::Ordering::Release); // Pop the exception context - restores caller's exception state self.pop_exception(); // Restore previous frame as current (unlink from chain) @@ -1244,6 +1253,14 @@ impl VirtualMachine { ) { if exc.class().is(self.ctx.exceptions.attribute_error) { let exc = exc.as_object(); + // Check if this exception was already augmented + let already_set = exc + .get_attr("name", self) + .ok() + .is_some_and(|v| !self.is_none(&v)); + if already_set { + return; + } exc.set_attr("name", name, self).unwrap(); exc.set_attr("obj", obj, self).unwrap(); } diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 8fc8108b6a3..2a06fda17f6 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -332,16 +332,54 @@ impl VirtualMachine { source: Option<&str>, allow_incomplete: bool, ) -> PyBaseExceptionRef { + let incomplete_or_syntax = |allow| -> &'static Py { + if allow { + self.ctx.exceptions.incomplete_input_error + } else { + self.ctx.exceptions.syntax_error + } + }; + let syntax_error_type = match &error { #[cfg(feature = "parser")] - // FIXME: this condition will cause TabError even when the matching actual error is IndentationError crate::compiler::CompileError::Parse(rustpython_compiler::ParseError { error: ruff_python_parser::ParseErrorType::Lexical( ruff_python_parser::LexicalErrorType::IndentationError, ), .. - }) => self.ctx.exceptions.tab_error, + }) => { + // Detect tab/space mixing to raise TabError instead of IndentationError. + // This checks both within a single line and across different lines. + let is_tab_error = source.is_some_and(|source| { + let mut has_space_indent = false; + let mut has_tab_indent = false; + for line in source.lines() { + let indent: Vec = line + .bytes() + .take_while(|&b| b == b' ' || b == b'\t') + .collect(); + if indent.is_empty() { + continue; + } + if indent.contains(&b' ') && indent.contains(&b'\t') { + return true; + } + if indent.contains(&b' ') { + has_space_indent = true; + } + if indent.contains(&b'\t') { + has_tab_indent = true; + } + } + has_space_indent && has_tab_indent + }); + if is_tab_error { + self.ctx.exceptions.tab_error + } else { + self.ctx.exceptions.indentation_error + } + } #[cfg(feature = "parser")] crate::compiler::CompileError::Parse(rustpython_compiler::ParseError { error: ruff_python_parser::ParseErrorType::UnexpectedIndentation, @@ -354,13 +392,13 @@ impl VirtualMachine { ruff_python_parser::LexicalErrorType::Eof, ), .. - }) => { - if allow_incomplete { - self.ctx.exceptions.incomplete_input_error - } else { - self.ctx.exceptions.syntax_error - } - } + }) => incomplete_or_syntax(allow_incomplete), + // Unclosed bracket errors (converted from Eof by from_ruff_parse_error) + #[cfg(feature = "parser")] + crate::compiler::CompileError::Parse(rustpython_compiler::ParseError { + is_unclosed_bracket: true, + .. + }) => incomplete_or_syntax(allow_incomplete), #[cfg(feature = "parser")] crate::compiler::CompileError::Parse(rustpython_compiler::ParseError { error: @@ -370,13 +408,7 @@ impl VirtualMachine { ), ), .. - }) => { - if allow_incomplete { - self.ctx.exceptions.incomplete_input_error - } else { - self.ctx.exceptions.syntax_error - } - } + }) => incomplete_or_syntax(allow_incomplete), #[cfg(feature = "parser")] crate::compiler::CompileError::Parse(rustpython_compiler::ParseError { error: @@ -400,11 +432,7 @@ impl VirtualMachine { } } - if is_incomplete { - self.ctx.exceptions.incomplete_input_error - } else { - self.ctx.exceptions.syntax_error - } + incomplete_or_syntax(is_incomplete) } else { self.ctx.exceptions.syntax_error } @@ -440,7 +468,7 @@ impl VirtualMachine { if is_incomplete { self.ctx.exceptions.incomplete_input_error } else { - self.ctx.exceptions.indentation_error + self.ctx.exceptions.indentation_error // not syntax_error } } else { self.ctx.exceptions.indentation_error @@ -458,6 +486,7 @@ impl VirtualMachine { let line = source .split('\n') .nth(loc?.line.to_zero_indexed())? + .trim_end_matches('\r') .to_owned(); Some(line + "\n") } @@ -480,8 +509,25 @@ impl VirtualMachine { | ruff_python_parser::ParseErrorType::UnexpectedExpressionToken, .. }) => msg.insert_str(0, "invalid syntax: "), + #[cfg(feature = "parser")] + crate::compiler::CompileError::Parse(rustpython_compiler::ParseError { + error: + ruff_python_parser::ParseErrorType::Lexical( + ruff_python_parser::LexicalErrorType::UnrecognizedToken { .. }, + ) + | ruff_python_parser::ParseErrorType::SimpleStatementsOnSameLine + | ruff_python_parser::ParseErrorType::SimpleAndCompoundStatementOnSameLine + | ruff_python_parser::ParseErrorType::ExpectedToken { .. } + | ruff_python_parser::ParseErrorType::ExpectedExpression, + .. + }) => { + msg = "invalid syntax".to_owned(); + } _ => {} } + if syntax_error_type.is(self.ctx.exceptions.tab_error) { + msg = "inconsistent use of tabs and spaces in indentation".to_owned(); + } let syntax_error = self.new_exception_msg(syntax_error_type, msg); let (lineno, offset) = error.python_location(); let lineno = self.ctx.new_int(lineno); @@ -517,6 +563,23 @@ impl VirtualMachine { .as_object() .set_attr("filename", self.ctx.new_str(error.source_path()), self) .unwrap(); + + // Set _metadata for keyword typo suggestions in traceback module. + // Format: (start_line, col_offset, source_code) + // start_line=0 means "include all lines from beginning" which provides + // full context needed by _find_keyword_typos to compile-check suggestions. + if let Some(source) = source { + let metadata = self.ctx.new_tuple(vec![ + self.ctx.new_int(0).into(), + self.ctx.new_int(0).into(), + self.ctx.new_str(source).into(), + ]); + syntax_error + .as_object() + .set_attr("_metadata", metadata, self) + .unwrap(); + } + syntax_error }