Repository: bpython/bpython

Issue:
Tab completion for dict key causes crash
This issue is not reproducible with several different `dict`s that I've tried, but it can be reproduced with the `dict` below.

```
my_dict = {
'Circle': {'Ellipse',
           'Shape',
           'ShapeDriver'},

'Ellipse': {'Shape'},

'Rectangle': {'Shape'},

'Shape': {'Circle'},

'ShapeDriver': {'Circle',
                'Ellipse',
                'Shape'},

'Square': {'Rectangle'}
}
```

Steps to recreate:
1. create new `dict`
2. type `my_dict[`
3. press <tab> key to use tab-completion
4. crash

```
~ % bpython
bpython version 0.21 on top of Python 3.8.2 /Library/Developer/CommandLineTools/usr/bin/python3
>>> my_dict = {
... 'a/Circle': {'Ellipse',
...            'Shape',
...            'ShapeDriver'},
... 
... 'a/Ellipse': {'Shape'},
... 
... 'a/Rectangle': {'Shape'},
... 
... 'a/Shape': {'Circle'},
... 
... 'a/ShapeDriver': {'Circle',
...                 'Ellipse',
...                 'Shape'},
... 
... 'a/Square': {'Rectangle'}
... }
>>> my_dict['b/banana']
Traceback (most recent call last):
  File "/Users/arian/Library/Python/3.8/bin/bpython", line 8, in <module>
    sys.exit(main())
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/curtsies.py", line 201, in main
    exit_value = repl.mainloop(True, paste)
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/curtsies.py", line 121, in mainloop
    self.process_event_and_paint(e)
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/curtsies.py", line 85, in process_event_and_paint
    self.process_event(e)
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/curtsiesfrontend/repl.py", line 615, in process_event
    return self.process_key_event(e)
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/curtsiesfrontend/repl.py", line 757, in process_key_event
    self.on_tab()
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/curtsiesfrontend/repl.py", line 891, in on_tab
    cursor_and_line = self.matches_iter.substitute_cseq()
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/repl.py", line 297, in substitute_cseq
    self.update(
  File "/Users/arian/Library/Python/3.8/lib/python/site-packages/bpython/repl.py", line 318, in update
    self.start, self.end, self.current_word = self.completer.locate(
TypeError: cannot unpack non-iterable NoneType object
```

Image below so you can see the colors as well:
![image](https://user-images.githubusercontent.com/71411328/134820762-07de5cae-70b0-486e-8b7a-ac529cf8533e.png)


Comments:

Patch:
diff --git a/bpython/args.py b/bpython/args.py
index 7895d84a..79ddcc67 100644
--- a/bpython/args.py
+++ b/bpython/args.py
@@ -216,11 +216,17 @@ def parse(args, extras=None, ignore_stdin=False) -> Tuple:
 
 def exec_code(interpreter, args):
     """
-    Helper to execute code in a given interpreter. args should be a [faked]
-    sys.argv
+    Helper to execute code in a given interpreter, e.g. to implement the behavior of python3 [-i] file.py
+
+    args should be a [faked] sys.argv.
     """
-    with open(args[0]) as sourcefile:
-        source = sourcefile.read()
+    try:
+        with open(args[0]) as sourcefile:
+            source = sourcefile.read()
+    except OSError as e:
+        # print an error and exit (if -i is specified the calling code will continue)
+        print(f"bpython: can't open file '{args[0]}: {e}", file=sys.stderr)
+        raise SystemExit(e.errno)
     old_argv, sys.argv = sys.argv, args
     sys.path.insert(0, os.path.abspath(os.path.dirname(args[0])))
     spec = importlib.util.spec_from_loader("__console__", loader=None)
diff --git a/bpython/line.py b/bpython/line.py
index 7ced3bf1..b98302dd 100644
--- a/bpython/line.py
+++ b/bpython/line.py
@@ -4,6 +4,8 @@ All functions take cursor offset from the beginning of the line and the line of
 Python code, and return None, or a tuple of the start index, end index, and the
 word."""
 
+import re
+
 from itertools import chain
 from typing import Optional, NamedTuple
 
@@ -34,7 +36,41 @@ def current_word(cursor_offset: int, line: str) -> Optional[LinePart]:
     return LinePart(start, end, word)
 
 
-_current_dict_key_re = LazyReCompile(r"""[\w_][\w0-9._]*\[([\w0-9._(), '"]*)""")
+# pieces of regex to match repr() of several hashable built-in types
+_match_all_dict_keys = r"""[^\]]*"""
+
+# https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals
+_match_single_quote_str_bytes = r"""
+    # bytes repr() begins with `b` character; bytes and str begin with `'`
+    b?'             
+    # match escape sequence; this handles `\'` in the string repr()
+    (?:\\['"nabfrtvxuU\\]|
+    # or match any non-`\` and non-single-quote character (most of the string)
+    [^'\\])*
+    # matches hanging `\` or ending `'` if one is present
+    [\\']?
+"""
+
+# bytes and str repr() only uses double quotes if the string contains 1 or more
+# `'` character and exactly 0 `"` characters
+_match_double_quote_str_bytes = r"""
+    # bytes repr() begins with `b` character
+    b?"
+    # string continues until a `"` character is reached
+    [^"]*
+    # end matching at closing double-quote if one is present
+    "?"""
+
+# match valid identifier name followed by `[` character
+_match_dict_before_key = r"""[\w_][\w0-9._]*\["""
+
+_current_dict_key_re = LazyReCompile(
+    f"{_match_dict_before_key}((?:"
+    f"{_match_single_quote_str_bytes}|"
+    f"{_match_double_quote_str_bytes}|"
+    f"{_match_all_dict_keys}|)*)",
+    re.VERBOSE,
+)
 
 
 def current_dict_key(cursor_offset: int, line: str) -> Optional[LinePart]:
@@ -45,7 +81,16 @@ def current_dict_key(cursor_offset: int, line: str) -> Optional[LinePart]:
     return None
 
 
-_current_dict_re = LazyReCompile(r"""([\w_][\w0-9._]*)\[([\w0-9._(), '"]*)""")
+# capture valid identifier name if followed by `[` character
+_capture_dict_name = r"""([\w_][\w0-9._]*)\["""
+
+_current_dict_re = LazyReCompile(
+    f"{_capture_dict_name}((?:"
+    f"{_match_single_quote_str_bytes}|"
+    f"{_match_double_quote_str_bytes}|"
+    f"{_match_all_dict_keys}|)*)",
+    re.VERBOSE,
+)
 
 
 def current_dict(cursor_offset: int, line: str) -> Optional[LinePart]:


Test patch:
diff --git a/bpython/test/test_line_properties.py b/bpython/test/test_line_properties.py
index fe1b0813..592a6176 100644
--- a/bpython/test/test_line_properties.py
+++ b/bpython/test/test_line_properties.py
@@ -178,10 +178,24 @@ class TestCurrentDictKey(LineTestCase):
         self.assertAccess("asdf[<(>|]")
         self.assertAccess("asdf[<(1>|]")
         self.assertAccess("asdf[<(1,>|]")
+        self.assertAccess("asdf[<(1,)>|]")
         self.assertAccess("asdf[<(1, >|]")
         self.assertAccess("asdf[<(1, 2)>|]")
         # TODO self.assertAccess('d[d[<12|>')
         self.assertAccess("d[<'a>|")
+        self.assertAccess("object.dict['a'bcd'], object.dict[<'abc>|")
+        self.assertAccess("object.dict[<'a'bcd'>|], object.dict['abc")
+        self.assertAccess(r"object.dict[<'a\'\\\"\n\\'>|")
+        self.assertAccess("object.dict[<\"abc'>|")
+        self.assertAccess("object.dict[<(1, 'apple', 2.134>|]")
+        self.assertAccess("object.dict[<(1, 'apple', 2.134)>|]")
+        self.assertAccess("object.dict[<-1000>|")
+        self.assertAccess("object.dict[<-0.23948>|")
+        self.assertAccess("object.dict[<'\U0001ffff>|")
+        self.assertAccess(r"object.dict[<'a\'\\\"\n\\'>|]")
+        self.assertAccess(r"object.dict[<'a\'\\\"\n\\|[[]'>")
+        self.assertAccess('object.dict[<"a]bc[|]">]')
+        self.assertAccess("object.dict[<'abcd[]>|")
 
 
 class TestCurrentDict(LineTestCase):