From a3cb94e2aba631ed64215ed71673599d21e6409a Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 00:28:53 +0900 Subject: [PATCH 1/8] Preserve interpolation source text and canonical metadata Source-only checkpoint; candidate validation and the existing annotation expectation reconciliation remain pending. Assisted-by: OpenAI Codex:model version unavailable --- Lib/test/test_tstring.py | 124 +++++++++++++++++++++- crates/codegen/src/compile.rs | 19 ++-- crates/codegen/src/lib.rs | 155 ++++++++++++++++++---------- crates/codegen/src/unparse.rs | 75 +++----------- crates/vm/src/stdlib/_ast/string.rs | 127 ++++------------------- 5 files changed, 269 insertions(+), 231 deletions(-) diff --git a/Lib/test/test_tstring.py b/Lib/test/test_tstring.py index 74653c77c55..1e5f14ca007 100644 --- a/Lib/test/test_tstring.py +++ b/Lib/test/test_tstring.py @@ -1,5 +1,9 @@ +import subprocess import unittest +from test import support +from test.support.os_helper import temp_cwd +from test.support.script_helper import assert_python_ok, spawn_python from test.test_string._support import TStringBaseCase, fstring @@ -79,6 +83,44 @@ def upper(self): ) self.assertEqual(fstring(t), "Name: Bob, Age: 30") + @support.requires_subprocess() + def test_expression_in_interactive_after_buffer_resize(self): + expression = "(\n" + (" " * 64 + "\n") * 256 + "1\n)" + source = ( + f"result = t'''{{{expression}}}'''\n" + "print(repr(result.interpolations[0].expression))\n" + ) + with spawn_python('-i', '-q', stderr=subprocess.PIPE) as process: + stdout, stderr = process.communicate( + source.encode(), timeout=support.SHORT_TIMEOUT) + self.assertEqual(process.returncode, 0, stderr) + self.assertEqual(stdout.decode().strip(), repr(expression)) + + def test_interpolation_expression_in_file_after_buffer_resize(self): + expression = "(\n" + (" " * 64 + "\n") * 256 + "1\n)" + with temp_cwd(): + script = 'script.py' + source = ( + f"template = t'''{{{expression}}}'''\n" + "interpolation = template.interpolations[0]\n" + f"assert interpolation.expression == {expression!r}\n" + ) + with open(script, 'w') as f: + f.write(source) + assert_python_ok(script) + + @support.requires_resource('cpu') + def test_many_tstrings_in_module(self): + fields = ''.join(f'{{x{i}}}' for i in range(100)) + source = ''.join( + f"value_{i} = t'{fields}'\n" for i in range(1_000) + ) + namespace = {f'x{i}': str(i) for i in range(100)} + expected = ''.join(str(i) for i in range(100)) + exec(source, namespace) + self.assertEqual(fstring(namespace['value_0']), expected) + self.assertEqual(fstring(namespace['value_999']), expected) + def test_format_specifiers(self): # Test basic format specifiers value = 3.14159 @@ -88,6 +130,14 @@ def test_format_specifiers(self): ) self.assertEqual(fstring(t), "Pi: 3.14") + a = 3 + b = 4 + t = t"{a!=b:>10}" + self.assertTStringEqual( + t, ("", ""), [(a != b, "a!=b", None, ">10")] + ) + self.assertEqual(fstring(t), " 1") + def test_conversions(self): # Test !s conversion (str) obj = object() @@ -136,10 +186,82 @@ def test_debug_specifier(self): # Test white space in debug specifier t = t"Value: {value = }" self.assertTStringEqual( - t, ("Value: value = ", ""), [(value, "value", "r")] + t, ("Value: value = ", ""), [(value, "value ", "r")] ) self.assertEqual(fstring(t), "Value: value = 42") + # Explicit line continuations after the debug marker are part of + # the debug text, not the interpolation expression. + for template, strings, interpolation, rendered in ( + ( + t"""Value: {value =\ +}""", + ("Value: value =\\\n", ""), + (value, "value ", "r"), + "Value: value =\\\n42", + ), + ( + t"""Value: {value =\ +!r}""", + ("Value: value =\\\n", ""), + (value, "value ", "r"), + "Value: value =\\\n42", + ), + ( + t"""Value: {value =\ +:04}""", + ("Value: value =\\\n", ""), + (value, "value ", None, "04"), + "Value: value =\\\n0042", + ), + ( + t"""Value: {value =\ +\ +}""", + ("Value: value =\\\n\\\n", ""), + (value, "value ", "r"), + "Value: value =\\\n\\\n42", + ), + ): + with self.subTest(template=template): + self.assertTStringEqual(template, strings, [interpolation]) + self.assertEqual(fstring(template), rendered) + + def test_interpolation_expression_whitespace(self): + x = 42 + for template, expected in ( + (t"{x}", "x"), + (t"{x }", "x "), + (t"{ x}", " x"), + (t"{ x }", " x "), + (t"{ x }", " x "), + (t"""{ + x +}""", "\n x\n"), + (t"{ x !r}", " x "), + (t"{ x :.2f}", " x "), + (t"{ x = }", " x "), + (t"{ x = !r}", " x "), + (t"{ x = :.2f}", " x "), + (t"{x == 42 = }", "x == 42 "), + ): + with self.subTest(template=template): + self.assertEqual( + template.interpolations[0].expression, + expected, + ) + + def test_interpolation_expression_with_reconstructed_metadata(self): + regular = t'''{( + 1, # Force lexer metadata reconstruction. + "\"#")}''' + debug = t'''{( + 1, # Force lexer metadata reconstruction. + "\"#")=}''' + expected = '(\n 1, \n "\\"#")' + self.assertEqual(regular.interpolations[0].expression, expected) + self.assertEqual(debug.interpolations[0].expression, expected) + def test_raw_tstrings(self): path = r"C:\Users" t = rt"{path}\Documents" diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 7d43a502c01..b09f1fc00c5 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -13821,18 +13821,13 @@ impl<'warnings> Compiler<'warnings> { self.compile_expression(&interp.expression)?; - let expr_range = interp.expression.range(); - let expr_source = if interp.range.start() < expr_range.start() - && interp.range.end() >= expr_range.end() - { - let after_brace = interp.range.start() + TextSize::new(1); - self.source_file - .source_text() - .slice(TextRange::new(after_brace, expr_range.end())) - } else { - self.source_file.source_text().slice(expr_range) - } - .to_string(); + let expr_source = crate::interpolation_expression_text(&self.source_file, interp) + .unwrap_or_else(|| { + self.source_file + .source_text() + .slice(interp.expression.range()) + .to_owned() + }); self.set_source_node(interp.range, &interp.node_index); self.emit_load_const(ConstantData::Str { value: expr_source.into(), diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 3e3f9e2757a..348fc2433ee 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -189,12 +189,14 @@ pub fn interpolation_debug_text( ) -> (String, TextRange) { let leading = debug_text.leading(); let trailing = debug_text.trailing(); - let text = [ - strip_python_comments(leading).as_str(), - source_file.source_text().slice(expression_range), - strip_python_comments(trailing).as_str(), - ] - .concat(); + let text = strip_python_comments( + &[ + leading, + source_file.source_text().slice(expression_range), + trailing, + ] + .concat(), + ); let width = |len: usize| TextSize::new(u32::try_from(len).expect("debug interpolation text too long")); let range = TextRange::new( @@ -204,64 +206,109 @@ pub fn interpolation_debug_text( (text, range) } +/// The source text stored in a template interpolation, including whitespace +/// before its debug marker, conversion, format specifier, or closing brace. +/// Returns `None` for a synthetic node without a source interpolation range. +#[must_use] +pub fn interpolation_expression_text( + source_file: &SourceFile, + interpolation: &ast::InterpolatedElement, +) -> Option { + if let Some(debug_text) = &interpolation.debug_text { + let mut text = strip_python_comments(debug_text.as_str()); + let end = interpolation_suffix_end(&text); + if text[..end].ends_with('=') { + text.truncate(end - 1); + } + return Some(text); + } + + let expression_range = interpolation.expression.range(); + if interpolation.range.start() >= expression_range.start() + || interpolation.range.end() <= expression_range.end() + { + return None; + } + let start = interpolation.range.start() + TextSize::new(1); + let end = interpolation + .format_spec + .as_ref() + .map_or_else( + || interpolation.range.end(), + |format_spec| format_spec.range.start(), + ) + .checked_sub(TextSize::new(1))?; + if start > expression_range.start() || end < expression_range.end() { + return None; + } + let mut text = + strip_python_comments(source_file.source_text().slice(TextRange::new(start, end))); + if interpolation.conversion != ast::ConversionFlag::None { + let end = interpolation_suffix_end(&text); + let conversion_start = end.checked_sub(2)?; + if text.as_bytes().get(conversion_start) != Some(&b'!') { + return None; + } + text.truncate(conversion_start); + } + Some(text) +} + +fn interpolation_suffix_end(text: &str) -> usize { + let mut end = text.len(); + // Debug markers and conversions can be followed by whitespace and line + // continuations. Whitespace preceding either marker remains significant. + loop { + let trimmed = text[..end].trim_end(); + let has_newline = text[trimmed.len()..end].contains(['\n', '\r']); + end = trimmed.len(); + if has_newline && text[..end].ends_with('\\') { + end -= 1; + } else { + return end; + } + } +} + fn strip_python_comments(text: &str) -> String { - let chars = text.chars().collect::>(); + // Tokenizer metadata uses universal newlines even when source ranges refer + // to the original CRLF or CR input. + let normalized; + let text = if text.contains('\r') { + normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + normalized.as_str() + } else { + text + }; + let mut chars = text.chars(); let mut result = String::with_capacity(text.len()); let mut quote = None; - let mut triple_quoted = false; - let mut escaped = false; - let mut in_comment = false; - let mut index = 0; - while index < chars.len() { - let ch = chars[index]; - if in_comment { - if matches!(ch, '\n' | '\r') { - in_comment = false; - result.push(ch); - } - index += 1; - continue; - } - - if let Some(delimiter) = quote { + while let Some(ch) = chars.next() { + // Match tokenizer metadata reconstruction, including escaped quotes + // outside literals and its per-quote handling of triple strings. + if ch == '\\' { result.push(ch); - if escaped { - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if triple_quoted - && ch == delimiter - && chars.get(index + 1) == Some(&delimiter) - && chars.get(index + 2) == Some(&delimiter) - { - result.push(delimiter); - result.push(delimiter); - quote = None; - index += 2; - } else if !triple_quoted && ch == delimiter { - quote = None; + if let Some(escaped) = chars.next() { + result.push(escaped); } - index += 1; - continue; - } - - match ch { - '#' => in_comment = true, - '\'' | '"' => { + } else if matches!(ch, '\'' | '"') { + if quote.is_none() { quote = Some(ch); - triple_quoted = - chars.get(index + 1) == Some(&ch) && chars.get(index + 2) == Some(&ch); - result.push(ch); - if triple_quoted { - result.push(ch); - result.push(ch); - index += 2; + } else if quote == Some(ch) { + quote = None; + } + result.push(ch); + } else if ch == '#' && quote.is_none() { + for comment_ch in chars.by_ref() { + if comment_ch == '\n' { + result.push('\n'); + break; } } - _ => result.push(ch), + } else { + result.push(ch); } - index += 1; } result } diff --git a/crates/codegen/src/unparse.rs b/crates/codegen/src/unparse.rs index 7c139e77a8e..6cc27bad1bc 100644 --- a/crates/codegen/src/unparse.rs +++ b/crates/codegen/src/unparse.rs @@ -1,8 +1,8 @@ -use crate::strip_python_comments; +use crate::{interpolation_debug_text, interpolation_expression_text}; use alloc::fmt; use core::fmt::Display as _; use ruff_python_ast as ast; -use ruff_text_size::{Ranged, TextSize, TextSlice}; +use ruff_text_size::Ranged; use rustpython_compiler_core::SourceFile; use rustpython_literal::escape::{AsciiEscape, UnicodeEscape}; @@ -615,11 +615,8 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { fmt::from_fn(|f| Unparser::new(f, self.source).unparse_expr(val, precedence::TEST + 1)) .to_string(); if let Some(debug_text) = debug_text { - let leading = debug_text.leading(); - let trailing = debug_text.trailing(); - self.p(leading)?; - self.p(self.source.source_text().slice(val.range()))?; - self.p(trailing)?; + let (text, _) = interpolation_debug_text(self.source, debug_text, val.range()); + self.p(&text)?; if conversion == ast::ConversionFlag::None && spec.is_none() { conversion = ast::ConversionFlag::Repr; } @@ -725,23 +722,11 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { &mut self, interpolation: &ast::InterpolatedElement, ) -> fmt::Result { - let source_conversion = interpolation.conversion; - let mut conversion = source_conversion; - let debug_parts = interpolation.debug_text.as_ref().map(|debug_text| { - ( - strip_python_comments(debug_text.leading()), - strip_python_comments( - self.source - .source_text() - .slice(interpolation.expression.range()), - ), - strip_python_comments(debug_text.trailing()), - ) - }); - if let Some((leading, source, trailing)) = &debug_parts { - self.p(leading)?; - self.p(source)?; - self.p(trailing)?; + let mut conversion = interpolation.conversion; + if let Some(debug_text) = &interpolation.debug_text { + let (text, _) = + interpolation_debug_text(self.source, debug_text, interpolation.expression.range()); + self.p(&text)?; if conversion == ast::ConversionFlag::None && interpolation.format_spec.is_none() { conversion = ast::ConversionFlag::Repr; } @@ -749,42 +734,14 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { let expression = if let Some(ast::ConstantValue::Str(value)) = &interpolation.runtime_str { value.to_string() - } else if let Some((leading, source, trailing)) = &debug_parts { - let mut expression = leading.clone(); - expression.push_str(source); - let equal = trailing - .rfind('=') - .expect("debug interpolation must contain '='"); - expression.push_str(&trailing[..equal]); - expression.trim_end().to_owned() + } else if let Some(expression) = interpolation_expression_text(self.source, interpolation) { + expression } else { - let expression_range = interpolation.expression.range(); - let after_brace = interpolation.range.start() + TextSize::new(1); - let mut expression_end = interpolation.format_spec.as_ref().map_or_else( - || interpolation.range.end() - TextSize::new(1), - |format_spec| format_spec.range.start() - TextSize::new(1), - ); - if source_conversion != ast::ConversionFlag::None { - expression_end -= TextSize::new(2); - } - if interpolation.range.start() < expression_range.start() - && interpolation.range.end() >= expression_range.end() - && after_brace <= expression_end - { - strip_python_comments( - self.source - .source_text() - .slice(ruff_text_size::TextRange::new(after_brace, expression_end)), - ) - .trim_end() - .to_owned() - } else { - fmt::from_fn(|f| { - Unparser::new(f, self.source) - .unparse_expr(&interpolation.expression, precedence::TEST + 1) - }) - .to_string() - } + fmt::from_fn(|f| { + Unparser::new(f, self.source) + .unparse_expr(&interpolation.expression, precedence::TEST + 1) + }) + .to_string() }; self.p(if expression.starts_with('{') { diff --git a/crates/vm/src/stdlib/_ast/string.rs b/crates/vm/src/stdlib/_ast/string.rs index 4cee870f92d..03daf9144e7 100644 --- a/crates/vm/src/stdlib/_ast/string.rs +++ b/crates/vm/src/stdlib/_ast/string.rs @@ -1000,38 +1000,34 @@ fn push_ruff_tstring_element( constant.node_index = literal.node_index; output.push(TemplateStrPart::Constant(constant)); } - ast::InterpolatedStringElement::Interpolation(ast::InterpolatedElement { - range, - expression, - debug_text, - mut conversion, - format_spec, - node_index, - runtime_str, - runtime_interpolation_format_spec, - runtime_formatted_value_format_spec: _, - }) => { - let expr_range = - extend_expr_range_with_wrapping_parens(source_file, range, expression.range()) - .unwrap_or_else(|| expression.range()); - let expr_str = if let Some(debug_text) = &debug_text { + ast::InterpolatedStringElement::Interpolation(interpolation) => { + let expr_str = + rustpython_codegen::interpolation_expression_text(source_file, &interpolation) + .unwrap_or_else(|| { + source_file + .source_text() + .slice(interpolation.expression.range()) + .to_owned() + }); + let ast::InterpolatedElement { + range, + expression, + debug_text, + mut conversion, + format_spec, + node_index, + runtime_str, + runtime_interpolation_format_spec, + .. + } = interpolation; + if let Some(debug_text) = &debug_text { output.push(TemplateStrPart::Constant(interpolation_debug_constant( source_file, debug_text, expression.range(), ))); conversion = debug_conversion(conversion, format_spec.is_some()); - let expr_source = source_file.source_text().slice(expr_range); - let mut expr_with_debug = String::with_capacity( - debug_text.leading().len() + expr_source.len() + debug_text.trailing().len(), - ); - expr_with_debug.push_str(debug_text.leading()); - expr_with_debug.push_str(expr_source); - expr_with_debug.push_str(debug_text.trailing()); - strip_interpolation_expr(&expr_with_debug) - } else { - tstring_interpolation_expr_str(source_file, range, expr_range) - }; + } let runtime_interpolation = super::constant::runtime_interpolation_object( vm, runtime_str, @@ -1056,85 +1052,6 @@ fn push_ruff_tstring_element( } } -fn tstring_interpolation_expr_str( - source_file: &AstSourceContext, - interpolation_range: TextRange, - expr_range: TextRange, -) -> String { - let expr_range = - extend_expr_range_with_wrapping_parens(source_file, interpolation_range, expr_range) - .unwrap_or(expr_range); - let start = interpolation_range.start() + TextSize::from(1); - let start = if start > expr_range.end() { - expr_range.start() - } else { - start - }; - let expr_source = source_file - .source_text() - .slice(TextRange::new(start, expr_range.end())); - strip_interpolation_expr(expr_source) -} - -fn extend_expr_range_with_wrapping_parens( - source_file: &AstSourceContext, - interpolation_range: TextRange, - expr_range: TextRange, -) -> Option { - let left_slice = source_file.source_text().slice(TextRange::new( - interpolation_range.start(), - expr_range.start(), - )); - let mut left_char: Option<(usize, char)> = None; - for (idx, ch) in left_slice - .char_indices() - .collect::>() - .into_iter() - .rev() - { - if !ch.is_whitespace() { - left_char = Some((idx, ch)); - break; - } - } - let (left_idx, left_ch) = left_char?; - if left_ch != '(' { - return None; - } - - let right_slice = source_file - .source_text() - .slice(TextRange::new(expr_range.end(), interpolation_range.end())); - let mut right_char: Option<(usize, char)> = None; - for (idx, ch) in right_slice.char_indices() { - if !ch.is_whitespace() { - right_char = Some((idx, ch)); - break; - } - } - let (right_idx, right_ch) = right_char?; - if right_ch != ')' { - return None; - } - - let left_pos = interpolation_range.start() + TextSize::from(left_idx as u32); - let right_pos = expr_range.end() + TextSize::from(right_idx as u32); - Some(TextRange::new(left_pos, right_pos + TextSize::from(1))) -} - -fn strip_interpolation_expr(expr_source: &str) -> String { - let mut end = expr_source.len(); - for (idx, ch) in expr_source.char_indices().rev() { - if ch.is_whitespace() || ch == '=' { - end = idx; - continue; - } - end = idx + ch.len_utf8(); - break; - } - expr_source[..end].to_owned() -} - #[derive(Debug)] pub(super) struct TemplateStr { node_index: ast::AtomicNodeIndex, From a2ea07b1a9b9ff0a850cb96f6c4d3893adea1e8b Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 00:45:31 +0900 Subject: [PATCH 2/8] Align existing annotation expectations with CPython metadata Update only the nine existing expected strings confirmed against pinned CPython 3.15, with explicit user approval. Assisted-by: OpenAI Codex:model version unavailable --- crates/codegen/src/compile.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index b09f1fc00c5..9fccb6a29d3 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -17551,22 +17551,22 @@ class C[T]: pass annotation_strings, [ "t'{a + b}'", - "t'{ a + b}'", + "t'{ a + b }'", "f'a + b ={a + b!r}'", - "t'a + b ={a + b!r}'", - "t'a + b ={a + b:>10}'", + "t'a + b ={a + b !r}'", + "t'a + b ={a + b :>10}'", "t'{(a)}'", "t'{((a))!r}'", - "t' ((a)) = { ((a))!r:>10}'", + "t' ((a)) = { ((a)) !r:>10}'", "t'(a)={(a)!r}'", - "t'a == b = {a == b!r}'", + "t'a == b = {a == b !r}'", "t'a= \\n{a!r}'", - "t'{a}'", + "t'{a \\n}'", "t'{(a \\n)}'", - "t'{a!r}'", - "t'{a:>10}'", + "t'{a \\n!r}'", + "t'{a \\n:>10}'", "t\"{'#'}\"", - "t\"{('#', a)}\"", + "t\"{('#', a) \\n}\"", ] ); } From f5aa5a4087443d2a3f9026f66486430f62fe321e Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 01:05:37 +0900 Subject: [PATCH 3/8] Preserve template metadata through compilation and interactive input Escape literal debug prefixes, retain None AST expression metadata internally, and continue incomplete triple-quoted t-strings in the existing shell. Assisted-by: OpenAI Codex:model version unavailable --- crates/codegen/src/unparse.rs | 4 ++-- crates/vm/src/builtins/interpolation.rs | 8 ++++---- crates/vm/src/frame.rs | 9 ++++++--- src/shell.rs | 3 +++ 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/codegen/src/unparse.rs b/crates/codegen/src/unparse.rs index 6cc27bad1bc..06e8f27a604 100644 --- a/crates/codegen/src/unparse.rs +++ b/crates/codegen/src/unparse.rs @@ -616,7 +616,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { .to_string(); if let Some(debug_text) = debug_text { let (text, _) = interpolation_debug_text(self.source, debug_text, val.range()); - self.p(&text)?; + self.unparse_fstring_str(&text)?; if conversion == ast::ConversionFlag::None && spec.is_none() { conversion = ast::ConversionFlag::Repr; } @@ -726,7 +726,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { if let Some(debug_text) = &interpolation.debug_text { let (text, _) = interpolation_debug_text(self.source, debug_text, interpolation.expression.range()); - self.p(&text)?; + self.unparse_fstring_str(&text)?; if conversion == ast::ConversionFlag::None && interpolation.format_spec.is_none() { conversion = ast::ConversionFlag::Repr; } diff --git a/crates/vm/src/builtins/interpolation.rs b/crates/vm/src/builtins/interpolation.rs index 73126102ea9..78505631563 100644 --- a/crates/vm/src/builtins/interpolation.rs +++ b/crates/vm/src/builtins/interpolation.rs @@ -19,7 +19,7 @@ pub struct PyInterpolation { #[pymember(type = "object_ex")] pub value: PyObjectRef, #[pymember(type = "object_ex")] - pub expression: PyStrRef, + pub expression: PyObjectRef, #[pymember(type = "object_ex")] pub conversion: PyObjectRef, // None or 's', 'r', 'a' #[pymember(type = "object_ex")] @@ -36,7 +36,7 @@ impl PyPayload for PyInterpolation { impl PyInterpolation { pub fn new( value: PyObjectRef, - expression: PyStrRef, + expression: PyObjectRef, conversion: PyObjectRef, format_spec: PyStrRef, vm: &VirtualMachine, @@ -80,7 +80,7 @@ impl Constructor for PyInterpolation { vm.ctx.none() }; - let expression = args.expression; + let expression = args.expression.into(); let format_spec = args.format_spec; Ok(Self { @@ -188,7 +188,7 @@ impl Representable for PyInterpolation { let mut result = Wtf8Buf::from("Interpolation("); result.push_wtf8(value_repr.as_wtf8()); result.push_str(", "); - result.push_str(&expr_repr); + result.push_wtf8(expr_repr.as_wtf8()); result.push_str(", "); if vm.is_none(&zelf.conversion) { result.push_str("None"); diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 9520ee3ef64..d76a8be717a 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -4667,9 +4667,12 @@ impl ExecutingFrame<'_> { vm.ctx.empty_str.to_owned() }; - let expression = self.pop_value().downcast::().map_err(|_| { - vm.new_type_error("BUILD_INTERPOLATION expected str for expression") - })?; + let expression = self.pop_value(); + if !vm.is_none(&expression) && expression.downcast_ref::().is_none() { + return Err(vm.new_type_error( + "BUILD_INTERPOLATION expected str or None for expression", + )); + } let value = self.pop_value(); // conversion: 0=None, 1=Str, 2=Repr, 3=Ascii diff --git a/src/shell.rs b/src/shell.rs index 73496981521..70d7907ed6b 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -62,6 +62,9 @@ fn shell_exec( | LexicalErrorType::FStringError( InterpolatedStringErrorType::UnterminatedTripleQuotedString { .. }, ) + | LexicalErrorType::TStringError( + InterpolatedStringErrorType::UnterminatedTripleQuotedString { .. }, + ) | LexicalErrorType::UnclosedStringError { triple_quoted: true, .. From 741b1cfd5f27e4ed995ae10f206f007248cf3fc3 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 01:19:13 +0900 Subject: [PATCH 4/8] Classify normalized unclosed brackets as REPL line continuations Assisted-by: OpenAI Codex:model version unavailable --- crates/codegen/src/lib.rs | 2 +- src/shell.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 348fc2433ee..089f7ab496c 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -8,7 +8,7 @@ extern crate log; extern crate alloc; -use alloc::{string::String, vec::Vec}; +use alloc::string::String; use rustpython_compiler_core::bytecode::ConstantData; type IndexMap = indexmap::IndexMap; diff --git a/src/shell.rs b/src/shell.rs index 70d7907ed6b..b5d29f8e329 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -71,6 +71,9 @@ fn shell_exec( } ), .. + })) | VmCompileError::Compile(CompileError::Parse(ParseError { + is_unclosed_bracket: true, + .. })) ) { return ShellExecResult::ContinueLine; From 11b4398cf11524c4bb017f3c60388fbe02cc611f Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 01:54:20 +0900 Subject: [PATCH 5/8] Nest equivalent REPL parse-error alternatives for Clippy Assisted-by: OpenAI Codex:model version unavailable --- src/shell.rs | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/shell.rs b/src/shell.rs index b5d29f8e329..0d5ab590faf 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -56,25 +56,27 @@ fn shell_exec( Err(err) => { if matches!( &err, - VmCompileError::Compile(CompileError::Parse(ParseError { - error: ParseErrorType::Lexical( - LexicalErrorType::Eof - | LexicalErrorType::FStringError( - InterpolatedStringErrorType::UnterminatedTripleQuotedString { .. }, - ) - | LexicalErrorType::TStringError( - InterpolatedStringErrorType::UnterminatedTripleQuotedString { .. }, - ) - | LexicalErrorType::UnclosedStringError { - triple_quoted: true, - .. - } - ), - .. - })) | VmCompileError::Compile(CompileError::Parse(ParseError { - is_unclosed_bracket: true, - .. - })) + VmCompileError::Compile(CompileError::Parse( + ParseError { + error: ParseErrorType::Lexical( + LexicalErrorType::Eof + | LexicalErrorType::FStringError( + InterpolatedStringErrorType::UnterminatedTripleQuotedString { .. }, + ) + | LexicalErrorType::TStringError( + InterpolatedStringErrorType::UnterminatedTripleQuotedString { .. }, + ) + | LexicalErrorType::UnclosedStringError { + triple_quoted: true, + .. + } + ), + .. + } | ParseError { + is_unclosed_bracket: true, + .. + } + )) ) { return ShellExecResult::ContinueLine; } From ee5829f165611021ac199360006ce5a63e2e2c7f Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 02:01:52 +0900 Subject: [PATCH 6/8] Remove fulfilled annotation migration lint workaround The canonical expected strings no longer trigger literal_string_with_formatting_args, so the previous lint expectation is obsolete. Assisted-by: OpenAI Codex:model version unavailable --- crates/codegen/src/compile.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 9fccb6a29d3..dc9a72139e8 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -17525,10 +17525,6 @@ class C[T]: pass } #[test] - #[expect( - clippy::literal_string_with_formatting_args, - reason = "the literal is the expected t-string annotation" - )] fn future_tstring_annotation_preserves_interpolation_source_like_cpython() { let code = compile_exec( "from __future__ import annotations\nx: t'{a + b}'\ny: t'{ a + b }'\nz: f'{a + b =}'\nu: t'{a + b =}'\nv: t'{a + b =:>10}'\np: t'{(a)}'\nq: t'{((a))!r}'\nr: t'{ ((a)) = !r:>10}'\ns: t'{(a)=}'\nt: t'{a == b = }'\na1: t'''{a= # x=y\n}'''\na2: t'''{a # x=y\n}'''\na3: t'''{(a # x=y\n)}'''\na4: t'''{a # x=y\n!r}'''\na5: t'''{a # x=y\n:>10}'''\na6: t'''{'#'}'''\na7: t'''{('#', a) # c=d\n}'''\n", From fe529bc0bc2320a70b6c89586c6eb3a53ef65ab9 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 13:50:21 +0900 Subject: [PATCH 7/8] Complete lazy-import exception and traceback library closure Copy canonical traceback and legacy exception mappings with preserved RustPython guards. Copy explicit non-Python library assets by exact path so exception hierarchy data does not expand into the entire test package. Assisted-by: OpenAI Codex:model version unavailable --- Lib/_compat_pickle.py | 4 +- Lib/test/exception_hierarchy.txt | 1 + Lib/test/test_traceback.py | 770 ++++++++++++++++++++-- Lib/traceback.py | 429 ++++++++++-- scripts/update_lib/cmd_copy_lib.py | 8 + scripts/update_lib/tests/test_copy_lib.py | 66 ++ 6 files changed, 1180 insertions(+), 98 deletions(-) diff --git a/Lib/_compat_pickle.py b/Lib/_compat_pickle.py index 60793c391ae..64db87d4fff 100644 --- a/Lib/_compat_pickle.py +++ b/Lib/_compat_pickle.py @@ -183,7 +183,6 @@ 'SimpleDialog': 'tkinter.simpledialog', 'DocXMLRPCServer': 'xmlrpc.server', 'SimpleHTTPServer': 'http.server', - 'CGIHTTPServer': 'http.server', # For compatibility with broken pickles saved in old Python 3 versions 'UserDict': 'collections', 'UserList': 'collections', @@ -225,8 +224,6 @@ ('DocXMLRPCServer', 'DocCGIXMLRPCRequestHandler'), ('http.server', 'SimpleHTTPRequestHandler'): ('SimpleHTTPServer', 'SimpleHTTPRequestHandler'), - ('http.server', 'CGIHTTPRequestHandler'): - ('CGIHTTPServer', 'CGIHTTPRequestHandler'), ('_socket', 'socket'): ('socket', '_socketobject'), }) @@ -251,6 +248,7 @@ REVERSE_NAME_MAPPING[('builtins', excname)] = ('exceptions', 'OSError') PYTHON3_IMPORTERROR_EXCEPTIONS = ( + 'ImportCycleError', 'ModuleNotFoundError', ) diff --git a/Lib/test/exception_hierarchy.txt b/Lib/test/exception_hierarchy.txt index f2649aa2d41..98a5e950602 100644 --- a/Lib/test/exception_hierarchy.txt +++ b/Lib/test/exception_hierarchy.txt @@ -14,6 +14,7 @@ BaseException ├── EOFError ├── ExceptionGroup [BaseExceptionGroup] ├── ImportError + │ └── ImportCycleError │ └── ModuleNotFoundError ├── LookupError │ ├── IndexError diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 88e39145c9b..fa5f1e30355 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -9,18 +9,21 @@ import builtins import unittest import unittest.mock +import os import re import tempfile import random import string +import importlib.machinery +import sysconfig from test import support import shutil from test.support import (Error, captured_output, cpython_only, ALWAYS_EQ, requires_debug_ranges, has_no_debug_ranges, - requires_subprocess) + requires_subprocess, os_helper) from test.support.os_helper import TESTFN, temp_dir, unlink from test.support.script_helper import assert_python_ok, assert_python_failure, make_script -from test.support.import_helper import forget +from test.support.import_helper import ensure_lazy_imports, forget from test.support import force_not_colorized, force_not_colorized_test_class import json @@ -37,7 +40,7 @@ test_frame = namedtuple('frame', ['f_code', 'f_globals', 'f_locals']) test_tb = namedtuple('tb', ['tb_frame', 'tb_lineno', 'tb_next', 'tb_lasti']) -color_overrides = {"reset": "z", "filename": "fn", "error_highlight": "E"} +color_overrides = {"reset": "z", "filename": "fn", "error_highlight": "E", "note": "n"} colors = { color_overrides.get(k, k[0].lower()): v for k, v in _colorize.default_theme.traceback.items() @@ -88,6 +91,12 @@ def syntax_error_bad_indentation2(self): def tokenizer_error_with_caret_range(self): compile("blech ( ", "?", "exec") + def syntax_error_with_caret_wide_char(self): + compile("女女女=1; 女女女/", "?", "exec") + + def syntax_error_with_caret_wide_char_range(self): + compile("f(x, 女女女 for 女女女 in range(30), z)", "?", "exec") + def test_caret(self): err = self.get_exception_format(self.syntax_error_with_caret, SyntaxError) @@ -125,6 +134,20 @@ def test_caret(self): self.assertEqual(err[1].find("("), err[2].find("^")) # in the right place self.assertEqual(err[2].count("^"), 1) + def test_caret_wide_char(self): + err = self.get_exception_format(self.syntax_error_with_caret_wide_char, + SyntaxError) + self.assertIn("^", err[2]) + # "女女女=1; 女女女/" has display width 17 + self.assertEqual(err[2].find("^"), 4 + 17) + + err = self.get_exception_format(self.syntax_error_with_caret_wide_char_range, + SyntaxError) + self.assertIn("^", err[2]) + self.assertEqual(err[2].find("^"), 4 + 5) + # "女女女 for 女女女 in range(30)" has display width 30 + self.assertEqual(err[2].count("^"), 30) + def test_nocaret(self): exc = SyntaxError("error", ("x.py", 23, None, "bad syntax")) err = traceback.format_exception_only(SyntaxError, exc) @@ -1767,6 +1790,7 @@ def f(): ] self.assertEqual(result_lines, expected) + class TestKeywordTypoSuggestions(unittest.TestCase): TYPO_CASES = [ ("with block ad something:\n pass", "and"), @@ -1811,6 +1835,23 @@ def test_keyword_suggestions_from_command_string(self): stderr_text = stderr.decode('utf-8') self.assertIn(f"Did you mean '{expected_kw}'", stderr_text) + def test_no_keyword_suggestion_for_comma_errors(self): + # When the parser identifies a missing comma, don't suggest + # bogus keyword replacements like 'print' -> 'not' + code = '''\ +import sys +print( + "line1" + "line2" + file=sys.stderr +) +''' + source = textwrap.dedent(code).strip() + rc, stdout, stderr = assert_python_failure('-c', source) + stderr_text = stderr.decode('utf-8') + self.assertIn("Perhaps you forgot a comma", stderr_text) + self.assertNotIn("Did you mean", stderr_text) + @requires_debug_ranges() @force_not_colorized_test_class class PurePythonTracebackErrorCaretTests( @@ -3852,7 +3893,7 @@ def f(): def test_dont_swallow_cause_or_context_of_falsey_exception(self): # see gh-132308: Ensure that __cause__ or __context__ attributes of exceptions # that evaluate as falsey are included in the output. For falsey term, - # see https://docs.python.org/3/library/stdtypes.html#truth-value-testing. + # see https://docs.python.org/3/builtins/stdtypes.html#truth-value-testing. try: raise FalseyException from KeyError @@ -4071,7 +4112,7 @@ def test_comparison(self): def test_dont_swallow_subexceptions_of_falsey_exceptiongroup(self): # see gh-132308: Ensure that subexceptions of exception groups # that evaluate as falsey are displayed in the output. For falsey term, - # see https://docs.python.org/3/library/stdtypes.html#truth-value-testing. + # see https://docs.python.org/3/builtins/stdtypes.html#truth-value-testing. try: raise FalseyExceptionGroup("Gih", (KeyError(), NameError())) @@ -4092,11 +4133,13 @@ def test_dont_swallow_subexceptions_of_falsey_exceptiongroup(self): global_for_suggestions = None -class SuggestionFormattingTestBase: +class SuggestionFormattingTestMixin: + attr_function = getattr + def get_suggestion(self, obj, attr_name=None): if attr_name is not None: def callable(): - getattr(obj, attr_name) + self.attr_function(obj, attr_name) else: callable = obj @@ -4105,7 +4148,9 @@ def callable(): ) return result_lines[0] - def test_getattr_suggestions(self): + +class BaseSuggestionTests(SuggestionFormattingTestMixin): + def test_suggestions(self): class Substitution: noise = more_noise = a = bc = None blech = None @@ -4136,40 +4181,41 @@ class CaseChangeOverSubstitution: BLuch = None for cls, suggestion in [ - (Addition, "'bluchin'?"), - (Substitution, "'blech'?"), - (Elimination, "'blch'?"), - (Addition, "'bluchin'?"), - (SubstitutionOverElimination, "'blach'?"), - (SubstitutionOverAddition, "'blach'?"), - (EliminationOverAddition, "'bluc'?"), - (CaseChangeOverSubstitution, "'BLuch'?"), + (Addition, "'.bluchin'"), + (Substitution, "'.blech'"), + (Elimination, "'.blch'"), + (Addition, "'.bluchin'"), + (SubstitutionOverElimination, "'.blach'"), + (SubstitutionOverAddition, "'.blach'"), + (EliminationOverAddition, "'.bluc'"), + (CaseChangeOverSubstitution, "'.BLuch'"), ]: actual = self.get_suggestion(cls(), 'bluch') - self.assertIn(suggestion, actual) + self.assertIn('Did you mean ' + suggestion, actual) - def test_getattr_suggestions_underscored(self): + def test_suggestions_underscored(self): class A: bluch = None - self.assertIn("'bluch'", self.get_suggestion(A(), 'blach')) - self.assertIn("'bluch'", self.get_suggestion(A(), '_luch')) - self.assertIn("'bluch'", self.get_suggestion(A(), '_bluch')) + self.assertIn("'.bluch'", self.get_suggestion(A(), 'blach')) + self.assertIn("'.bluch'", self.get_suggestion(A(), '_luch')) + self.assertIn("'.bluch'", self.get_suggestion(A(), '_bluch')) + attr_function = self.attr_function class B: _bluch = None def method(self, name): - getattr(self, name) + attr_function(self, name) - self.assertIn("'_bluch'", self.get_suggestion(B(), '_blach')) - self.assertIn("'_bluch'", self.get_suggestion(B(), '_luch')) - self.assertNotIn("'_bluch'", self.get_suggestion(B(), 'bluch')) + self.assertIn("'._bluch'", self.get_suggestion(B(), '_blach')) + self.assertIn("'._bluch'", self.get_suggestion(B(), '_luch')) + self.assertNotIn("'._bluch'", self.get_suggestion(B(), 'bluch')) - self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, '_blach'))) - self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, '_luch'))) - self.assertIn("'_bluch'", self.get_suggestion(partial(B().method, 'bluch'))) + self.assertIn("'._bluch'", self.get_suggestion(partial(B().method, '_blach'))) + self.assertIn("'._bluch'", self.get_suggestion(partial(B().method, '_luch'))) + self.assertIn("'._bluch'", self.get_suggestion(partial(B().method, 'bluch'))) - def test_getattr_suggestions_with_custom___dir__(self): + def test_suggestions_with_custom___dir__(self): class M(type): def __dir__(cls): return [None, "fox"] @@ -4181,29 +4227,30 @@ def __dir__(self): class C1(C0, metaclass=M): pass - self.assertNotIn("'bluch'", self.get_suggestion(C0, "blach")) - self.assertIn("'bluch'", self.get_suggestion(C0(), "blach")) + self.assertNotIn("'.bluch'", self.get_suggestion(C0, "blach")) + self.assertIn("'.bluch'", self.get_suggestion(C0(), "blach")) - self.assertIn("'fox'", self.get_suggestion(C1, "foo")) - self.assertNotIn("'fox'", self.get_suggestion(C1(), "foo")) + self.assertIn("'.fox'", self.get_suggestion(C1, "foo")) + self.assertNotIn("'.fox'", self.get_suggestion(C1(), "foo")) - self.assertNotIn("'bluch'", self.get_suggestion(C1, "blach")) - self.assertIn("'bluch'", self.get_suggestion(C1(), "blach")) + self.assertNotIn("'.bluch'", self.get_suggestion(C1, "blach")) + self.assertIn("'.bluch'", self.get_suggestion(C1(), "blach")) - def test_getattr_suggestions_do_not_trigger_for_long_attributes(self): + + def test_do_not_trigger_for_long_attributes(self): class A: blech = None actual = self.get_suggestion(A(), 'somethingverywrong') self.assertNotIn("blech", actual) - def test_getattr_error_bad_suggestions_do_not_trigger_for_small_names(self): + def test_do_not_trigger_for_small_names(self): class MyClass: vvv = mom = w = id = pytho = None for name in ("b", "v", "m", "py"): with self.subTest(name=name): - actual = self.get_suggestion(MyClass, name) + actual = self.get_suggestion(MyClass(), name) self.assertNotIn("Did you mean", actual) self.assertNotIn("'vvv", actual) self.assertNotIn("'mom'", actual) @@ -4211,7 +4258,7 @@ class MyClass: self.assertNotIn("'w'", actual) self.assertNotIn("'pytho'", actual) - def test_getattr_suggestions_do_not_trigger_for_big_dicts(self): + def test_do_not_trigger_for_big_dicts(self): class A: blech = None # A class with a very big __dict__ will not be considered @@ -4222,7 +4269,36 @@ class A: actual = self.get_suggestion(A(), 'bluch') self.assertNotIn("blech", actual) - def test_getattr_suggestions_no_args(self): + def test_suggestions_for_same_name(self): + class A: + def __dir__(self): + return ['blech'] + actual = self.get_suggestion(A(), 'blech') + self.assertNotIn("Did you mean", actual) + + def test_suggestions_not_normalized(self): + class A: + analization = None + fiⁿₐˡᵢᶻₐᵗᵢᵒₙ = None + + suggestion = self.get_suggestion(A(), 'fiⁿₐˡᵢᶻₐᵗᵢᵒₙ') + self.assertIn("'.finalization'", suggestion) + self.assertNotIn("analization", suggestion) + + class B: + attr_a = None + attr_µ = None # attr_\xb5 + + suggestion = self.get_suggestion(B(), 'attr_\xb5') + self.assertIn( + "'.attr_\u03bc' ('attr_\\u03bc') " + "instead of '.attr_\xb5' ('attr_\\xb5')", + suggestion) + self.assertNotIn("attr_a", suggestion) + + +class GetattrSuggestionTests(BaseSuggestionTests): + def test_suggestions_no_args(self): class A: blech = None def __getattr__(self, attr): @@ -4239,7 +4315,7 @@ def __getattr__(self, attr): actual = self.get_suggestion(A(), 'bluch') self.assertIn("blech", actual) - def test_getattr_suggestions_invalid_args(self): + def test_suggestions_invalid_args(self): class NonStringifyClass: __str__ = None __repr__ = None @@ -4263,13 +4339,12 @@ def __getattr__(self, attr): actual = self.get_suggestion(cls(), 'bluch') self.assertIn("blech", actual) - def test_getattr_suggestions_for_same_name(self): - class A: - def __dir__(self): - return ['blech'] - actual = self.get_suggestion(A(), 'blech') - self.assertNotIn("Did you mean", actual) +class DelattrSuggestionTests(BaseSuggestionTests): + attr_function = delattr + + +class SuggestionFormattingTestBase(SuggestionFormattingTestMixin): def test_attribute_error_with_failing_dict(self): class T: bluch = 1 @@ -4311,6 +4386,275 @@ def __getattribute__(self, attr): self.assertIn("Did you mean", actual) self.assertIn("bluch", actual) + def test_getattr_nested_attribute_suggestions(self): + # Test that nested attributes are suggested when no direct match + class Inner: + def __init__(self): + self.value = 42 + self.data = "test" + + class Outer: + def __init__(self): + self.inner = Inner() + + # Should suggest 'inner.value' + actual = self.get_suggestion(Outer(), 'value') + self.assertIn("Did you mean '.inner.value' instead of '.value'", actual) + + # Should suggest 'inner.data' + actual = self.get_suggestion(Outer(), 'data') + self.assertIn("Did you mean '.inner.data' instead of '.data'", actual) + + def test_getattr_nested_prioritizes_direct_matches(self): + # Test that direct attribute matches are prioritized over nested ones + class Inner: + def __init__(self): + self.foo = 42 + + class Outer: + def __init__(self): + self.inner = Inner() + self.fooo = 100 # Similar to 'foo' + + # Should suggest 'fooo' (direct) not 'inner.foo' (nested) + actual = self.get_suggestion(Outer(), 'foo') + self.assertIn("Did you mean '.fooo'", actual) + self.assertNotIn("inner.foo", actual) + + def test_getattr_nested_with_property(self): + # Property suggestions should not execute the property getter. + class Inner: + @property + def computed(self): + return missing_name + + class Outer: + def __init__(self): + self.inner = Inner() + + actual = self.get_suggestion(Outer(), 'computed') + self.assertIn( + "Did you mean '.inner.computed' instead of '.computed'", + actual, + ) + + def test_getattr_nested_no_suggestion_for_deep_nesting(self): + # Test that deeply nested attributes (2+ levels) are not suggested + class Deep: + def __init__(self): + self.value = 42 + + class Middle: + def __init__(self): + self.deep = Deep() + + class Outer: + def __init__(self): + self.middle = Middle() + + # Should not suggest 'middle.deep.value' (too deep) + actual = self.get_suggestion(Outer(), 'value') + self.assertNotIn("Did you mean", actual) + + def test_getattr_nested_ignores_private_attributes(self): + # Test that nested suggestions ignore private attributes + class Inner: + def __init__(self): + self.public_value = 42 + + class Outer: + def __init__(self): + self._private_inner = Inner() + + # Should not suggest '_private_inner.public_value' + actual = self.get_suggestion(Outer(), 'public_value') + self.assertNotIn("Did you mean", actual) + + def test_getattr_nested_limits_attribute_checks(self): + # Test that nested suggestions are limited to checking first 20 non-private attributes + class Inner: + def __init__(self): + self.target_value = 42 + + class Outer: + def __init__(self): + # Add many attributes before 'inner' + for i in range(25): + setattr(self, f'attr_{i:02d}', i) + # Add the inner object after 20+ attributes + self.inner = Inner() + + obj = Outer() + # Verify that 'inner' is indeed present but after position 20 + attrs = [x for x in sorted(dir(obj)) if not x.startswith('_')] + inner_position = attrs.index('inner') + self.assertGreater(inner_position, 19, "inner should be after position 20 in sorted attributes") + + # Should not suggest 'inner.target_value' because inner is beyond the first 20 attributes checked + actual = self.get_suggestion(obj, 'target_value') + self.assertNotIn("inner.target_value", actual) + + def test_getattr_nested_returns_first_match_only(self): + # Test that only the first nested match is returned (not multiple) + class Inner1: + def __init__(self): + self.value = 1 + + class Inner2: + def __init__(self): + self.value = 2 + + class Inner3: + def __init__(self): + self.value = 3 + + class Outer: + def __init__(self): + # Multiple inner objects with same attribute + self.a_inner = Inner1() + self.b_inner = Inner2() + self.c_inner = Inner3() + + # Should suggest only the first match (alphabetically) + actual = self.get_suggestion(Outer(), 'value') + self.assertIn("'.a_inner.value'", actual) + # Verify it's a single suggestion, not multiple + self.assertEqual(actual.count("Did you mean"), 1) + + def test_getattr_nested_handles_attribute_access_exceptions(self): + # Test that exceptions raised when accessing attributes don't crash the suggestion system + class ExplodingProperty: + @property + def exploding_attr(self): + raise RuntimeError("BOOM! This property always explodes") + + def __repr__(self): + raise RuntimeError("repr also explodes") + + class SafeInner: + def __init__(self): + self.target = 42 + + class Outer: + def __init__(self): + self.exploder = ExplodingProperty() # Accessing attributes will raise + self.safe_inner = SafeInner() + + # Should still suggest '.safe_inner.target' without crashing + # even though accessing exploder.target would raise an exception + actual = self.get_suggestion(Outer(), 'target') + self.assertIn("'.safe_inner.target'", actual) + + def test_getattr_nested_handles_hasattr_exceptions(self): + # Test that exceptions in hasattr don't crash the system + class WeirdObject: + def __getattr__(self, name): + if name == 'target': + raise RuntimeError("Can't check for target attribute") + raise AttributeError(f"No attribute {name}") + + class NormalInner: + def __init__(self): + self.target = 100 + + class Outer: + def __init__(self): + self.weird = WeirdObject() # hasattr will raise for 'target' + self.normal = NormalInner() + + # Should still find 'normal.target' even though weird.target check fails + actual = self.get_suggestion(Outer(), 'target') + self.assertIn("'.normal.target'", actual) + + @force_not_colorized + def test_cross_language(self): + cases = [ + # (type, attr, hint_attr) + (list, 'push', 'append'), + (list, 'concat', 'extend'), + (list, 'addAll', 'extend'), + (str, 'toUpperCase', 'upper'), + (str, 'toLowerCase', 'lower'), + (str, 'trimStart', 'lstrip'), + (str, 'trimEnd', 'rstrip'), + (dict, 'keySet', 'keys'), + (dict, 'entrySet', 'items'), + (dict, 'entries', 'items'), + (dict, 'putAll', 'update'), + ] + for test_type, attr, hint_attr in cases: + with self.subTest(type=test_type.__name__, attr=attr): + obj = test_type() + actual = self.get_suggestion(obj, attr) + self.assertEndsWith(actual, f"Did you mean '.{hint_attr}'?") + + cases = [ + # (type, attr, hint) + (list, 'contains', "Use 'x in list'."), + (list, 'add', "Did you mean to use a 'set' object?"), + (dict, 'put', "Use d[k] = v."), + ] + for test_type, attr, expected in cases: + with self.subTest(type=test_type, attr=attr): + obj = test_type() + actual = self.get_suggestion(obj, attr) + self.assertEndsWith(actual, expected) + + @force_not_colorized + def test_cross_language_levenshtein_fallback(self): + # When no cross-language entry exists, Levenshtein still works + # (e.g., trim->strip is not in the table but Levenshtein catches it) + actual = self.get_suggestion('', 'trim') + self.assertIn("strip", actual) + + @force_not_colorized + def test_cross_language_no_hint_for_unknown_attr(self): + actual = self.get_suggestion([], 'completely_unknown_method') + self.assertNotIn("Did you mean", actual) + + @force_not_colorized + def test_cross_language_works_for_subclasses(self): + # isinstance() check means subclasses also get hints + class MyList(list): + pass + actual = self.get_suggestion(MyList(), 'push') + self.assertEndsWith(actual, "Did you mean '.append'?") + + class MyDict(dict): + pass + actual = self.get_suggestion(MyDict(), 'keySet') + self.assertEndsWith(actual, "Did you mean '.keys'?") + + @force_not_colorized + def test_cross_language_mutable_on_immutable(self): + # Mutable method on immutable type suggests the mutable counterpart + cases = [ + (tuple, 'append', "Did you mean to use a 'list' object?"), + (tuple, 'extend', "Did you mean to use a 'list' object?"), + (tuple, 'insert', "Did you mean to use a 'list' object?"), + (tuple, 'remove', "Did you mean to use a 'list' object?"), + (frozenset, 'add', "Did you mean to use a 'set' object?"), + (frozenset, 'discard', "Did you mean to use a 'set' object?"), + (frozenset, 'remove', "Did you mean to use a 'set' object?"), + (frozenset, 'update', "Did you mean to use a 'set' object?"), + (frozendict, 'update', "Did you mean to use a 'dict' object?"), + ] + for test_type, attr, expected in cases: + with self.subTest(type=test_type.__name__, attr=attr): + obj = test_type() + actual = self.get_suggestion(obj, attr) + self.assertEndsWith(actual, expected) + + @force_not_colorized + def test_cross_language_float_bitwise(self): + # Bitwise operators on float suggest using int + cases = ['__or__', '__and__', '__xor__', '__lshift__', '__rshift__'] + for attr in cases: + with self.subTest(attr=attr): + actual = self.get_suggestion(1.0, attr) + self.assertIn("'int'", actual) + self.assertIn("Bitwise operators", actual) + def make_module(self, code): tmpdir = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, tmpdir) @@ -4665,6 +5009,34 @@ def foo(self): actual = self.get_suggestion(instance.foo) self.assertIn("self.blech", actual) + def test_name_error_with_instance_not_normalized(self): + class A: + def __init__(self): + self.fiⁿₐˡᵢᶻₐᵗᵢᵒₙ = None + def foo(self): + analization = 1 + x = fiⁿₐˡᵢᶻₐᵗᵢᵒₙ + + instance = A() + actual = self.get_suggestion(instance.foo) + self.assertIn("self.finalization", actual) + self.assertNotIn("fiⁿₐˡᵢᶻₐᵗᵢᵒₙ", actual) + self.assertNotIn("analization", actual) + + class B: + def __init__(self): + self.attr_µ = None # attr_\xb5 + def foo(self): + attr_a = 1 + x = attr_µ # attr_\xb5 + + instance = B() + actual = self.get_suggestion(instance.foo) + self.assertIn("self.attr_\u03bc", actual) + self.assertIn(r"self.attr_\u03bc", actual) + self.assertNotIn("attr_\xb5", actual) + self.assertNotIn("attr_a", actual) + def test_unbound_local_error_with_instance(self): class A: def __init__(self): @@ -4747,6 +5119,51 @@ class CPythonSuggestionFormattingTests( """ +class PurePythonGetattrSuggestionFormattingTests( + PurePythonExceptionFormattingMixin, + GetattrSuggestionTests, + unittest.TestCase, +): + """ + Same set of tests (for attribute access) as above using the pure Python + implementation of traceback printing in traceback.py. + """ + + +class PurePythonDelattrSuggestionFormattingTests( + PurePythonExceptionFormattingMixin, + DelattrSuggestionTests, + unittest.TestCase, +): + """ + Same set of tests (for attribute deletion) as above using the pure Python + implementation of traceback printing in traceback.py. + """ + + +@cpython_only +class CPythonGetattrSuggestionFormattingTests( + CAPIExceptionFormattingMixin, + GetattrSuggestionTests, + unittest.TestCase, +): + """ + Same set of tests (for attribute access) as above but with Python's + internal traceback printing. + """ + + +@cpython_only +class CPythonDelattrSuggestionFormattingTests( + CAPIExceptionFormattingMixin, + DelattrSuggestionTests, + unittest.TestCase, +): + """ + Same set of tests (for attribute deletion) as above but with Python's + internal traceback printing. + """ + class MiscTest(unittest.TestCase): def test_all(self): @@ -4841,7 +5258,109 @@ class MyList(list): with self.assertRaises(TypeError): _suggestions._generate_suggestions(MyList(), "") + def test_no_site_package_flavour(self): + code = """import boo""" + _, _, stderr = assert_python_failure('-S', '-c', code) + self.assertIn( + (b"Site initialization is disabled, did you forget to " + b"add the site-packages directory to sys.path " + b"or to enable your virtual environment?"), stderr + ) + + code = """ + import sys + sys.stdlib_module_names = sys.stdlib_module_names + ("boo",) + import boo + """ + _, _, stderr = assert_python_failure('-S', '-c', code) + + self.assertNotIn( + (b"Site initialization is disabled, did you forget to " + b"add the site-packages directory to sys.path " + b"or to enable your virtual environment?"), stderr + ) + + def test_missing_stdlib_module(self): + code = """ + import sys + sys.stdlib_module_names |= {'spam'} + import spam + """ + _, _, stderr = assert_python_failure('-S', '-c', code) + + self.assertIn(b"Standard library module 'spam' was not found", stderr) + + code = """ + import sys + import traceback + traceback._MISSING_STDLIB_MODULE_MESSAGES = {'spam': "Install 'spam4life' for 'spam'"} + sys.stdlib_module_names |= {'spam'} + import spam + """ + _, _, stderr = assert_python_failure('-S', '-c', code) + + self.assertIn(b"Install 'spam4life' for 'spam'", stderr) + + @unittest.skipIf(sys.platform == "win32", "Non-Windows test") + def test_windows_only_module_error(self): + try: + import msvcrt # noqa: F401 + except ModuleNotFoundError: + formatted = traceback.format_exc() + self.assertIn("Unsupported platform for Windows-only standard library module 'msvcrt'", formatted) + else: + self.fail("ModuleNotFoundError was not raised") + + @unittest.skipIf(not importlib.machinery.EXTENSION_SUFFIXES, 'Platform does not support extension modules') + def test_find_incompatible_extension_modules(self): + """_find_incompatible_extension_modules assumes the last extension in + importlib.machinery.EXTENSION_SUFFIXES (defined in Python/dynload_*.c) + is untagged (eg. .so, .pyd). + + This test exists to make sure that assumption is correct. + """ + last_extension_suffix = importlib.machinery.EXTENSION_SUFFIXES[-1] + if shlib_suffix := sysconfig.get_config_var('SHLIB_SUFFIX'): + self.assertEqual(last_extension_suffix, shlib_suffix) + else: + before_dot, *extensions = last_extension_suffix.split('.') + expected_prefixes = [''] + if os.name == 'nt': + # Windows puts the debug tag in the module file stem (eg. foo_d.pyd) + expected_prefixes.append('_d') + self.assertIn(before_dot, expected_prefixes, msg=( + f'Unexpected prefix {before_dot!r} in extension module ' + f'suffix {last_extension_suffix!r}. ' + 'traceback._find_incompatible_extension_module needs to be ' + 'updated to take this into account!' + )) + # if SHLIB_SUFFIX is not define, we assume the native + # shared library suffix only contains one extension + # (eg. '.so', bad eg. '.cpython-315-x86_64-linux-gnu.so') + self.assertEqual(len(extensions), 1, msg=( + 'The last suffix in importlib.machinery.EXTENSION_SUFFIXES ' + 'contains more than one extension, so it is probably different ' + 'than SHLIB_SUFFIX. It probably contains an ABI tag! ' + 'If this is a false positive, define SHLIB_SUFFIX in sysconfig.' + )) + + @unittest.skipIf(not importlib.machinery.EXTENSION_SUFFIXES, 'Platform does not support extension modules') + def test_incompatible_extension_modules_hint(self): + untagged_suffix = importlib.machinery.EXTENSION_SUFFIXES[-1] + with os_helper.temp_dir() as tmp: + # create a module with a incompatible ABI tag + incompatible_module = f'foo.some-abi{untagged_suffix}' + open(os.path.join(tmp, incompatible_module), "wb").close() + # try importing it + code = f''' + import sys + sys.path.insert(0, {tmp!r}) + import foo + ''' + _, _, stderr = assert_python_failure('-c', code, __cwd=tmp) + hint = f'Although a module with this name was found for a different Python version ({incompatible_module}).' + self.assertIn(hint, stderr.decode()) class TestColorizedTraceback(unittest.TestCase): @@ -4879,6 +5398,23 @@ def bar(): self.assertIn("return baz1(1,\n 2,3\n ,4)", lines) self.assertIn(red + "bar" + reset + boldr + "()" + reset, lines) + def test_colorized_exception_notes(self): + def foo(): + raise ValueError() + + try: + foo() + except Exception as e: + e.add_note("First note") + e.add_note("Second note") + exc = traceback.TracebackException.from_exception(e) + + lines = "".join(exc.format(colorize=True)) + note = colors["n"] + reset = colors["z"] + self.assertIn(note + "First note" + reset, lines) + self.assertIn(note + "Second note" + reset, lines) + def test_colorized_syntax_error(self): try: compile("a $ b", "", "exec") @@ -4887,7 +5423,7 @@ def test_colorized_syntax_error(self): e, capture_locals=True ) actual = "".join(exc.format(colorize=True)) - def expected(t, m, fn, l, f, E, e, z): + def expected(t, m, fn, l, f, E, e, z, n): return "".join( [ f' File {fn}""{z}, line {l}1{z}\n', @@ -4914,7 +5450,7 @@ def foo(): actual = tbstderr.getvalue().splitlines() lno_foo = foo.__code__.co_firstlineno - def expected(t, m, fn, l, f, E, e, z): + def expected(t, m, fn, l, f, E, e, z, n): return [ 'Traceback (most recent call last):', f' File {fn}"{__file__}"{z}, ' @@ -4947,7 +5483,7 @@ def foo(): lno_foo = foo.__code__.co_firstlineno actual = "".join(exc.format(colorize=True)).splitlines() - def expected(t, m, fn, l, f, E, e, z): + def expected(t, m, fn, l, f, E, e, z, n): return [ f" + Exception Group Traceback (most recent call last):", f' | File {fn}"{__file__}"{z}, line {l}{lno_foo+9}{z}, in {f}test_colorized_traceback_from_exception_group{z}', @@ -4971,5 +5507,139 @@ def expected(t, m, fn, l, f, E, e, z): ] self.assertEqual(actual, expected(**colors)) + def test_colorized_traceback_unicode(self): + try: + 啊哈=1; 啊哈/0#### + except Exception as e: + exc = traceback.TracebackException.from_exception(e) + + actual = "".join(exc.format(colorize=True)).splitlines() + def expected(t, m, fn, l, f, E, e, z, n): + return [ + f" 啊哈=1; {e}啊哈{z}{E}/{z}{e}0{z}####", + f" {e}~~~~{z}{E}^{z}{e}~{z}", + ] + self.assertEqual(actual[2:4], expected(**colors)) + + try: + ééééé/0 + except Exception as e: + exc = traceback.TracebackException.from_exception(e) + + actual = "".join(exc.format(colorize=True)).splitlines() + def expected(t, m, fn, l, f, E, e, z, n): + return [ + f" {E}ééééé{z}/0", + f" {E}^^^^^{z}", + ] + self.assertEqual(actual[2:4], expected(**colors)) + + def test_colorized_syntax_error_ascii_display_width(self): + """Caret alignment for ASCII edge cases handled by _wlen. + + The old ASCII fast track in _display_width returned the raw character + offset for ASCII strings, which is wrong for CTRL-Z (display width 2) + and ANSI escape sequences (display width 0). + """ + E = colors["E"] + z = colors["z"] + t = colors["t"] + m = colors["m"] + fn = colors["fn"] + l = colors["l"] + + def _make_syntax_error(text, offset, end_offset): + err = SyntaxError("invalid syntax") + err.filename = "" + err.lineno = 1 + err.end_lineno = 1 + err.text = text + err.offset = offset + err.end_offset = end_offset + return err + + # CTRL-Z (\x1a) is ASCII but displayed as ^Z (2 columns). + # Verify caret aligns when CTRL-Z precedes the error. + err = _make_syntax_error("a\x1a$\n", offset=3, end_offset=4) + exc = traceback.TracebackException.from_exception(err) + actual = "".join(exc.format(colorize=True)) + # 'a' (1 col) + '\x1a' (2 cols) = 3 cols before '$' + self.assertIn( + f' File {fn}""{z}, line {l}1{z}\n' + f' a\x1a{E}${z}\n' + f' {" " * 3}{E}^{z}\n' + f'{t}SyntaxError{z}: {m}invalid syntax{z}\n', + actual, + ) + + # CTRL-Z in the highlighted (error) region counts as 2 columns. + err = _make_syntax_error("$\x1a\n", offset=1, end_offset=3) + exc = traceback.TracebackException.from_exception(err) + actual = "".join(exc.format(colorize=True)) + # '$' (1 col) + '\x1a' (2 cols) = 3 columns of carets + self.assertIn( + f' {E}$\x1a{z}\n' + f' {E}{"^" * 3}{z}\n', + actual, + ) + + # ANSI escape sequences are ASCII but take 0 display columns. + err = _make_syntax_error("a\x1b[1mb$\n", offset=7, end_offset=8) + exc = traceback.TracebackException.from_exception(err) + actual = "".join(exc.format(colorize=True)) + # 'a' (1 col) + '\x1b[1m' (0 cols) + 'b' (1 col) = 2 before '$' + self.assertIn( + f' a\x1b[1mb{E}${z}\n' + f' {" " * 2}{E}^{z}\n', + actual, + ) + +class TestLazyImportSuggestions(unittest.TestCase): + """Test that lazy imports are not reified when computing AttributeError suggestions.""" + + def test_attribute_error_does_not_reify_lazy_imports(self): + """Printing an AttributeError should not trigger lazy import reification.""" + # lazypkg.bar prints "BAR_MODULE_LOADED" when imported. + # If lazy import is reified during suggestion computation, we'll see it. + code = textwrap.dedent(""" + lazy import test.test_lazy_import.data.lazypkg + test.test_lazy_import.data.lazypkg.nonexistent + """) + rc, stdout, stderr = assert_python_failure('-c', code) + self.assertNotIn(b"BAR_MODULE_LOADED", stdout) + + def test_traceback_formatting_does_not_reify_lazy_imports(self): + """Formatting a traceback should not trigger lazy import reification.""" + code = textwrap.dedent(""" + import traceback + lazy import test.test_lazy_import.data.lazypkg + try: + test.test_lazy_import.data.lazypkg.nonexistent + except AttributeError: + traceback.format_exc() + print("OK") + """) + rc, stdout, stderr = assert_python_ok('-c', code) + self.assertIn(b"OK", stdout) + self.assertNotIn(b"BAR_MODULE_LOADED", stdout) + + def test_suggestion_still_works_for_non_lazy_attributes(self): + """Suggestions should still work for non-lazy module attributes.""" + code = textwrap.dedent(""" + lazy import test.test_lazy_import.data.lazypkg + # Typo for __name__ + test.test_lazy_import.data.lazypkg.__nme__ + """) + rc, stdout, stderr = assert_python_failure('-c', code) + self.assertIn(b"__name__", stderr) + self.assertNotIn(b"BAR_MODULE_LOADED", stdout) + + +class LazyImportTest(unittest.TestCase): + @support.cpython_only + def test_lazy_import(self): + ensure_lazy_imports("traceback", {"_colorize"}) + + if __name__ == "__main__": unittest.main() diff --git a/Lib/traceback.py b/Lib/traceback.py index 227ca8efa17..d16ab468db9 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1,18 +1,29 @@ """Extract, format and print information about Python stack traces.""" import collections.abc +import functools import itertools import linecache +import os +import re import sys import textwrap +import types import warnings import codeop import keyword import tokenize import io -import _colorize +import importlib.util +import pathlib from contextlib import suppress +lazy import _colorize + +try: + from _missing_stdlib_info import _MISSING_STDLIB_MODULE_MESSAGES +except ImportError: + _MISSING_STDLIB_MODULE_MESSAGES = {} __all__ = ['extract_stack', 'extract_tb', 'format_exception', 'format_exception_only', 'format_list', 'format_stack', @@ -21,6 +32,36 @@ 'FrameSummary', 'StackSummary', 'TracebackException', 'walk_stack', 'walk_tb', 'print_list'] + +class _ShutdownTheme: + """Empty stand-in if `_colorize` cannot be imported during late shutdown.""" + def __getattr__(self, _): return self + def __getitem__(self, _): return "" + def __format__(self, _): return "" + def __str__(self): return "" + def __add__(self, other): return other + __radd__ = __add__ + + +_shutdown_theme = _ShutdownTheme() + + +def _safe_get_theme(*, force_color=False, force_no_color=False): + try: + return _colorize.get_theme( + force_color=force_color, force_no_color=force_no_color + ) + except ImportError: + return _shutdown_theme + + +def _safe_can_colorize(*, file=None): + try: + return _colorize.can_colorize(file=file) + except ImportError: + return False + + # # Formatting and printing lists of traceback lines. # @@ -137,9 +178,10 @@ def print_exception(exc, /, value=_sentinel, tb=_sentinel, limit=None, \ BUILTIN_EXCEPTION_LIMIT = object() -def _print_exception_bltin(exc, /): - file = sys.stderr if sys.stderr is not None else sys.__stderr__ - colorize = _colorize.can_colorize(file=file) +def _print_exception_bltin(exc, file=None, /): + if file is None: + file = sys.stderr if sys.stderr is not None else sys.__stderr__ + colorize = _safe_can_colorize(file=file) return print_exception(exc, limit=BUILTIN_EXCEPTION_LIMIT, file=file, colorize=colorize) @@ -187,9 +229,9 @@ def _format_final_exc_line(etype, value, *, insert_final_newline=True, colorize= valuestr = _safe_string(value, 'exception') end_char = "\n" if insert_final_newline else "" if colorize: - theme = _colorize.get_theme(force_color=True).traceback + theme = _safe_get_theme(force_color=True).traceback else: - theme = _colorize.get_theme(force_no_color=True).traceback + theme = _safe_get_theme(force_no_color=True).traceback if value is None or not valuestr: line = f"{theme.type}{etype}{theme.reset}{end_char}" else: @@ -205,9 +247,9 @@ def _safe_string(value, what, func=str): # -- -def print_exc(limit=None, file=None, chain=True): +def print_exc(limit=None, file=None, chain=True, **kwargs): """Shorthand for 'print_exception(sys.exception(), limit=limit, file=file, chain=chain)'.""" - print_exception(sys.exception(), limit=limit, file=file, chain=chain) + print_exception(sys.exception(), limit=limit, file=file, chain=chain, **kwargs) def format_exc(limit=None, chain=True): """Like print_exc() but return a string.""" @@ -286,6 +328,12 @@ class FrameSummary: of code that was running when the frame was captured. - :attr:`locals` Either None if locals were not supplied, or a dict mapping the name to the repr() of the variable. + - :attr:`end_lineno` The last line number of the source code for this frame. + By default, it is set to lineno and indexation starts from 1. + - :attr:`colno` The column number of the source code for this frame. + By default, it is None and indexation starts from 0. + - :attr:`end_colno` The last column number of the source code for this frame. + By default, it is None and indexation starts from 0. """ __slots__ = ('filename', 'lineno', 'end_lineno', 'colno', 'end_colno', @@ -536,9 +584,9 @@ def format_frame_summary(self, frame_summary, **kwargs): if frame_summary.filename.startswith("'): filename = "" if colorize: - theme = _colorize.get_theme(force_color=True).traceback + theme = _safe_get_theme(force_color=True).traceback else: - theme = _colorize.get_theme(force_no_color=True).traceback + theme = _safe_get_theme(force_no_color=True).traceback row.append( ' File {}"{}"{}, line {}{}{}, in {}{}{}\n'.format( theme.filename, @@ -667,12 +715,12 @@ def output_line(lineno): colorized_line_parts = [] colorized_carets_parts = [] - for color, group in itertools.groupby(itertools.zip_longest(line, carets, fillvalue=""), key=lambda x: x[1]): + for color, group in itertools.groupby(_zip_display_width(line, carets), key=lambda x: x[1]): caret_group = list(group) - if color == "^": + if "^" in color: colorized_line_parts.append(theme.error_highlight + "".join(char for char, _ in caret_group) + theme.reset) colorized_carets_parts.append(theme.error_highlight + "".join(caret for _, caret in caret_group) + theme.reset) - elif color == "~": + elif "~" in color: colorized_line_parts.append(theme.error_range + "".join(char for char, _ in caret_group) + theme.reset) colorized_carets_parts.append(theme.error_range + "".join(caret for _, caret in caret_group) + theme.reset) else: @@ -954,7 +1002,54 @@ def setup_positions(expr, force_valid=True): return None -_WIDE_CHAR_SPECIFIERS = "WF" + +def _zip_display_width(line, carets): + carets = iter(carets) + if line.isascii() and '\x1a' not in line: + for char in line: + yield char, next(carets, "") + return + + import unicodedata + for char in unicodedata.iter_graphemes(line): + char = str(char) + char_width = _display_width(char) + yield char, "".join(itertools.islice(carets, char_width)) + + +@functools.cache +def _str_width(c: str) -> int: + # copied from _pyrepl.utils to fix gh-130273 + + if ord(c) < 128: + return 1 + import unicodedata + # gh-139246 for zero-width joiner and combining characters + if unicodedata.combining(c): + return 0 + category = unicodedata.category(c) + if category == "Cf" and c != "\u00ad": + return 0 + w = unicodedata.east_asian_width(c) + if w in ("N", "Na", "H", "A"): + return 1 + return 2 + + +_ANSI_ESCAPE_SEQUENCE = re.compile(r"\x1b\[[ -@]*[A-~]") + + +def _wlen(s: str) -> int: + # copied from _pyrepl.utils to fix gh-130273 + + if len(s) == 1 and s != "\x1a": + return _str_width(s) + length = sum(_str_width(i) for i in s) + # remove lengths of any escape sequences + sequence = _ANSI_ESCAPE_SEQUENCE.findall(s) + ctrl_z_cnt = s.count("\x1a") + return length - sum(len(i) for i in sequence) + ctrl_z_cnt + def _display_width(line, offset=None): """Calculate the amount of width space the given source @@ -962,19 +1057,14 @@ def _display_width(line, offset=None): width output device. Supports wide unicode characters and emojis.""" if offset is None: - offset = len(line) - - # Fast track for ASCII-only strings - if line.isascii(): - return offset + return _wlen(line) - import unicodedata + return _wlen(line[:offset]) - return sum( - 2 if unicodedata.east_asian_width(char) in _WIDE_CHAR_SPECIFIERS else 1 - for char in line[:offset] - ) +def _format_note(note, indent, theme): + for l in note.split("\n"): + yield f"{indent}{theme.note}{l}{theme.reset}\n" class _ExceptionPrintContext: @@ -1098,20 +1188,62 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, wrong_name = getattr(exc_value, "name_from", None) suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name) if suggestion: - self._str += f". Did you mean: '{suggestion}'?" - elif exc_type and issubclass(exc_type, (NameError, AttributeError)) and \ + if suggestion.isascii(): + self._str += f". Did you mean: '{suggestion}'?" + else: + self._str += f". Did you mean: '{suggestion}' ({suggestion!a})?" + elif exc_type and issubclass(exc_type, ModuleNotFoundError): + module_name = getattr(exc_value, "name", None) + if module_name in sys.stdlib_module_names: + message = _MISSING_STDLIB_MODULE_MESSAGES.get( + module_name, + f"Standard library module {module_name!r} was not found" + ) + self._str = message + elif sys.flags.no_site: + self._str += (". Site initialization is disabled, did you forget to " + + "add the site-packages directory to sys.path " + + "or to enable your virtual environment?") + elif abi_tag := _find_incompatible_extension_module(module_name): + self._str += ( + ". Although a module with this name was found for a " + f"different Python version ({abi_tag})." + ) + else: + suggestion = _compute_suggestion_error(exc_value, exc_traceback, module_name) + if suggestion: + self._str += f". Did you mean: '{suggestion}'?" + elif exc_type and issubclass(exc_type, AttributeError) and \ + getattr(exc_value, "name", None) is not None: + wrong_name = getattr(exc_value, "name", None) + # Check cross-language/wrong-type hints first (more specific), + # then fall back to Levenshtein distance suggestions. + hint = None + if hasattr(exc_value, 'obj'): + hint = _get_cross_language_hint(exc_value.obj, wrong_name) + if hint: + self._str += f". {hint}" + else: + suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name) + if suggestion: + if suggestion.isascii(): + self._str += f". Did you mean '.{suggestion}' instead of '.{wrong_name}'?" + else: + self._str += f". Did you mean '.{suggestion}' ({suggestion!a}) instead of '.{wrong_name}' ({wrong_name!a})?" + elif exc_type and issubclass(exc_type, NameError) and \ getattr(exc_value, "name", None) is not None: wrong_name = getattr(exc_value, "name", None) suggestion = _compute_suggestion_error(exc_value, exc_traceback, wrong_name) if suggestion: - self._str += f". Did you mean: '{suggestion}'?" - if issubclass(exc_type, NameError): - wrong_name = getattr(exc_value, "name", None) - if wrong_name is not None and wrong_name in sys.stdlib_module_names: - if suggestion: - self._str += f" Or did you forget to import '{wrong_name}'?" - else: - self._str += f". Did you forget to import '{wrong_name}'?" + if suggestion.isascii(): + self._str += f". Did you mean: '{suggestion}'?" + else: + self._str += f". Did you mean: '{suggestion}' ({suggestion!a})?" + if wrong_name is not None and wrong_name in sys.stdlib_module_names: + if suggestion: + self._str += f" Or did you forget to import '{wrong_name}'?" + else: + self._str += f". Did you forget to import '{wrong_name}'?" if lookup_lines: self._load_lines() self.__suppress_context__ = \ @@ -1240,6 +1372,10 @@ def format_exception_only(self, *, show_group=False, _depth=0, **kwargs): well, recursively, with indentation relative to their nesting depth. """ colorize = kwargs.get("colorize", False) + if colorize: + theme = _safe_get_theme(force_color=True).traceback + else: + theme = _safe_get_theme(force_no_color=True).traceback indent = 3 * _depth * ' ' if not self._have_exc_type: @@ -1268,9 +1404,10 @@ def format_exception_only(self, *, show_group=False, _depth=0, **kwargs): ): for note in self.__notes__: note = _safe_string(note, 'note') - yield from [indent + l + '\n' for l in note.split('\n')] + yield from _format_note(note, indent, theme) elif self.__notes__ is not None: - yield indent + "{}\n".format(_safe_string(self.__notes__, '__notes__', func=repr)) + note = _safe_string(self.__notes__, '__notes__', func=repr) + yield from _format_note(note, indent, theme) if self.exceptions and show_group: for ex in self.exceptions: @@ -1315,6 +1452,15 @@ def _find_keyword_typos(self): if len(error_code) > 1024: return + # If the original code doesn't raise SyntaxError, we can't validate + # that a keyword replacement actually fixes anything + try: + codeop.compile_command(error_code, symbol="exec", flags=codeop.PyCF_ONLY_AST) + except SyntaxError: + pass # Good - the original code has a syntax error we might fix + else: + return # Original code compiles or is incomplete - can't validate fixes + error_lines = error_code.splitlines() tokens = tokenize.generate_tokens(io.StringIO(error_code).readline) tokens_left_to_process = 10 @@ -1377,9 +1523,9 @@ def _format_syntax_error(self, stype, **kwargs): # Show exactly where the problem was found. colorize = kwargs.get("colorize", False) if colorize: - theme = _colorize.get_theme(force_color=True).traceback + theme = _safe_get_theme(force_color=True).traceback else: - theme = _colorize.get_theme(force_no_color=True).traceback + theme = _safe_get_theme(force_no_color=True).traceback filename_suffix = '' if self.lineno is not None: yield ' File {}"{}"{}, line {}{}{}\n'.format( @@ -1430,10 +1576,11 @@ def _format_syntax_error(self, stype, **kwargs): # Convert 1-based column offset to 0-based index into stripped text colno = offset - 1 - spaces end_colno = end_offset - 1 - spaces - caretspace = ' ' if colno >= 0: - # non-space whitespace (likes tabs) must be kept for alignment - caretspace = ((c if c.isspace() else ' ') for c in ltext[:colno]) + # Calculate display width to account for wide characters + dp_colno = _display_width(ltext, colno) + highlighted = ltext[colno:end_colno] + caret_count = _display_width(highlighted) if highlighted else (end_colno - colno) start_color = end_color = "" if colorize: # colorize from colno to end_colno @@ -1446,9 +1593,9 @@ def _format_syntax_error(self, stype, **kwargs): end_color = theme.reset yield ' {}\n'.format(ltext) yield ' {}{}{}{}\n'.format( - "".join(caretspace), + ' ' * dp_colno, start_color, - ('^' * (end_colno - colno)), + '^' * caret_count, end_color, ) else: @@ -1579,6 +1726,62 @@ def print(self, *, file=None, chain=True, **kwargs): _MOVE_COST = 2 _CASE_COST = 1 +# Cross-language method suggestions for builtin types. +# Consulted as a fallback when Levenshtein-based suggestions find no match. +# +# Inclusion criteria: +# +# 1. Must have evidence of real cross-language confusion (Stack Overflow +# traffic, bug reports in production repos, developer survey data). +# 2. Must not be catchable by Levenshtein distance (too different from +# the correct Python method name). +# +# Each entry maps a wrong method name to a list of (type, suggestion, is_raw) +# tuples. The lookup checks isinstance() so subclasses are also matched. +# If is_raw is False, the suggestion is wrapped in "Did you mean '.X'?". +# If is_raw is True, the suggestion is rendered as-is. +# +# See https://github.com/python/cpython/issues/146406. +_CROSS_LANGUAGE_HINTS = frozendict({ + # list -- JavaScript/Ruby equivalents + "push": ((list, "append", False),), + "concat": ((list, "extend", False),), + # list -- Java/C# equivalents + "addAll": ((list, "extend", False),), + "contains": ((list, "Use 'x in list'.", True),), + # list -- wrong-type suggestion (user expected a set) + "add": ((list, "Did you mean to use a 'set' object?", True), + (frozenset, "Did you mean to use a 'set' object?", True)), + # str -- JavaScript equivalents + "toUpperCase": ((str, "upper", False),), + "toLowerCase": ((str, "lower", False),), + "trimStart": ((str, "lstrip", False),), + "trimEnd": ((str, "rstrip", False),), + # dict -- Java/JavaScript equivalents + "keySet": ((dict, "keys", False),), + "entrySet": ((dict, "items", False),), + "entries": ((dict, "items", False),), + "putAll": ((dict, "update", False),), + "put": ((dict, "Use d[k] = v.", True),), + # tuple -- mutable method on immutable type (user expected a list) + "append": ((tuple, "Did you mean to use a 'list' object?", True),), + "extend": ((tuple, "Did you mean to use a 'list' object?", True),), + "insert": ((tuple, "Did you mean to use a 'list' object?", True),), + "remove": ((tuple, "Did you mean to use a 'list' object?", True), + (frozenset, "Did you mean to use a 'set' object?", True)), + # frozenset -- mutable method on immutable type (user expected a set) + "discard": ((frozenset, "Did you mean to use a 'set' object?", True),), + # frozendict -- mutable method on immutable type (user expected a dict) + "update": ((frozenset, "Did you mean to use a 'set' object?", True), + (frozendict, "Did you mean to use a 'dict' object?", True)), + # float -- bitwise operators belong to int + "__or__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),), + "__and__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),), + "__xor__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),), + "__lshift__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),), + "__rshift__": ((float, "Did you mean to use an 'int' object? Bitwise operators are not supported by 'float'.", True),), +}) + def _substitution_cost(ch_a, ch_b): if ch_a == ch_b: @@ -1588,19 +1791,100 @@ def _substitution_cost(ch_a, ch_b): return _MOVE_COST +def _is_lazy_import(obj, attr_name): + """Check if attr_name in obj's __dict__ is a lazy import. + + Returns True if obj is a module and the attribute is a LazyImportType, + False otherwise. This avoids triggering module loading when computing + suggestions for AttributeError. + """ + if not isinstance(obj, types.ModuleType): + return False + obj_dict = getattr(obj, '__dict__', None) + if obj_dict is None: + return False + attr_value = obj_dict.get(attr_name) + return isinstance(attr_value, types.LazyImportType) + + +def _check_for_nested_attribute(obj, wrong_name, attrs): + """Check if any attribute of obj has the wrong_name as a nested attribute. + + Returns the first nested attribute suggestion found, or None. + Limited to checking 20 attributes. + Only considers non-descriptor outer attributes to avoid executing + arbitrary code. Checks nested attributes statically so descriptors such + as properties can still be suggested without invoking them. + Skips lazy imports to avoid triggering module loading. + """ + from inspect import getattr_static + + # Check for nested attributes (only one level deep) + attrs_to_check = [x for x in attrs if not x.startswith('_')][:20] # Limit number of attributes to check + for attr_name in attrs_to_check: + with suppress(Exception): + # Check if attr_name is a descriptor - if so, skip it + attr_from_class = getattr_static(type(obj), attr_name, _sentinel) + if attr_from_class is not _sentinel and hasattr(attr_from_class, '__get__'): + continue # Skip descriptors to avoid executing arbitrary code + + # Skip lazy imports to avoid triggering module loading + if _is_lazy_import(obj, attr_name): + continue + + # Safe to get the attribute since it's not a descriptor + attr_obj = getattr(obj, attr_name) + + if _is_lazy_import(attr_obj, wrong_name): + continue + + if getattr_static(attr_obj, wrong_name, _sentinel) is not _sentinel: + return f"{attr_name}.{wrong_name}" + + return None + + +def _get_cross_language_hint(obj, wrong_name): + """Check if wrong_name is a common method name from another language, + a mutable method on an immutable type, or a method tried on None. + + Uses isinstance() so subclasses of builtin types also get hints. + Returns a formatted hint string, or None. + """ + entries = _CROSS_LANGUAGE_HINTS.get(wrong_name) + if entries is None: + return None + for check_type, hint, is_raw in entries: + if isinstance(obj, check_type): + if is_raw: + return hint + return f"Did you mean '.{hint}'?" + return None + + def _get_safe___dir__(obj): # Use obj.__dir__() to avoid a TypeError when calling dir(obj). # See gh-131001 and gh-139933. + # Also filters out lazy imports to avoid triggering module loading. try: d = obj.__dir__() except TypeError: # when obj is a class d = type(obj).__dir__(obj) - return sorted(x for x in d if isinstance(x, str)) + return sorted( + x for x in d if isinstance(x, str) and not _is_lazy_import(obj, x) + ) def _compute_suggestion_error(exc_value, tb, wrong_name): if wrong_name is None or not isinstance(wrong_name, str): return None + not_normalized = False + if not wrong_name.isascii(): + from unicodedata import normalize + normalized_name = normalize('NFKC', wrong_name) + if normalized_name != wrong_name: + not_normalized = True + wrong_name = normalized_name if isinstance(exc_value, AttributeError): obj = exc_value.obj try: @@ -1616,6 +1900,18 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): d = [x for x in d if x[:1] != '_'] except Exception: return None + elif isinstance(exc_value, ModuleNotFoundError): + try: + if parent_name := wrong_name.rpartition('.')[0]: + parent = importlib.util.find_spec(parent_name) + else: + parent = None + d = [] + for finder in sys.meta_path: + if discover := getattr(finder, 'discover', None): + d += [spec.name for spec in discover(parent)] + except Exception: + return None elif isinstance(exc_value, ImportError): try: mod = __import__(exc_value.name) @@ -1638,6 +1934,8 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): + list(frame.f_builtins) ) d = [x for x in d if isinstance(x, str)] + if not_normalized and wrong_name in d: + return wrong_name # Check first if we are in a method and the instance # has the wrong name as attribute @@ -1650,12 +1948,16 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): if has_wrong_name: return f"self.{wrong_name}" + if not_normalized and wrong_name in d: + return wrong_name try: import _suggestions except ImportError: pass else: - return _suggestions._generate_suggestions(d, wrong_name) + suggestion = _suggestions._generate_suggestions(d, wrong_name) + if suggestion: + return suggestion # Compute closest match @@ -1680,6 +1982,14 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): if not suggestion or current_distance < best_distance: suggestion = possible_name best_distance = current_distance + + # If no direct attribute match found, check for nested attributes + if not suggestion and isinstance(exc_value, AttributeError): + with suppress(Exception): + nested_suggestion = _check_for_nested_attribute(exc_value.obj, wrong_name, d) + if nested_suggestion: + return nested_suggestion + return suggestion @@ -1742,3 +2052,32 @@ def _levenshtein_distance(a, b, max_cost): # Everything in this row is too big, so bail early. return max_cost + 1 return result + + +def _find_incompatible_extension_module(module_name): + import importlib.machinery + import importlib.resources.readers + + if not module_name or not importlib.machinery.EXTENSION_SUFFIXES: + return + + # We assume the last extension is untagged (eg. .so, .pyd)! + # tests.test_traceback.MiscTest.test_find_incompatible_extension_modules + # tests that assumption. + untagged_suffix = importlib.machinery.EXTENSION_SUFFIXES[-1] + # On Windows the debug tag is part of the module file stem, instead of the + # extension (eg. foo_d.pyd), so let's remove it and just look for .pyd. + if os.name == 'nt': + untagged_suffix = untagged_suffix.removeprefix('_d') + + parent, _, child = module_name.rpartition('.') + if parent: + traversable = importlib.resources.files(parent) + else: + traversable = importlib.resources.readers.MultiplexedPath( + *map(pathlib.Path, filter(os.path.isdir, sys.path)) + ) + + for entry in traversable.iterdir(): + if entry.name.startswith(child + '.') and entry.name.endswith(untagged_suffix): + return entry.name diff --git a/scripts/update_lib/cmd_copy_lib.py b/scripts/update_lib/cmd_copy_lib.py index 26b899d21de..848ee9213c2 100644 --- a/scripts/update_lib/cmd_copy_lib.py +++ b/scripts/update_lib/cmd_copy_lib.py @@ -74,6 +74,14 @@ def copy_lib( path_str = str(src_path).replace("\\", "/") if "/Lib/" not in path_str: raise ValueError(f"Path must contain '/Lib/' (got: {src_path})") + if not src_path.exists(): + raise FileNotFoundError(f"Source path does not exist: {src_path}") + + # Data files name an exact asset, not the containing top-level module. + # Expanding Lib/test/data.txt as "test" would replace the entire test tree. + if src_path.is_file() and src_path.suffix != ".py": + _copy_single(src_path, parse_lib_path(src_path), verbose) + return cpython_prefix, after_lib = path_str.split("/Lib/", 1) # Get module name (first component, without .py) diff --git a/scripts/update_lib/tests/test_copy_lib.py b/scripts/update_lib/tests/test_copy_lib.py index 2268a830cc9..69b430af985 100644 --- a/scripts/update_lib/tests/test_copy_lib.py +++ b/scripts/update_lib/tests/test_copy_lib.py @@ -1,5 +1,6 @@ """Tests for copy_lib.py - library copying with dependencies.""" +import contextlib import pathlib import tempfile import unittest @@ -100,6 +101,71 @@ def test_directory_copy_excludes_bytecode_cache_directories(self): class TestCopyLib(unittest.TestCase): """Tests for copy_lib function.""" + def test_nested_data_file_preserves_siblings(self): + from update_lib.cmd_copy_lib import copy_lib + + for name in ("hierarchy.txt", "fixture.bin", "README"): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + source = root / "cpython/Lib/test/data" / name + source.parent.mkdir(parents=True) + source.write_bytes(b"canonical\x00\xff\n") + (source.parent / "other.txt").write_bytes(b"unselected source") + target = root / "Lib/test/data" / name + target.parent.mkdir(parents=True) + target.write_bytes(b"old") + sibling = root / "Lib/test/test_local.py" + sibling.write_bytes(b"local guards\n") + with contextlib.chdir(root): + copy_lib(source, verbose=False) + self.assertEqual(target.read_bytes(), source.read_bytes()) + self.assertEqual(sibling.read_bytes(), b"local guards\n") + self.assertFalse((target.parent / "other.txt").exists()) + + def test_missing_data_file_does_not_copy_containing_package(self): + from update_lib.cmd_copy_lib import copy_lib + + with tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + source = root / "cpython/Lib/test/missing.txt" + source.parent.mkdir(parents=True) + (source.parent / "other.py").write_text("unselected source") + sibling = root / "Lib/test/test_local.py" + sibling.parent.mkdir(parents=True) + sibling.write_bytes(b"local guards\n") + with contextlib.chdir(root), self.assertRaises(FileNotFoundError): + copy_lib(source, verbose=False) + self.assertEqual(sibling.read_bytes(), b"local guards\n") + self.assertFalse((sibling.parent / "other.py").exists()) + + def test_python_module_still_copies_hard_dependencies(self): + from update_lib.cmd_copy_lib import copy_lib + + with tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + source = root / "cpython/Lib" + source.mkdir(parents=True) + for name in ("pickle.py", "_compat_pickle.py"): + (source / name).write_text(name) + with contextlib.chdir(root): + copy_lib(source / "pickle.py", verbose=False) + for name in ("pickle.py", "_compat_pickle.py"): + self.assertEqual((root / "Lib" / name).read_text(), name) + + def test_package_still_copies_directory(self): + from update_lib.cmd_copy_lib import copy_lib + + with tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + source = root / "cpython/Lib/json" + source.mkdir(parents=True) + for name in ("__init__.py", "decoder.py"): + (source / name).write_text(name) + with contextlib.chdir(root): + copy_lib(source, verbose=False) + for name in ("__init__.py", "decoder.py"): + self.assertEqual((root / "Lib/json" / name).read_text(), name) + def test_raises_on_path_without_lib(self): """Test that copy_lib raises ValueError when path doesn't contain /Lib/.""" from update_lib.cmd_copy_lib import copy_lib From 812cbe681df9194e14b8a12ca1a59bf1ebcc4af9 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 18 Oct 2026 14:01:43 +0900 Subject: [PATCH 8/8] Provide Python 3.15 traceback metadata for missing modules and attributes Preserve AttributeError context for missing class attribute deletion, matching type_update_dict. Expose canonical generated missing-stdlib messages through the native sysconfig modules without importing CPython build-specific disabled-module assumptions. Assisted-by: OpenAI Codex:model version unavailable --- crates/vm/src/builtins/type.rs | 10 ++++++-- crates/vm/src/stdlib/_sysconfig.rs | 39 ++++++++++++++++++++++++++++++ crates/vm/src/stdlib/mod.rs | 1 + 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index d75a937bb27..d7dca044bbe 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -3214,11 +3214,17 @@ impl SetAttr for PyType { } else { let prev_value = zelf.attributes().remove(attr_name); if prev_value.is_none() { - return Err(vm.new_attribute_error(format!( + let exc = vm.new_attribute_error(format!( "type object '{}' has no attribute '{}'", zelf.name(), attr_name, - ))); + )); + vm.set_attribute_error_context( + &exc, + zelf.to_owned().into(), + attr_name.to_owned(), + ); + return Err(exc); } prev_value }; diff --git a/crates/vm/src/stdlib/_sysconfig.rs b/crates/vm/src/stdlib/_sysconfig.rs index 2de27d71ac9..5581be0227f 100644 --- a/crates/vm/src/stdlib/_sysconfig.rs +++ b/crates/vm/src/stdlib/_sysconfig.rs @@ -1,5 +1,44 @@ pub(crate) use _sysconfig::module_def; +#[pymodule] +pub(crate) mod _missing_stdlib_info { + use crate::{VirtualMachine, builtins::PyDictRef, convert::ToPyObject}; + + #[pyattr(name = "_MISSING_STDLIB_MODULE_MESSAGES")] + fn missing_stdlib_module_messages(vm: &VirtualMachine) -> PyDictRef { + let messages = vm.ctx.new_dict(); + // CPython's Tools/build/check_extension_modules.py emits these entries + // unconditionally, including on Windows. Do not infer disabled build + // features from CPython's module inventory: ours is different. + for name in [ + "_overlapped", + "_testconsole", + "_winapi", + "_wmi", + "msvcrt", + "nt", + "winreg", + "winsound", + ] { + let message = + format!("Unsupported platform for Windows-only standard library module '{name}'"); + messages + .set_item(name, message.to_pyobject(vm), vm) + .unwrap(); + } + // This module is only implemented for macOS in our stdlib as well. + #[cfg(not(target_os = "macos"))] + messages + .set_item( + "_scproxy", + "Unsupported platform for standard library module '_scproxy'".to_pyobject(vm), + vm, + ) + .unwrap(); + messages + } +} + #[pymodule] pub(crate) mod _sysconfig { use crate::{VirtualMachine, builtins::PyDictRef, convert::ToPyObject}; diff --git a/crates/vm/src/stdlib/mod.rs b/crates/vm/src/stdlib/mod.rs index d5dde116ec1..b1442645b7f 100644 --- a/crates/vm/src/stdlib/mod.rs +++ b/crates/vm/src/stdlib/mod.rs @@ -140,6 +140,7 @@ pub fn builtin_module_defs(ctx: &Context) -> Vec<&'static PyModuleDef> { _symtable::module_def(ctx), _sysconfigdata::module_def(ctx), _sysconfig::module_def(ctx), + _sysconfig::_missing_stdlib_info::module_def(ctx), #[cfg(feature = "threading")] _thread::module_def(ctx), _interpchannels::module_def(ctx),