Repository: asottile/pyupgrade

Issue:
Valid f-string conversion throws KeyError
While working on a conversion project, I noticed that certain files would throw `KeyError`'s for `\N{...}` strings.

A minimal test case looks like:
```python
world = "world"
print("hello {} \N{snowman}".format(world))
```

Which I would expect to be left alone or rewritten as:
```python
world = "world"
print(f"hello {world} \N{snowman}")
```

I had trouble reproducing it in pytests. This seemed to trigger the same error:
```diff
diff --git tests/fstrings_test.py tests/fstrings_test.py
index d850293..a772a0f 100644
--- tests/fstrings_test.py
+++ tests/fstrings_test.py
@@ -48,6 +48,7 @@ def test_fix_fstrings_noop(s):
         ('"hello {}!".format(name)', 'f"hello {name}!"'),
         ('"{}{{}}{}".format(escaped, y)', 'f"{escaped}{{}}{y}"'),
         ('"{}{b}{}".format(a, c, b=b)', 'f"{a}{b}{c}"'),
+        (r'"{}\N{snowman}".format(a)', r'f"{a}\N{snowman}"'),
         # TODO: poor man's f-strings?
         # '"{foo}".format(**locals())'
     ),
```

Discussion:

Patch:
diff --git a/pyupgrade.py b/pyupgrade.py
index dab7eb2..bcd4250 100644
--- a/pyupgrade.py
+++ b/pyupgrade.py
@@ -1058,6 +1058,10 @@ def _fix_percent_format(contents_text: str) -> str:
         if node is None:
             continue
 
+        # TODO: handle \N escape sequences
+        if r'\N' in token.src:
+            continue
+
         if isinstance(node.right, ast.Tuple):
             _fix_percent_format_tuple(tokens, i, node)
         elif isinstance(node.right, ast.Dict):
@@ -2255,6 +2259,10 @@ def _fix_fstrings(contents_text: str) -> str:
         if node is None:
             continue
 
+        # TODO: handle \N escape sequences
+        if r'\N' in token.src:
+            continue
+
         paren = i + 3
         if tokens_to_src(tokens[i + 1:paren + 1]) != '.format(':
             continue


Test patch:
diff --git a/tests/format_literals_test.py b/tests/format_literals_test.py
index ae656d1..f3610a1 100644
--- a/tests/format_literals_test.py
+++ b/tests/format_literals_test.py
@@ -49,6 +49,8 @@ def test_intentionally_not_round_trip(s, expected):
         "'{' '0}'.format(1)",
         # comment looks like placeholder but is not!
         '("{0}" # {1}\n"{2}").format(1, 2, 3)',
+        # TODO: this works by accident (extended escape treated as placeholder)
+        r'"\N{snowman} {}".format(1)',
     ),
 )
 def test_format_literals_noop(s):
diff --git a/tests/fstrings_test.py b/tests/fstrings_test.py
index d850293..2090930 100644
--- a/tests/fstrings_test.py
+++ b/tests/fstrings_test.py
@@ -26,6 +26,8 @@ from pyupgrade import _fix_fstrings
         '"{:{}}".format(x, y)',
         '"{a[b]}".format(a=a)',
         '"{a.a[b]}".format(a=a)',
+        # TODO: handle \N escape sequences
+        r'"\N{snowman} {}".format(a)',
         # not enough placeholders / placeholders missing
         '"{}{}".format(a)', '"{a}{b}".format(a=a)',
     ),
diff --git a/tests/percent_format_test.py b/tests/percent_format_test.py
index 6301620..e1c5f8d 100644
--- a/tests/percent_format_test.py
+++ b/tests/percent_format_test.py
@@ -176,6 +176,8 @@ def test_simplify_conversion_flag(s, expected):
         '"%(and)s" % {"and": 2}',
         # invalid string formats
         '"%" % {}', '"%(hi)" % {}', '"%2" % {}',
+        # TODO: handle \N escape sequences
+        r'"%s \N{snowman}" % (a,)',
     ),
 )
 def test_percent_format_noop(s):