From b47df514781bf4dc085fbff4aed6e69f299b3c23 Mon Sep 17 00:00:00 2001 From: Pavel Minaev Date: Thu, 27 Sep 2018 00:36:48 -0700 Subject: [PATCH] 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. --- .flake8 | 7 +- .gitignore | 3 +- .pylintrc | 6 + .travis.yml | 8 +- Makefile | 2 + ptvsd.code-workspace | 13 ++ ptvsd/compat.py | 23 +++ ptvsd/messaging.py | 293 ++++++++++++++++++++++++++++ ptvsd/wrapper.py | 2 +- pytests/__init__.py | 5 + pytests/func/__init__.py | 5 + pytests/helpers/__init__.py | 5 + pytests/helpers/jsonre.py | 81 ++++++++ pytests/helpers/test_jsonre.py | 99 ++++++++++ pytests/pytest.ini | 3 + pytests/test_messaging.py | 343 +++++++++++++++++++++++++++++++++ setup.cfg | 8 +- setup.py | 4 +- 18 files changed, 902 insertions(+), 8 deletions(-) create mode 100644 .pylintrc create mode 100644 ptvsd.code-workspace create mode 100644 ptvsd/compat.py create mode 100644 ptvsd/messaging.py create mode 100644 pytests/__init__.py create mode 100644 pytests/func/__init__.py create mode 100644 pytests/helpers/__init__.py create mode 100644 pytests/helpers/jsonre.py create mode 100644 pytests/helpers/test_jsonre.py create mode 100644 pytests/pytest.ini create mode 100644 pytests/test_messaging.py diff --git a/.flake8 b/.flake8 index 8a29a45b..62aba857 100644 --- a/.flake8 +++ b/.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 diff --git a/.gitignore b/.gitignore index 7986c75d..2af35dfe 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..195b5bc7 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,6 @@ +[MESSAGES CONTROL] +# Disable +disable=C,R, + attribute-defined-outside-init, + redefined-builtin, + unused-argument diff --git a/.travis.yml b/.travis.yml index 2a8663cb..851dd732 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 + diff --git a/Makefile b/Makefile index 19ac82c1..decdbac7 100644 --- a/Makefile +++ b/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 diff --git a/ptvsd.code-workspace b/ptvsd.code-workspace new file mode 100644 index 00000000..56973b35 --- /dev/null +++ b/ptvsd.code-workspace @@ -0,0 +1,13 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "python.linting.enabled": true, + "python.linting.pylintEnabled": false, + // "python.unitTest.pyTestEnabled": true, + // "python.unitTest.pyTestArgs": ["--no-cov"], + } +} diff --git a/ptvsd/compat.py b/ptvsd/compat.py new file mode 100644 index 00000000..d0f32286 --- /dev/null +++ b/ptvsd/compat.py @@ -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 diff --git a/ptvsd/messaging.py b/ptvsd/messaging.py new file mode 100644 index 00000000..3650cd88 --- /dev/null +++ b/ptvsd/messaging.py @@ -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 diff --git a/ptvsd/wrapper.py b/ptvsd/wrapper.py index 46256565..fe0ae6dc 100644 --- a/ptvsd/wrapper.py +++ b/ptvsd/wrapper.py @@ -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: diff --git a/pytests/__init__.py b/pytests/__init__.py new file mode 100644 index 00000000..f29ccac2 --- /dev/null +++ b/pytests/__init__.py @@ -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.""" diff --git a/pytests/func/__init__.py b/pytests/func/__init__.py new file mode 100644 index 00000000..22b212ac --- /dev/null +++ b/pytests/func/__init__.py @@ -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.""" diff --git a/pytests/helpers/__init__.py b/pytests/helpers/__init__.py new file mode 100644 index 00000000..d08b7ca0 --- /dev/null +++ b/pytests/helpers/__init__.py @@ -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.""" diff --git a/pytests/helpers/jsonre.py b/pytests/helpers/jsonre.py new file mode 100644 index 00000000..86d7878e --- /dev/null +++ b/pytests/helpers/jsonre.py @@ -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 diff --git a/pytests/helpers/test_jsonre.py b/pytests/helpers/test_jsonre.py new file mode 100644 index 00000000..489a1ec8 --- /dev/null +++ b/pytests/helpers/test_jsonre.py @@ -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': {}, + }), + ]) diff --git a/pytests/pytest.ini b/pytests/pytest.ini new file mode 100644 index 00000000..ca9806a3 --- /dev/null +++ b/pytests/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +timeout=3 +#addopts=--verbose diff --git a/pytests/test_messaging.py b/pytests/test_messaging.py new file mode 100644 index 00000000..8a27aa46 --- /dev/null +++ b/pytests/test_messaging.py @@ -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 + diff --git a/setup.cfg b/setup.cfg index d3231891..3f15cc69 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,4 +10,10 @@ style = pep440 versionfile_source = ptvsd/_version.py versionfile_build = ptvsd/_version.py tag_prefix = v -parentdir_prefix = ptvsd- \ No newline at end of file +parentdir_prefix = ptvsd- + +[aliases] +test=pytest + +[tool:pytest] +testpaths=pytests diff --git a/setup.py b/setup.py index ff81bc2e..6025fc23 100644 --- a/setup.py +++ b/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',