mirror of
https://github.com/microsoft/debugpy.git
synced 2025-12-23 08:48:12 +00:00
Test & infrastructure cleanup, stage 1 (#850)
* Add new JSON IPC implementation to be shared between the product and the tests. Add pytest-based test support, and wire it up to setup.py and Travis. Dial pylint down to complain about important things only. Various minor fixes exposed by pylint. Add basic .vscode/settings.json for linter settings (and anything else that's workspace-specific). Fixes #831.
This commit is contained in:
parent
04cdf337ff
commit
b47df51478
18 changed files with 902 additions and 8 deletions
7
.flake8
7
.flake8
|
|
@ -1,5 +1,8 @@
|
|||
[flake8]
|
||||
ignore = E24,E121,E123,E125,E126,E221,E226,E266,E704,E265
|
||||
exclude =
|
||||
ignore = W,
|
||||
E24,E121,E123,E125,E126,E221,E226,E266,E704,
|
||||
E265,E722,E501,E731,E306,E401,E302,E222
|
||||
exclude =
|
||||
ptvsd/_vendored/pydevd,
|
||||
./.eggs,
|
||||
./versioneer.py
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -45,6 +45,7 @@ nosetests.xml
|
|||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
|
@ -101,8 +102,8 @@ ENV/
|
|||
.mypy_cache/
|
||||
|
||||
# vs code and vs
|
||||
.vscode/
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
# PyDev
|
||||
.project
|
||||
|
|
|
|||
6
.pylintrc
Normal file
6
.pylintrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[MESSAGES CONTROL]
|
||||
# Disable
|
||||
disable=C,R,
|
||||
attribute-defined-outside-init,
|
||||
redefined-builtin,
|
||||
unused-argument
|
||||
|
|
@ -22,8 +22,11 @@ matrix:
|
|||
#- python: 3.6
|
||||
#- env: TARGET=ci-check-schemafile
|
||||
|
||||
before_install: |
|
||||
export TRAVIS_PYTHON_PATH=`which python`
|
||||
before_install:
|
||||
- export TRAVIS_PYTHON_PATH=`which python`
|
||||
# Travis comes with an ancient version of pytest preinstalled globally,
|
||||
# which breaks automatic dependency checks for setup.py. Force upgrade.
|
||||
- $TRAVIS_PYTHON_PATH -m pip install --upgrade pytest
|
||||
|
||||
install:
|
||||
- make depends PYTHON=$TRAVIS_PYTHON_PATH
|
||||
|
|
@ -31,3 +34,4 @@ install:
|
|||
script:
|
||||
#- make $TARGET PYTHON=python$TRAVIS_PYTHON_VERSION
|
||||
- make $TARGET PYTHON=$TRAVIS_PYTHON_PATH
|
||||
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -15,6 +15,7 @@ depends:
|
|||
$(PYTHON) -m pip install requests
|
||||
$(PYTHON) -m pip install flask
|
||||
$(PYTHON) -m pip install django
|
||||
$(PYTHON) -m pip install pytest
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Lint the Python source code.
|
||||
|
|
@ -49,6 +50,7 @@ ci-lint: depends lint
|
|||
ci-test: depends
|
||||
# For now we use --quickpy2.
|
||||
$(PYTHON) -m tests -v --full --no-network --quick-py2
|
||||
$(PYTHON) setup.py test
|
||||
|
||||
.PHONY: ci-coverage
|
||||
ci-coverage: depends
|
||||
|
|
|
|||
13
ptvsd.code-workspace
Normal file
13
ptvsd.code-workspace
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
// "python.unitTest.pyTestEnabled": true,
|
||||
// "python.unitTest.pyTestArgs": ["--no-cov"],
|
||||
}
|
||||
}
|
||||
23
ptvsd/compat.py
Normal file
23
ptvsd/compat.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
|
||||
# Python 2.x/3.x compatibility helpers
|
||||
|
||||
try:
|
||||
import builtins
|
||||
except:
|
||||
import __builtin__ as builtins
|
||||
|
||||
try:
|
||||
unicode = builtins.unicode
|
||||
bytes = builtins.str
|
||||
except:
|
||||
unicode = builtins.str
|
||||
bytes = builtins.bytes
|
||||
|
||||
try:
|
||||
xrange = builtins.xrange
|
||||
except:
|
||||
xrange = builtins.range
|
||||
293
ptvsd/messaging.py
Normal file
293
ptvsd/messaging.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
from __future__ import print_function, with_statement, absolute_import
|
||||
|
||||
import collections
|
||||
import itertools
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
class JsonIOStream(object):
|
||||
"""Implements a JSON value stream over two byte streams (input and output).
|
||||
|
||||
Each value is encoded as a packet consisting of a header and a body, as defined by the
|
||||
Debug Adapter Protocol (https://microsoft.github.io/debug-adapter-protocol/overview).
|
||||
"""
|
||||
|
||||
MAX_BODY_SIZE = 0xFFFFFF
|
||||
|
||||
@classmethod
|
||||
def from_stdio(cls):
|
||||
if sys.version_info >= (3,):
|
||||
stdin = sys.stdin.buffer
|
||||
stdout = sys.stdout.buffer
|
||||
else:
|
||||
stdin = sys.stdin
|
||||
stdout = sys.stdout
|
||||
if sys.platform == 'win32':
|
||||
import os, msvcrt
|
||||
msvcrt.setmode(stdin.fileno(), os.O_BINARY)
|
||||
msvcrt.setmode(stdout.fileno(), os.O_BINARY)
|
||||
return cls(stdin, stdout)
|
||||
|
||||
def __init__(self, reader, writer):
|
||||
"""Creates a new JsonIOStream.
|
||||
|
||||
reader is a BytesIO-like object from which incoming messages are read;
|
||||
reader.readline() must treat '\n' as the line terminator, and must leave
|
||||
'\r' as is (i.e. it must not translate '\r\n' to just plain '\n'!).
|
||||
|
||||
writer is a BytesIO-like object to which outgoing messages are written.
|
||||
"""
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
|
||||
def _read_line(self):
|
||||
line = b''
|
||||
while True:
|
||||
line += self._reader.readline()
|
||||
if not line:
|
||||
raise EOFError
|
||||
if line.endswith(b'\r\n'):
|
||||
line = line[0:-2]
|
||||
return line
|
||||
|
||||
def read_json(self):
|
||||
"""Read a single JSON value from reader.
|
||||
|
||||
Returns JSON value as parsed by json.loads(), or raises EOFError
|
||||
if there are no more objects to be read.
|
||||
"""
|
||||
headers = {}
|
||||
while True:
|
||||
line = self._read_line()
|
||||
if line == b'':
|
||||
break
|
||||
key, _, value = line.partition(b':')
|
||||
headers[key] = value
|
||||
try:
|
||||
length = int(headers[b'Content-Length'])
|
||||
if not (0 <= length <= self.MAX_BODY_SIZE):
|
||||
raise ValueError
|
||||
except (KeyError, ValueError):
|
||||
raise IOError('Content-Length is missing or invalid')
|
||||
body = self._reader.read(length)
|
||||
if isinstance(body, bytes):
|
||||
body = body.decode('utf-8')
|
||||
return json.loads(body)
|
||||
|
||||
def write_json(self, value):
|
||||
"""Write a single JSON object to writer.
|
||||
|
||||
object must be in the format suitable for json.dump().
|
||||
"""
|
||||
body = json.dumps(value, sort_keys=True)
|
||||
if not isinstance(body, bytes):
|
||||
body = body.encode('utf-8')
|
||||
header = 'Content-Length: %d\r\n\r\n' % len(body)
|
||||
if not isinstance(header, bytes):
|
||||
header = header.encode('ascii')
|
||||
self._writer.write(header)
|
||||
self._writer.write(body)
|
||||
|
||||
|
||||
class JsonMemoryStream(object):
|
||||
"""Like JsonIOStream, but without serialization, working directly
|
||||
with values stored as-is in memory.
|
||||
|
||||
For input, values are read from the supplied sequence or iterator.
|
||||
For output, values are appended to the supplied collection.
|
||||
"""
|
||||
|
||||
def __init__(self, input, output):
|
||||
self._input = iter(input)
|
||||
self._output = output
|
||||
|
||||
def read_json(self):
|
||||
try:
|
||||
return next(self._input)
|
||||
except StopIteration:
|
||||
raise EOFError
|
||||
|
||||
def write_json(self, value):
|
||||
self._output.append(value)
|
||||
|
||||
|
||||
Response = collections.namedtuple('Response', ('success', 'command', 'error_message', 'body'))
|
||||
Response.__new__.__defaults__ = (None, None)
|
||||
class Response(Response):
|
||||
"""Represents a received response to a Request."""
|
||||
|
||||
|
||||
class Request(object):
|
||||
"""Represents a request that was sent to the other party, and is awaiting or has
|
||||
already received a response.
|
||||
"""
|
||||
|
||||
def __init__(self, channel, seq):
|
||||
self.channel = channel
|
||||
self.seq = seq
|
||||
self.response = None
|
||||
self._lock = threading.Lock()
|
||||
self._got_response = threading.Event()
|
||||
self._handler = lambda _: None
|
||||
|
||||
def _handle_response(self, success, command, error_message=None, body=None):
|
||||
assert self.response is None
|
||||
with self._lock:
|
||||
response = Response(success, command, error_message, body)
|
||||
self.response = response
|
||||
handler = self._handler
|
||||
handler(response)
|
||||
self._got_response.set()
|
||||
|
||||
def wait_for_response(self):
|
||||
self._got_response.wait()
|
||||
return self.response
|
||||
|
||||
def on_response(self, handler):
|
||||
with self._lock:
|
||||
response = self.response
|
||||
if response is None:
|
||||
self._handler = handler
|
||||
return
|
||||
handler(response)
|
||||
|
||||
|
||||
class JsonMessageChannel(object):
|
||||
"""Implements a JSON message channel on top of a JSON stream, with
|
||||
support for generic Request, Response and Event messages as defined by the
|
||||
Debug Adapter Protocol (https://microsoft.github.io/debug-adapter-protocol/overview).
|
||||
"""
|
||||
|
||||
def __init__(self, stream, handlers=None):
|
||||
self.send_callback = lambda channel, message: None
|
||||
self.receive_callback = lambda channel, message: None
|
||||
self._lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._stream = stream
|
||||
self._seq_iter = itertools.count(1)
|
||||
self._requests = {}
|
||||
self._handlers = handlers
|
||||
self._worker = threading.Thread(target=self._process_incoming_messages)
|
||||
self._worker.daemon = True
|
||||
|
||||
def start(self):
|
||||
self._worker.start()
|
||||
|
||||
def wait(self):
|
||||
self._worker.join()
|
||||
|
||||
def _send_message(self, type, rest={}):
|
||||
with self._lock:
|
||||
seq = next(self._seq_iter)
|
||||
message = {
|
||||
'seq': seq,
|
||||
'type': type,
|
||||
}
|
||||
message.update(rest)
|
||||
with self._lock:
|
||||
self._stream.write_json(message)
|
||||
self.send_callback(self, message)
|
||||
return seq
|
||||
|
||||
def send_request(self, command, arguments=None):
|
||||
d = {'command': command}
|
||||
if arguments is not None:
|
||||
d['arguments'] = arguments
|
||||
seq = self._send_message('request', d)
|
||||
request = Request(self, seq)
|
||||
with self._lock:
|
||||
self._requests[seq] = request
|
||||
return request
|
||||
|
||||
def send_event(self, event, body=None):
|
||||
d = {'event': event}
|
||||
if body is not None:
|
||||
d['body'] = body
|
||||
self._send_message('event', d)
|
||||
|
||||
def send_response(self, request_seq, success, command, error_message=None, body=None):
|
||||
d = {
|
||||
'request_seq': request_seq,
|
||||
'success': success,
|
||||
'command': command,
|
||||
}
|
||||
if success:
|
||||
if body is not None:
|
||||
d['body'] = body
|
||||
else:
|
||||
if error_message is not None:
|
||||
d['message'] = error_message
|
||||
self._send_message('response', d)
|
||||
|
||||
def on_message(self, message):
|
||||
self.receive_callback(self, message)
|
||||
seq = message['seq']
|
||||
typ = message['type']
|
||||
if typ == 'request':
|
||||
command = message['command']
|
||||
arguments = message.get('arguments', None)
|
||||
self.on_request(seq, command, arguments)
|
||||
elif typ == 'event':
|
||||
event = message['event']
|
||||
body = message.get('body', None)
|
||||
self.on_event(seq, event, body)
|
||||
elif typ == 'response':
|
||||
request_seq = message['request_seq']
|
||||
success = message['success']
|
||||
command = message['command']
|
||||
error_message = message.get('message', None)
|
||||
body = message.get('body', None)
|
||||
self.on_response(seq, request_seq, success, command, error_message, body)
|
||||
else:
|
||||
raise IOError('Incoming message has invalid "type":\n%r' % message)
|
||||
|
||||
def on_request(self, seq, command, arguments):
|
||||
handler_name = '%s_request' % command
|
||||
specific_handler = getattr(self._handlers, handler_name, None)
|
||||
if specific_handler is not None:
|
||||
handler = lambda: specific_handler(self, arguments)
|
||||
else:
|
||||
generic_handler = getattr(self._handlers, 'request')
|
||||
handler = lambda: generic_handler(self, command, arguments)
|
||||
try:
|
||||
response_body = handler()
|
||||
except Exception as ex:
|
||||
self.send_response(seq, False, command, str(ex))
|
||||
else:
|
||||
self.send_response(seq, True, command, None, response_body)
|
||||
|
||||
def on_event(self, seq, event, body):
|
||||
handler_name = '%s_event' % event
|
||||
specific_handler = getattr(self._handlers, handler_name, None)
|
||||
if specific_handler is not None:
|
||||
handler = lambda: specific_handler(self, body)
|
||||
else:
|
||||
generic_handler = getattr(self._handlers, 'event')
|
||||
handler = lambda: generic_handler(self, event, body)
|
||||
handler()
|
||||
|
||||
def on_response(self, seq, request_seq, success, command, error_message, body):
|
||||
try:
|
||||
with self._lock:
|
||||
request = self._requests.pop(request_seq)
|
||||
except KeyError:
|
||||
raise KeyError('Received response to unknown request %d', request_seq)
|
||||
return request._handle_response(success, command, error_message, body)
|
||||
|
||||
def _process_incoming_messages(self):
|
||||
while True:
|
||||
try:
|
||||
message = self._stream.read_json()
|
||||
except EOFError:
|
||||
break
|
||||
try:
|
||||
self.on_message(message)
|
||||
except Exception:
|
||||
print('Error while processing message:\n%r\n\n' % message, file=sys.__stderr__)
|
||||
raise
|
||||
|
|
@ -2144,7 +2144,7 @@ class VSCodeMessageProcessor(VSCLifecycleMsgProcessor):
|
|||
else:
|
||||
is_logpoint = True
|
||||
condition = None
|
||||
expressions = re.findall('\{.*?\}', logMessage)
|
||||
expressions = re.findall(r'\{.*?\}', logMessage)
|
||||
if len(expressions) == 0:
|
||||
expression = '{}'.format(repr(logMessage)) # noqa
|
||||
else:
|
||||
|
|
|
|||
5
pytests/__init__.py
Normal file
5
pytests/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
__doc__ = """pytest-based ptvsd tests."""
|
||||
5
pytests/func/__init__.py
Normal file
5
pytests/func/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
__doc__ = """ptvsd functional tests."""
|
||||
5
pytests/helpers/__init__.py
Normal file
5
pytests/helpers/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
__doc__ = """ptvsd functional test helpers."""
|
||||
81
pytests/helpers/jsonre.py
Normal file
81
pytests/helpers/jsonre.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class Any(object):
|
||||
"""Represents a wildcard in a pattern as used by json_matches(),
|
||||
and matches any single object in the same place in input data.
|
||||
"""
|
||||
|
||||
def __repr__(self):
|
||||
return '<*>'
|
||||
|
||||
@staticmethod
|
||||
def dict_with(items):
|
||||
return Any.DictWith(items)
|
||||
|
||||
class DictWith(defaultdict):
|
||||
"""A dict subclass that returns ANY for any non-existent key.
|
||||
|
||||
This can be used in conjunction with json_matches to match some keys
|
||||
in a dict while ignoring others. For example:
|
||||
|
||||
d1 = {'a': 1, 'b': 2, 'c': 3}
|
||||
d2 = {'a': 1, 'b': 2}
|
||||
|
||||
json_matches(d1, d2) # False
|
||||
json_matches(d1, ANY.dict_with(d2)) # True
|
||||
"""
|
||||
|
||||
def __init__(self, other=None):
|
||||
super(Any.DictWith, self).__init__(lambda: ANY, other or {})
|
||||
|
||||
def __repr__(self):
|
||||
return dict.__repr__(self)[:-2] + ', ...}'
|
||||
|
||||
|
||||
ANY = Any()
|
||||
|
||||
|
||||
def json_matches(data, pattern):
|
||||
"""Match data against pattern, returning True if it matches, and False otherwise.
|
||||
|
||||
The data argument must be an object obtained via json.load, or equivalent.
|
||||
In other words, it must be a recursive data structure consisting only of
|
||||
dicts, lists, strings, numbers, Booleans, and None.
|
||||
|
||||
The pattern argument is like data, but can also use the special value ANY.
|
||||
|
||||
For strings, numbers, Booleans and None, the data matches the pattern if they're
|
||||
equal as defined by ==, or if the pattern is ANY.
|
||||
|
||||
For lists, the data matches the pattern if they're both of the same length,
|
||||
and if every element json_matches() the element in the other list with the same
|
||||
index.
|
||||
|
||||
For dicts, the data matches the pattern if, for every K in data.keys() + pattern.keys(),
|
||||
data.has_key(K) and json_matches(data[K], pattern[K]). ANY.dict_with() can be used to
|
||||
perform partial matches.
|
||||
|
||||
See test_jsonre.py for examples.
|
||||
"""
|
||||
|
||||
if isinstance(pattern, Any):
|
||||
return True
|
||||
elif isinstance(data, list):
|
||||
return len(data) == len(pattern) and all((json_matches(x, y) for x, y in zip(data, pattern)))
|
||||
elif isinstance(data, dict):
|
||||
keys = set(tuple(data.keys()) + tuple(pattern.keys()))
|
||||
def pairs_match(key):
|
||||
try:
|
||||
dval = data[key]
|
||||
pval = pattern[key]
|
||||
except KeyError:
|
||||
return False
|
||||
return json_matches(dval, pval)
|
||||
return all((pairs_match(key) for key in keys))
|
||||
else:
|
||||
return data == pattern
|
||||
99
pytests/helpers/test_jsonre.py
Normal file
99
pytests/helpers/test_jsonre.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
from .jsonre import ANY, json_matches
|
||||
|
||||
|
||||
def test_scalars():
|
||||
values = [None, True, False, 0, -1, -1.0, 1.23, 'abc', 'abcd']
|
||||
for x in values:
|
||||
assert json_matches(x, ANY)
|
||||
for y in values:
|
||||
assert json_matches(x, y) == (x == y)
|
||||
|
||||
|
||||
def test_lists():
|
||||
assert json_matches([], [])
|
||||
|
||||
assert json_matches(
|
||||
[1, 2, 3],
|
||||
ANY)
|
||||
|
||||
assert json_matches(
|
||||
[1, 2, 3],
|
||||
[1, 2, 3])
|
||||
|
||||
assert not json_matches(
|
||||
[1, 2, 3],
|
||||
[1, 2, 3, 4])
|
||||
|
||||
assert not json_matches(
|
||||
[1, 2, 3],
|
||||
[1, 2, 3, 4])
|
||||
|
||||
assert not json_matches(
|
||||
[1, 2, 3],
|
||||
[2, 3, 1])
|
||||
|
||||
assert json_matches(
|
||||
[1, 2, 3],
|
||||
[1, ANY, 3])
|
||||
|
||||
assert not json_matches(
|
||||
[1, 2, 3, 4],
|
||||
[1, ANY, 4])
|
||||
|
||||
|
||||
def test_dicts():
|
||||
assert json_matches({}, {})
|
||||
|
||||
assert json_matches(
|
||||
{'a': 1, 'b': 2},
|
||||
ANY)
|
||||
|
||||
assert json_matches(
|
||||
{'a': 1, 'b': 2},
|
||||
{'b': 2, 'a': 1})
|
||||
|
||||
assert not json_matches(
|
||||
{'a': 1, 'b': 2},
|
||||
{'a': 1, 'b': 2, 'c': 3})
|
||||
|
||||
assert not json_matches(
|
||||
{'a': 1, 'b': 2, 'c': 3},
|
||||
{'a': 1, 'b': 2})
|
||||
|
||||
assert json_matches(
|
||||
{'a': 1, 'b': 2},
|
||||
{'a': ANY, 'b': 2})
|
||||
|
||||
assert json_matches(
|
||||
{'a': 1, 'b': 2},
|
||||
ANY.dict_with({'a': 1}))
|
||||
|
||||
|
||||
def test_recursive():
|
||||
assert json_matches(
|
||||
[
|
||||
False,
|
||||
True,
|
||||
[1, 2, 3, {'aa': 4}],
|
||||
{
|
||||
'ba': [5, 6],
|
||||
'bb': [None],
|
||||
'bc': {},
|
||||
'bd': True,
|
||||
'be': [],
|
||||
}
|
||||
],
|
||||
[
|
||||
ANY,
|
||||
True,
|
||||
[1, ANY, 3, {'aa': 4}],
|
||||
ANY.dict_with({
|
||||
'ba': ANY,
|
||||
'bb': [None],
|
||||
'bc': {},
|
||||
}),
|
||||
])
|
||||
3
pytests/pytest.ini
Normal file
3
pytests/pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[pytest]
|
||||
timeout=3
|
||||
#addopts=--verbose
|
||||
343
pytests/test_messaging.py
Normal file
343
pytests/test_messaging.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See LICENSE in the project root
|
||||
# for license information.
|
||||
|
||||
from __future__ import print_function, with_statement, absolute_import
|
||||
|
||||
import json
|
||||
import io
|
||||
import pytest
|
||||
import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ptvsd.messaging import JsonIOStream, JsonMemoryStream, JsonMessageChannel, Response
|
||||
|
||||
|
||||
class TestJsonIOStream(object):
|
||||
MESSAGE_BODY_TEMPLATE = u'{"arguments": {"threadId": 3}, "command": "next", "seq": %d, "type": "request"}'
|
||||
MESSAGES = []
|
||||
SERIALIZED_MESSAGES = b''
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
for seq in range(0, 3):
|
||||
message_body = cls.MESSAGE_BODY_TEMPLATE % seq
|
||||
message = json.loads(message_body)
|
||||
message_body = message_body.encode('utf-8')
|
||||
cls.MESSAGES.append(message)
|
||||
message_header = u'Content-Length: %d\r\n\r\n' % len(message_body)
|
||||
cls.SERIALIZED_MESSAGES += message_header.encode('ascii') + message_body
|
||||
|
||||
def test_read(self):
|
||||
data = io.BytesIO(self.SERIALIZED_MESSAGES)
|
||||
stream = JsonIOStream(data, data)
|
||||
for expected_message in self.MESSAGES:
|
||||
message = stream.read_json()
|
||||
assert message == expected_message
|
||||
with pytest.raises(EOFError):
|
||||
stream.read_json()
|
||||
|
||||
def test_write(self):
|
||||
data = io.BytesIO()
|
||||
stream = JsonIOStream(data, data)
|
||||
for message in self.MESSAGES:
|
||||
stream.write_json(message)
|
||||
data = data.getvalue()
|
||||
assert data == self.SERIALIZED_MESSAGES
|
||||
|
||||
|
||||
class TestJsonMemoryStream(object):
|
||||
MESSAGES = [
|
||||
{'seq': 1, 'type': 'request', 'command': 'next', 'arguments': {'threadId': 3}},
|
||||
{'seq': 2, 'type': 'request', 'command': 'next', 'arguments': {'threadId': 5}},
|
||||
]
|
||||
|
||||
def test_read(self):
|
||||
stream = JsonMemoryStream(self.MESSAGES, [])
|
||||
for expected_message in self.MESSAGES:
|
||||
message = stream.read_json()
|
||||
assert message == expected_message
|
||||
with pytest.raises(EOFError):
|
||||
stream.read_json()
|
||||
|
||||
def test_write(self):
|
||||
messages = []
|
||||
stream = JsonMemoryStream([], messages)
|
||||
for message in self.MESSAGES:
|
||||
stream.write_json(message)
|
||||
assert messages == self.MESSAGES
|
||||
|
||||
|
||||
class TestJsonMessageChannel(object):
|
||||
logging_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def make_channel_with_logging(stream, handlers):
|
||||
def send_callback(channel, message):
|
||||
with TestJsonMessageChannel.logging_lock:
|
||||
print(id(channel), '->', message)
|
||||
|
||||
def receive_callback(channel, message):
|
||||
with TestJsonMessageChannel.logging_lock:
|
||||
print(id(channel), '<-', message)
|
||||
|
||||
channel = JsonMessageChannel(stream, handlers)
|
||||
channel.send_callback = send_callback
|
||||
channel.receive_callback = receive_callback
|
||||
return channel
|
||||
|
||||
@staticmethod
|
||||
def iter_with_event(collection):
|
||||
"""Like iter(), but also exposes a threading.Event that is set
|
||||
when the returned iterator is exhausted.
|
||||
"""
|
||||
exhausted = threading.Event()
|
||||
def iterate():
|
||||
for x in collection:
|
||||
yield x
|
||||
exhausted.set()
|
||||
return iterate(), exhausted
|
||||
|
||||
def test_events(self):
|
||||
EVENTS = [
|
||||
{'seq': 1, 'type': 'event', 'event': 'stopped', 'body': {'reason': 'pause'}},
|
||||
{'seq': 2, 'type': 'event', 'event': 'unknown', 'body': {'something': 'else'}},
|
||||
]
|
||||
|
||||
events_received = []
|
||||
class Handlers(object):
|
||||
def stopped_event(self, channel, body):
|
||||
events_received.append((channel, body))
|
||||
|
||||
def event(self, channel, event, body):
|
||||
events_received.append((channel, event, body))
|
||||
|
||||
input, input_exhausted = self.iter_with_event(EVENTS)
|
||||
stream = JsonMemoryStream(input, [])
|
||||
handlers = Handlers()
|
||||
channel = self.make_channel_with_logging(stream, handlers)
|
||||
channel.start()
|
||||
input_exhausted.wait()
|
||||
|
||||
assert events_received == [
|
||||
(channel, EVENTS[0]['body']),
|
||||
(channel, 'unknown', EVENTS[1]['body']),
|
||||
]
|
||||
|
||||
def test_requests(self):
|
||||
REQUESTS = [
|
||||
{'seq': 1, 'type': 'request', 'command': 'next', 'arguments': {'threadId': 3}},
|
||||
{'seq': 2, 'type': 'request', 'command': 'unknown', 'arguments': {'answer': 42}},
|
||||
{'seq': 3, 'type': 'request', 'command': 'pause', 'arguments': {'threadId': 5}},
|
||||
]
|
||||
|
||||
requests_received = []
|
||||
class Handlers(object):
|
||||
def next_request(self, channel, arguments):
|
||||
requests_received.append((channel, arguments))
|
||||
return {'threadId': 7}
|
||||
|
||||
def request(self, channel, command, arguments):
|
||||
requests_received.append((channel, command, arguments))
|
||||
|
||||
def pause_request(self, channel, arguments):
|
||||
requests_received.append((channel, arguments))
|
||||
raise RuntimeError('pause error')
|
||||
|
||||
input, input_exhausted = self.iter_with_event(REQUESTS)
|
||||
output = []
|
||||
stream = JsonMemoryStream(input, output)
|
||||
channel = self.make_channel_with_logging(stream, Handlers())
|
||||
channel.start()
|
||||
input_exhausted.wait()
|
||||
|
||||
assert requests_received == [
|
||||
(channel, REQUESTS[0]['arguments']),
|
||||
(channel, 'unknown', REQUESTS[1]['arguments']),
|
||||
(channel, REQUESTS[2]['arguments']),
|
||||
]
|
||||
|
||||
assert output == [
|
||||
{'seq': 1, 'type': 'response', 'request_seq': 1, 'command': 'next', 'success': True, 'body': {'threadId': 7}},
|
||||
{'seq': 2, 'type': 'response', 'request_seq': 2, 'command': 'unknown', 'success': True},
|
||||
{'seq': 3, 'type': 'response', 'request_seq': 3, 'command': 'pause', 'success': False, 'message': 'pause error'},
|
||||
]
|
||||
|
||||
def test_responses(self):
|
||||
request1_sent = threading.Event()
|
||||
request2_sent = threading.Event()
|
||||
request3_sent = threading.Event()
|
||||
|
||||
def iter_responses():
|
||||
request1_sent.wait()
|
||||
yield {'seq': 1, 'type': 'response', 'request_seq': 1, 'command': 'next', 'success': True, 'body': {'threadId': 3}}
|
||||
request2_sent.wait()
|
||||
yield {'seq': 2, 'type': 'response', 'request_seq': 2, 'command': 'pause', 'success': False, 'message': 'pause error'}
|
||||
request3_sent.wait()
|
||||
yield {'seq': 3, 'type': 'response', 'request_seq': 3, 'command': 'next', 'success': True, 'body': {'threadId': 5}}
|
||||
|
||||
stream = JsonMemoryStream(iter_responses(), [])
|
||||
channel = self.make_channel_with_logging(stream, None)
|
||||
channel.start()
|
||||
|
||||
# Blocking wait.
|
||||
request1 = channel.send_request('next')
|
||||
request1_sent.set()
|
||||
response1 = request1.wait_for_response()
|
||||
assert response1 == Response(True, 'next', body={'threadId': 3})
|
||||
|
||||
# Async callback, registered before response is received.
|
||||
request2 = channel.send_request('pause')
|
||||
response2 = [None]
|
||||
response2_received = threading.Event()
|
||||
def response2_handler(response):
|
||||
response2[0] = response
|
||||
response2_received.set()
|
||||
request2.on_response(response2_handler)
|
||||
request2_sent.set()
|
||||
response2_received.wait()
|
||||
assert response2[0] == Response(False, 'pause', error_message='pause error')
|
||||
|
||||
# Async callback, registered after response is received.
|
||||
request3 = channel.send_request('next')
|
||||
request3_sent.set()
|
||||
request3.wait_for_response()
|
||||
response3 = [None]
|
||||
response3_received = threading.Event()
|
||||
def response3_handler(response):
|
||||
response3[0] = response
|
||||
response3_received.set()
|
||||
request3.on_response(response3_handler)
|
||||
response3_received.wait()
|
||||
assert response3[0] == Response(True, 'next', body={'threadId': 5})
|
||||
|
||||
def test_fuzz(self):
|
||||
# Set up two channels over the same stream that send messages to each other
|
||||
# asynchronously, and record everything that they send and receive.
|
||||
# All records should match at the end.
|
||||
|
||||
class Fuzzer(object):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.lock = threading.Lock()
|
||||
self.sent = []
|
||||
self.received = []
|
||||
self.responses_sent = []
|
||||
self.responses_received = []
|
||||
|
||||
def start(self, channel):
|
||||
self._worker = threading.Thread(name=self.name, target=lambda: self._send_requests_and_events(channel))
|
||||
self._worker.daemon = True
|
||||
self._worker.start()
|
||||
|
||||
def wait(self):
|
||||
self._worker.join()
|
||||
|
||||
def fizz_event(self, channel, body):
|
||||
with self.lock:
|
||||
self.received.append(('event', 'fizz', body))
|
||||
|
||||
def buzz_event(self, channel, body):
|
||||
with self.lock:
|
||||
self.received.append(('event', 'buzz', body))
|
||||
|
||||
def event(self, channel, event, body):
|
||||
with self.lock:
|
||||
self.received.append(('event', event, body))
|
||||
|
||||
def make_and_log_response(self, command):
|
||||
x = random.randint(-100, 100)
|
||||
if x >= 0:
|
||||
response = Response(True, command, body=x)
|
||||
else:
|
||||
response = Response(False, command, error_message=str(x))
|
||||
with self.lock:
|
||||
self.responses_sent.append(response)
|
||||
if response.success:
|
||||
return x
|
||||
else:
|
||||
raise RuntimeError(response.error_message)
|
||||
|
||||
def fizz_request(self, channel, arguments):
|
||||
with self.lock:
|
||||
self.received.append(('request', 'fizz', arguments))
|
||||
return self.make_and_log_response('fizz')
|
||||
|
||||
def buzz_request(self, channel, arguments):
|
||||
with self.lock:
|
||||
self.received.append(('request', 'buzz', arguments))
|
||||
return self.make_and_log_response('buzz')
|
||||
|
||||
def request(self, channel, command, arguments):
|
||||
with self.lock:
|
||||
self.received.append(('request', command, arguments))
|
||||
return self.make_and_log_response(command)
|
||||
|
||||
def _send_requests_and_events(self, channel):
|
||||
pending_requests = [0]
|
||||
for _ in range(0, 100):
|
||||
typ = random.choice(('event', 'request'))
|
||||
name = random.choice(('fizz', 'buzz', 'fizzbuzz'))
|
||||
body = random.randint(0, 100)
|
||||
with self.lock:
|
||||
self.sent.append((typ, name, body))
|
||||
if typ == 'event':
|
||||
channel.send_event(name, body)
|
||||
elif typ == 'request':
|
||||
with self.lock:
|
||||
pending_requests[0] += 1
|
||||
req = channel.send_request(name, body)
|
||||
def response_handler(response):
|
||||
with self.lock:
|
||||
self.responses_received.append(response)
|
||||
pending_requests[0] -= 1
|
||||
req.on_response(response_handler)
|
||||
# Spin until we get responses to all requests.
|
||||
while True:
|
||||
with self.lock:
|
||||
if pending_requests[0] == 0:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
fuzzer1 = Fuzzer('fuzzer1')
|
||||
fuzzer2 = Fuzzer('fuzzer2')
|
||||
|
||||
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server_socket.bind(('localhost', 0))
|
||||
_, port = server_socket.getsockname()
|
||||
server_socket.listen(0)
|
||||
|
||||
socket1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
socket1_thread = threading.Thread(target=lambda: socket1.connect(('localhost', port)))
|
||||
socket1_thread.start()
|
||||
socket2, _ = server_socket.accept()
|
||||
socket1_thread.join()
|
||||
|
||||
try:
|
||||
io1 = socket1.makefile('rwb', 0)
|
||||
io2 = socket2.makefile('rwb', 0)
|
||||
|
||||
stream1 = JsonIOStream(io1, io1)
|
||||
channel1 = self.make_channel_with_logging(stream1, fuzzer1)
|
||||
channel1.start()
|
||||
fuzzer1.start(channel1)
|
||||
|
||||
stream2 = JsonIOStream(io2, io2)
|
||||
channel2 = self.make_channel_with_logging(stream2, fuzzer2)
|
||||
channel2.start()
|
||||
fuzzer2.start(channel2)
|
||||
|
||||
fuzzer1.wait()
|
||||
fuzzer2.wait()
|
||||
|
||||
finally:
|
||||
socket1.close()
|
||||
socket2.close()
|
||||
|
||||
assert fuzzer1.sent == fuzzer2.received
|
||||
assert fuzzer2.sent == fuzzer1.received
|
||||
assert fuzzer1.responses_sent == fuzzer2.responses_received
|
||||
assert fuzzer2.responses_sent == fuzzer1.responses_received
|
||||
|
||||
|
|
@ -10,4 +10,10 @@ style = pep440
|
|||
versionfile_source = ptvsd/_version.py
|
||||
versionfile_build = ptvsd/_version.py
|
||||
tag_prefix = v
|
||||
parentdir_prefix = ptvsd-
|
||||
parentdir_prefix = ptvsd-
|
||||
|
||||
[aliases]
|
||||
test=pytest
|
||||
|
||||
[tool:pytest]
|
||||
testpaths=pytests
|
||||
|
|
|
|||
4
setup.py
4
setup.py
|
|
@ -57,7 +57,9 @@ if __name__ == '__main__':
|
|||
author='Microsoft Corporation',
|
||||
author_email='ptvshelp@microsoft.com',
|
||||
url='https://aka.ms/ptvs',
|
||||
python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*",
|
||||
python_requires='>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*',
|
||||
setup_requires=['pytest_runner>=4.2'],
|
||||
tests_require=['pytest>=3.8', 'pytest-timeout>=1.3'],
|
||||
classifiers=[
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue