[`pyupgrade`] Fix parsing named Unicode escape sequences (`UP032`) (#21901)

## Summary

Fixes https://github.com/astral-sh/ruff/issues/19771

Fixes incorrect parsing of Unicode named escape sequences like `Hey
\N{snowman}` in `FormatString`, which were being incorrectly split into
separate literal and field parts instead of being treated as a single
literal unit.

## Problem

The `FormatString` parser incorrectly handles Unicode named escape
sequences:
- **Current**: `Hey \N{snowman}` is parsed into 2 parts `Literal("Hey
\N")` & `Field("snowman")`
- **Expected**: `Hey \N{snowman}` should be parsed into 1 part
`Literal("Hey \N{snowman}")`

This affects f-string conversion rules when fixing `UP032` that rely on
proper format string parsing.

## Solution

I modified `parse_literal` to detect and handle Unicode named escape
sequences before parsing single characters:
- Introduced a flag to track when a backslash is "available" to escape
something.
- When the flag is `true`, and the text starts with `N{`, try to parse
the complete Unicode escape sequence as one unit, and set the flag to
`false` after parsing successfully.
- Set the flag to `false` when the backslash is already consumed.

## Manual Verification

`"\N{angle}AOB = {angle}°".format(angle=180)` 

**Result**

```bash
 def foo():
-    "\N{angle}AOB = {angle}°".format(angle=180)
+    f"\N{angle}AOB = {180}°"

Would fix 1 error.
```

`"\N{snowman} {snowman}".format(snowman=1)`

**Result**
```bash
 def foo():
-    "\N{snowman} {snowman}".format(snowman=1)
+    f"\N{snowman} {1}"

Would fix 1 error.
```

`"\\N{snowman} {snowman}".format(snowman=1)`

**Result**
```bash
 def foo():
-    "\\N{snowman} {snowman}".format(snowman=1)
+    f"\\N{1} {1}"

Would fix 1 error.
```

## Test Plan

- Added test cases (happy case, invalid case, edge case) for
`FormatString` when parsing Unicode escape sequence.
- Updated snapshots.
This commit is contained in:
Phong Do 2025-12-16 22:33:39 +01:00 committed by GitHub
parent ad3de4e488
commit b0bc990cbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 389 additions and 288 deletions

View File

@ -132,7 +132,6 @@ async def c():
# Non-errors # Non-errors
### ###
# False-negative: RustPython doesn't parse the `\N{snowman}`.
"\N{snowman} {}".format(a) "\N{snowman} {}".format(a)
"{".format(a) "{".format(a)
@ -276,3 +275,6 @@ if __name__ == "__main__":
number = 0 number = 0
string = "{}".format(number := number + 1) string = "{}".format(number := number + 1)
print(string) print(string)
# Unicode escape
"\N{angle}AOB = {angle}°".format(angle=180)

View File

@ -902,56 +902,76 @@ help: Convert to f-string
132 | # Non-errors 132 | # Non-errors
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:160:1 --> UP032_0.py:135:1
| |
158 | r'"\N{snowman} {}".format(a)' 133 | ###
159 | 134 |
160 | / "123456789 {}".format( 135 | "\N{snowman} {}".format(a)
161 | | 11111111111111111111111111111111111111111111111111111111111111111111111111, | ^^^^^^^^^^^^^^^^^^^^^^^^^^
162 | | ) 136 |
| |_^ 137 | "{".format(a)
163 |
164 | """
| |
help: Convert to f-string help: Convert to f-string
157 | 132 | # Non-errors
158 | r'"\N{snowman} {}".format(a)' 133 | ###
159 | 134 |
- "\N{snowman} {}".format(a)
135 + f"\N{snowman} {a}"
136 |
137 | "{".format(a)
138 |
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:159:1
|
157 | r'"\N{snowman} {}".format(a)'
158 |
159 | / "123456789 {}".format(
160 | | 11111111111111111111111111111111111111111111111111111111111111111111111111,
161 | | )
| |_^
162 |
163 | """
|
help: Convert to f-string
156 |
157 | r'"\N{snowman} {}".format(a)'
158 |
- "123456789 {}".format( - "123456789 {}".format(
- 11111111111111111111111111111111111111111111111111111111111111111111111111, - 11111111111111111111111111111111111111111111111111111111111111111111111111,
- ) - )
160 + f"123456789 {11111111111111111111111111111111111111111111111111111111111111111111111111}" 159 + f"123456789 {11111111111111111111111111111111111111111111111111111111111111111111111111}"
161 | 160 |
162 | """ 161 | """
163 | {} 162 | {}
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:164:1 --> UP032_0.py:163:1
| |
162 | ) 161 | )
163 | 162 |
164 | / """ 163 | / """
164 | | {}
165 | | {} 165 | | {}
166 | | {} 166 | | {}
167 | | {} 167 | | """.format(
168 | | """.format( 168 | | 1,
169 | | 1, 169 | | 2,
170 | | 2, 170 | | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
171 | | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111, 171 | | )
172 | | )
| |_^ | |_^
173 | 172 |
174 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{} 173 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
| |
help: Convert to f-string help: Convert to f-string
161 | 11111111111111111111111111111111111111111111111111111111111111111111111111, 160 | 11111111111111111111111111111111111111111111111111111111111111111111111111,
162 | ) 161 | )
163 | 162 |
164 + f""" 163 + f"""
165 + {1} 164 + {1}
166 + {2} 165 + {2}
167 + {111111111111111111111111111111111111111111111111111111111111111111111111111111111111111} 166 + {111111111111111111111111111111111111111111111111111111111111111111111111111111111111111}
168 | """ 167 | """
- {} - {}
- {} - {}
- {} - {}
@ -960,392 +980,408 @@ help: Convert to f-string
- 2, - 2,
- 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111, - 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
- ) - )
169 | 168 |
170 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{} 169 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
171 | """.format( 170 | """.format(
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:174:84 --> UP032_0.py:173:84
| |
172 | ) 171 | )
173 | 172 |
174 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{} 173 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
| ____________________________________________________________________________________^ | ____________________________________________________________________________________^
175 | | """.format( 174 | | """.format(
176 | | 111111 175 | | 111111
177 | | ) 176 | | )
| |_^ | |_^
178 | 177 |
179 | "{}".format( 178 | "{}".format(
| |
help: Convert to f-string help: Convert to f-string
171 | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111, 170 | 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111,
172 | ) 171 | )
173 | 172 |
- aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{} - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = """{}
- """.format( - """.format(
- 111111 - 111111
- ) - )
174 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = f"""{111111} 173 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa = f"""{111111}
175 + """ 174 + """
176 | 175 |
177 | "{}".format( 176 | "{}".format(
178 | [ 177 | [
UP032 Use f-string instead of `format` call UP032 Use f-string instead of `format` call
--> UP032_0.py:202:1 --> UP032_0.py:201:1
| |
200 | "{}".format(**c) 199 | "{}".format(**c)
201 | 200 |
202 | / "{}".format( 201 | / "{}".format(
203 | | 1 # comment 202 | | 1 # comment
204 | | ) 203 | | )
| |_^ | |_^
| |
help: Convert to f-string help: Convert to f-string
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:209:1 --> UP032_0.py:208:1
| |
207 | # The fixed string will exceed the line length, but it's still smaller than the 206 | # The fixed string will exceed the line length, but it's still smaller than the
208 | # existing line length, so it's fine. 207 | # existing line length, so it's fine.
209 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others) 208 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
210 | 209 |
211 | # When fixing, trim the trailing empty string. 210 | # When fixing, trim the trailing empty string.
| |
help: Convert to f-string help: Convert to f-string
206 | 205 |
207 | # The fixed string will exceed the line length, but it's still smaller than the 206 | # The fixed string will exceed the line length, but it's still smaller than the
208 | # existing line length, so it's fine. 207 | # existing line length, so it's fine.
- "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others) - "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
209 + f"<Customer: {self.internal_ids}, {self.external_ids}, {self.properties}, {self.tags}, {self.others}>" 208 + f"<Customer: {self.internal_ids}, {self.external_ids}, {self.properties}, {self.tags}, {self.others}>"
210 | 209 |
211 | # When fixing, trim the trailing empty string. 210 | # When fixing, trim the trailing empty string.
212 | raise ValueError("Conflicting configuration dicts: {!r} {!r}" 211 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:212:18 --> UP032_0.py:211:18
| |
211 | # When fixing, trim the trailing empty string. 210 | # When fixing, trim the trailing empty string.
212 | raise ValueError("Conflicting configuration dicts: {!r} {!r}" 211 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^ | __________________^
213 | | "".format(new_dict, d)) 212 | | "".format(new_dict, d))
| |_______________________________________^ | |_______________________________________^
214 | 213 |
215 | # When fixing, trim the trailing empty string. 214 | # When fixing, trim the trailing empty string.
| |
help: Convert to f-string help: Convert to f-string
209 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others) 208 | "<Customer: {}, {}, {}, {}, {}>".format(self.internal_ids, self.external_ids, self.properties, self.tags, self.others)
210 | 209 |
211 | # When fixing, trim the trailing empty string. 210 | # When fixing, trim the trailing empty string.
- raise ValueError("Conflicting configuration dicts: {!r} {!r}" - raise ValueError("Conflicting configuration dicts: {!r} {!r}"
- "".format(new_dict, d)) - "".format(new_dict, d))
212 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}") 211 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}")
212 |
213 | # When fixing, trim the trailing empty string.
214 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:215:18
|
214 | # When fixing, trim the trailing empty string.
215 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^
216 | | .format(new_dict, d))
| |_____________________________________^
217 |
218 | raise ValueError(
|
help: Convert to f-string
212 | "".format(new_dict, d))
213 | 213 |
214 | # When fixing, trim the trailing empty string. 214 | # When fixing, trim the trailing empty string.
215 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:216:18
|
215 | # When fixing, trim the trailing empty string.
216 | raise ValueError("Conflicting configuration dicts: {!r} {!r}"
| __________________^
217 | | .format(new_dict, d))
| |_____________________________________^
218 |
219 | raise ValueError(
|
help: Convert to f-string
213 | "".format(new_dict, d))
214 |
215 | # When fixing, trim the trailing empty string.
- raise ValueError("Conflicting configuration dicts: {!r} {!r}" - raise ValueError("Conflicting configuration dicts: {!r} {!r}"
- .format(new_dict, d)) - .format(new_dict, d))
216 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}" 215 + raise ValueError(f"Conflicting configuration dicts: {new_dict!r} {d!r}"
217 + ) 216 + )
218 | 217 |
219 | raise ValueError( 218 | raise ValueError(
220 | "Conflicting configuration dicts: {!r} {!r}" 219 | "Conflicting configuration dicts: {!r} {!r}"
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:220:5 --> UP032_0.py:219:5
| |
219 | raise ValueError( 218 | raise ValueError(
220 | / "Conflicting configuration dicts: {!r} {!r}" 219 | / "Conflicting configuration dicts: {!r} {!r}"
221 | | "".format(new_dict, d) 220 | | "".format(new_dict, d)
| |__________________________^ | |__________________________^
222 | ) 221 | )
| |
help: Convert to f-string help: Convert to f-string
217 | .format(new_dict, d)) 216 | .format(new_dict, d))
218 | 217 |
219 | raise ValueError( 218 | raise ValueError(
- "Conflicting configuration dicts: {!r} {!r}" - "Conflicting configuration dicts: {!r} {!r}"
- "".format(new_dict, d) - "".format(new_dict, d)
220 + f"Conflicting configuration dicts: {new_dict!r} {d!r}" 219 + f"Conflicting configuration dicts: {new_dict!r} {d!r}"
220 | )
221 |
222 | raise ValueError(
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:224:5
|
223 | raise ValueError(
224 | / "Conflicting configuration dicts: {!r} {!r}"
225 | | "".format(new_dict, d)
| |__________________________^
226 |
227 | )
|
help: Convert to f-string
221 | ) 221 | )
222 | 222 |
223 | raise ValueError( 223 | raise ValueError(
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:225:5
|
224 | raise ValueError(
225 | / "Conflicting configuration dicts: {!r} {!r}"
226 | | "".format(new_dict, d)
| |__________________________^
227 |
228 | )
|
help: Convert to f-string
222 | )
223 |
224 | raise ValueError(
- "Conflicting configuration dicts: {!r} {!r}" - "Conflicting configuration dicts: {!r} {!r}"
- "".format(new_dict, d) - "".format(new_dict, d)
225 + f"Conflicting configuration dicts: {new_dict!r} {d!r}" 224 + f"Conflicting configuration dicts: {new_dict!r} {d!r}"
226 | 225 |
227 | ) 226 | )
228 | 227 |
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:231:1 --> UP032_0.py:230:1
| |
230 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped 229 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped
231 | / ( 230 | / (
232 | | "{}" 231 | | "{}"
233 | | "{{}}" 232 | | "{{}}"
234 | | ).format(a) 233 | | ).format(a)
| |___________^ | |___________^
235 | 234 |
236 | ("{}" "{{}}").format(a) 235 | ("{}" "{{}}").format(a)
| |
help: Convert to f-string help: Convert to f-string
229 | 228 |
230 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped 229 | # The first string will be converted to an f-string and the curly braces in the second should be converted to be unescaped
231 | ( 230 | (
232 + f"{a}" 231 + f"{a}"
233 | "{}" 232 | "{}"
- "{{}}" - "{{}}"
- ).format(a) - ).format(a)
234 + ) 233 + )
235 | 234 |
236 | ("{}" "{{}}").format(a) 235 | ("{}" "{{}}").format(a)
237 | 236 |
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:236:1 --> UP032_0.py:235:1
| |
234 | ).format(a) 233 | ).format(a)
235 | 234 |
236 | ("{}" "{{}}").format(a) 235 | ("{}" "{{}}").format(a)
| ^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^
| |
help: Convert to f-string help: Convert to f-string
233 | "{{}}" 232 | "{{}}"
234 | ).format(a) 233 | ).format(a)
235 | 234 |
- ("{}" "{{}}").format(a) - ("{}" "{{}}").format(a)
236 + (f"{a}" "{}") 235 + (f"{a}" "{}")
236 |
237 | 237 |
238 | 238 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
239 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:240:1 --> UP032_0.py:239:1
| |
239 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped 238 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
240 | / ( 239 | / (
241 | | "{}" 240 | | "{}"
242 | | "{{{}}}" 241 | | "{{{}}}"
243 | | ).format(a, b) 242 | | ).format(a, b)
| |______________^ | |______________^
244 | 243 |
245 | ("{}" "{{{}}}").format(a, b) 244 | ("{}" "{{{}}}").format(a, b)
| |
help: Convert to f-string help: Convert to f-string
238 | 237 |
239 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped 238 | # Both strings will be converted to an f-string and the curly braces in the second should left escaped
240 | ( 239 | (
- "{}" - "{}"
- "{{{}}}" - "{{{}}}"
- ).format(a, b) - ).format(a, b)
241 + f"{a}" 240 + f"{a}"
242 + f"{{{b}}}" 241 + f"{{{b}}}"
243 + ) 242 + )
244 | 243 |
245 | ("{}" "{{{}}}").format(a, b) 244 | ("{}" "{{{}}}").format(a, b)
246 | 245 |
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:245:1 --> UP032_0.py:244:1
| |
243 | ).format(a, b) 242 | ).format(a, b)
244 | 243 |
245 | ("{}" "{{{}}}").format(a, b) 244 | ("{}" "{{{}}}").format(a, b)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
246 | 245 |
247 | # The dictionary should be parenthesized. 246 | # The dictionary should be parenthesized.
| |
help: Convert to f-string help: Convert to f-string
242 | "{{{}}}" 241 | "{{{}}}"
243 | ).format(a, b) 242 | ).format(a, b)
244 | 243 |
- ("{}" "{{{}}}").format(a, b) - ("{}" "{{{}}}").format(a, b)
245 + (f"{a}" f"{{{b}}}") 244 + (f"{a}" f"{{{b}}}")
246 | 245 |
247 | # The dictionary should be parenthesized. 246 | # The dictionary should be parenthesized.
248 | "{}".format({0: 1}[0]) 247 | "{}".format({0: 1}[0])
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:248:1 --> UP032_0.py:247:1
| |
247 | # The dictionary should be parenthesized. 246 | # The dictionary should be parenthesized.
248 | "{}".format({0: 1}[0]) 247 | "{}".format({0: 1}[0])
| ^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^
249 | 248 |
250 | # The dictionary should be parenthesized. 249 | # The dictionary should be parenthesized.
| |
help: Convert to f-string help: Convert to f-string
245 | ("{}" "{{{}}}").format(a, b) 244 | ("{}" "{{{}}}").format(a, b)
246 | 245 |
247 | # The dictionary should be parenthesized. 246 | # The dictionary should be parenthesized.
- "{}".format({0: 1}[0]) - "{}".format({0: 1}[0])
248 + f"{({0: 1}[0])}" 247 + f"{({0: 1}[0])}"
249 | 248 |
250 | # The dictionary should be parenthesized. 249 | # The dictionary should be parenthesized.
251 | "{}".format({0: 1}.bar) 250 | "{}".format({0: 1}.bar)
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:251:1 --> UP032_0.py:250:1
| |
250 | # The dictionary should be parenthesized. 249 | # The dictionary should be parenthesized.
251 | "{}".format({0: 1}.bar) 250 | "{}".format({0: 1}.bar)
| ^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^
252 | 251 |
253 | # The dictionary should be parenthesized. 252 | # The dictionary should be parenthesized.
| |
help: Convert to f-string help: Convert to f-string
248 | "{}".format({0: 1}[0]) 247 | "{}".format({0: 1}[0])
249 | 248 |
250 | # The dictionary should be parenthesized. 249 | # The dictionary should be parenthesized.
- "{}".format({0: 1}.bar) - "{}".format({0: 1}.bar)
251 + f"{({0: 1}.bar)}" 250 + f"{({0: 1}.bar)}"
252 | 251 |
253 | # The dictionary should be parenthesized. 252 | # The dictionary should be parenthesized.
254 | "{}".format({0: 1}()) 253 | "{}".format({0: 1}())
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:254:1 --> UP032_0.py:253:1
| |
253 | # The dictionary should be parenthesized. 252 | # The dictionary should be parenthesized.
254 | "{}".format({0: 1}()) 253 | "{}".format({0: 1}())
| ^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^
255 | 254 |
256 | # The string shouldn't be converted, since it would require repeating the function call. 255 | # The string shouldn't be converted, since it would require repeating the function call.
| |
help: Convert to f-string help: Convert to f-string
251 | "{}".format({0: 1}.bar) 250 | "{}".format({0: 1}.bar)
252 | 251 |
253 | # The dictionary should be parenthesized. 252 | # The dictionary should be parenthesized.
- "{}".format({0: 1}()) - "{}".format({0: 1}())
254 + f"{({0: 1}())}" 253 + f"{({0: 1}())}"
255 | 254 |
256 | # The string shouldn't be converted, since it would require repeating the function call. 255 | # The string shouldn't be converted, since it would require repeating the function call.
257 | "{x} {x}".format(x=foo()) 256 | "{x} {x}".format(x=foo())
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:261:1 --> UP032_0.py:260:1
| |
260 | # The string _should_ be converted, since the function call is repeated in the arguments. 259 | # The string _should_ be converted, since the function call is repeated in the arguments.
261 | "{0} {1}".format(foo(), foo()) 260 | "{0} {1}".format(foo(), foo())
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
262 | 261 |
263 | # The call should be removed, but the string itself should remain. 262 | # The call should be removed, but the string itself should remain.
| |
help: Convert to f-string help: Convert to f-string
258 | "{0} {0}".format(foo()) 257 | "{0} {0}".format(foo())
259 | 258 |
260 | # The string _should_ be converted, since the function call is repeated in the arguments. 259 | # The string _should_ be converted, since the function call is repeated in the arguments.
- "{0} {1}".format(foo(), foo()) - "{0} {1}".format(foo(), foo())
261 + f"{foo()} {foo()}" 260 + f"{foo()} {foo()}"
262 | 261 |
263 | # The call should be removed, but the string itself should remain. 262 | # The call should be removed, but the string itself should remain.
264 | ''.format(self.project) 263 | ''.format(self.project)
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:264:1 --> UP032_0.py:263:1
| |
263 | # The call should be removed, but the string itself should remain. 262 | # The call should be removed, but the string itself should remain.
264 | ''.format(self.project) 263 | ''.format(self.project)
| ^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^
265 | 264 |
266 | # The call should be removed, but the string itself should remain. 265 | # The call should be removed, but the string itself should remain.
| |
help: Convert to f-string help: Convert to f-string
261 | "{0} {1}".format(foo(), foo()) 260 | "{0} {1}".format(foo(), foo())
262 | 261 |
263 | # The call should be removed, but the string itself should remain. 262 | # The call should be removed, but the string itself should remain.
- ''.format(self.project) - ''.format(self.project)
264 + '' 263 + ''
265 | 264 |
266 | # The call should be removed, but the string itself should remain. 265 | # The call should be removed, but the string itself should remain.
267 | "".format(self.project) 266 | "".format(self.project)
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:267:1 --> UP032_0.py:266:1
| |
266 | # The call should be removed, but the string itself should remain. 265 | # The call should be removed, but the string itself should remain.
267 | "".format(self.project) 266 | "".format(self.project)
| ^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^
268 | 267 |
269 | # Not a valid type annotation but this test shouldn't result in a panic. 268 | # Not a valid type annotation but this test shouldn't result in a panic.
| |
help: Convert to f-string help: Convert to f-string
264 | ''.format(self.project) 263 | ''.format(self.project)
265 | 264 |
266 | # The call should be removed, but the string itself should remain. 265 | # The call should be removed, but the string itself should remain.
- "".format(self.project) - "".format(self.project)
267 + "" 266 + ""
268 | 267 |
269 | # Not a valid type annotation but this test shouldn't result in a panic. 268 | # Not a valid type annotation but this test shouldn't result in a panic.
270 | # Refer: https://github.com/astral-sh/ruff/issues/11736 269 | # Refer: https://github.com/astral-sh/ruff/issues/11736
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:271:5 --> UP032_0.py:270:5
| |
269 | # Not a valid type annotation but this test shouldn't result in a panic. 268 | # Not a valid type annotation but this test shouldn't result in a panic.
270 | # Refer: https://github.com/astral-sh/ruff/issues/11736 269 | # Refer: https://github.com/astral-sh/ruff/issues/11736
271 | x: "'{} + {}'.format(x, y)" 270 | x: "'{} + {}'.format(x, y)"
| ^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^
272 | 271 |
273 | # Regression https://github.com/astral-sh/ruff/issues/21000 272 | # Regression https://github.com/astral-sh/ruff/issues/21000
| |
help: Convert to f-string help: Convert to f-string
268 | 267 |
269 | # Not a valid type annotation but this test shouldn't result in a panic. 268 | # Not a valid type annotation but this test shouldn't result in a panic.
270 | # Refer: https://github.com/astral-sh/ruff/issues/11736 269 | # Refer: https://github.com/astral-sh/ruff/issues/11736
- x: "'{} + {}'.format(x, y)" - x: "'{} + {}'.format(x, y)"
271 + x: "f'{x} + {y}'" 270 + x: "f'{x} + {y}'"
272 | 271 |
273 | # Regression https://github.com/astral-sh/ruff/issues/21000 272 | # Regression https://github.com/astral-sh/ruff/issues/21000
274 | # Fix should parenthesize walrus 273 | # Fix should parenthesize walrus
UP032 [*] Use f-string instead of `format` call UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:277:14 --> UP032_0.py:276:14
| |
275 | if __name__ == "__main__": 274 | if __name__ == "__main__":
276 | number = 0 275 | number = 0
277 | string = "{}".format(number := number + 1) 276 | string = "{}".format(number := number + 1)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
278 | print(string) 277 | print(string)
| |
help: Convert to f-string help: Convert to f-string
274 | # Fix should parenthesize walrus 273 | # Fix should parenthesize walrus
275 | if __name__ == "__main__": 274 | if __name__ == "__main__":
276 | number = 0 275 | number = 0
- string = "{}".format(number := number + 1) - string = "{}".format(number := number + 1)
277 + string = f"{(number := number + 1)}" 276 + string = f"{(number := number + 1)}"
278 | print(string) 277 | print(string)
278 |
279 | # Unicode escape
UP032 [*] Use f-string instead of `format` call
--> UP032_0.py:280:1
|
279 | # Unicode escape
280 | "\N{angle}AOB = {angle}°".format(angle=180)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
help: Convert to f-string
277 | print(string)
278 |
279 | # Unicode escape
- "\N{angle}AOB = {angle}°".format(angle=180)
280 + f"\N{angle}AOB = {180}°"

View File

@ -592,11 +592,23 @@ impl FormatString {
fn parse_literal(text: &str) -> Result<(FormatPart, &str), FormatParseError> { fn parse_literal(text: &str) -> Result<(FormatPart, &str), FormatParseError> {
let mut cur_text = text; let mut cur_text = text;
let mut result_string = String::new(); let mut result_string = String::new();
let mut pending_escape = false;
while !cur_text.is_empty() { while !cur_text.is_empty() {
if pending_escape
&& let Some((unicode_string, remaining)) =
FormatString::parse_escaped_unicode_string(cur_text)
{
result_string.push_str(unicode_string);
cur_text = remaining;
pending_escape = false;
continue;
}
match FormatString::parse_literal_single(cur_text) { match FormatString::parse_literal_single(cur_text) {
Ok((next_char, remaining)) => { Ok((next_char, remaining)) => {
result_string.push(next_char); result_string.push(next_char);
cur_text = remaining; cur_text = remaining;
pending_escape = next_char == '\\' && !pending_escape;
} }
Err(err) => { Err(err) => {
return if result_string.is_empty() { return if result_string.is_empty() {
@ -678,6 +690,13 @@ impl FormatString {
} }
Err(FormatParseError::UnmatchedBracket) Err(FormatParseError::UnmatchedBracket)
} }
fn parse_escaped_unicode_string(text: &str) -> Option<(&str, &str)> {
text.strip_prefix("N{")?.find('}').map(|idx| {
let end_idx = idx + 3; // 3 for "N{"
(&text[..end_idx], &text[end_idx..])
})
}
} }
pub trait FromTemplate<'a>: Sized { pub trait FromTemplate<'a>: Sized {
@ -1020,4 +1039,48 @@ mod tests {
Err(FormatParseError::InvalidCharacterAfterRightBracket) Err(FormatParseError::InvalidCharacterAfterRightBracket)
); );
} }
#[test]
fn test_format_unicode_escape() {
let expected = Ok(FormatString {
format_parts: vec![FormatPart::Literal("I am a \\N{snowman}".to_owned())],
});
assert_eq!(FormatString::from_str("I am a \\N{snowman}"), expected);
}
#[test]
fn test_format_unicode_escape_with_field() {
let expected = Ok(FormatString {
format_parts: vec![
FormatPart::Literal("I am a \\N{snowman}".to_owned()),
FormatPart::Field {
field_name: "snowman".to_owned(),
conversion_spec: None,
format_spec: String::new(),
},
],
});
assert_eq!(
FormatString::from_str("I am a \\N{snowman}{snowman}"),
expected
);
}
#[test]
fn test_format_multiple_escape_with_field() {
let expected = Ok(FormatString {
format_parts: vec![
FormatPart::Literal("I am a \\\\N".to_owned()),
FormatPart::Field {
field_name: "snowman".to_owned(),
conversion_spec: None,
format_spec: String::new(),
},
],
});
assert_eq!(FormatString::from_str("I am a \\\\N{snowman}"), expected);
}
} }