Skip to content

Commit 053ae2f

Browse files
committed
Fix traceback, syntax errors, and exception handling
- Restore source range for BinOp, UnaryOp, Subscript in codegen - Convert EOF parse errors to unclosed bracket errors with location - Adjust IndentationError caret to end of line content - Detect tab/space mixing to raise TabError instead of IndentationError - Implement Frame.clear() with executing-frame check - Add __dir__ to PyTraceback, fix set_tb_next delete handling - Distinguish new raise vs propagated exception for __context__ - Remove aggressive traceback clearing on function return - Set ImportError.name to module name, add name_from for symbol - Add ImportError suggestions via name_from lookup - Prevent set_attribute_error_context from overwriting existing attrs - Normalize ruff error messages to "invalid syntax" where appropriate - Route sys.excepthook through traceback.print_exception - Add SyntaxError._metadata for keyword typo suggestions - Remove expectedFailure from passing tests
1 parent 69b50c4 commit 053ae2f

File tree

12 files changed

+364
-100
lines changed

12 files changed

+364
-100
lines changed

Lib/test/test_exceptions.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,7 +1745,6 @@ def __del__(self):
17451745
f"deallocator {obj_repr}")
17461746
self.assertIsNotNone(cm.unraisable.exc_traceback)
17471747

1748-
@unittest.expectedFailure # TODO: RUSTPYTHON
17491748
def test_unhandled(self):
17501749
# Check for sensible reporting of unhandled exceptions
17511750
for exc_type in (ValueError, BrokenStrException):
@@ -2283,7 +2282,6 @@ def test_multiline_not_highlighted(self):
22832282
class SyntaxErrorTests(unittest.TestCase):
22842283
maxDiff = None
22852284

2286-
@unittest.expectedFailure # TODO: RUSTPYTHON
22872285
@force_not_colorized
22882286
def test_range_of_offsets(self):
22892287
cases = [

Lib/test/test_syntax.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2293,7 +2293,6 @@ def test_expression_with_assignment(self):
22932293
offset=7
22942294
)
22952295

2296-
@unittest.expectedFailure # TODO: RUSTPYTHON
22972296
def test_curly_brace_after_primary_raises_immediately(self):
22982297
self._check_error("f{}", "invalid syntax", mode="single")
22992298

Lib/test/test_traceback.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ def syntax_error_bad_indentation2(self):
8888
def tokenizer_error_with_caret_range(self):
8989
compile("blech ( ", "?", "exec")
9090

91-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 11 != 14
9291
def test_caret(self):
9392
err = self.get_exception_format(self.syntax_error_with_caret,
9493
SyntaxError)
@@ -201,7 +200,6 @@ def f():
201200
finally:
202201
unlink(TESTFN)
203202

204-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 3 != 4
205203
def test_bad_indentation(self):
206204
err = self.get_exception_format(self.syntax_error_bad_indentation,
207205
IndentationError)
@@ -1797,7 +1795,6 @@ class TestKeywordTypoSuggestions(unittest.TestCase):
17971795
("for x im n:\n pass", "in"),
17981796
]
17991797

1800-
@unittest.expectedFailure # TODO: RUSTPYTHON
18011798
def test_keyword_suggestions_from_file(self):
18021799
with tempfile.TemporaryDirectory() as script_dir:
18031800
for i, (code, expected_kw) in enumerate(self.TYPO_CASES):
@@ -1808,7 +1805,6 @@ def test_keyword_suggestions_from_file(self):
18081805
stderr_text = stderr.decode('utf-8')
18091806
self.assertIn(f"Did you mean '{expected_kw}'", stderr_text)
18101807

1811-
@unittest.expectedFailure # TODO: RUSTPYTHON
18121808
def test_keyword_suggestions_from_command_string(self):
18131809
for code, expected_kw in self.TYPO_CASES:
18141810
with self.subTest(typo=expected_kw):
@@ -3352,7 +3348,6 @@ class MiscTracebackCases(unittest.TestCase):
33523348
# Check non-printing functions in traceback module
33533349
#
33543350

3355-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 1 != 0
33563351
def test_clear(self):
33573352
def outer():
33583353
middle()
@@ -3574,7 +3569,6 @@ def format_frame_summary(self, frame_summary, colorize=False):
35743569
f' File "{__file__}", line {lno}, in f\n 1/0\n'
35753570
)
35763571

3577-
@unittest.expectedFailure # TODO: RUSTPYTHON; Actual: _should_show_carets(13, 14, ['# this line will be used during rendering'], None)
35783572
def test_summary_should_show_carets(self):
35793573
# See: https://github.com/python/cpython/issues/122353
35803574

@@ -3731,7 +3725,6 @@ def test_context(self):
37313725
self.assertEqual(type(exc_obj).__name__, exc.exc_type_str)
37323726
self.assertEqual(str(exc_obj), str(exc))
37333727

3734-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 11 not greater than 1000
37353728
def test_long_context_chain(self):
37363729
def f():
37373730
try:
@@ -4059,7 +4052,6 @@ def test_exception_group_format_exception_onlyi_recursive(self):
40594052

40604053
self.assertEqual(formatted, expected)
40614054

4062-
@unittest.expectedFailure # TODO: RUSTPYTHON; Diff is 2265 characters long. Set self.maxDiff to None to see it.
40634055
def test_exception_group_format(self):
40644056
teg = traceback.TracebackException.from_exception(self.eg)
40654057

@@ -4841,19 +4833,15 @@ class PurePythonSuggestionFormattingTests(
48414833
traceback printing in traceback.py.
48424834
"""
48434835

4844-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "'bluch'" not found in "ImportError: cannot import name 'blach'"
48454836
def test_import_from_suggestions_underscored(self):
48464837
return super().test_import_from_suggestions_underscored()
48474838

4848-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "'bluch'" not found in "ImportError: cannot import name 'blech'"
48494839
def test_import_from_suggestions_non_string(self):
48504840
return super().test_import_from_suggestions_non_string()
48514841

4852-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "'bluchin'?" not found in "ImportError: cannot import name 'bluch'"
48534842
def test_import_from_suggestions(self):
48544843
return super().test_import_from_suggestions()
48554844

4856-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 'Did you mean' not found in "AttributeError: 'A' object has no attribute 'blich'"
48574845
def test_attribute_error_inside_nested_getattr(self):
48584846
return super().test_attribute_error_inside_nested_getattr()
48594847

@@ -4969,7 +4957,6 @@ class MyList(list):
49694957
class TestColorizedTraceback(unittest.TestCase):
49704958
maxDiff = None
49714959

4972-
@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 = <function TestColorizedTraceback.test_colorized_traceback.<locals>.bar at 0xb57b09180>\n baz1 = <function TestColorizedTraceback.test_colorized_traceback.<locals>.baz1 at 0xb57b09e00>\n baz2 = <function TestColorizedTraceback.test_colorized_traceback.<locals>.baz2 at 0xb57b09cc0>\n e = TypeError("\'NoneType\' object is not subscriptable")\n foo = <function TestColorizedTraceback.test_colorized_traceback.<locals>.foo at 0xb57b08140>\n self = <test.test_traceback.TestColorizedTraceback testMethod=test_colorized_traceback>\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 = <function TestColorizedTraceback.test_colorized_traceback.<locals>.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 = <function TestColorizedTraceback.test_colorized_traceback.<locals>.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 = <function TestColorizedTraceback.test_colorized_traceback.<locals>.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<lambda>\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 = <function TestColorizedTraceback.test_colorized_traceback.<locals>.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'
49734960
def test_colorized_traceback(self):
49744961
def foo(*args):
49754962
x = {'a':{'b': None}}
@@ -5002,7 +4989,6 @@ def bar():
50024989
self.assertIn("return baz1(1,\n 2,3\n ,4)", lines)
50034990
self.assertIn(red + "bar" + reset + boldr + "()" + reset, lines)
50044991

5005-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ' File \x1b[35m"<string>"\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", "<string>", "exec")\x1b[0m\n \x1b[31m~~~~~~~\x1b[0m\x1b[1;31m^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\x1b[0m\n e = SyntaxError(\'got unexpected token $\')\n self = <test.test_traceback.TestColorizedTraceback testMethod=test_colorized_syntax_error>\n File \x1b[35m"<string>"\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'
50064992
def test_colorized_syntax_error(self):
50074993
try:
50084994
compile("a $ b", "<string>", "exec")
@@ -5053,7 +5039,6 @@ def expected(t, m, fn, l, f, E, e, z):
50535039
]
50545040
self.assertEqual(actual, expected(**colors))
50555041

5056-
@unittest.expectedFailure # TODO: RUSTPYTHON; Diff is 1795 characters long. Set self.maxDiff to None to see it.
50575042
def test_colorized_traceback_from_exception_group(self):
50585043
def foo():
50595044
exceptions = []

crates/codegen/src/compile.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,9 @@ impl Compiler {
493493
slice: &ast::Expr,
494494
ctx: ast::ExprContext,
495495
) -> CompileResult<()> {
496+
// Save the subscript expression's source range
497+
let subscript_range = self.current_source_range;
498+
496499
// 1. Check subscripter and index for Load context
497500
// 2. VISIT value
498501
// 3. Handle two-element slice specially
@@ -515,6 +518,8 @@ impl Compiler {
515518
"should_use_slice_optimization should only return true for ast::Expr::Slice"
516519
),
517520
};
521+
// Restore full subscript expression range before emitting
522+
self.set_source_range(subscript_range);
518523
match ctx {
519524
ast::ExprContext::Load => {
520525
emit!(self, Instruction::BinarySlice);
@@ -527,6 +532,8 @@ impl Compiler {
527532
} else {
528533
// VISIT(c, expr, e->v.Subscript.slice)
529534
self.compile_expression(slice)?;
535+
// Restore full subscript expression range before emitting
536+
self.set_source_range(subscript_range);
530537

531538
// Emit appropriate instruction based on context
532539
match ctx {
@@ -6603,7 +6610,8 @@ impl Compiler {
66036610
self.compile_expression(left)?;
66046611
self.compile_expression(right)?;
66056612

6606-
// Perform operation:
6613+
// Restore full expression range before emitting the operation
6614+
self.set_source_range(range);
66076615
self.compile_op(op, false);
66086616
}
66096617
ast::Expr::Subscript(ast::ExprSubscript {
@@ -6614,7 +6622,8 @@ impl Compiler {
66146622
ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => {
66156623
self.compile_expression(operand)?;
66166624

6617-
// Perform operation:
6625+
// Restore full expression range before emitting the operation
6626+
self.set_source_range(range);
66186627
match op {
66196628
ast::UnaryOp::UAdd => emit!(
66206629
self,

crates/compiler/src/lib.rs

Lines changed: 144 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,64 @@ pub enum CompileError {
4646
impl CompileError {
4747
pub fn from_ruff_parse_error(error: parser::ParseError, source_file: &SourceFile) -> Self {
4848
let source_code = source_file.to_source_code();
49-
let location = source_code.source_location(error.location.start(), PositionEncoding::Utf8);
50-
let mut end_location =
51-
source_code.source_location(error.location.end(), PositionEncoding::Utf8);
52-
53-
// If the error range ends at the start of a new line (column 1),
54-
// adjust it to the end of the previous line
55-
if end_location.character_offset.get() == 1 && end_location.line > location.line {
56-
// Get the end of the previous line
57-
let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1);
58-
end_location = source_code.source_location(prev_line_end, PositionEncoding::Utf8);
59-
// Adjust column to be after the last character
60-
end_location.character_offset = end_location.character_offset.saturating_add(1);
61-
}
49+
let source_text = source_file.source_text();
50+
51+
// For EOF errors (unclosed brackets), find the unclosed bracket position
52+
// and adjust both the error location and message
53+
let (error_type, location, end_location) = if matches!(
54+
&error.error,
55+
parser::ParseErrorType::Lexical(parser::LexicalErrorType::Eof)
56+
) {
57+
if let Some((bracket_char, bracket_offset)) = find_unclosed_bracket(source_text) {
58+
let bracket_text_size = ruff_text_size::TextSize::new(bracket_offset as u32);
59+
let loc = source_code.source_location(bracket_text_size, PositionEncoding::Utf8);
60+
let end_loc = SourceLocation {
61+
line: loc.line,
62+
character_offset: loc.character_offset.saturating_add(1),
63+
};
64+
let msg = format!("'{}' was never closed", bracket_char);
65+
(parser::ParseErrorType::OtherError(msg), loc, end_loc)
66+
} else {
67+
let loc =
68+
source_code.source_location(error.location.start(), PositionEncoding::Utf8);
69+
let end_loc =
70+
source_code.source_location(error.location.end(), PositionEncoding::Utf8);
71+
(error.error, loc, end_loc)
72+
}
73+
} else if matches!(
74+
&error.error,
75+
parser::ParseErrorType::Lexical(parser::LexicalErrorType::IndentationError)
76+
) {
77+
// For IndentationError, point the offset to the end of the line content
78+
// (matching CPython behavior) instead of the beginning
79+
let loc = source_code.source_location(error.location.start(), PositionEncoding::Utf8);
80+
let line_idx = loc.line.to_zero_indexed();
81+
let line = source_text.split('\n').nth(line_idx).unwrap_or("");
82+
let line_end_col = line.len() + 1; // 1-indexed, past last char (the newline)
83+
let end_loc = SourceLocation {
84+
line: loc.line,
85+
character_offset: ruff_source_file::OneIndexed::new(line_end_col)
86+
.unwrap_or(loc.character_offset),
87+
};
88+
(error.error, end_loc, end_loc)
89+
} else {
90+
let loc = source_code.source_location(error.location.start(), PositionEncoding::Utf8);
91+
let mut end_loc =
92+
source_code.source_location(error.location.end(), PositionEncoding::Utf8);
93+
94+
// If the error range ends at the start of a new line (column 1),
95+
// adjust it to the end of the previous line
96+
if end_loc.character_offset.get() == 1 && end_loc.line > loc.line {
97+
let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1);
98+
end_loc = source_code.source_location(prev_line_end, PositionEncoding::Utf8);
99+
end_loc.character_offset = end_loc.character_offset.saturating_add(1);
100+
}
101+
102+
(error.error, loc, end_loc)
103+
};
62104

63105
Self::Parse(ParseError {
64-
error: error.error,
106+
error: error_type,
65107
raw_location: error.location,
66108
location,
67109
end_location,
@@ -102,6 +144,94 @@ impl CompileError {
102144
}
103145
}
104146

147+
/// Find the last unclosed opening bracket in source code.
148+
/// Returns the bracket character and its byte offset, or None if all brackets are balanced.
149+
fn find_unclosed_bracket(source: &str) -> Option<(char, usize)> {
150+
let mut stack: Vec<(char, usize)> = Vec::new();
151+
let mut in_string = false;
152+
let mut string_quote = '\0';
153+
let mut triple_quote = false;
154+
let mut escape_next = false;
155+
156+
let chars: Vec<char> = source.chars().collect();
157+
let mut i = 0;
158+
let mut byte_offset = 0;
159+
160+
while i < chars.len() {
161+
let ch = chars[i];
162+
let ch_len = ch.len_utf8();
163+
164+
if escape_next {
165+
escape_next = false;
166+
byte_offset += ch_len;
167+
i += 1;
168+
continue;
169+
}
170+
171+
if in_string {
172+
if ch == '\\' {
173+
escape_next = true;
174+
} else if triple_quote {
175+
if ch == string_quote
176+
&& i + 2 < chars.len()
177+
&& chars[i + 1] == string_quote
178+
&& chars[i + 2] == string_quote
179+
{
180+
in_string = false;
181+
byte_offset += ch_len * 3;
182+
i += 3;
183+
continue;
184+
}
185+
} else if ch == string_quote {
186+
in_string = false;
187+
}
188+
byte_offset += ch_len;
189+
i += 1;
190+
continue;
191+
}
192+
193+
// Check for comments
194+
if ch == '#' {
195+
// Skip to end of line
196+
while i < chars.len() && chars[i] != '\n' {
197+
byte_offset += chars[i].len_utf8();
198+
i += 1;
199+
}
200+
continue;
201+
}
202+
203+
// Check for string start
204+
if ch == '\'' || ch == '"' {
205+
string_quote = ch;
206+
if i + 2 < chars.len() && chars[i + 1] == ch && chars[i + 2] == ch {
207+
triple_quote = true;
208+
in_string = true;
209+
byte_offset += ch_len * 3;
210+
i += 3;
211+
continue;
212+
}
213+
triple_quote = false;
214+
in_string = true;
215+
byte_offset += ch_len;
216+
i += 1;
217+
continue;
218+
}
219+
220+
match ch {
221+
'(' | '[' | '{' => stack.push((ch, byte_offset)),
222+
')' | ']' | '}' => {
223+
stack.pop();
224+
}
225+
_ => {}
226+
}
227+
228+
byte_offset += ch_len;
229+
i += 1;
230+
}
231+
232+
stack.last().copied()
233+
}
234+
105235
/// Compile a given source code into a bytecode object.
106236
pub fn compile(
107237
source: &str,

0 commit comments

Comments
 (0)