diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1db02dd17a..a2ab43a695 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -73,6 +73,14 @@ The `Lib/` directory contains Python standard library files copied from the CPyt - `unittest.skip("TODO: RustPython ")` - `unittest.expectedFailure` with `# TODO: RUSTPYTHON ` comment +### Clean Build + +When you modify bytecode instructions, a full clean is required: + +```bash +rm -r target/debug/build/rustpython-* && find . | grep -E "\.pyc$" | xargs rm -r +``` + ### Testing ```bash diff --git a/Lib/test/test_except_star.py b/Lib/test/test_except_star.py new file mode 100644 index 0000000000..3e0f8caa9b --- /dev/null +++ b/Lib/test/test_except_star.py @@ -0,0 +1,1220 @@ +import sys +import unittest +import textwrap +from test.support.testcase import ExceptionIsLikeMixin + +class TestInvalidExceptStar(unittest.TestCase): + def test_mixed_except_and_except_star_is_syntax_error(self): + errors = [ + "try: pass\nexcept ValueError: pass\nexcept* TypeError: pass\n", + "try: pass\nexcept* ValueError: pass\nexcept TypeError: pass\n", + "try: pass\nexcept ValueError as e: pass\nexcept* TypeError: pass\n", + "try: pass\nexcept* ValueError as e: pass\nexcept TypeError: pass\n", + "try: pass\nexcept ValueError: pass\nexcept* TypeError as e: pass\n", + "try: pass\nexcept* ValueError: pass\nexcept TypeError as e: pass\n", + "try: pass\nexcept ValueError: pass\nexcept*: pass\n", + "try: pass\nexcept* ValueError: pass\nexcept: pass\n", + ] + + for err in errors: + with self.assertRaises(SyntaxError): + compile(err, "", "exec") + + def test_except_star_ExceptionGroup_is_runtime_error_single(self): + with self.assertRaises(TypeError): + try: + raise OSError("blah") + except* ExceptionGroup as e: + pass + + def test_except_star_ExceptionGroup_is_runtime_error_tuple(self): + with self.assertRaises(TypeError): + try: + raise ExceptionGroup("eg", [ValueError(42)]) + except* (TypeError, ExceptionGroup): + pass + + def test_except_star_invalid_exception_type(self): + with self.assertRaises(TypeError): + try: + raise ValueError + except* 42: + pass + + with self.assertRaises(TypeError): + try: + raise ValueError + except* (ValueError, 42): + pass + + +class TestBreakContinueReturnInExceptStarBlock(unittest.TestCase): + MSG = (r"'break', 'continue' and 'return'" + r" cannot appear in an except\* block") + + def check_invalid(self, src): + with self.assertRaisesRegex(SyntaxError, self.MSG): + compile(textwrap.dedent(src), "", "exec") + + def test_break_in_except_star(self): + self.check_invalid( + """ + try: + raise ValueError + except* Exception as e: + break + """) + + self.check_invalid( + """ + for i in range(5): + try: + pass + except* Exception as e: + if i == 2: + break + """) + + self.check_invalid( + """ + for i in range(5): + try: + pass + except* Exception as e: + if i == 2: + break + finally: + return 0 + """) + + + def test_continue_in_except_star_block_invalid(self): + self.check_invalid( + """ + for i in range(5): + try: + raise ValueError + except* Exception as e: + continue + """) + + self.check_invalid( + """ + for i in range(5): + try: + pass + except* Exception as e: + if i == 2: + continue + """) + + self.check_invalid( + """ + for i in range(5): + try: + pass + except* Exception as e: + if i == 2: + continue + finally: + return 0 + """) + + def test_return_in_except_star_block_invalid(self): + self.check_invalid( + """ + def f(): + try: + raise ValueError + except* Exception as e: + return 42 + """) + + self.check_invalid( + """ + def f(): + try: + pass + except* Exception as e: + return 42 + finally: + finished = True + """) + + def test_break_continue_in_except_star_block_valid(self): + try: + raise ValueError(42) + except* Exception as e: + count = 0 + for i in range(5): + if i == 0: + continue + if i == 4: + break + count += 1 + + self.assertEqual(count, 3) + self.assertEqual(i, 4) + exc = e + self.assertIsInstance(exc, ExceptionGroup) + + def test_return_in_except_star_block_valid(self): + try: + raise ValueError(42) + except* Exception as e: + def f(x): + return 2*x + r = f(3) + exc = e + self.assertEqual(r, 6) + self.assertIsInstance(exc, ExceptionGroup) + + +class ExceptStarTest(ExceptionIsLikeMixin, unittest.TestCase): + def assertMetadataEqual(self, e1, e2): + if e1 is None or e2 is None: + self.assertTrue(e1 is None and e2 is None) + else: + self.assertEqual(e1.__context__, e2.__context__) + self.assertEqual(e1.__cause__, e2.__cause__) + self.assertEqual(e1.__traceback__, e2.__traceback__) + + def assertMetadataNotEqual(self, e1, e2): + if e1 is None or e2 is None: + self.assertNotEqual(e1, e2) + else: + return not (e1.__context__ == e2.__context__ + and e1.__cause__ == e2.__cause__ + and e1.__traceback__ == e2.__traceback__) + + +class TestExceptStarSplitSemantics(ExceptStarTest): + def doSplitTestNamed(self, exc, T, match_template, rest_template): + initial_sys_exception = sys.exception() + sys_exception = match = rest = None + try: + try: + raise exc + except* T as e: + sys_exception = sys.exception() + match = e + except BaseException as e: + rest = e + + self.assertEqual(sys_exception, match) + self.assertExceptionIsLike(match, match_template) + self.assertExceptionIsLike(rest, rest_template) + self.assertEqual(sys.exception(), initial_sys_exception) + + def doSplitTestUnnamed(self, exc, T, match_template, rest_template): + initial_sys_exception = sys.exception() + sys_exception = match = rest = None + try: + try: + raise exc + except* T: + sys_exception = match = sys.exception() + else: + if rest_template: + self.fail("Exception not raised") + except BaseException as e: + rest = e + self.assertExceptionIsLike(match, match_template) + self.assertExceptionIsLike(rest, rest_template) + self.assertEqual(sys.exception(), initial_sys_exception) + + def doSplitTestInExceptHandler(self, exc, T, match_template, rest_template): + try: + raise ExceptionGroup('eg', [TypeError(1), ValueError(2)]) + except Exception: + self.doSplitTestNamed(exc, T, match_template, rest_template) + self.doSplitTestUnnamed(exc, T, match_template, rest_template) + + def doSplitTestInExceptStarHandler(self, exc, T, match_template, rest_template): + try: + raise ExceptionGroup('eg', [TypeError(1), ValueError(2)]) + except* Exception: + self.doSplitTestNamed(exc, T, match_template, rest_template) + self.doSplitTestUnnamed(exc, T, match_template, rest_template) + + def doSplitTest(self, exc, T, match_template, rest_template): + self.doSplitTestNamed(exc, T, match_template, rest_template) + self.doSplitTestUnnamed(exc, T, match_template, rest_template) + self.doSplitTestInExceptHandler(exc, T, match_template, rest_template) + self.doSplitTestInExceptStarHandler(exc, T, match_template, rest_template) + + def test_no_match_single_type(self): + self.doSplitTest( + ExceptionGroup("test1", [ValueError("V"), TypeError("T")]), + SyntaxError, + None, + ExceptionGroup("test1", [ValueError("V"), TypeError("T")])) + + def test_match_single_type(self): + self.doSplitTest( + ExceptionGroup("test2", [ValueError("V1"), ValueError("V2")]), + ValueError, + ExceptionGroup("test2", [ValueError("V1"), ValueError("V2")]), + None) + + def test_match_single_type_partial_match(self): + self.doSplitTest( + ExceptionGroup( + "test3", + [ValueError("V1"), OSError("OS"), ValueError("V2")]), + ValueError, + ExceptionGroup("test3", [ValueError("V1"), ValueError("V2")]), + ExceptionGroup("test3", [OSError("OS")])) + + def test_match_single_type_nested(self): + self.doSplitTest( + ExceptionGroup( + "g1", [ + ValueError("V1"), + OSError("OS1"), + ExceptionGroup( + "g2", [ + OSError("OS2"), + ValueError("V2"), + TypeError("T")])]), + ValueError, + ExceptionGroup( + "g1", [ + ValueError("V1"), + ExceptionGroup("g2", [ValueError("V2")])]), + ExceptionGroup("g1", [ + OSError("OS1"), + ExceptionGroup("g2", [ + OSError("OS2"), TypeError("T")])])) + + def test_match_type_tuple_nested(self): + self.doSplitTest( + ExceptionGroup( + "h1", [ + ValueError("V1"), + OSError("OS1"), + ExceptionGroup( + "h2", [OSError("OS2"), ValueError("V2"), TypeError("T")])]), + (ValueError, TypeError), + ExceptionGroup( + "h1", [ + ValueError("V1"), + ExceptionGroup("h2", [ValueError("V2"), TypeError("T")])]), + ExceptionGroup( + "h1", [ + OSError("OS1"), + ExceptionGroup("h2", [OSError("OS2")])])) + + def test_empty_groups_removed(self): + self.doSplitTest( + ExceptionGroup( + "eg", [ + ExceptionGroup("i1", [ValueError("V1")]), + ExceptionGroup("i2", [ValueError("V2"), TypeError("T1")]), + ExceptionGroup("i3", [TypeError("T2")])]), + TypeError, + ExceptionGroup("eg", [ + ExceptionGroup("i2", [TypeError("T1")]), + ExceptionGroup("i3", [TypeError("T2")])]), + ExceptionGroup("eg", [ + ExceptionGroup("i1", [ValueError("V1")]), + ExceptionGroup("i2", [ValueError("V2")])])) + + def test_singleton_groups_are_kept(self): + self.doSplitTest( + ExceptionGroup("j1", [ + ExceptionGroup("j2", [ + ExceptionGroup("j3", [ValueError("V1")]), + ExceptionGroup("j4", [TypeError("T")])])]), + TypeError, + ExceptionGroup( + "j1", + [ExceptionGroup("j2", [ExceptionGroup("j4", [TypeError("T")])])]), + ExceptionGroup( + "j1", + [ExceptionGroup("j2", [ExceptionGroup("j3", [ValueError("V1")])])])) + + def test_naked_exception_matched_wrapped1(self): + self.doSplitTest( + ValueError("V"), + ValueError, + ExceptionGroup("", [ValueError("V")]), + None) + + def test_naked_exception_matched_wrapped2(self): + self.doSplitTest( + ValueError("V"), + Exception, + ExceptionGroup("", [ValueError("V")]), + None) + + def test_exception_group_except_star_Exception_not_wrapped(self): + self.doSplitTest( + ExceptionGroup("eg", [ValueError("V")]), + Exception, + ExceptionGroup("eg", [ValueError("V")]), + None) + + def test_plain_exception_not_matched(self): + self.doSplitTest( + ValueError("V"), + TypeError, + None, + ValueError("V")) + + def test_match__supertype(self): + self.doSplitTest( + ExceptionGroup("st", [BlockingIOError("io"), TypeError("T")]), + OSError, + ExceptionGroup("st", [BlockingIOError("io")]), + ExceptionGroup("st", [TypeError("T")])) + + def test_multiple_matches_named(self): + try: + raise ExceptionGroup("mmn", [OSError("os"), BlockingIOError("io")]) + except* BlockingIOError as e: + self.assertExceptionIsLike(e, + ExceptionGroup("mmn", [BlockingIOError("io")])) + except* OSError as e: + self.assertExceptionIsLike(e, + ExceptionGroup("mmn", [OSError("os")])) + else: + self.fail("Exception not raised") + + def test_multiple_matches_unnamed(self): + try: + raise ExceptionGroup("mmu", [OSError("os"), BlockingIOError("io")]) + except* BlockingIOError: + e = sys.exception() + self.assertExceptionIsLike(e, + ExceptionGroup("mmu", [BlockingIOError("io")])) + except* OSError: + e = sys.exception() + self.assertExceptionIsLike(e, + ExceptionGroup("mmu", [OSError("os")])) + else: + self.fail("Exception not raised") + + def test_first_match_wins_named(self): + try: + raise ExceptionGroup("fst", [BlockingIOError("io")]) + except* OSError as e: + self.assertExceptionIsLike(e, + ExceptionGroup("fst", [BlockingIOError("io")])) + except* BlockingIOError: + self.fail("Should have been matched as OSError") + else: + self.fail("Exception not raised") + + def test_first_match_wins_unnamed(self): + try: + raise ExceptionGroup("fstu", [BlockingIOError("io")]) + except* OSError: + e = sys.exception() + self.assertExceptionIsLike(e, + ExceptionGroup("fstu", [BlockingIOError("io")])) + except* BlockingIOError: + pass + else: + self.fail("Exception not raised") + + def test_nested_except_stars(self): + try: + raise ExceptionGroup("n", [BlockingIOError("io")]) + except* BlockingIOError: + try: + raise ExceptionGroup("n", [ValueError("io")]) + except* ValueError: + pass + else: + self.fail("Exception not raised") + e = sys.exception() + self.assertExceptionIsLike(e, + ExceptionGroup("n", [BlockingIOError("io")])) + else: + self.fail("Exception not raised") + + def test_nested_in_loop(self): + for _ in range(2): + try: + raise ExceptionGroup("nl", [BlockingIOError("io")]) + except* BlockingIOError: + pass + else: + self.fail("Exception not raised") + + +class TestExceptStarReraise(ExceptStarTest): + def test_reraise_all_named(self): + try: + try: + raise ExceptionGroup( + "eg", [TypeError(1), ValueError(2), OSError(3)]) + except* TypeError as e: + raise + except* ValueError as e: + raise + # OSError not handled + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, + ExceptionGroup("eg", [TypeError(1), ValueError(2), OSError(3)])) + + def test_reraise_all_unnamed(self): + try: + try: + raise ExceptionGroup( + "eg", [TypeError(1), ValueError(2), OSError(3)]) + except* TypeError: + raise + except* ValueError: + raise + # OSError not handled + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, + ExceptionGroup("eg", [TypeError(1), ValueError(2), OSError(3)])) + + def test_reraise_some_handle_all_named(self): + try: + try: + raise ExceptionGroup( + "eg", [TypeError(1), ValueError(2), OSError(3)]) + except* TypeError as e: + raise + except* ValueError as e: + pass + # OSError not handled + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("eg", [TypeError(1), OSError(3)])) + + def test_reraise_partial_handle_all_unnamed(self): + try: + try: + raise ExceptionGroup( + "eg", [TypeError(1), ValueError(2)]) + except* TypeError: + raise + except* ValueError: + pass + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("eg", [TypeError(1)])) + + def test_reraise_partial_handle_some_named(self): + try: + try: + raise ExceptionGroup( + "eg", [TypeError(1), ValueError(2), OSError(3)]) + except* TypeError as e: + raise + except* ValueError as e: + pass + # OSError not handled + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("eg", [TypeError(1), OSError(3)])) + + def test_reraise_partial_handle_some_unnamed(self): + try: + try: + raise ExceptionGroup( + "eg", [TypeError(1), ValueError(2), OSError(3)]) + except* TypeError: + raise + except* ValueError: + pass + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("eg", [TypeError(1), OSError(3)])) + + def test_reraise_plain_exception_named(self): + try: + try: + raise ValueError(42) + except* ValueError as e: + raise + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("", [ValueError(42)])) + + def test_reraise_plain_exception_unnamed(self): + try: + try: + raise ValueError(42) + except* ValueError: + raise + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("", [ValueError(42)])) + + +class TestExceptStarRaise(ExceptStarTest): + def test_raise_named(self): + orig = ExceptionGroup("eg", [ValueError(1), OSError(2)]) + try: + try: + raise orig + except* OSError as e: + raise TypeError(3) + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, + ExceptionGroup( + "", [TypeError(3), ExceptionGroup("eg", [ValueError(1)])])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [OSError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + + def test_raise_unnamed(self): + orig = ExceptionGroup("eg", [ValueError(1), OSError(2)]) + try: + try: + raise orig + except* OSError: + raise TypeError(3) + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, + ExceptionGroup( + "", [TypeError(3), ExceptionGroup("eg", [ValueError(1)])])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [OSError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + + def test_raise_handle_all_raise_one_named(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* (TypeError, ValueError) as e: + raise SyntaxError(3) + except SyntaxError as e: + exc = e + + self.assertExceptionIsLike(exc, SyntaxError(3)) + + self.assertExceptionIsLike( + exc.__context__, + ExceptionGroup("eg", [TypeError(1), ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.__context__) + + def test_raise_handle_all_raise_one_unnamed(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* (TypeError, ValueError) as e: + raise SyntaxError(3) + except SyntaxError as e: + exc = e + + self.assertExceptionIsLike(exc, SyntaxError(3)) + + self.assertExceptionIsLike( + exc.__context__, + ExceptionGroup("eg", [TypeError(1), ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.__context__) + + def test_raise_handle_all_raise_two_named(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* TypeError as e: + raise SyntaxError(3) + except* ValueError as e: + raise SyntaxError(4) + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("", [SyntaxError(3), SyntaxError(4)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [TypeError(1)])) + + self.assertExceptionIsLike( + exc.exceptions[1].__context__, + ExceptionGroup("eg", [ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + self.assertMetadataEqual(orig, exc.exceptions[1].__context__) + + def test_raise_handle_all_raise_two_unnamed(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* TypeError: + raise SyntaxError(3) + except* ValueError: + raise SyntaxError(4) + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("", [SyntaxError(3), SyntaxError(4)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [TypeError(1)])) + + self.assertExceptionIsLike( + exc.exceptions[1].__context__, + ExceptionGroup("eg", [ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + self.assertMetadataEqual(orig, exc.exceptions[1].__context__) + + +class TestExceptStarRaiseFrom(ExceptStarTest): + def test_raise_named(self): + orig = ExceptionGroup("eg", [ValueError(1), OSError(2)]) + try: + try: + raise orig + except* OSError as e: + raise TypeError(3) from e + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, + ExceptionGroup( + "", [TypeError(3), ExceptionGroup("eg", [ValueError(1)])])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [OSError(2)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__cause__, + ExceptionGroup("eg", [OSError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + self.assertMetadataEqual(orig, exc.exceptions[0].__cause__) + self.assertMetadataNotEqual(orig, exc.exceptions[1].__context__) + self.assertMetadataNotEqual(orig, exc.exceptions[1].__cause__) + + def test_raise_unnamed(self): + orig = ExceptionGroup("eg", [ValueError(1), OSError(2)]) + try: + try: + raise orig + except* OSError: + e = sys.exception() + raise TypeError(3) from e + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, + ExceptionGroup( + "", [TypeError(3), ExceptionGroup("eg", [ValueError(1)])])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [OSError(2)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__cause__, + ExceptionGroup("eg", [OSError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + self.assertMetadataEqual(orig, exc.exceptions[0].__cause__) + self.assertMetadataNotEqual(orig, exc.exceptions[1].__context__) + self.assertMetadataNotEqual(orig, exc.exceptions[1].__cause__) + + def test_raise_handle_all_raise_one_named(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* (TypeError, ValueError) as e: + raise SyntaxError(3) from e + except SyntaxError as e: + exc = e + + self.assertExceptionIsLike(exc, SyntaxError(3)) + + self.assertExceptionIsLike( + exc.__context__, + ExceptionGroup("eg", [TypeError(1), ValueError(2)])) + + self.assertExceptionIsLike( + exc.__cause__, + ExceptionGroup("eg", [TypeError(1), ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.__context__) + self.assertMetadataEqual(orig, exc.__cause__) + + def test_raise_handle_all_raise_one_unnamed(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* (TypeError, ValueError) as e: + e = sys.exception() + raise SyntaxError(3) from e + except SyntaxError as e: + exc = e + + self.assertExceptionIsLike(exc, SyntaxError(3)) + + self.assertExceptionIsLike( + exc.__context__, + ExceptionGroup("eg", [TypeError(1), ValueError(2)])) + + self.assertExceptionIsLike( + exc.__cause__, + ExceptionGroup("eg", [TypeError(1), ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.__context__) + self.assertMetadataEqual(orig, exc.__cause__) + + def test_raise_handle_all_raise_two_named(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* TypeError as e: + raise SyntaxError(3) from e + except* ValueError as e: + raise SyntaxError(4) from e + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("", [SyntaxError(3), SyntaxError(4)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [TypeError(1)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__cause__, + ExceptionGroup("eg", [TypeError(1)])) + + self.assertExceptionIsLike( + exc.exceptions[1].__context__, + ExceptionGroup("eg", [ValueError(2)])) + + self.assertExceptionIsLike( + exc.exceptions[1].__cause__, + ExceptionGroup("eg", [ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + self.assertMetadataEqual(orig, exc.exceptions[0].__cause__) + + def test_raise_handle_all_raise_two_unnamed(self): + orig = ExceptionGroup("eg", [TypeError(1), ValueError(2)]) + try: + try: + raise orig + except* TypeError: + e = sys.exception() + raise SyntaxError(3) from e + except* ValueError: + e = sys.exception() + raise SyntaxError(4) from e + except ExceptionGroup as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("", [SyntaxError(3), SyntaxError(4)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__context__, + ExceptionGroup("eg", [TypeError(1)])) + + self.assertExceptionIsLike( + exc.exceptions[0].__cause__, + ExceptionGroup("eg", [TypeError(1)])) + + self.assertExceptionIsLike( + exc.exceptions[1].__context__, + ExceptionGroup("eg", [ValueError(2)])) + + self.assertExceptionIsLike( + exc.exceptions[1].__cause__, + ExceptionGroup("eg", [ValueError(2)])) + + self.assertMetadataNotEqual(orig, exc) + self.assertMetadataEqual(orig, exc.exceptions[0].__context__) + self.assertMetadataEqual(orig, exc.exceptions[0].__cause__) + self.assertMetadataEqual(orig, exc.exceptions[1].__context__) + self.assertMetadataEqual(orig, exc.exceptions[1].__cause__) + + +class TestExceptStarExceptionGroupSubclass(ExceptStarTest): + # TODO: RUSTPYTHON + @unittest.expectedFailure + def test_except_star_EG_subclass(self): + class EG(ExceptionGroup): + def __new__(cls, message, excs, code): + obj = super().__new__(cls, message, excs) + obj.code = code + return obj + + def derive(self, excs): + return EG(self.message, excs, self.code) + + try: + try: + try: + try: + raise TypeError(2) + except TypeError as te: + raise EG("nested", [te], 101) from None + except EG as nested: + try: + raise ValueError(1) + except ValueError as ve: + raise EG("eg", [ve, nested], 42) + except* ValueError as eg: + veg = eg + except EG as eg: + teg = eg + + self.assertIsInstance(veg, EG) + self.assertIsInstance(teg, EG) + self.assertIsInstance(teg.exceptions[0], EG) + self.assertMetadataEqual(veg, teg) + self.assertEqual(veg.code, 42) + self.assertEqual(teg.code, 42) + self.assertEqual(teg.exceptions[0].code, 101) + + def test_falsy_exception_group_subclass(self): + class FalsyEG(ExceptionGroup): + def __bool__(self): + return False + + def derive(self, excs): + return FalsyEG(self.message, excs) + + try: + try: + raise FalsyEG("eg", [TypeError(1), ValueError(2)]) + except *TypeError as e: + tes = e + raise + except *ValueError as e: + ves = e + pass + except Exception as e: + exc = e + + for e in [tes, ves, exc]: + self.assertFalse(e) + self.assertIsInstance(e, FalsyEG) + + self.assertExceptionIsLike(exc, FalsyEG("eg", [TypeError(1)])) + self.assertExceptionIsLike(tes, FalsyEG("eg", [TypeError(1)])) + self.assertExceptionIsLike(ves, FalsyEG("eg", [ValueError(2)])) + + def test_exception_group_subclass_with_bad_split_func(self): + # see gh-128049. + class BadEG1(ExceptionGroup): + def split(self, *args): + return "NOT A 2-TUPLE!" + + class BadEG2(ExceptionGroup): + def split(self, *args): + return ("NOT A 2-TUPLE!",) + + eg_list = [ + (BadEG1("eg", [OSError(123), ValueError(456)]), + r"split must return a tuple, not str"), + (BadEG2("eg", [OSError(123), ValueError(456)]), + r"split must return a 2-tuple, got tuple of size 1") + ] + + for eg_class, msg in eg_list: + with self.assertRaisesRegex(TypeError, msg) as m: + try: + raise eg_class + except* ValueError: + pass + except* OSError: + pass + + self.assertExceptionIsLike(m.exception.__context__, eg_class) + + # we allow tuples of length > 2 for backwards compatibility + class WeirdEG(ExceptionGroup): + def split(self, *args): + return super().split(*args) + ("anything", 123456, None) + + try: + raise WeirdEG("eg", [OSError(123), ValueError(456)]) + except* OSError as e: + oeg = e + except* ValueError as e: + veg = e + + self.assertExceptionIsLike(oeg, WeirdEG("eg", [OSError(123)])) + self.assertExceptionIsLike(veg, WeirdEG("eg", [ValueError(456)])) + + +class TestExceptStarCleanup(ExceptStarTest): + def test_sys_exception_restored(self): + try: + try: + raise ValueError(42) + except: + try: + raise TypeError(int) + except* Exception: + pass + 1/0 + except Exception as e: + exc = e + + self.assertExceptionIsLike(exc, ZeroDivisionError('division by zero')) + self.assertExceptionIsLike(exc.__context__, ValueError(42)) + self.assertEqual(sys.exception(), None) + + +class TestExceptStar_WeirdLeafExceptions(ExceptStarTest): + # Test that except* works when leaf exceptions are + # unhashable or have a bad custom __eq__ + + class UnhashableExc(ValueError): + __hash__ = None + + class AlwaysEqualExc(ValueError): + def __eq__(self, other): + return True + + class NeverEqualExc(ValueError): + def __eq__(self, other): + return False + + class BrokenEqualExc(ValueError): + def __eq__(self, other): + raise RuntimeError() + + def setUp(self): + self.bad_types = [self.UnhashableExc, + self.AlwaysEqualExc, + self.NeverEqualExc, + self.BrokenEqualExc] + + def except_type(self, eg, type): + match, rest = None, None + try: + try: + raise eg + except* type as e: + match = e + except Exception as e: + rest = e + return match, rest + + def test_catch_unhashable_leaf_exception(self): + for Bad in self.bad_types: + with self.subTest(Bad): + eg = ExceptionGroup("eg", [TypeError(1), Bad(2)]) + match, rest = self.except_type(eg, Bad) + self.assertExceptionIsLike( + match, ExceptionGroup("eg", [Bad(2)])) + self.assertExceptionIsLike( + rest, ExceptionGroup("eg", [TypeError(1)])) + + def test_propagate_unhashable_leaf(self): + for Bad in self.bad_types: + with self.subTest(Bad): + eg = ExceptionGroup("eg", [TypeError(1), Bad(2)]) + match, rest = self.except_type(eg, TypeError) + self.assertExceptionIsLike( + match, ExceptionGroup("eg", [TypeError(1)])) + self.assertExceptionIsLike( + rest, ExceptionGroup("eg", [Bad(2)])) + + def test_catch_nothing_unhashable_leaf(self): + for Bad in self.bad_types: + with self.subTest(Bad): + eg = ExceptionGroup("eg", [TypeError(1), Bad(2)]) + match, rest = self.except_type(eg, OSError) + self.assertIsNone(match) + self.assertExceptionIsLike(rest, eg) + + def test_catch_everything_unhashable_leaf(self): + for Bad in self.bad_types: + with self.subTest(Bad): + eg = ExceptionGroup("eg", [TypeError(1), Bad(2)]) + match, rest = self.except_type(eg, Exception) + self.assertExceptionIsLike(match, eg) + self.assertIsNone(rest) + + def test_reraise_unhashable_leaf(self): + for Bad in self.bad_types: + with self.subTest(Bad): + eg = ExceptionGroup( + "eg", [TypeError(1), Bad(2), ValueError(3)]) + + try: + try: + raise eg + except* TypeError: + pass + except* Bad: + raise + except Exception as e: + exc = e + + self.assertExceptionIsLike( + exc, ExceptionGroup("eg", [Bad(2), ValueError(3)])) + + +class TestExceptStar_WeirdExceptionGroupSubclass(ExceptStarTest): + # Test that except* works with exception groups that are + # unhashable or have a bad custom __eq__ + + class UnhashableEG(ExceptionGroup): + __hash__ = None + + def derive(self, excs): + return type(self)(self.message, excs) + + class AlwaysEqualEG(ExceptionGroup): + def __eq__(self, other): + return True + + def derive(self, excs): + return type(self)(self.message, excs) + + class NeverEqualEG(ExceptionGroup): + def __eq__(self, other): + return False + + def derive(self, excs): + return type(self)(self.message, excs) + + class BrokenEqualEG(ExceptionGroup): + def __eq__(self, other): + raise RuntimeError() + + def derive(self, excs): + return type(self)(self.message, excs) + + def setUp(self): + self.bad_types = [self.UnhashableEG, + self.AlwaysEqualEG, + self.NeverEqualEG, + self.BrokenEqualEG] + + def except_type(self, eg, type): + match, rest = None, None + try: + try: + raise eg + except* type as e: + match = e + except Exception as e: + rest = e + return match, rest + + def test_catch_some_unhashable_exception_group_subclass(self): + for BadEG in self.bad_types: + with self.subTest(BadEG): + eg = BadEG("eg", + [TypeError(1), + BadEG("nested", [ValueError(2)])]) + + match, rest = self.except_type(eg, TypeError) + self.assertExceptionIsLike(match, BadEG("eg", [TypeError(1)])) + self.assertExceptionIsLike(rest, + BadEG("eg", [BadEG("nested", [ValueError(2)])])) + + def test_catch_none_unhashable_exception_group_subclass(self): + for BadEG in self.bad_types: + with self.subTest(BadEG): + + eg = BadEG("eg", + [TypeError(1), + BadEG("nested", [ValueError(2)])]) + + match, rest = self.except_type(eg, OSError) + self.assertIsNone(match) + self.assertExceptionIsLike(rest, eg) + + def test_catch_all_unhashable_exception_group_subclass(self): + for BadEG in self.bad_types: + with self.subTest(BadEG): + + eg = BadEG("eg", + [TypeError(1), + BadEG("nested", [ValueError(2)])]) + + match, rest = self.except_type(eg, Exception) + self.assertExceptionIsLike(match, eg) + self.assertIsNone(rest) + + def test_reraise_unhashable_eg(self): + for BadEG in self.bad_types: + with self.subTest(BadEG): + + eg = BadEG("eg", + [TypeError(1), ValueError(2), + BadEG("nested", [ValueError(3), OSError(4)])]) + + try: + try: + raise eg + except* ValueError: + pass + except* OSError: + raise + except Exception as e: + exc = e + + self.assertExceptionIsLike( + exc, BadEG("eg", [TypeError(1), + BadEG("nested", [OSError(4)])])) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 4a0ef83a57..7909e92425 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -1580,13 +1580,18 @@ impl Compiler { } Stmt::Break(_) => { // Find the innermost loop in fblock stack + // Error if we encounter ExceptionGroupHandler before finding a loop let found_loop = { let code = self.current_code_info(); - let mut result = None; + let mut result = Ok(None); for i in (0..code.fblock.len()).rev() { match code.fblock[i].fb_type { FBlockType::WhileLoop | FBlockType::ForLoop => { - result = Some(code.fblock[i].fb_exit); + result = Ok(Some(code.fblock[i].fb_exit)); + break; + } + FBlockType::ExceptionGroupHandler => { + result = Err(()); break; } _ => continue, @@ -1596,25 +1601,36 @@ impl Compiler { }; match found_loop { - Some(exit_block) => { + Ok(Some(exit_block)) => { emit!(self, Instruction::Break { target: exit_block }); } - None => { + Ok(None) => { return Err( self.error_ranged(CodegenErrorType::InvalidBreak, statement.range()) ); } + Err(()) => { + return Err(self.error_ranged( + CodegenErrorType::BreakContinueReturnInExceptStar, + statement.range(), + )); + } } } Stmt::Continue(_) => { // Find the innermost loop in fblock stack + // Error if we encounter ExceptionGroupHandler before finding a loop let found_loop = { let code = self.current_code_info(); - let mut result = None; + let mut result = Ok(None); for i in (0..code.fblock.len()).rev() { match code.fblock[i].fb_type { FBlockType::WhileLoop | FBlockType::ForLoop => { - result = Some(code.fblock[i].fb_block); + result = Ok(Some(code.fblock[i].fb_block)); + break; + } + FBlockType::ExceptionGroupHandler => { + result = Err(()); break; } _ => continue, @@ -1624,14 +1640,20 @@ impl Compiler { }; match found_loop { - Some(loop_block) => { + Ok(Some(loop_block)) => { emit!(self, Instruction::Continue { target: loop_block }); } - None => { + Ok(None) => { return Err( self.error_ranged(CodegenErrorType::InvalidContinue, statement.range()) ); } + Err(()) => { + return Err(self.error_ranged( + CodegenErrorType::BreakContinueReturnInExceptStar, + statement.range(), + )); + } } } Stmt::Return(StmtReturn { value, .. }) => { @@ -1640,6 +1662,18 @@ impl Compiler { self.error_ranged(CodegenErrorType::InvalidReturn, statement.range()) ); } + // Check if we're inside an except* block in the current function + { + let code = self.current_code_info(); + for block in code.fblock.iter().rev() { + if matches!(block.fb_type, FBlockType::ExceptionGroupHandler) { + return Err(self.error_ranged( + CodegenErrorType::BreakContinueReturnInExceptStar, + statement.range(), + )); + } + } + } match value { Some(v) => { if self.ctx.func == FunctionContext::AsyncFunction @@ -2126,9 +2160,14 @@ impl Compiler { orelse: &[Stmt], finalbody: &[Stmt], ) -> CompileResult<()> { - // Simplified except* implementation using CheckEgMatch + // Simplified except* implementation using PrepReraiseStar intrinsic + // Stack layout during handler processing: [orig, list, rest] let handler_block = self.new_block(); let finally_block = self.new_block(); + let else_block = self.new_block(); + let end_block = self.new_block(); + let reraise_star_block = self.new_block(); + let reraise_block = self.new_block(); if !finalbody.is_empty() { emit!( @@ -2139,8 +2178,6 @@ impl Compiler { ); } - let else_block = self.new_block(); - emit!( self, Instruction::SetupExcept { @@ -2151,20 +2188,32 @@ impl Compiler { emit!(self, Instruction::PopBlock); emit!(self, Instruction::Jump { target: else_block }); + // Exception handler entry self.switch_to_block(handler_block); // Stack: [exc] - for handler in handlers { + // Create list for tracking exception results and copy orig + emit!(self, Instruction::BuildList { size: 0 }); + // Stack: [exc, []] + // CopyItem is 1-indexed: CopyItem(1)=TOS, CopyItem(2)=second from top + // With stack [exc, []], CopyItem(2) copies exc + emit!(self, Instruction::CopyItem { index: 2 }); + // Stack: [exc, [], exc_copy] + + // Now stack is: [orig, list, rest] + + let n = handlers.len(); + for (i, handler) in handlers.iter().enumerate() { let ExceptHandler::ExceptHandler(ExceptHandlerExceptHandler { type_, name, body, .. }) = handler; - let skip_block = self.new_block(); + let no_match_block = self.new_block(); let next_block = self.new_block(); // Compile exception type if let Some(exc_type) = type_ { - // Check for unparenthesized tuple (e.g., `except* A, B:` instead of `except* (A, B):`) + // Check for unparenthesized tuple if let Expr::Tuple(ExprTuple { elts, range, .. }) = exc_type.as_ref() && let Some(first) = elts.first() && range.start().to_u32() == first.range().start().to_u32() @@ -2179,67 +2228,161 @@ impl Compiler { "except* must specify an exception type".to_owned(), ))); } - // Stack: [exc, type] + // Stack: [orig, list, rest, type] emit!(self, Instruction::CheckEgMatch); - // Stack: [rest, match] + // Stack: [orig, list, new_rest, match] - // Check if match is None (truthy check) + // Check if match is not None (use identity check, not truthiness) + // CopyItem is 1-indexed: CopyItem(1) = TOS, CopyItem(2) = second from top emit!(self, Instruction::CopyItem { index: 1 }); - emit!(self, Instruction::ToBool); - emit!(self, Instruction::PopJumpIfFalse { target: skip_block }); + self.emit_load_const(ConstantData::None); + emit!(self, Instruction::IsOp(bytecode::Invert::No)); // is None? + emit!( + self, + Instruction::PopJumpIfTrue { + target: no_match_block + } + ); - // Handler matched - store match to name if provided - // Stack: [rest, match] + // Handler matched + // Stack: [orig, list, new_rest, match] + let handler_except_block = self.new_block(); + let handler_done_block = self.new_block(); + + // Set matched exception as current exception for bare 'raise' + emit!(self, Instruction::SetExcInfo); + + // Store match to name if provided if let Some(alias) = name { + // CopyItem(1) copies TOS (match) + emit!(self, Instruction::CopyItem { index: 1 }); self.store_name(alias.as_str())?; - } else { - emit!(self, Instruction::PopTop); } - // Stack: [rest] + // Stack: [orig, list, new_rest, match] + + // Setup exception handler to catch 'raise' in handler body + emit!( + self, + Instruction::SetupExcept { + handler: handler_except_block, + } + ); + + // Push fblock to disallow break/continue/return in except* handler + self.push_fblock( + FBlockType::ExceptionGroupHandler, + handler_done_block, + end_block, + )?; + // Execute handler body self.compile_statements(body)?; + // Handler body completed normally (didn't raise) + self.pop_fblock(FBlockType::ExceptionGroupHandler); + emit!(self, Instruction::PopBlock); + + // Cleanup name binding + if let Some(alias) = name { + self.emit_load_const(ConstantData::None); + self.store_name(alias.as_str())?; + self.compile_name(alias.as_str(), NameUsage::Delete)?; + } + + // Stack: [orig, list, new_rest, match] + // Pop match (handler consumed it) + emit!(self, Instruction::PopTop); + // Stack: [orig, list, new_rest] + + // Append None to list (exception was consumed, not reraised) + self.emit_load_const(ConstantData::None); + // Stack: [orig, list, new_rest, None] + emit!(self, Instruction::ListAppend { i: 1 }); + // Stack: [orig, list, new_rest] + + emit!( + self, + Instruction::Jump { + target: handler_done_block + } + ); + + // Handler raised an exception (bare 'raise' or other) + self.switch_to_block(handler_except_block); + // Stack: [orig, list, new_rest, match, raised_exc] + + // Cleanup name binding if let Some(alias) = name { self.emit_load_const(ConstantData::None); self.store_name(alias.as_str())?; self.compile_name(alias.as_str(), NameUsage::Delete)?; } + // Append raised_exc to list (the actual exception that was raised) + // Stack: [orig, list, new_rest, match, raised_exc] + // ListAppend(2): pop raised_exc, then append to list at stack[4-2-1]=stack[1] + emit!(self, Instruction::ListAppend { i: 2 }); + // Stack: [orig, list, new_rest, match] + + // Pop match (no longer needed) + emit!(self, Instruction::PopTop); + // Stack: [orig, list, new_rest] + + self.switch_to_block(handler_done_block); + // Stack: [orig, list, new_rest] + emit!(self, Instruction::Jump { target: next_block }); - // No match - pop match (None) and continue with rest - self.switch_to_block(skip_block); - emit!(self, Instruction::PopTop); // drop match (None) - // Stack: [rest] + // No match - pop match (None), keep rest unchanged + self.switch_to_block(no_match_block); + emit!(self, Instruction::PopTop); // pop match (None) + // Stack: [orig, list, new_rest] self.switch_to_block(next_block); - // Stack: [rest] - continue with rest for next handler + // Stack: [orig, list, rest] (rest may have been updated) + + // After last handler, append remaining rest to list + if i == n - 1 { + // Stack: [orig, list, rest] + // ListAppend(i) pops TOS, then accesses stack[len - i - 1] + // After pop, stack is [orig, list], len=2 + // We want list at index 1, so 2 - i - 1 = 1, i = 0 + emit!(self, Instruction::ListAppend { i: 0 }); + // Stack: [orig, list] + emit!( + self, + Instruction::Jump { + target: reraise_star_block + } + ); + } } - let handled_block = self.new_block(); - - // Check if remainder is truthy (has unhandled exceptions) - // Stack: [rest] - emit!(self, Instruction::CopyItem { index: 1 }); - emit!(self, Instruction::ToBool); + // Reraise star block + self.switch_to_block(reraise_star_block); + // Stack: [orig, list] emit!( self, - Instruction::PopJumpIfFalse { - target: handled_block + Instruction::CallIntrinsic2 { + func: bytecode::IntrinsicFunction2::PrepReraiseStar } ); - // Reraise unhandled exceptions + // Stack: [result] (exception to reraise or None) + + // Check if result is not None (use identity check, not truthiness) + emit!(self, Instruction::CopyItem { index: 1 }); + self.emit_load_const(ConstantData::None); + emit!(self, Instruction::IsOp(bytecode::Invert::Yes)); // is not None? emit!( self, - Instruction::Raise { - kind: bytecode::RaiseKind::Raise + Instruction::PopJumpIfTrue { + target: reraise_block } ); - // All exceptions handled - self.switch_to_block(handled_block); - emit!(self, Instruction::PopTop); // drop remainder (None) + // Nothing to reraise + emit!(self, Instruction::PopTop); emit!(self, Instruction::PopException); if !finalbody.is_empty() { @@ -2247,10 +2390,17 @@ impl Compiler { emit!(self, Instruction::EnterFinally); } + emit!(self, Instruction::Jump { target: end_block }); + + // Reraise the result + self.switch_to_block(reraise_block); + // Don't call PopException before Raise - it truncates the stack and removes the result. + // When Raise is executed, the exception propagates through unwind_blocks which + // will properly handle the ExceptHandler block. emit!( self, - Instruction::Jump { - target: finally_block, + Instruction::Raise { + kind: bytecode::RaiseKind::Raise } ); @@ -2263,8 +2413,11 @@ impl Compiler { emit!(self, Instruction::EnterFinally); } - self.switch_to_block(finally_block); + emit!(self, Instruction::Jump { target: end_block }); + + self.switch_to_block(end_block); if !finalbody.is_empty() { + self.switch_to_block(finally_block); self.compile_statements(finalbody)?; emit!(self, Instruction::EndFinally); } diff --git a/crates/codegen/src/error.rs b/crates/codegen/src/error.rs index a0e36bf29a..70e2f13f25 100644 --- a/crates/codegen/src/error.rs +++ b/crates/codegen/src/error.rs @@ -88,6 +88,8 @@ pub enum CodegenErrorType { UnreachablePattern(PatternUnreachableReason), RepeatedAttributePattern, ConflictingNameBindPattern, + /// break/continue/return inside except* block + BreakContinueReturnInExceptStar, NotImplementedYet, // RustPython marker for unimplemented features } @@ -148,6 +150,12 @@ impl fmt::Display for CodegenErrorType { ConflictingNameBindPattern => { write!(f, "alternative patterns bind different names") } + BreakContinueReturnInExceptStar => { + write!( + f, + "'break', 'continue' and 'return' cannot appear in an except* block" + ) + } NotImplementedYet => { write!(f, "RustPython does not implement this feature yet") } diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index add2c1c2f6..8df5d9caf6 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -865,11 +865,14 @@ pub enum Instruction { WithCleanupStart, YieldFrom, YieldValue, + /// Set the current exception to TOS (for except* handlers). + /// Does not pop the value. + SetExcInfo, // If you add a new instruction here, be sure to keep LAST_INSTRUCTION updated } // This must be kept up to date to avoid marshaling errors -const LAST_INSTRUCTION: Instruction = Instruction::YieldValue; +const LAST_INSTRUCTION: Instruction = Instruction::SetExcInfo; const _: () = assert!(mem::size_of::() == 1); @@ -1743,6 +1746,7 @@ impl Instruction { Resume { .. } => 0, YieldValue => 0, YieldFrom => -1, + SetExcInfo => 0, SetupAnnotation | SetupLoop | SetupFinally { .. } | EnterFinally | EndFinally => 0, SetupExcept { .. } => jump as i32, SetupWith { .. } => (!jump) as i32, @@ -1961,6 +1965,7 @@ impl Instruction { SetupWith { end } => w!(SETUP_WITH, end), StoreAttr { idx } => w!(STORE_ATTR, name = idx), StoreDeref(idx) => w!(STORE_DEREF, cell_name = idx), + SetExcInfo => w!(SET_EXC_INFO), StoreFast(idx) => w!(STORE_FAST, varname = idx), StoreGlobal(idx) => w!(STORE_GLOBAL, name = idx), StoreLocal(idx) => w!(STORE_LOCAL, name = idx), diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 7b8af83489..0e2051e549 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -2433,6 +2433,40 @@ pub(super) mod types { pub struct PyEncodingWarning(PyWarning); } +/// Check if match_type is valid for except* (must be exception type, not ExceptionGroup). +fn check_except_star_type_valid(match_type: &PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + let base_exc: PyObjectRef = vm.ctx.exceptions.base_exception_type.to_owned().into(); + let base_eg: PyObjectRef = vm.ctx.exceptions.base_exception_group.to_owned().into(); + + // Helper to check a single type + let check_one = |exc_type: &PyObjectRef| -> PyResult<()> { + // Must be a subclass of BaseException + if !exc_type.is_subclass(&base_exc, vm)? { + return Err(vm.new_type_error( + "catching classes that do not inherit from BaseException is not allowed".to_owned(), + )); + } + // Must not be a subclass of BaseExceptionGroup + if exc_type.is_subclass(&base_eg, vm)? { + return Err(vm.new_type_error( + "catching ExceptionGroup with except* is not allowed. Use except instead." + .to_owned(), + )); + } + Ok(()) + }; + + // If it's a tuple, check each element + if let Ok(tuple) = match_type.clone().downcast::() { + for item in tuple.iter() { + check_one(item)?; + } + } else { + check_one(match_type)?; + } + Ok(()) +} + /// Match exception against except* handler type. /// Returns (rest, match) tuple. pub fn exception_group_match( @@ -2447,6 +2481,9 @@ pub fn exception_group_match( return Ok((vm.ctx.none(), vm.ctx.none())); } + // Validate match_type and reject ExceptionGroup/BaseExceptionGroup + check_except_star_type_valid(match_type, vm)?; + // Check if exc_value matches match_type if exc_value.is_instance(match_type, vm)? { // Full match of exc itself @@ -2473,6 +2510,13 @@ pub fn exception_group_match( // Check for partial match if it's an exception group if exc_value.fast_isinstance(vm.ctx.exceptions.base_exception_group) { let pair = vm.call_method(exc_value, "split", (match_type.clone(),))?; + if !pair.class().is(vm.ctx.types.tuple_type) { + return Err(vm.new_type_error(format!( + "{}.split must return a tuple, not {}", + exc_value.class().name(), + pair.class().name() + ))); + } let pair_tuple: PyTupleRef = pair.try_into_value(vm)?; if pair_tuple.len() < 2 { return Err(vm.new_type_error(format!( @@ -2491,8 +2535,8 @@ pub fn exception_group_match( } /// Prepare exception for reraise in except* block. +/// Implements _PyExc_PrepReraiseStar pub fn prep_reraise_star(orig: PyObjectRef, excs: PyObjectRef, vm: &VirtualMachine) -> PyResult { - // Implements _PyExc_PrepReraiseStar use crate::builtins::PyList; let excs_list = excs @@ -2501,7 +2545,20 @@ pub fn prep_reraise_star(orig: PyObjectRef, excs: PyObjectRef, vm: &VirtualMachi let excs_vec: Vec = excs_list.borrow_vec().to_vec(); - // Filter out None values + // If no exceptions to process, return None + if excs_vec.is_empty() { + return Ok(vm.ctx.none()); + } + + // Special case: naked exception (not an ExceptionGroup) + // Only one except* clause could have executed, so there's at most one exception to raise + if !orig.fast_isinstance(vm.ctx.exceptions.base_exception_group) { + // Find first non-None exception + let first = excs_vec.into_iter().find(|e| !vm.is_none(e)); + return Ok(first.unwrap_or_else(|| vm.ctx.none())); + } + + // Split excs into raised (new) and reraised (from original) by comparing metadata let mut raised: Vec = Vec::new(); let mut reraised: Vec = Vec::new(); @@ -2509,8 +2566,8 @@ pub fn prep_reraise_star(orig: PyObjectRef, excs: PyObjectRef, vm: &VirtualMachi if vm.is_none(&exc) { continue; } - // Check if this exception was in the original exception group - if !vm.is_none(&orig) && is_same_exception_metadata(&exc, &orig, vm) { + // Check if this exception came from the original group + if is_exception_from_orig(&exc, &orig, vm) { reraised.push(exc); } else { raised.push(exc); @@ -2522,37 +2579,134 @@ pub fn prep_reraise_star(orig: PyObjectRef, excs: PyObjectRef, vm: &VirtualMachi return Ok(vm.ctx.none()); } - // Combine raised and reraised exceptions - let mut all_excs = raised; - all_excs.extend(reraised); + // Project reraised exceptions onto original structure to preserve nesting + let reraised_eg = exception_group_projection(&orig, &reraised, vm)?; + + // If no new raised exceptions, just return the reraised projection + if raised.is_empty() { + return Ok(reraised_eg); + } + + // Combine raised with reraised_eg + if !vm.is_none(&reraised_eg) { + raised.push(reraised_eg); + } - if all_excs.len() == 1 { - // If only one exception, just return it - return Ok(all_excs.into_iter().next().unwrap()); + // If only one exception, return it directly + if raised.len() == 1 { + return Ok(raised.into_iter().next().unwrap()); } - // Create new ExceptionGroup - let excs_tuple = vm.ctx.new_tuple(all_excs); + // Create new ExceptionGroup for multiple exceptions + let excs_tuple = vm.ctx.new_tuple(raised); let eg_type: PyObjectRef = crate::exception_group::exception_group().to_owned().into(); eg_type.call((vm.ctx.new_str(""), excs_tuple), vm) } -/// Check if two exceptions have the same metadata (for reraise detection) -fn is_same_exception_metadata(exc1: &PyObjectRef, exc2: &PyObjectRef, vm: &VirtualMachine) -> bool { - // Check if exc1 is part of exc2's exception group - if exc2.fast_isinstance(vm.ctx.exceptions.base_exception_group) { - let exc_class: PyObjectRef = exc1.class().to_owned().into(); - if let Ok(result) = vm.call_method(exc2, "subgroup", (exc_class,)) - && !vm.is_none(&result) - && let Ok(subgroup_excs) = result.get_attr("exceptions", vm) - && let Ok(tuple) = subgroup_excs.downcast::() - { - for e in tuple.iter() { - if e.is(exc1) { - return true; - } - } +/// Check if an exception came from the original group (for reraise detection). +/// Instead of comparing metadata (which can be modified when caught), we compare +/// leaf exception object IDs. split() preserves leaf exception identity. +fn is_exception_from_orig(exc: &PyObjectRef, orig: &PyObjectRef, vm: &VirtualMachine) -> bool { + // Collect leaf exception IDs from exc + let mut exc_leaf_ids = HashSet::new(); + collect_exception_group_leaf_ids(exc, &mut exc_leaf_ids, vm); + + if exc_leaf_ids.is_empty() { + return false; + } + + // Collect leaf exception IDs from orig + let mut orig_leaf_ids = HashSet::new(); + collect_exception_group_leaf_ids(orig, &mut orig_leaf_ids, vm); + + // If ALL of exc's leaves are in orig's leaves, it's a reraise + exc_leaf_ids.iter().all(|id| orig_leaf_ids.contains(id)) +} + +/// Collect all leaf exception IDs from an exception (group). +fn collect_exception_group_leaf_ids( + exc: &PyObjectRef, + leaf_ids: &mut HashSet, + vm: &VirtualMachine, +) { + if vm.is_none(exc) { + return; + } + + // If not an exception group, it's a leaf - add its ID + if !exc.fast_isinstance(vm.ctx.exceptions.base_exception_group) { + leaf_ids.insert(exc.get_id()); + return; + } + + // Recurse into exception group's exceptions + if let Ok(excs_attr) = exc.get_attr("exceptions", vm) + && let Ok(tuple) = excs_attr.downcast::() + { + for e in tuple.iter() { + collect_exception_group_leaf_ids(e, leaf_ids, vm); + } + } +} + +/// Project orig onto keep list, preserving nested structure. +/// Returns an exception group containing only the exceptions from orig +/// that are also in the keep list. +fn exception_group_projection( + orig: &PyObjectRef, + keep: &[PyObjectRef], + vm: &VirtualMachine, +) -> PyResult { + if keep.is_empty() { + return Ok(vm.ctx.none()); + } + + // Collect all leaf IDs from keep list + let mut leaf_ids = HashSet::new(); + for e in keep { + collect_exception_group_leaf_ids(e, &mut leaf_ids, vm); + } + + // Split orig by matching leaf IDs, preserving structure + split_by_leaf_ids(orig, &leaf_ids, vm) +} + +/// Recursively split an exception (group) by leaf IDs. +/// Returns the projection containing only matching leaves with preserved structure. +fn split_by_leaf_ids( + exc: &PyObjectRef, + leaf_ids: &HashSet, + vm: &VirtualMachine, +) -> PyResult { + if vm.is_none(exc) { + return Ok(vm.ctx.none()); + } + + // If not an exception group, check if it's in our set + if !exc.fast_isinstance(vm.ctx.exceptions.base_exception_group) { + if leaf_ids.contains(&exc.get_id()) { + return Ok(exc.clone()); } + return Ok(vm.ctx.none()); } - exc1.is(exc2) + + // Exception group - recurse and reconstruct + let excs_attr = exc.get_attr("exceptions", vm)?; + let tuple: PyTupleRef = excs_attr.try_into_value(vm)?; + + let mut matched = Vec::new(); + for e in tuple.iter() { + let m = split_by_leaf_ids(e, leaf_ids, vm)?; + if !vm.is_none(&m) { + matched.push(m); + } + } + + if matched.is_empty() { + return Ok(vm.ctx.none()); + } + + // Reconstruct using derive() to preserve the structure (not necessarily the subclass type) + let matched_tuple = vm.ctx.new_tuple(matched); + vm.call_method(exc, "derive", (matched_tuple,)) } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 6cb41f41d9..3f470e5453 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -1,8 +1,8 @@ use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, builtins::{ - PyBaseExceptionRef, PyCode, PyCoroutine, PyDict, PyDictRef, PyGenerator, PyList, PySet, - PySlice, PyStr, PyStrInterned, PyStrRef, PyTraceback, PyType, + PyBaseException, PyBaseExceptionRef, PyCode, PyCoroutine, PyDict, PyDictRef, PyGenerator, + PyList, PySet, PySlice, PyStr, PyStrInterned, PyStrRef, PyTraceback, PyType, asyncgenerator::PyAsyncGenWrappedValue, function::{PyCell, PyCellRef, PyFunction}, tuple::{PyTuple, PyTupleRef}, @@ -361,10 +361,6 @@ impl ExecutingFrame<'_> { let mut arg_state = bytecode::OpArgState::default(); loop { let idx = self.lasti() as usize; - // eprintln!( - // "location: {:?} {}", - // self.code.locations[idx], self.code.source_path - // ); self.update_lasti(|i| *i += 1); let bytecode::CodeUnit { op, arg } = instructions[idx]; let arg = arg_state.extend(arg); @@ -1406,6 +1402,15 @@ impl ExecutingFrame<'_> { set.add(item, vm)?; Ok(None) } + bytecode::Instruction::SetExcInfo => { + // Set the current exception to TOS (for except* handlers) + // This updates sys.exc_info() so bare 'raise' will reraise the matched exception + let exc = self.top_value(); + if let Some(exc) = exc.downcast_ref::() { + vm.set_exception(Some(exc.to_owned())); + } + Ok(None) + } bytecode::Instruction::SetFunctionAttribute { attr } => { self.execute_set_function_attribute(vm, attr.get(arg)) }