From 8190ab2557488edab498d633c60636347f90dd7c Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:37:14 +0100 Subject: [PATCH 1/2] Fix F507 false negative for literal non-tuple RHS in %-formatting --- .../resources/test/fixtures/pyflakes/F50x.py | 20 +++++ .../src/rules/pyflakes/rules/strings.rs | 22 +++++ ..._rules__pyflakes__tests__F507_F50x.py.snap | 89 +++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py index 692bda5e19a43..7c90dcf68d1cc 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py @@ -25,3 +25,23 @@ '%(k)s' % {**k} '%s' % [1, 2, 3] '%s' % {1, 2, 3} +# F507: literal non-tuple RHS with multiple positional placeholders +'%s %s' % 42 # F507 +'%s %s' % 3.14 # F507 +'%s %s' % "hello" # F507 +'%s %s' % b"hello" # F507 +'%s %s' % True # F507 +'%s %s' % None # F507 +'%s %s' % ... # F507 +'%s %s' % f"hello {name}" # F507 +# ok: single placeholder with literal RHS +'%s' % 42 +'%s' % "hello" +'%s' % True +# ok: variables/expressions could be tuples at runtime +'%s %s' % banana +'%s %s' % obj.attr +'%s %s' % arr[0] +'%s %s' % get_args() +'%s %s' % (a if cond else b) +'%s %s' % (a + b) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs index 9df61638c40f5..59d0fb1bc4776 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs @@ -757,6 +757,28 @@ pub(crate) fn percent_format_positional_count_mismatch( location, ); } + } else if matches!( + right, + Expr::NumberLiteral(_) + | Expr::StringLiteral(_) + | Expr::BytesLiteral(_) + | Expr::BooleanLiteral(_) + | Expr::NoneLiteral(_) + | Expr::EllipsisLiteral(_) + | Expr::FString(_) + ) { + // A literal non-tuple right-hand side is always a single positional + // argument. Variables, attribute accesses, subscripts, etc. are not + // flagged because they could be tuples at runtime. + if summary.num_positional != 1 { + checker.report_diagnostic( + PercentFormatPositionalCountMismatch { + wanted: summary.num_positional, + got: 1, + }, + location, + ); + } } } diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap index 6528dd1901388..1e2b20c2cd631 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap @@ -1,5 +1,6 @@ --- source: crates/ruff_linter/src/rules/pyflakes/mod.rs +assertion_line: 192 --- F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) --> F50x.py:5:1 @@ -22,3 +23,91 @@ F507 `%`-format string has 2 placeholder(s) but 3 substitution(s) 7 | '%(bar)s' % {} # F505 8 | '%(bar)s' % {'bar': 1, 'baz': 2} # F504 | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:29:1 + | +27 | '%s' % {1, 2, 3} +28 | # F507: literal non-tuple RHS with multiple positional placeholders +29 | '%s %s' % 42 # F507 + | ^^^^^^^^^^^^ +30 | '%s %s' % 3.14 # F507 +31 | '%s %s' % "hello" # F507 + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:30:1 + | +28 | # F507: literal non-tuple RHS with multiple positional placeholders +29 | '%s %s' % 42 # F507 +30 | '%s %s' % 3.14 # F507 + | ^^^^^^^^^^^^^^ +31 | '%s %s' % "hello" # F507 +32 | '%s %s' % b"hello" # F507 + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:31:1 + | +29 | '%s %s' % 42 # F507 +30 | '%s %s' % 3.14 # F507 +31 | '%s %s' % "hello" # F507 + | ^^^^^^^^^^^^^^^^^ +32 | '%s %s' % b"hello" # F507 +33 | '%s %s' % True # F507 + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:32:1 + | +30 | '%s %s' % 3.14 # F507 +31 | '%s %s' % "hello" # F507 +32 | '%s %s' % b"hello" # F507 + | ^^^^^^^^^^^^^^^^^^ +33 | '%s %s' % True # F507 +34 | '%s %s' % None # F507 + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:33:1 + | +31 | '%s %s' % "hello" # F507 +32 | '%s %s' % b"hello" # F507 +33 | '%s %s' % True # F507 + | ^^^^^^^^^^^^^^ +34 | '%s %s' % None # F507 +35 | '%s %s' % ... # F507 + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:34:1 + | +32 | '%s %s' % b"hello" # F507 +33 | '%s %s' % True # F507 +34 | '%s %s' % None # F507 + | ^^^^^^^^^^^^^^ +35 | '%s %s' % ... # F507 +36 | '%s %s' % f"hello {name}" # F507 + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:35:1 + | +33 | '%s %s' % True # F507 +34 | '%s %s' % None # F507 +35 | '%s %s' % ... # F507 + | ^^^^^^^^^^^^^ +36 | '%s %s' % f"hello {name}" # F507 +37 | # ok: single placeholder with literal RHS + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:36:1 + | +34 | '%s %s' % None # F507 +35 | '%s %s' % ... # F507 +36 | '%s %s' % f"hello {name}" # F507 + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +37 | # ok: single placeholder with literal RHS +38 | '%s' % 42 + | From e802e57df52376eba42dfe62e1f9a6c94c582d09 Mon Sep 17 00:00:00 2001 From: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:07:17 +0100 Subject: [PATCH 2/2] Use ResolvedPythonType for F507 non-tuple RHS detection --- .../resources/test/fixtures/pyflakes/F50x.py | 7 ++ .../src/rules/pyflakes/rules/strings.rs | 21 ++--- ..._rules__pyflakes__tests__F507_F50x.py.snap | 82 ++++++++++++++++++- 3 files changed, 93 insertions(+), 17 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py index 7c90dcf68d1cc..4119de68ebaa2 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F50x.py @@ -34,6 +34,12 @@ '%s %s' % None # F507 '%s %s' % ... # F507 '%s %s' % f"hello {name}" # F507 +# F507: ResolvedPythonType catches compound expressions with known types +'%s %s' % -1 # F507 (unary op on int → int) +'%s %s' % (1 + 2) # F507 (int + int → int) +'%s %s' % (not x) # F507 (not → bool) +'%s %s' % ("a" + "b") # F507 (str + str → str) +'%s %s' % (1 if True else 2) # F507 (int if ... else int → int) # ok: single placeholder with literal RHS '%s' % 42 '%s' % "hello" @@ -43,5 +49,6 @@ '%s %s' % obj.attr '%s %s' % arr[0] '%s %s' % get_args() +# ok: ternary/binop where one branch could be a tuple → Unknown '%s %s' % (a if cond else b) '%s %s' % (a + b) diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs index 59d0fb1bc4776..4c5e1140434b2 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs @@ -2,6 +2,7 @@ use std::string::ToString; use ruff_diagnostics::Applicability; use ruff_python_ast::helpers::contains_effect; +use ruff_python_semantic::analyze::type_inference::{PythonType, ResolvedPythonType}; use rustc_hash::FxHashSet; use ruff_macros::{ViolationMetadata, derive_message_formats}; @@ -757,20 +758,12 @@ pub(crate) fn percent_format_positional_count_mismatch( location, ); } - } else if matches!( - right, - Expr::NumberLiteral(_) - | Expr::StringLiteral(_) - | Expr::BytesLiteral(_) - | Expr::BooleanLiteral(_) - | Expr::NoneLiteral(_) - | Expr::EllipsisLiteral(_) - | Expr::FString(_) - ) { - // A literal non-tuple right-hand side is always a single positional - // argument. Variables, attribute accesses, subscripts, etc. are not - // flagged because they could be tuples at runtime. - if summary.num_positional != 1 { + } else if let ResolvedPythonType::Atom(resolved_type) = ResolvedPythonType::from(right) { + // If we can infer a concrete non-tuple type for the RHS, it's always + // a single positional argument. Variables, attribute accesses, calls, + // etc. resolve to `Unknown` and are not flagged because they could be + // tuples at runtime. + if resolved_type != PythonType::Tuple && summary.num_positional != 1 { checker.report_diagnostic( PercentFormatPositionalCountMismatch { wanted: summary.num_positional, diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap index 1e2b20c2cd631..0989f6c0e3f74 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F507_F50x.py.snap @@ -24,6 +24,27 @@ F507 `%`-format string has 2 placeholder(s) but 3 substitution(s) 8 | '%(bar)s' % {'bar': 1, 'baz': 2} # F504 | +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:10:1 + | + 8 | '%(bar)s' % {'bar': 1, 'baz': 2} # F504 + 9 | '%(bar)s' % (1, 2, 3) # F502 +10 | '%s %s' % {'k': 'v'} # F503 + | ^^^^^^^^^^^^^^^^^^^^ +11 | '%(bar)*s' % {'bar': 'baz'} # F506, F508 + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:22:1 + | +20 | # ok *args and **kwargs +21 | a = [] +22 | '%s %s' % [*a] + | ^^^^^^^^^^^^^^ +23 | '%s %s' % (*a,) +24 | k = {} + | + F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) --> F50x.py:29:1 | @@ -98,7 +119,7 @@ F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) 35 | '%s %s' % ... # F507 | ^^^^^^^^^^^^^ 36 | '%s %s' % f"hello {name}" # F507 -37 | # ok: single placeholder with literal RHS +37 | # F507: ResolvedPythonType catches compound expressions with known types | F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) @@ -108,6 +129,61 @@ F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) 35 | '%s %s' % ... # F507 36 | '%s %s' % f"hello {name}" # F507 | ^^^^^^^^^^^^^^^^^^^^^^^^^ -37 | # ok: single placeholder with literal RHS -38 | '%s' % 42 +37 | # F507: ResolvedPythonType catches compound expressions with known types +38 | '%s %s' % -1 # F507 (unary op on int → int) + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:38:1 + | +36 | '%s %s' % f"hello {name}" # F507 +37 | # F507: ResolvedPythonType catches compound expressions with known types +38 | '%s %s' % -1 # F507 (unary op on int → int) + | ^^^^^^^^^^^^ +39 | '%s %s' % (1 + 2) # F507 (int + int → int) +40 | '%s %s' % (not x) # F507 (not → bool) + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:39:1 + | +37 | # F507: ResolvedPythonType catches compound expressions with known types +38 | '%s %s' % -1 # F507 (unary op on int → int) +39 | '%s %s' % (1 + 2) # F507 (int + int → int) + | ^^^^^^^^^^^^^^^^^ +40 | '%s %s' % (not x) # F507 (not → bool) +41 | '%s %s' % ("a" + "b") # F507 (str + str → str) + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:40:1 + | +38 | '%s %s' % -1 # F507 (unary op on int → int) +39 | '%s %s' % (1 + 2) # F507 (int + int → int) +40 | '%s %s' % (not x) # F507 (not → bool) + | ^^^^^^^^^^^^^^^^^ +41 | '%s %s' % ("a" + "b") # F507 (str + str → str) +42 | '%s %s' % (1 if True else 2) # F507 (int if ... else int → int) + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:41:1 + | +39 | '%s %s' % (1 + 2) # F507 (int + int → int) +40 | '%s %s' % (not x) # F507 (not → bool) +41 | '%s %s' % ("a" + "b") # F507 (str + str → str) + | ^^^^^^^^^^^^^^^^^^^^^ +42 | '%s %s' % (1 if True else 2) # F507 (int if ... else int → int) +43 | # ok: single placeholder with literal RHS + | + +F507 `%`-format string has 2 placeholder(s) but 1 substitution(s) + --> F50x.py:42:1 + | +40 | '%s %s' % (not x) # F507 (not → bool) +41 | '%s %s' % ("a" + "b") # F507 (str + str → str) +42 | '%s %s' % (1 if True else 2) # F507 (int if ... else int → int) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +43 | # ok: single placeholder with literal RHS +44 | '%s' % 42 |