Repository: microsoft/ptvsd

Issue:
Requesting completions in debug console raises exception if no completions are available
## Environment data

- PTVSD version: 4.1.3
- OS and version: Any
- Python version (& distribution if applicable, e.g. Anaconda): Conda
- Using VS Code or Visual Studio: VS Code

When requesting completions in console and no completions are available the traceback below appears in the terminal:

```
Traceback (most recent call last):
  File "/home/aulty/.vscode/extensions/ms-python.python-2018.9.0/pythonFiles/experimental/ptvsd/ptvsd/wrapper.py", line 1292, in done
    fut.result()
  File "/home/aulty/.vscode/extensions/ms-python.python-2018.9.0/pythonFiles/experimental/ptvsd/ptvsd/futures.py", line 40, in result
    reraise(self._exc_info)
  File "/home/aulty/.vscode/extensions/ms-python.python-2018.9.0/pythonFiles/experimental/ptvsd/ptvsd/reraise3.py", line 8, in reraise
    raise exc_info[1].with_traceback(exc_info[2])
  File "/home/aulty/.vscode/extensions/ms-python.python-2018.9.0/pythonFiles/experimental/ptvsd/ptvsd/futures.py", line 155, in callback
    x = it.send(fut.result())
  File "/home/aulty/.vscode/extensions/ms-python.python-2018.9.0/pythonFiles/experimental/ptvsd/ptvsd/wrapper.py", line 2274, in on_completions
    for item in list(xml.comp):
  File "/home/aulty/.vscode/extensions/ms-python.python-2018.9.0/pythonFiles/experimental/ptvsd/ptvsd/untangle.py", line 87, in __getattr__
    "'%s' has no attribute '%s'" % (self._name, key)
AttributeError: 'xml' has no attribute 'comp'
```

Originally reported as a comment in: https://github.com/Microsoft/vscode-python/issues/2860#issuecomment-430838762


Discussion:

Resolving patch:
diff --git a/ptvsd/wrapper.py b/ptvsd/wrapper.py
--- a/ptvsd/wrapper.py
+++ b/ptvsd/wrapper.py
@@ -2317,7 +2317,7 @@ def on_completions(self, request, args):
 
         xml = self.parse_xml_response(resp_args)
         targets = []
-        for item in list(xml.comp):
+        for item in list(getattr(xml, 'comp', [])):
             target = {}
             target['label'] = unquote(item['p0'])
             try:
diff --git a/pytests/conftest.py b/pytests/conftest.py
--- a/pytests/conftest.py
+++ b/pytests/conftest.py
@@ -5,6 +5,7 @@
 from __future__ import print_function, with_statement, absolute_import
 
 import inspect
+import os
 import pytest
 import threading
 import types
@@ -121,9 +122,30 @@ def factory(source):
     return factory
 
 
-@pytest.fixture(params=[
-    'launch', 'attach_socket'  # 'attach_pid'
-])
+if os.environ.get('PTVSD_SIMPLE_TESTS', '').lower() in ('1', 'true'):
+    # Setting PTVSD_SIMPLE_TESTS locally is useful to not have to run
+    # all the test permutations while developing.
+    _ATTACH_PARAMS = [
+        'launch',
+    ]
+
+    _RUN_AS_PARAMS = [
+        'file',
+    ]
+else:
+    _ATTACH_PARAMS = [
+        'launch',
+        'attach_socket',
+        # 'attach_pid',
+    ]
+
+    _RUN_AS_PARAMS = [
+        'file',
+        'module',
+    ]
+
+
+@pytest.fixture(params=_ATTACH_PARAMS)
 def debug_session(request):
     session = DebugSession(request.param)
     try:
@@ -137,3 +159,45 @@ def debug_session(request):
                 session.wait_for_exit()
     finally:
         session.stop()
+
+
+@pytest.fixture(
+    name='run_as',
+    params=_RUN_AS_PARAMS
+)
+def _run_as(request):
+    return request.param
+
+
+@pytest.fixture
+def simple_hit_paused_on_break(debug_session, pyfile, run_as):
+    '''
+    Starts debug session with a pre-defined code sample, yields with
+    a breakpoint hit and when finished, resumes the execution
+    and waits for the debug session to exit.
+
+    :note: fixture will run with all permutations of the debug_session
+    parameters as well as the run_as parameters.
+    '''
+
+    from pytests.helpers.timeline import Event
+
+    @pyfile
+    def code_to_debug():
+        a = 1
+        b = {"one": 1, "two": 2}
+        c = 3
+        print([a, b, c])
+
+    bp_line = 4
+    bp_file = code_to_debug
+    debug_session.common_setup(bp_file, run_as, breakpoints=[bp_line])
+    debug_session.start_debugging()
+    hit = debug_session.wait_for_thread_stopped()
+
+    yield hit
+
+    debug_session.send_request('continue').wait_for_response()
+    debug_session.wait_for_next(Event('continued'))
+
+    debug_session.wait_for_exit()
diff --git a/pytests/helpers/session.py b/pytests/helpers/session.py
--- a/pytests/helpers/session.py
+++ b/pytests/helpers/session.py
@@ -6,6 +6,7 @@
 
 import os
 import psutil
+import pytest
 import socket
 import subprocess
 import sys
@@ -21,6 +22,9 @@
 from .messaging import LoggingJsonStream
 from .pattern import ANY
 from .timeline import Timeline, Event, Response
+from collections import namedtuple
+
+_Hit = namedtuple('_Hit', 'thread_stopped, stacktrace, thread_id, frame_id')
 
 
 class DebugSession(object):
@@ -167,7 +171,7 @@ def prepare_to_run(self, perform_handshake=True, filename=None, module=None, cod
         print('Spawning %r' % argv)
         self.process = subprocess.Popen(argv, env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.cwd)
         self.pid = self.process.pid
-        self.psutil_process =  psutil.Process(self.pid)
+        self.psutil_process = psutil.Process(self.pid)
         self.is_running = True
         watchdog.create(self.pid)
 
@@ -322,6 +326,7 @@ def causing(*expectations):
             for exp in expectations:
                 (request >> exp).wait()
             return request
+
         request.causing = causing
 
         return request
@@ -391,7 +396,7 @@ def setup_backchannel(self):
         _, self.backchannel_port = self.backchannel_socket.getsockname()
         self.backchannel_socket.listen(0)
 
-        backchannel_thread = threading.Thread(target=self._backchannel_worker, name='bchan#%d listener'  % self.ptvsd_port)
+        backchannel_thread = threading.Thread(target=self._backchannel_worker, name='bchan#%d listener' % self.ptvsd_port)
         backchannel_thread.daemon = True
         backchannel_thread.start()
 
@@ -425,6 +430,7 @@ def write_json(self, value):
         return t
 
     def _capture_output(self, pipe, name):
+
         def _output_worker():
             while True:
                 try:
@@ -447,3 +453,42 @@ def _output_worker():
     def _wait_for_remaining_output(self):
         for thread in self._output_capture_threads:
             thread.join()
+
+    def common_setup(self, path, run_as, breakpoints=()):
+        self.ignore_unobserved += [
+            Event('thread', ANY.dict_with({'reason': 'started'})),
+            Event('module')
+        ]
+        if run_as == 'file':
+            self.prepare_to_run(filename=path)
+        elif run_as == 'module':
+            self.add_file_to_pythonpath(path)
+            self.prepare_to_run(module='code_to_debug')
+        elif run_as == 'code':
+            with open(path, 'r') as f:
+                code = f.read()
+            self.prepare_to_run(code=code)
+        else:
+            pytest.fail()
+
+        if breakpoints:
+            self.send_request('setBreakpoints', arguments={
+                'source': {'path': path},
+                'breakpoints': [{'line': bp_line} for bp_line in breakpoints],
+            }).wait_for_response()
+
+    def wait_for_thread_stopped(self, reason='breakpoint'):
+        thread_stopped = self.wait_for_next(Event('stopped'), ANY.dict_with({'reason': reason}))
+
+        tid = thread_stopped.body['threadId']
+        assert tid is not None
+
+        resp_stacktrace = self.send_request('stackTrace', arguments={
+            'threadId': tid,
+        }).wait_for_response()
+        assert resp_stacktrace.body['totalFrames'] > 0
+        frames = resp_stacktrace.body['stackFrames']
+
+        fid = frames[0]['id']
+
+        return _Hit(thread_stopped, resp_stacktrace, tid, fid)


Test patch:
diff --git a/pytests/func/test_completions.py b/pytests/func/test_completions.py
new file mode 100644
--- /dev/null
+++ b/pytests/func/test_completions.py
@@ -0,0 +1,17 @@
+def test_completions(debug_session, simple_hit_paused_on_break):
+    hit = simple_hit_paused_on_break
+
+    response = debug_session.send_request('completions', arguments={
+        'frameId': hit.frame_id,
+        'text': 'b.'
+    }).wait_for_response()
+
+    labels = set(target['label'] for target in response.body['targets'])
+    assert labels.issuperset(['get', 'items', 'keys', 'setdefault', 'update', 'values'])
+
+    response = debug_session.send_request('completions', arguments={
+        'frameId': hit.frame_id,
+        'text': 'not_there'
+    }).wait_for_response()
+
+    assert not response.body['targets']
diff --git a/pytests/func/test_evaluate.py b/pytests/func/test_evaluate.py
--- a/pytests/func/test_evaluate.py
+++ b/pytests/func/test_evaluate.py
@@ -4,63 +4,16 @@
 
 from __future__ import print_function, with_statement, absolute_import
 
-import pytest
-
 from pytests.helpers import print
 from pytests.helpers.pattern import ANY
 from pytests.helpers.timeline import Event
 
 
-def _common_setup(debug_session, path, run_as):
-    debug_session.ignore_unobserved += [
-        Event('thread', ANY.dict_with({'reason': 'started'})),
-        Event('module')
-    ]
-    if run_as == 'file':
-        debug_session.prepare_to_run(filename=path)
-    elif run_as == 'module':
-        debug_session.add_file_to_pythonpath(path)
-        debug_session.prepare_to_run(module='code_to_debug')
-    elif run_as == 'code':
-        with open(path, 'r') as f:
-            code = f.read()
-        debug_session.prepare_to_run(code=code)
-    else:
-        pytest.fail()
-
+def test_variables_and_evaluate(debug_session, simple_hit_paused_on_break):
+    hit = simple_hit_paused_on_break
 
-@pytest.mark.parametrize('run_as', ['file', 'module'])
-def test_variables_and_evaluate(debug_session, pyfile, run_as):
-    @pyfile
-    def code_to_debug():
-        a = 1
-        b = {"one": 1, "two": 2}
-        c = 3
-        print([a, b, c])
-    bp_line = 4
-    bp_file = code_to_debug
-    _common_setup(debug_session, bp_file, run_as)
-
-    debug_session.send_request('setBreakpoints', arguments={
-        'source': {'path': bp_file},
-        'breakpoints': [{'line': bp_line}, ],
-    }).wait_for_response()
-    debug_session.start_debugging()
-
-    thread_stopped = debug_session.wait_for_next(Event('stopped'), ANY.dict_with({'reason': 'breakpoint'}))
-    assert thread_stopped.body['threadId'] is not None
-
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-
-    fid = frames[0]['id']
     resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': fid
+        'frameId': hit.frame_id,
     }).wait_for_response()
     scopes = resp_scopes.body['scopes']
     assert len(scopes) > 0
@@ -101,7 +54,7 @@ def code_to_debug():
 
     # simple variable
     resp_evaluate1 = debug_session.send_request('evaluate', arguments={
-        'expression': 'a', 'frameId': fid
+        'expression': 'a', 'frameId': hit.frame_id,
     }).wait_for_response()
     assert resp_evaluate1.body == ANY.dict_with({
         'type': 'int',
@@ -110,7 +63,7 @@ def code_to_debug():
 
     # dict variable
     resp_evaluate2 = debug_session.send_request('evaluate', arguments={
-        'expression': 'b["one"]', 'frameId': fid
+        'expression': 'b["one"]', 'frameId': hit.frame_id,
     }).wait_for_response()
     assert resp_evaluate2.body == ANY.dict_with({
         'type': 'int',
@@ -119,49 +72,29 @@ def code_to_debug():
 
     # expression evaluate
     resp_evaluate3 = debug_session.send_request('evaluate', arguments={
-        'expression': 'a + b["one"]', 'frameId': fid
+        'expression': 'a + b["one"]', 'frameId': hit.frame_id,
     }).wait_for_response()
     assert resp_evaluate3.body == ANY.dict_with({
         'type': 'int',
         'result': '2'
     })
 
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    debug_session.wait_for_exit()
-
 
-@pytest.mark.parametrize('run_as', ['file', 'module'])
 def test_set_variable(debug_session, pyfile, run_as):
+
     @pyfile
     def code_to_debug():
         a = 1
         print(a)
+
     bp_line = 2
     bp_file = code_to_debug
-    _common_setup(debug_session, bp_file, run_as)
-
-    debug_session.send_request('setBreakpoints', arguments={
-        'source': {'path': bp_file},
-        'breakpoints': [{'line': bp_line}, ],
-    }).wait_for_response()
+    debug_session.common_setup(bp_file, run_as, breakpoints=[bp_line])
     debug_session.start_debugging()
+    hit = debug_session.wait_for_thread_stopped()
 
-    thread_stopped = debug_session.wait_for_next(Event('stopped'), ANY.dict_with({'reason': 'breakpoint'}))
-    assert thread_stopped.body['threadId'] is not None
-
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-
-    fid = frames[0]['id']
     resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': fid
+        'frameId': hit.frame_id
     }).wait_for_response()
     scopes = resp_scopes.body['scopes']
     assert len(scopes) > 0
@@ -199,8 +132,8 @@ def code_to_debug():
     debug_session.wait_for_exit()
 
 
-@pytest.mark.parametrize('run_as', ['file', 'module'])
 def test_variable_sort(debug_session, pyfile, run_as):
+
     @pyfile
     def code_to_debug():
         b_test = {"spam": "A", "eggs": "B", "abcd": "C"}  # noqa
@@ -220,28 +153,12 @@ def code_to_debug():
 
     bp_line = 13
     bp_file = code_to_debug
-    _common_setup(debug_session, bp_file, run_as)
-
-    debug_session.send_request('setBreakpoints', arguments={
-        'source': {'path': bp_file},
-        'breakpoints': [{'line': bp_line}, ],
-    }).wait_for_response()
+    debug_session.common_setup(bp_file, run_as, breakpoints=[bp_line])
     debug_session.start_debugging()
+    hit = debug_session.wait_for_thread_stopped()
 
-    thread_stopped = debug_session.wait_for_next(Event('stopped'), ANY.dict_with({'reason': 'breakpoint'}))
-    assert thread_stopped.body['threadId'] is not None
-
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-
-    fid = frames[0]['id']
     resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': fid
+        'frameId': hit.frame_id
     }).wait_for_response()
     scopes = resp_scopes.body['scopes']
     assert len(scopes) > 0