Repository: asottile/pyupgrade

Issue:
--py36-plus: KeyError on broken `.format()` call
Currently garbage in garbage out, however this shouldn't crash:

```python
'{}{}'.format(a)
```

```console
$ pyupgrade --py36-plus t.py
Traceback (most recent call last):
  File "/home/asottile/workspace/ambassador/v/bin/pyupgrade", line 10, in <module>
    sys.exit(main())
  File "/home/asottile/workspace/ambassador/v/lib/python3.6/site-packages/pyupgrade.py", line 1763, in main
    ret |= fix_file(filename, args)
  File "/home/asottile/workspace/ambassador/v/lib/python3.6/site-packages/pyupgrade.py", line 1739, in fix_file
    contents_text = _fix_fstrings(contents_text)
  File "/home/asottile/workspace/ambassador/v/lib/python3.6/site-packages/pyupgrade.py", line 1715, in _fix_fstrings
    tokens[i] = token._replace(src=_to_fstring(token.src, node))
  File "/home/asottile/workspace/ambassador/v/lib/python3.6/site-packages/pyupgrade.py", line 1674, in _to_fstring
    name = ''.join((params[k or str(i)], dot, rest))
KeyError: '1'
```

Discussion:

Patch:
diff --git a/pyupgrade.py b/pyupgrade.py
index b83d15e..6e6051f 100644
--- a/pyupgrade.py
+++ b/pyupgrade.py
@@ -1672,6 +1672,15 @@ def _starargs(call):
     )
 
 
+def _format_params(call):
+    params = {}
+    for i, arg in enumerate(call.args):
+        params[str(i)] = _unparse(arg)
+    for kwd in call.keywords:
+        params[kwd.arg] = _unparse(kwd.value)
+    return params
+
+
 class FindSimpleFormats(ast.NodeVisitor):
     def __init__(self):
         self.found = {}
@@ -1685,7 +1694,9 @@ class FindSimpleFormats(ast.NodeVisitor):
                 all(_simple_arg(k.value) for k in node.keywords) and
                 not _starargs(node)
         ):
+            params = _format_params(node)
             seen = set()
+            i = 0
             for _, name, spec, _ in parse_format(node.func.value.s):
                 # timid: difficult to rewrite correctly
                 if spec is not None and '{' in spec:
@@ -1699,6 +1710,13 @@ class FindSimpleFormats(ast.NodeVisitor):
                     elif '[' in name:
                         break
                     seen.add(candidate)
+
+                    key = candidate or str(i)
+                    # their .format() call is broken currently
+                    if key not in params:
+                        break
+                    if not candidate:
+                        i += 1
             else:
                 self.found[_ast_to_offset(node)] = node
 
@@ -1715,11 +1733,7 @@ def _unparse(node):
 
 
 def _to_fstring(src, call):
-    params = {}
-    for i, arg in enumerate(call.args):
-        params[str(i)] = _unparse(arg)
-    for kwd in call.keywords:
-        params[kwd.arg] = _unparse(kwd.value)
+    params = _format_params(call)
 
     parts = []
     i = 0
@@ -1727,7 +1741,8 @@ def _to_fstring(src, call):
         if name is not None:
             k, dot, rest = name.partition('.')
             name = ''.join((params[k or str(i)], dot, rest))
-            i += 1
+            if not k:  # named and auto params can be in different orders
+                i += 1
         parts.append((s, name, spec, conv))
     return unparse_parsed_string(parts)
 


Test patch:
diff --git a/tests/pyupgrade_test.py b/tests/pyupgrade_test.py
index fe3d328..d4b5bdc 100644
--- a/tests/pyupgrade_test.py
+++ b/tests/pyupgrade_test.py
@@ -2004,6 +2004,8 @@ def test_fix_py2_blocks(s, expected):
         '"{:{}}".format(x, y)',
         '"{a[b]}".format(a=a)',
         '"{a.a[b]}".format(a=a)',
+        # not enough placeholders / placeholders missing
+        '"{}{}".format(a)', '"{a}{b}".format(a=a)',
     ),
 )
 def test_fix_fstrings_noop(s):
@@ -2020,7 +2022,7 @@ def test_fix_fstrings_noop(s):
         ('"{} {}".format(a.b, c.d)', 'f"{a.b} {c.d}"'),
         ('"hello {}!".format(name)', 'f"hello {name}!"'),
         ('"{}{{}}{}".format(escaped, y)', 'f"{escaped}{{}}{y}"'),
-
+        ('"{}{b}{}".format(a, c, b=b)', 'f"{a}{b}{c}"'),
         # TODO: poor man's f-strings?
         # '"{foo}".format(**locals())'
     ),