Skip to content

Commit 25968ba

Browse files
committed
fix: (UP032) Handle \N{...} in raw strings for f-string conversion
1 parent f8a2670 commit 25968ba

4 files changed

Lines changed: 32 additions & 25 deletions

File tree

crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,5 +279,5 @@ async def c():
279279
# Unicode escape in regular string, should convert.
280280
"\N{angle}AOB = {angle}°".format(angle=180)
281281

282-
# Unicode escape in raw string, should not convert - would change semantics.
282+
# Raw string with \N{...} - both {angle} are format placeholders, should convert.
283283
r"\N{angle}AOB = {angle}°".format(angle=180)

crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ use ruff_python_ast::helpers::any_over_expr;
88
use ruff_python_ast::str::{leading_quote, trailing_quote};
99
use ruff_python_ast::token::TokenKind;
1010
use ruff_python_ast::{self as ast, Expr, Keyword, StringFlags};
11-
use ruff_python_literal::format::{
12-
FieldName, FieldNamePart, FieldType, FormatPart, FormatString, FromTemplate,
13-
};
11+
use ruff_python_literal::format::{FieldName, FieldNamePart, FieldType, FormatPart, FormatString};
1412
use ruff_source_file::LineRanges;
1513
use ruff_text_size::{Ranged, TextRange};
1614

@@ -277,8 +275,8 @@ impl FStringConversion {
277275
return Ok(Self::EmptyLiteral);
278276
}
279277

280-
// Parse the format string.
281-
let format_string = FormatString::from_str(contents)?;
278+
// Parse the format string, passing raw flag to handle \N{...} correctly
279+
let format_string = FormatString::parse(contents, raw)?;
282280

283281
// If the format string contains only literal parts, it doesn't need to be converted.
284282
if format_string

crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,7 +1379,7 @@ UP032 [*] Use f-string instead of `format` call
13791379
280 | "\N{angle}AOB = {angle}°".format(angle=180)
13801380
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
13811381
281 |
1382-
282 | # Unicode escape in raw string, should not convert - would change semantics.
1382+
282 | # Raw string with \N{...} - both {angle} are format placeholders, should convert.
13831383
|
13841384
help: Convert to f-string
13851385
277 | print(string)
@@ -1388,14 +1388,19 @@ help: Convert to f-string
13881388
- "\N{angle}AOB = {angle}°".format(angle=180)
13891389
280 + f"\N{angle}AOB = {180}°"
13901390
281 |
1391-
282 | # Unicode escape in raw string, should not convert - would change semantics.
1391+
282 | # Raw string with \N{...} - both {angle} are format placeholders, should convert.
13921392
283 | r"\N{angle}AOB = {angle}°".format(angle=180)
13931393

1394-
UP032 Use f-string instead of `format` call
1394+
UP032 [*] Use f-string instead of `format` call
13951395
--> UP032_0.py:283:1
13961396
|
1397-
282 | # Unicode escape in raw string, should not convert - would change semantics.
1397+
282 | # Raw string with \N{...} - both {angle} are format placeholders, should convert.
13981398
283 | r"\N{angle}AOB = {angle}°".format(angle=180)
13991399
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14001400
|
14011401
help: Convert to f-string
1402+
280 | "\N{angle}AOB = {angle}°".format(angle=180)
1403+
281 |
1404+
282 | # Raw string with \N{...} - both {angle} are format placeholders, should convert.
1405+
- r"\N{angle}AOB = {angle}°".format(angle=180)
1406+
283 + rf"\N{180}AOB = {180}°"

crates/ruff_python_literal/src/format.rs

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -589,12 +589,14 @@ impl FormatString {
589589
Ok((first_char, chars.as_str()))
590590
}
591591

592-
fn parse_literal(text: &str) -> Result<(FormatPart, &str), FormatParseError> {
592+
fn parse_literal(text: &str, is_raw: bool) -> Result<(FormatPart, &str), FormatParseError> {
593593
let mut cur_text = text;
594594
let mut result_string = String::new();
595595
let mut pending_escape = false;
596596
while !cur_text.is_empty() {
597-
if pending_escape
597+
// Raw strings: \N{...} is literal, not a Unicode escape
598+
if !is_raw
599+
&& pending_escape
598600
&& let Some((unicode_string, remaining)) =
599601
FormatString::parse_escaped_unicode_string(cur_text)
600602
{
@@ -697,23 +699,12 @@ impl FormatString {
697699
(&text[..end_idx], &text[end_idx..])
698700
})
699701
}
700-
}
701-
702-
pub trait FromTemplate<'a>: Sized {
703-
type Err;
704-
fn from_str(s: &'a str) -> Result<Self, Self::Err>;
705-
}
706702

707-
impl<'a> FromTemplate<'a> for FormatString {
708-
type Err = FormatParseError;
709-
710-
fn from_str(text: &'a str) -> Result<Self, Self::Err> {
703+
pub fn parse(text: &str, is_raw: bool) -> Result<Self, FormatParseError> {
711704
let mut cur_text: &str = text;
712705
let mut parts: Vec<FormatPart> = Vec::new();
713706
while !cur_text.is_empty() {
714-
// Try to parse both literals and bracketed format parts until we
715-
// run out of text
716-
cur_text = FormatString::parse_literal(cur_text)
707+
cur_text = FormatString::parse_literal(cur_text, is_raw)
717708
.or_else(|_| FormatString::parse_spec(cur_text, AllowPlaceholderNesting::Yes))
718709
.map(|(part, new_text)| {
719710
parts.push(part);
@@ -726,6 +717,19 @@ impl<'a> FromTemplate<'a> for FormatString {
726717
}
727718
}
728719

720+
pub trait FromTemplate<'a>: Sized {
721+
type Err;
722+
fn from_str(s: &'a str) -> Result<Self, Self::Err>;
723+
}
724+
725+
impl<'a> FromTemplate<'a> for FormatString {
726+
type Err = FormatParseError;
727+
728+
fn from_str(text: &'a str) -> Result<Self, Self::Err> {
729+
FormatString::parse(text, false)
730+
}
731+
}
732+
729733
#[cfg(test)]
730734
mod tests {
731735
use super::*;

0 commit comments

Comments
 (0)