From f8a26700f056fddf538cdb06fe5b99b7f07e5a61 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Mon, 22 Dec 2025 23:47:41 +0100 Subject: [PATCH 1/5] fix: Avoid autofix for raw strings with \N escapes --- .../test/fixtures/pyupgrade/UP032_0.py | 5 +++- .../src/rules/pyupgrade/rules/f_strings.rs | 24 ++++++++++++++++--- ...__rules__pyupgrade__tests__UP032_0.py.snap | 20 +++++++++++++--- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py index 3214801237aae..5eb899c3b9f9b 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py @@ -276,5 +276,8 @@ async def c(): string = "{}".format(number := number + 1) print(string) -# Unicode escape +# Unicode escape in regular string, should convert. "\N{angle}AOB = {angle}°".format(angle=180) + +# Unicode escape in raw string, should not convert - would change semantics. +r"\N{angle}AOB = {angle}°".format(angle=180) diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs index 9749969691d13..5bc9018124926 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs @@ -229,6 +229,9 @@ enum FStringConversion { /// The format call uses arguments with side effects which are repeated within the /// format string. For example: `"{x} {x}".format(x=foo())`. SideEffects, + /// The format string is a raw string containing `\N{...}` which would be + /// misinterpreted in an f-string. + UnsafeRawString, /// The format string should be converted to an f-string. Convert(String), } @@ -286,6 +289,15 @@ impl FStringConversion { return Ok(Self::NonEmptyLiteral); } + // `\N{...}` is literal in raw strings but becomes a Unicode escape in f-strings. + if raw + && format_string.format_parts.iter().any( + |part| matches!(part, FormatPart::Literal(literal) if literal.contains("\\N{")), + ) + { + return Ok(Self::UnsafeRawString); + } + let mut converted = String::with_capacity(contents.len()); let mut seen = FxHashSet::default(); for part in format_string.format_parts { @@ -416,6 +428,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma let mut patches: Vec<(TextRange, FStringConversion)> = vec![]; let mut tokens = checker.tokens().in_range(call.func.range()).iter(); + let mut unsafe_conversion = false; let end = loop { let Some(token) = tokens.next() else { unreachable!("Should break from the `Tok::Dot` arm"); @@ -441,6 +454,11 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma // If the format string contains side effects that would need to be repeated, // we can't convert it to an f-string. Ok(FStringConversion::SideEffects) => return, + // If the format string is a raw string with `\N{...}`, conversion would be unsafe. + // We still want to emit the diagnostic, but without offering a fix. + Ok(FStringConversion::UnsafeRawString) => { + unsafe_conversion = true; + } // If any of the segments fail to convert, then we can't convert the entire // expression. Err(_) => return, @@ -451,7 +469,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma _ => {} } }; - if patches.is_empty() { + if patches.is_empty() && !unsafe_conversion { return; } @@ -466,7 +484,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma Some(curly_unescape(checker.locator().slice(range)).to_string()) } // We handled this in the previous loop. - FStringConversion::SideEffects => unreachable!(), + FStringConversion::SideEffects | FStringConversion::UnsafeRawString => unreachable!(), }; if let Some(fstring) = fstring { contents.push_str( @@ -520,7 +538,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma // ``` let has_comments = checker.comment_ranges().intersects(call.arguments.range()); - if !has_comments { + if !has_comments && !unsafe_conversion { if contents.is_empty() { // Ex) `''.format(self.project)` diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap index f15311cad0975..28d293636144f 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap @@ -1370,18 +1370,32 @@ help: Convert to f-string 276 + string = f"{(number := number + 1)}" 277 | print(string) 278 | -279 | # Unicode escape +279 | # Unicode escape in regular string, should convert. UP032 [*] Use f-string instead of `format` call --> UP032_0.py:280:1 | -279 | # Unicode escape +279 | # Unicode escape in regular string, should convert. 280 | "\N{angle}AOB = {angle}°".format(angle=180) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +281 | +282 | # Unicode escape in raw string, should not convert - would change semantics. | help: Convert to f-string 277 | print(string) 278 | -279 | # Unicode escape +279 | # Unicode escape in regular string, should convert. - "\N{angle}AOB = {angle}°".format(angle=180) 280 + f"\N{angle}AOB = {180}°" +281 | +282 | # Unicode escape in raw string, should not convert - would change semantics. +283 | r"\N{angle}AOB = {angle}°".format(angle=180) + +UP032 Use f-string instead of `format` call + --> UP032_0.py:283:1 + | +282 | # Unicode escape in raw string, should not convert - would change semantics. +283 | r"\N{angle}AOB = {angle}°".format(angle=180) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Convert to f-string From 383d400b89fe82d710fc2e6327a1dded502fcc7a Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Wed, 31 Dec 2025 02:14:11 +0100 Subject: [PATCH 2/5] fix: (UP032) Handle \N{...} in raw strings for f-string conversion --- .../test/fixtures/pyupgrade/UP032_0.py | 4 +-- .../src/rules/pyupgrade/rules/f_strings.rs | 32 ++++------------- ...__rules__pyupgrade__tests__UP032_0.py.snap | 19 +++++++---- crates/ruff_python_literal/src/format.rs | 34 +++++++++++-------- 4 files changed, 39 insertions(+), 50 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py index 5eb899c3b9f9b..24870c52c48d3 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py @@ -276,8 +276,8 @@ async def c(): string = "{}".format(number := number + 1) print(string) -# Unicode escape in regular string, should convert. +# Unicode escape "\N{angle}AOB = {angle}°".format(angle=180) -# Unicode escape in raw string, should not convert - would change semantics. +# Raw string with \N{...} r"\N{angle}AOB = {angle}°".format(angle=180) diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs index 5bc9018124926..3381f91ddf135 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs @@ -8,9 +8,7 @@ use ruff_python_ast::helpers::any_over_expr; use ruff_python_ast::str::{leading_quote, trailing_quote}; use ruff_python_ast::token::TokenKind; use ruff_python_ast::{self as ast, Expr, Keyword, StringFlags}; -use ruff_python_literal::format::{ - FieldName, FieldNamePart, FieldType, FormatPart, FormatString, FromTemplate, -}; +use ruff_python_literal::format::{FieldName, FieldNamePart, FieldType, FormatPart, FormatString}; use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; @@ -229,9 +227,6 @@ enum FStringConversion { /// The format call uses arguments with side effects which are repeated within the /// format string. For example: `"{x} {x}".format(x=foo())`. SideEffects, - /// The format string is a raw string containing `\N{...}` which would be - /// misinterpreted in an f-string. - UnsafeRawString, /// The format string should be converted to an f-string. Convert(String), } @@ -277,8 +272,8 @@ impl FStringConversion { return Ok(Self::EmptyLiteral); } - // Parse the format string. - let format_string = FormatString::from_str(contents)?; + // Parse the format string + let format_string = FormatString::parse(contents, raw)?; // If the format string contains only literal parts, it doesn't need to be converted. if format_string @@ -289,15 +284,6 @@ impl FStringConversion { return Ok(Self::NonEmptyLiteral); } - // `\N{...}` is literal in raw strings but becomes a Unicode escape in f-strings. - if raw - && format_string.format_parts.iter().any( - |part| matches!(part, FormatPart::Literal(literal) if literal.contains("\\N{")), - ) - { - return Ok(Self::UnsafeRawString); - } - let mut converted = String::with_capacity(contents.len()); let mut seen = FxHashSet::default(); for part in format_string.format_parts { @@ -428,7 +414,6 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma let mut patches: Vec<(TextRange, FStringConversion)> = vec![]; let mut tokens = checker.tokens().in_range(call.func.range()).iter(); - let mut unsafe_conversion = false; let end = loop { let Some(token) = tokens.next() else { unreachable!("Should break from the `Tok::Dot` arm"); @@ -454,11 +439,6 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma // If the format string contains side effects that would need to be repeated, // we can't convert it to an f-string. Ok(FStringConversion::SideEffects) => return, - // If the format string is a raw string with `\N{...}`, conversion would be unsafe. - // We still want to emit the diagnostic, but without offering a fix. - Ok(FStringConversion::UnsafeRawString) => { - unsafe_conversion = true; - } // If any of the segments fail to convert, then we can't convert the entire // expression. Err(_) => return, @@ -469,7 +449,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma _ => {} } }; - if patches.is_empty() && !unsafe_conversion { + if patches.is_empty() { return; } @@ -484,7 +464,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma Some(curly_unescape(checker.locator().slice(range)).to_string()) } // We handled this in the previous loop. - FStringConversion::SideEffects | FStringConversion::UnsafeRawString => unreachable!(), + FStringConversion::SideEffects => unreachable!(), }; if let Some(fstring) = fstring { contents.push_str( @@ -538,7 +518,7 @@ pub(crate) fn f_strings(checker: &Checker, call: &ast::ExprCall, summary: &Forma // ``` let has_comments = checker.comment_ranges().intersects(call.arguments.range()); - if !has_comments && !unsafe_conversion { + if !has_comments { if contents.is_empty() { // Ex) `''.format(self.project)` diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement( diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap index 28d293636144f..119dd00c447e0 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap @@ -1370,32 +1370,37 @@ help: Convert to f-string 276 + string = f"{(number := number + 1)}" 277 | print(string) 278 | -279 | # Unicode escape in regular string, should convert. +279 | # Unicode escape UP032 [*] Use f-string instead of `format` call --> UP032_0.py:280:1 | -279 | # Unicode escape in regular string, should convert. +279 | # Unicode escape 280 | "\N{angle}AOB = {angle}°".format(angle=180) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 281 | -282 | # Unicode escape in raw string, should not convert - would change semantics. +282 | # Raw string with \N{...} | help: Convert to f-string 277 | print(string) 278 | -279 | # Unicode escape in regular string, should convert. +279 | # Unicode escape - "\N{angle}AOB = {angle}°".format(angle=180) 280 + f"\N{angle}AOB = {180}°" 281 | -282 | # Unicode escape in raw string, should not convert - would change semantics. +282 | # Raw string with \N{...} 283 | r"\N{angle}AOB = {angle}°".format(angle=180) -UP032 Use f-string instead of `format` call +UP032 [*] Use f-string instead of `format` call --> UP032_0.py:283:1 | -282 | # Unicode escape in raw string, should not convert - would change semantics. +282 | # Raw string with \N{...} 283 | r"\N{angle}AOB = {angle}°".format(angle=180) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | help: Convert to f-string +280 | "\N{angle}AOB = {angle}°".format(angle=180) +281 | +282 | # Raw string with \N{...} + - r"\N{angle}AOB = {angle}°".format(angle=180) +283 + rf"\N{180}AOB = {180}°" diff --git a/crates/ruff_python_literal/src/format.rs b/crates/ruff_python_literal/src/format.rs index 687694c8bc9d1..8286f3f1b1796 100644 --- a/crates/ruff_python_literal/src/format.rs +++ b/crates/ruff_python_literal/src/format.rs @@ -589,12 +589,14 @@ impl FormatString { Ok((first_char, chars.as_str())) } - fn parse_literal(text: &str) -> Result<(FormatPart, &str), FormatParseError> { + fn parse_literal(text: &str, is_raw: bool) -> Result<(FormatPart, &str), FormatParseError> { let mut cur_text = text; let mut result_string = String::new(); let mut pending_escape = false; while !cur_text.is_empty() { - if pending_escape + // Raw strings: \N{...} is literal, not a Unicode escape + if !is_raw + && pending_escape && let Some((unicode_string, remaining)) = FormatString::parse_escaped_unicode_string(cur_text) { @@ -697,23 +699,12 @@ impl FormatString { (&text[..end_idx], &text[end_idx..]) }) } -} - -pub trait FromTemplate<'a>: Sized { - type Err; - fn from_str(s: &'a str) -> Result; -} -impl<'a> FromTemplate<'a> for FormatString { - type Err = FormatParseError; - - fn from_str(text: &'a str) -> Result { + pub fn parse(text: &str, is_raw: bool) -> Result { let mut cur_text: &str = text; let mut parts: Vec = Vec::new(); while !cur_text.is_empty() { - // Try to parse both literals and bracketed format parts until we - // run out of text - cur_text = FormatString::parse_literal(cur_text) + cur_text = FormatString::parse_literal(cur_text, is_raw) .or_else(|_| FormatString::parse_spec(cur_text, AllowPlaceholderNesting::Yes)) .map(|(part, new_text)| { parts.push(part); @@ -726,6 +717,19 @@ impl<'a> FromTemplate<'a> for FormatString { } } +pub trait FromTemplate<'a>: Sized { + type Err; + fn from_str(s: &'a str) -> Result; +} + +impl<'a> FromTemplate<'a> for FormatString { + type Err = FormatParseError; + + fn from_str(text: &'a str) -> Result { + FormatString::parse(text, false) + } +} + #[cfg(test)] mod tests { use super::*; From 3fa51acddbf3fefc22bc37d66ba1aecfcfc15a57 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 20 Feb 2026 09:59:59 -0500 Subject: [PATCH 3/5] restore comment and update FromTemplate api --- .../ruff_linter/src/rules/pyupgrade/rules/f_strings.rs | 10 ++++++++-- crates/ruff_python_literal/src/format.rs | 9 ++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs index 3381f91ddf135..6b300e3734417 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs @@ -8,7 +8,9 @@ use ruff_python_ast::helpers::any_over_expr; use ruff_python_ast::str::{leading_quote, trailing_quote}; use ruff_python_ast::token::TokenKind; use ruff_python_ast::{self as ast, Expr, Keyword, StringFlags}; -use ruff_python_literal::format::{FieldName, FieldNamePart, FieldType, FormatPart, FormatString}; +use ruff_python_literal::format::{ + FieldName, FieldNamePart, FieldType, FormatPart, FormatString, FromTemplate, +}; use ruff_source_file::LineRanges; use ruff_text_size::{Ranged, TextRange}; @@ -273,7 +275,11 @@ impl FStringConversion { } // Parse the format string - let format_string = FormatString::parse(contents, raw)?; + let format_string = if raw { + FormatString::from_raw_str(contents) + } else { + FormatString::from_str(contents) + }?; // If the format string contains only literal parts, it doesn't need to be converted. if format_string diff --git a/crates/ruff_python_literal/src/format.rs b/crates/ruff_python_literal/src/format.rs index 8286f3f1b1796..26009115a9428 100644 --- a/crates/ruff_python_literal/src/format.rs +++ b/crates/ruff_python_literal/src/format.rs @@ -700,9 +700,11 @@ impl FormatString { }) } - pub fn parse(text: &str, is_raw: bool) -> Result { + fn parse(text: &str, is_raw: bool) -> Result { let mut cur_text: &str = text; let mut parts: Vec = Vec::new(); + // Try to parse both literals and bracketed format parts until we + // run out of text while !cur_text.is_empty() { cur_text = FormatString::parse_literal(cur_text, is_raw) .or_else(|_| FormatString::parse_spec(cur_text, AllowPlaceholderNesting::Yes)) @@ -720,6 +722,7 @@ impl FormatString { pub trait FromTemplate<'a>: Sized { type Err; fn from_str(s: &'a str) -> Result; + fn from_raw_str(s: &'a str) -> Result; } impl<'a> FromTemplate<'a> for FormatString { @@ -728,6 +731,10 @@ impl<'a> FromTemplate<'a> for FormatString { fn from_str(text: &'a str) -> Result { FormatString::parse(text, false) } + + fn from_raw_str(text: &'a str) -> Result { + FormatString::parse(text, true) + } } #[cfg(test)] From 70c28b340ee8731630d6a3de992981d43ffc451c Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 20 Feb 2026 10:06:19 -0500 Subject: [PATCH 4/5] move comment back into loop --- crates/ruff_python_literal/src/format.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruff_python_literal/src/format.rs b/crates/ruff_python_literal/src/format.rs index 26009115a9428..f1eba8e8f54ed 100644 --- a/crates/ruff_python_literal/src/format.rs +++ b/crates/ruff_python_literal/src/format.rs @@ -703,9 +703,9 @@ impl FormatString { fn parse(text: &str, is_raw: bool) -> Result { let mut cur_text: &str = text; let mut parts: Vec = Vec::new(); - // Try to parse both literals and bracketed format parts until we - // run out of text while !cur_text.is_empty() { + // Try to parse both literals and bracketed format parts until we + // run out of text cur_text = FormatString::parse_literal(cur_text, is_raw) .or_else(|_| FormatString::parse_spec(cur_text, AllowPlaceholderNesting::Yes)) .map(|(part, new_text)| { From d5bba80f4ee8c0441c984292c9b9b0aefb1ff290 Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 20 Feb 2026 10:06:38 -0500 Subject: [PATCH 5/5] restore period --- crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs index 6b300e3734417..88374e5d84e2f 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs @@ -274,7 +274,7 @@ impl FStringConversion { return Ok(Self::EmptyLiteral); } - // Parse the format string + // Parse the format string. let format_string = if raw { FormatString::from_raw_str(contents) } else {