[flake8-pyi] Fix PYI016 false positive for f-string debug specifier (#24098)

Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
Eyüp Can Akman
2026-04-30 12:14:25 +03:00
committed by GitHub
parent bbc529bc8a
commit f32733c063
25 changed files with 217 additions and 27 deletions
@@ -145,4 +145,14 @@ field49: typing.Optional[complex | complex] | complex
# Regression test for https://github.com/astral-sh/ruff/issues/19403
# Should throw duplicate union member but not fix
isinstance(None, typing.Union[None, None])
isinstance(None, typing.Union[None, None])
# Regression test for https://github.com/astral-sh/ruff/issues/19914
# f-string debug (=) specifier: different source text means different output
field50: typing.Literal[f"{00=}"] | typing.Literal[f"{000=}"] # OK (f"{00=}" -> "00=0", f"{000=}" -> "000=0")
field51: typing.Literal[f"{x=}"] | typing.Literal[f"{x}"] # OK (f"{x=}" -> "x=1", f"{x}" -> "1")
field52: typing.Literal[f"{x=}"] | typing.Literal[f"{x=}"] # Error (true duplicate)
field53: typing.Literal[f"{x:.2f}"] | typing.Literal[f"{x:.3f}"] # OK (different format specs)
field54: typing.Literal[f"{x}"] | typing.Literal[f"{x}"] # Error (true duplicate)
field55: typing.Literal[f"{x =}"] | typing.Literal[f"{x=}"] # OK (different debug text due to spaces)
field56: typing.Literal[f"{0x0=}"] | typing.Literal[f"{0o0=}"] # OK (different source text: "0x0" vs "0o0")
+20 -1
View File
@@ -12,7 +12,8 @@ mod tests {
use crate::registry::Rule;
use crate::rules::pep8_naming;
use crate::settings::types::PreviewMode;
use crate::test::test_path;
use crate::source_kind::SourceKind;
use crate::test::{test_contents, test_path};
use crate::{assert_diagnostics, assert_diagnostics_diff, settings};
#[test_case(Rule::AnyEqNeAnnotation, Path::new("PYI032.py"))]
@@ -214,4 +215,22 @@ mod tests {
assert_diagnostics!(diagnostics);
Ok(())
}
#[test]
fn pyi016_multiline_debug_fstring_mixed_newlines() {
let path = Path::new("<filename>.pyi");
let diagnostics = test_contents(
&SourceKind::Python {
code: "from typing import Literal\n\
value: Literal[f\"\"\"{(\r\n1\r\n)=}\"\"\"] | Literal[f\"\"\"{(\n1\n)=}\"\"\"]\n"
.to_string(),
is_stub: true,
},
path,
&settings::LinterSettings::for_rule(Rule::DuplicateUnionMember),
)
.0;
assert_eq!(diagnostics.len(), 1);
}
}
@@ -1175,5 +1175,46 @@ PYI016 Duplicate union member `None`
147 | # Should throw duplicate union member but not fix
148 | isinstance(None, typing.Union[None, None])
| ^^^^
149 |
150 | # Regression test for https://github.com/astral-sh/ruff/issues/19914
|
help: Remove duplicate union member `None`
PYI016 [*] Duplicate union member `typing.Literal[f"{x=}"]`
--> PYI016.py:154:36
|
152 | field50: typing.Literal[f"{00=}"] | typing.Literal[f"{000=}"] # OK (f"{00=}" -> "00=0", f"{000=}" -> "000=0")
153 | field51: typing.Literal[f"{x=}"] | typing.Literal[f"{x}"] # OK (f"{x=}" -> "x=1", f"{x}" -> "1")
154 | field52: typing.Literal[f"{x=}"] | typing.Literal[f"{x=}"] # Error (true duplicate)
| ^^^^^^^^^^^^^^^^^^^^^^^
155 | field53: typing.Literal[f"{x:.2f}"] | typing.Literal[f"{x:.3f}"] # OK (different format specs)
156 | field54: typing.Literal[f"{x}"] | typing.Literal[f"{x}"] # Error (true duplicate)
|
help: Remove duplicate union member `typing.Literal[f"{x=}"]`
151 | # f-string debug (=) specifier: different source text means different output
152 | field50: typing.Literal[f"{00=}"] | typing.Literal[f"{000=}"] # OK (f"{00=}" -> "00=0", f"{000=}" -> "000=0")
153 | field51: typing.Literal[f"{x=}"] | typing.Literal[f"{x}"] # OK (f"{x=}" -> "x=1", f"{x}" -> "1")
- field52: typing.Literal[f"{x=}"] | typing.Literal[f"{x=}"] # Error (true duplicate)
154 + field52: typing.Literal[f"{x=}"] # Error (true duplicate)
155 | field53: typing.Literal[f"{x:.2f}"] | typing.Literal[f"{x:.3f}"] # OK (different format specs)
156 | field54: typing.Literal[f"{x}"] | typing.Literal[f"{x}"] # Error (true duplicate)
157 | field55: typing.Literal[f"{x =}"] | typing.Literal[f"{x=}"] # OK (different debug text due to spaces)
PYI016 [*] Duplicate union member `typing.Literal[f"{x}"]`
--> PYI016.py:156:35
|
154 | field52: typing.Literal[f"{x=}"] | typing.Literal[f"{x=}"] # Error (true duplicate)
155 | field53: typing.Literal[f"{x:.2f}"] | typing.Literal[f"{x:.3f}"] # OK (different format specs)
156 | field54: typing.Literal[f"{x}"] | typing.Literal[f"{x}"] # Error (true duplicate)
| ^^^^^^^^^^^^^^^^^^^^^^
157 | field55: typing.Literal[f"{x =}"] | typing.Literal[f"{x=}"] # OK (different debug text due to spaces)
158 | field56: typing.Literal[f"{0x0=}"] | typing.Literal[f"{0o0=}"] # OK (different source text: "0x0" vs "0o0")
|
help: Remove duplicate union member `typing.Literal[f"{x}"]`
153 | field51: typing.Literal[f"{x=}"] | typing.Literal[f"{x}"] # OK (f"{x=}" -> "x=1", f"{x}" -> "1")
154 | field52: typing.Literal[f"{x=}"] | typing.Literal[f"{x=}"] # Error (true duplicate)
155 | field53: typing.Literal[f"{x:.2f}"] | typing.Literal[f"{x:.3f}"] # OK (different format specs)
- field54: typing.Literal[f"{x}"] | typing.Literal[f"{x}"] # Error (true duplicate)
156 + field54: typing.Literal[f"{x}"] # Error (true duplicate)
157 | field55: typing.Literal[f"{x =}"] | typing.Literal[f"{x=}"] # OK (different debug text due to spaces)
158 | field56: typing.Literal[f"{0x0=}"] | typing.Literal[f"{0o0=}"] # OK (different source text: "0x0" vs "0o0")
+32 -3
View File
@@ -517,10 +517,39 @@ pub enum ComparableInterpolatedStringElement<'a> {
InterpolatedElement(InterpolatedElement<'a>),
}
/// Comparable wrapper for [`ast::DebugText`].
///
/// Compares the full debug text (leading + expression source + trailing) rather than only the
/// expression source, because whitespace is part of the f-string's runtime output: `f"{x =}"`
/// produces `"x =<value>"` while `f"{x=}"` produces `"x=<value>"`, making them distinct
/// `Literal` types.
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct ComparableDebugText<'a> {
text: Cow<'a, str>,
}
impl<'a> From<&'a ast::DebugText> for ComparableDebugText<'a> {
fn from(debug_text: &'a ast::DebugText) -> Self {
// Normalizing newlines is safe because Python normalizes `\r\n` and `\r` to `\n`
// at compile time, so they produce identical runtime values.
Self {
text: normalize_newlines(debug_text.as_str()),
}
}
}
fn normalize_newlines(contents: &str) -> Cow<'_, str> {
if contents.contains('\r') {
Cow::Owned(contents.replace("\r\n", "\n").replace('\r', "\n"))
} else {
Cow::Borrowed(contents)
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct InterpolatedElement<'a> {
expression: ComparableExpr<'a>,
debug_text: Option<&'a ast::DebugText>,
debug_text: Option<ComparableDebugText<'a>>,
conversion: ast::ConversionFlag,
format_spec: Option<Vec<ComparableInterpolatedStringElement<'a>>>,
}
@@ -552,7 +581,7 @@ impl<'a> From<&'a ast::InterpolatedElement> for InterpolatedElement<'a> {
Self {
expression: (expression).into(),
debug_text: debug_text.as_ref(),
debug_text: debug_text.as_ref().map(Into::into),
conversion: *conversion,
format_spec: format_spec
.as_ref()
@@ -926,7 +955,7 @@ pub struct ExprCall<'a> {
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct ExprInterpolatedElement<'a> {
value: Box<ComparableExpr<'a>>,
debug_text: Option<&'a ast::DebugText>,
debug_text: Option<ComparableDebugText<'a>>,
conversion: ast::ConversionFlag,
format_spec: Vec<ComparableInterpolatedStringElement<'a>>,
}
+62 -4
View File
@@ -387,13 +387,71 @@ impl ConversionFlag {
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
/// The debug text of a self-documenting f-string expression (e.g., `f"{x=}"`).
///
/// Stores the concatenation of leading text, expression source, and trailing text as a single
/// [`CompactString`], with byte offsets to split them. The offsets are needed because the leading
/// and trailing portions can contain non-whitespace characters (grouping parentheses, comments in
/// triple-quoted f-strings) that cannot be distinguished from expression content by scanning.
///
/// [`CompactString`]: compact_str::CompactString
#[derive(Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "get-size", derive(get_size2::GetSize))]
pub struct DebugText {
/// The full text between the `{` and the conversion / `format_spec` / `}`.
text: compact_str::CompactString,
/// Byte offset where the expression source begins.
expression_start: u32,
/// Byte offset where the expression source ends.
expression_end: u32,
}
impl std::fmt::Debug for DebugText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DebugText")
.field("leading", &self.leading())
.field("expression", &self.expression())
.field("trailing", &self.trailing())
.finish()
}
}
impl DebugText {
pub fn new(leading: &str, expression: &str, trailing: &str) -> Self {
let expression_start = leading.text_len().to_u32();
let expression_end = expression_start + expression.text_len().to_u32();
let mut buf = compact_str::CompactString::with_capacity(
leading.len() + expression.len() + trailing.len(),
);
buf.push_str(leading);
buf.push_str(expression);
buf.push_str(trailing);
Self {
text: buf,
expression_start,
expression_end,
}
}
/// The full debug text between the `{` and the conversion / `format_spec` / `}`.
pub fn as_str(&self) -> &str {
&self.text
}
/// The text between the `{` and the expression node.
pub leading: String,
/// The text between the expression and the conversion, the `format_spec`, or the `}`, depending on what's present in the source
pub trailing: String,
pub fn leading(&self) -> &str {
&self.text[..self.expression_start as usize]
}
/// The source text of the expression (e.g., `0x0` in `f"{0x0=}"`).
pub fn expression(&self) -> &str {
&self.text[self.expression_start as usize..self.expression_end as usize]
}
/// The text between the expression and the conversion, the `format_spec`, or the `}`.
pub fn trailing(&self) -> &str {
&self.text[self.expression_end as usize..]
}
}
impl ExprFString {
+2 -2
View File
@@ -1500,13 +1500,13 @@ impl<'a> Generator<'a> {
self.p(brace);
if let Some(debug_text) = debug_text {
self.buffer += debug_text.leading.as_str();
self.buffer += debug_text.leading();
}
self.buffer += &generator.buffer;
if let Some(debug_text) = debug_text {
self.buffer += debug_text.trailing.as_str();
self.buffer += debug_text.trailing();
}
if !conversion.is_none() {
@@ -133,9 +133,9 @@ impl Format<PyFormatContext<'_>> for FormatInterpolatedElement<'_> {
write!(
f,
[
NormalizedDebugText(&debug_text.leading),
NormalizedDebugText(debug_text.leading()),
verbatim_text(expression),
NormalizedDebugText(&debug_text.trailing),
NormalizedDebugText(debug_text.trailing()),
]
)?;
@@ -112,8 +112,8 @@ impl StringLikeExtensions for ast::StringLike<'_> {
contains_line_break_or_comments(&spec.elements, context, triple_quotes)
})
|| expression.debug_text.as_ref().is_some_and(|debug_text| {
memchr2(b'\n', b'\r', debug_text.leading.as_bytes()).is_some()
|| memchr2(b'\n', b'\r', debug_text.trailing.as_bytes()).is_some()
memchr2(b'\n', b'\r', debug_text.leading().as_bytes()).is_some()
|| memchr2(b'\n', b'\r', debug_text.trailing().as_bytes()).is_some()
})
}
})
@@ -189,9 +189,11 @@ impl Transformer for Normalizer {
return;
};
// Changing the newlines to the configured newline is okay because Python normalizes all newlines to `\n`
debug.leading = debug.leading.replace("\r\n", "\n").replace('\r', "\n");
debug.trailing = debug.trailing.replace("\r\n", "\n").replace('\r', "\n");
// The formatter normalizes newlines in the text around a debug expression.
let leading = debug.leading().replace("\r\n", "\n").replace('\r', "\n");
let expression = debug.expression().to_string();
let trailing = debug.trailing().replace("\r\n", "\n").replace('\r', "\n");
*debug = ast::DebugText::new(&leading, &expression, &trailing);
}
fn visit_string_literal(&self, string_literal: &mut ast::StringLiteral) {
@@ -1719,10 +1719,11 @@ impl<'src> Parser<'src> {
let debug_text = if self.eat(TokenKind::Equal) {
let leading_range = TextRange::new(start + "{".text_len(), value.start());
let trailing_range = TextRange::new(value.end(), self.current_token_range().start());
Some(ast::DebugText {
leading: self.src_text(leading_range).to_string(),
trailing: self.src_text(trailing_range).to_string(),
})
Some(ast::DebugText::new(
self.src_text(leading_range),
self.src_text(value.range()),
self.src_text(trailing_range),
))
} else {
None
};
@@ -33,6 +33,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "user",
trailing: "=",
},
),
@@ -40,6 +40,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "user",
trailing: "=",
},
),
@@ -69,6 +70,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "second",
trailing: "=",
},
),
@@ -33,6 +33,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "user",
trailing: "=",
},
),
@@ -33,6 +33,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: " =",
},
),
@@ -33,6 +33,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: "= ",
},
),
@@ -32,6 +32,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: " =",
},
),
@@ -32,6 +32,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: "= ",
},
),
@@ -32,6 +32,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "user",
trailing: "=",
},
),
@@ -39,6 +39,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "user",
trailing: "=",
},
),
@@ -68,6 +69,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "second",
trailing: "=",
},
),
@@ -32,6 +32,7 @@ expression: suite
debug_text: Some(
DebugText {
leading: "",
expression: "user",
trailing: "=",
},
),
@@ -1,6 +1,5 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/inline/err/f_string_unclosed_lbrace.py
---
## AST
@@ -134,6 +133,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "foo",
trailing: "=",
},
),
@@ -1,6 +1,5 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/invalid/re_lexing/ty_1828.py
---
## AST
@@ -75,6 +74,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "d",
trailing: "=",
},
),
@@ -1,6 +1,5 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/inline/err/t_string_unclosed_lbrace.py
---
## AST
@@ -129,6 +128,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "foo",
trailing: "=",
},
),
@@ -1,6 +1,5 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/valid/expressions/f_string.py
---
## AST
@@ -608,6 +607,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "foo",
trailing: " = ",
},
),
@@ -660,6 +660,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "foo",
trailing: " = ",
},
),
@@ -726,6 +727,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "foo",
trailing: " = ",
},
),
@@ -798,6 +800,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "1, 2",
trailing: " = ",
},
),
@@ -866,6 +869,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "3.1415",
trailing: "=",
},
),
@@ -1391,6 +1395,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ( ",
expression: "foo",
trailing: " ) = ",
},
),
@@ -1938,6 +1943,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: " =",
},
),
@@ -1990,6 +1996,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "x",
trailing: " = ",
},
),
@@ -2042,6 +2049,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: "=",
},
),
@@ -2155,6 +2163,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: " = ",
},
),
@@ -1,6 +1,5 @@
---
source: crates/ruff_python_parser/tests/fixtures.rs
input_file: crates/ruff_python_parser/resources/valid/expressions/t_string.py
---
## AST
@@ -585,6 +584,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "foo",
trailing: " = ",
},
),
@@ -635,6 +635,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "foo",
trailing: " = ",
},
),
@@ -699,6 +700,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "foo",
trailing: " = ",
},
),
@@ -769,6 +771,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "1, 2",
trailing: " = ",
},
),
@@ -834,6 +837,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "3.1415",
trailing: "=",
},
),
@@ -1360,6 +1364,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ( ",
expression: "foo",
trailing: " ) = ",
},
),
@@ -1895,6 +1900,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: " =",
},
),
@@ -1945,6 +1951,7 @@ Module(
debug_text: Some(
DebugText {
leading: " ",
expression: "x",
trailing: " = ",
},
),
@@ -1995,6 +2002,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: "=",
},
),
@@ -2104,6 +2112,7 @@ Module(
debug_text: Some(
DebugText {
leading: "",
expression: "x",
trailing: " = ",
},
),