diff --git a/.gitignore b/.gitignore index e95c34ea..28066860 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ eggs/ lib/ lib64/ parts/ +pip-wheel-metadata/ sdist/ var/ wheels/ diff --git a/setup.py b/setup.py index e9d8e08c..97842d34 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,7 @@ import os.path import subprocess import sys + pure = None if '--pure' in sys.argv: pure = True @@ -21,11 +22,14 @@ elif '--abi' in sys.argv: from setuptools import setup # noqa + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import versioneer # noqa +del sys.path[0] sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')) -import ptvsd # noqa -import ptvsd._vendored # noqa +import ptvsd +import ptvsd._vendored del sys.path[0] diff --git a/src/ptvsd/__init__.py b/src/ptvsd/__init__.py index 0e506edd..140f2f34 100644 --- a/src/ptvsd/__init__.py +++ b/src/ptvsd/__init__.py @@ -2,6 +2,13 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. +from __future__ import absolute_import, print_function, unicode_literals + +"""An implementation of the Debug Adapter Protocol (DAP) for Python. + +https://microsoft.github.io/debug-adapter-protocol/ +""" + __all__ = [ "__version__", "attach", @@ -12,12 +19,14 @@ __all__ = [ "wait_for_attach", ] +# Force absolute path on Python 2. +from os import path +__file__ = path.abspath(__file__) +del path -from ._version import get_versions - -__version__ = get_versions()["version"] -del get_versions - +from ptvsd import _version +__version__ = _version.get_versions()["version"] +del _version from ptvsd.server.attach_server import ( attach, diff --git a/src/ptvsd/adapter/messages.py b/src/ptvsd/adapter/messages.py index 20ef6109..a9c8310e 100644 --- a/src/ptvsd/adapter/messages.py +++ b/src/ptvsd/adapter/messages.py @@ -220,7 +220,7 @@ class IDEMessages(Messages): return # Can happen if the IDE was force-closed or crashed. - log.warn('IDE disconnected without sending "disconnect" or "terminate".') + log.warning('IDE disconnected without sending "disconnect" or "terminate".') state.change("shutting_down") if self._channels.server is None: diff --git a/src/ptvsd/common/_util.py b/src/ptvsd/common/_util.py index 0f3edfcb..6a2a72ca 100644 --- a/src/ptvsd/common/_util.py +++ b/src/ptvsd/common/_util.py @@ -19,7 +19,7 @@ def ignore_errors(log=None): yield except Exception: if log is not None: - ptvsd.common.log.exception('Ignoring error', category='I') + ptvsd.common.log.exception('Ignoring error', level='info') def call_all(callables, *args, **kwargs): diff --git a/src/ptvsd/common/compat.py b/src/ptvsd/common/compat.py index de236f06..0dc6ef4b 100644 --- a/src/ptvsd/common/compat.py +++ b/src/ptvsd/common/compat.py @@ -18,11 +18,6 @@ try: except ImportError: import __builtin__ as builtins # noqa -try: - import queue -except ImportError: - import Queue as queue # noqa - try: unicode = builtins.unicode bytes = builtins.str @@ -35,6 +30,17 @@ try: except AttributeError: xrange = builtins.range +try: + reload = builtins.reload +except AttributeError: + from importlib import reload # noqa + +try: + import queue +except ImportError: + import Queue as queue # noqa + + def force_unicode(s, encoding, errors="strict"): """Converts s to Unicode, using the provided encoding. If s is already Unicode, diff --git a/src/ptvsd/common/fmt.py b/src/ptvsd/common/fmt.py index 7091396b..5f353a70 100644 --- a/src/ptvsd/common/fmt.py +++ b/src/ptvsd/common/fmt.py @@ -34,7 +34,6 @@ class Formatter(string.Formatter, types.ModuleType): # that were imported globally, but that are referenced by method bodies that # can run after substitition occurred, must be re-imported here, so that they # can be accessed via self. - import types json_encoder = json.JSONEncoder(indent=4) @@ -43,7 +42,9 @@ class Formatter(string.Formatter, types.ModuleType): def __init__(self): # Set self up as a proper module, and copy globals. - self.types.ModuleType.__init__(self, __name__) + # types must be re-imported, because globals aren't there yet at this point. + import types + types.ModuleType.__init__(self, __name__) self.__dict__.update(sys.modules[__name__].__dict__) def __call__(self, format_string, *args, **kwargs): diff --git a/src/ptvsd/common/log.py b/src/ptvsd/common/log.py index 8ecd189e..671d5b01 100644 --- a/src/ptvsd/common/log.py +++ b/src/ptvsd/common/log.py @@ -11,71 +11,103 @@ import platform import os import sys import threading -import time import traceback import ptvsd -import ptvsd.common.options -from ptvsd.common import fmt +from ptvsd.common import compat, fmt, options, timestamp -lock = threading.Lock() +LEVELS = ( + "debug", + "info", + "warning", + "error", +) +"""Logging levels, lowest to highest importance. +""" + +stderr_levels = {"warning", "error"} +"""What should be logged to stderr. +""" + +file_levels = set(LEVELS) +"""What should be logged to file, when it is not None. +""" + file = None -tls = threading.local() +"""If not None, which file to log to. + +This can be automatically set by to_file(). +""" + +timestamp_format = "09.3f" +"""Format spec used for timestamps. Can be changed to dial precision up or down. +""" -if sys.version_info >= (3, 5): - clock = time.monotonic -else: - clock = time.clock +_lock = threading.Lock() +_tls = threading.local() -timestamp_zero = clock() +def write(level, text): + assert level in LEVELS -def timestamp(): - return clock() - timestamp_zero + t = timestamp.current() + format_string = "{0}+{1:" + timestamp_format + "}: " + prefix = fmt(format_string, level[0].upper(), t) - -def is_enabled(): - return bool(file) - - -def write(category, format_string, *args, **kwargs): - assert category in 'DIWE' - if not file and category not in 'WE': - return - - t = timestamp() - - try: - message = fmt(format_string, *args, **kwargs) - except Exception: - exception('ptvsd.common.log.write({0!r}): invalid format string', (category, fmt, args, kwargs)) - raise - - prefix = '{}{:09.3f}: '.format(category, t) indent = '\n' + (' ' * len(prefix)) - message = indent.join(message.split('\n')) + output = indent.join(text.split('\n')) if current_handler(): prefix += '(while handling {}){}'.format(current_handler(), indent) - message = prefix + message + '\n\n' - with lock: - if file: - file.write(message) - file.flush() - if category in 'WE': + output = prefix + output + '\n\n' + + with _lock: + if level in stderr_levels: try: - sys.__stderr__.write(message) - except: + sys.__stderr__.write(output) + except Exception: pass + if file and level in file_levels: + try: + file.write(output) + file.flush() + except Exception: + pass -debug = functools.partial(write, 'D') -info = functools.partial(write, 'I') -warn = functools.partial(write, 'W') -error = functools.partial(write, 'E') + return text + + +def write_format(level, format_string, *args, **kwargs): + try: + text = fmt(format_string, *args, **kwargs) + except Exception: + exception() + raise + return write(level, text) + + +debug = functools.partial(write_format, 'debug') +info = functools.partial(write_format, 'info') +warning = functools.partial(write_format, 'warning') + + +def error(*args, **kwargs): + """Logs an error. + + Returns the output wrapped in AssertionError. Thus, the following:: + + raise log.error(...) + + has the same effect as:: + + log.error(...) + assert False, fmt(...) + """ + return AssertionError(write_format('error', *args, **kwargs)) def stack(title='Stack trace'): @@ -83,36 +115,64 @@ def stack(title='Stack trace'): debug('{0}:\n\n{1}', title, stack) -def exception(fmt='', *args, **kwargs): - category = kwargs.pop('category', 'E') - exc_info = kwargs.pop('exc_info', None) +def exception(format_string='', *args, **kwargs): + """Logs an exception with full traceback. - if fmt: - fmt += '\n\n' - fmt += '{exception}' + If format_string is specified, it is formatted with fmt(*args, **kwargs), and + prepended to the exception traceback on a separate line. - exception = traceback.format_exception(*exc_info) if exc_info else traceback.format_exc() - write(category, fmt, *args, exception=exception, **kwargs) + If exc_info is specified, the exception it describes will be logged. Otherwise, + sys.exc_info() - i.e. the exception being handled currently - will be logged. + + If level is specified, the exception will be logged as a message of that level. + The default is "error". + + Returns the exception object, for convenient re-raising:: + + try: + ... + except Exception: + raise log.exception() # log it and re-raise + """ + + level = kwargs.pop('level', 'error') + exc_info = kwargs.pop('exc_info', sys.exc_info()) + + if format_string: + format_string += '\n\n' + format_string += '{exception}' + + exception = "".join(traceback.format_exception(*exc_info)) + write_format(level, format_string, *args, exception=exception, **kwargs) + + return exc_info[1] def escaped_exceptions(f): def g(*args, **kwargs): try: return f(*args, **kwargs) - except: + except Exception: # Must not use try/except here to avoid overwriting the caught exception. - name = f.__qualname__ if hasattr(f, '__qualname__') else f.__name__ - exception('Exception escaped from {0}', name) + exception('Exception escaped from {0}', compat.srcnameof(f)) raise + return g -def to_file(): +def to_file(filename=None): + # TODO: warn when options.log_dir is unset, after fixing improper use in ptvsd.server global file + if file is not None or options.log_dir is None: + return - if ptvsd.common.options.log_dir and not file: - filename = ptvsd.common.options.log_dir + '/ptvsd-{}.log'.format(os.getpid()) - file = io.open(filename, 'w', encoding='utf-8') + if filename is None: + if options.log_dir is None: + warning("ptvsd.to_file() cannot generate log file name - ptvsd.options.log_dir is not set") + return + filename = fmt("{0}/ptvsd-{1}.log", options.log_dir, os.getpid()) + + file = io.open(filename, 'w', encoding='utf-8') info( '{0} {1}\n{2} {3} ({4}-bit)\nptvsd {5}', @@ -127,27 +187,27 @@ def to_file(): def current_handler(): try: - return tls.current_handler + return _tls.current_handler except AttributeError: - tls.current_handler = None + _tls.current_handler = None return None @contextlib.contextmanager def handling(what): - assert current_handler() is None, "Can't handle {} - already handling {}".format(what, current_handler()) - tls.current_handler = what + assert current_handler() is None, fmt("Can't handle {0} - already handling {1}", what, current_handler()) + _tls.current_handler = what try: yield finally: - tls.current_handler = None + _tls.current_handler = None @contextlib.contextmanager def suspend_handling(): what = current_handler() - tls.current_handler = None + _tls.current_handler = None try: yield finally: - tls.current_handler = what + _tls.current_handler = what diff --git a/src/ptvsd/common/messaging.py b/src/ptvsd/common/messaging.py index 76ad6422..aca3d189 100644 --- a/src/ptvsd/common/messaging.py +++ b/src/ptvsd/common/messaging.py @@ -148,14 +148,12 @@ class JsonIOStream(object): try: body = body.decode("utf-8") except Exception: - log.exception("{0} --> {1}", self.name, body) - raise + raise log.exception("{0} --> {1}", self.name, body) try: body = decoder.decode(body) except Exception: - log.exception("{0} --> {1}", self.name, body) - raise + raise log.exception("{0} --> {1}", self.name, body) log.debug("{0} --> {1!j}", self.name, body) return body @@ -175,18 +173,18 @@ class JsonIOStream(object): try: body = encoder.encode(value) except Exception: - log.exception("{0} <-- {1!r}", self.name, value) - + raise log.exception("{0} <-- {1!r}", self.name, value) if not isinstance(body, bytes): body = body.encode("utf-8") - header = fmt("Content-Length: {0}\r\n\r\n", len(body)).encode("ascii") + header = fmt("Content-Length: {0}\r\n\r\n", len(body)) + header = header.encode("ascii") + try: - self._writer.write(header) - self._writer.write(body) + self._writer.write(header + body) + self._writer.flush() except Exception: - log.exception("{0} <-- {1!j}", self.name, value) - raise + raise log.exception("{0} <-- {1!j}", self.name, value) log.debug("{0} <-- {1!j}", self.name, value) @@ -389,8 +387,8 @@ class OutgoingRequest(Request): def on_response(self, callback): """Registers a callback to invoke when a response is received for this request. The callback is invoked with Response as its sole argument. - - If response has already been received, invokes the callback immediately. + + If response has already been received, invokes the callback immediately. It is guaranteed that self.response is set before the callback is invoked. @@ -522,7 +520,7 @@ class MessageHandlingError(Exception): raise self except MessageHandlingError: # TODO: change to E after unifying logging with tests - log.exception(category="I") + log.exception(level="info") def __hash__(self): return hash((self.reason, id(self.cause))) @@ -598,7 +596,7 @@ class InvalidMessageError(MessageHandlingError): class JsonMessageChannel(object): """Implements a JSON message channel on top of a raw JSON message stream, with support for DAP requests, responses, and events. - + The channel can be locked for exclusive use via the with-statement:: with channel: diff --git a/src/ptvsd/common/timestamp.py b/src/ptvsd/common/timestamp.py new file mode 100644 index 00000000..3687fe04 --- /dev/null +++ b/src/ptvsd/common/timestamp.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Provides monotonic timestamps with a resetable zero. +""" + +import sys +import time + +__all__ = ['current', 'reset'] + + +if sys.version_info >= (3, 5): + clock = time.monotonic +else: + clock = time.clock + + +def current(): + return clock() - timestamp_zero + + +def reset(): + global timestamp_zero + timestamp_zero = clock() + + +reset() diff --git a/src/ptvsd/server/__main__.py b/src/ptvsd/server/__main__.py index b93f3593..1fa1b261 100644 --- a/src/ptvsd/server/__main__.py +++ b/src/ptvsd/server/__main__.py @@ -440,7 +440,7 @@ def main(argv=sys.argv): }[ptvsd.server.options.target_kind] run() except SystemExit as ex: - ptvsd.common.log.exception('Debuggee exited via SystemExit', category='D') + ptvsd.common.log.exception('Debuggee exited via SystemExit', level='debug') if daemon is not None: if ex.code is None: daemon.exitcode = 0 diff --git a/src/ptvsd/server/daemon.py b/src/ptvsd/server/daemon.py index 3dfa81ca..cc43d70c 100644 --- a/src/ptvsd/server/daemon.py +++ b/src/ptvsd/server/daemon.py @@ -190,7 +190,7 @@ class DaemonBase(object): ptvsd.server.log.debug('Session started.') return self._session except Exception: - ptvsd.server.log.exception(category=('D' if hidebadsessions else 'E')) + ptvsd.server.log.exception(level=('debug' if hidebadsessions else 'error')) with ignore_errors(): self._finish_session() if hidebadsessions: @@ -356,7 +356,7 @@ class DaemonBase(object): try: sessionlock.release() except Exception: # TODO: Make it more specific? - ptvsd.server.log.exception('Session lock not released', category='D') + ptvsd.server.log.exception('Session lock not released', level='debug') else: ptvsd.server.log.debug('Session lock released') diff --git a/src/ptvsd/server/futures.py b/src/ptvsd/server/futures.py index 482f8b77..2176aff5 100644 --- a/src/ptvsd/server/futures.py +++ b/src/ptvsd/server/futures.py @@ -25,7 +25,7 @@ class Future(object): self._handling = ptvsd.server.log.current_handler() # It's expensive, so only capture the origin if logging is enabled. - if ptvsd.server.log.is_enabled(): + if ptvsd.server.log.file: self._origin = traceback.extract_stack() else: self._origin = None diff --git a/src/ptvsd/server/ipcjson.py b/src/ptvsd/server/ipcjson.py index 0946cbd4..7a0a39aa 100644 --- a/src/ptvsd/server/ipcjson.py +++ b/src/ptvsd/server/ipcjson.py @@ -259,7 +259,7 @@ class IpcChannel(object): except (AssertionError, EOFError): raise except Exception: - ptvsd.server.log.exception(category=('D' if self.__exit else 'E')) + ptvsd.server.log.exception(level=('debug' if self.__exit else 'error')) if not self.__exit: raise diff --git a/src/ptvsd/server/multiproc.py b/src/ptvsd/server/multiproc.py index a013466c..7ff9edbe 100644 --- a/src/ptvsd/server/multiproc.py +++ b/src/ptvsd/server/multiproc.py @@ -109,7 +109,7 @@ def kill_subprocesses(): try: os.kill(pid, signal.SIGTERM) except Exception: - ptvsd.server.log.exception('Failed to kill process with PID={0}.', pid, category='D') + ptvsd.server.log.exception('Failed to kill process with PID={0}.', pid, level='debug') def subprocess_listener_port(): diff --git a/src/ptvsd/server/pydevd_hooks.py b/src/ptvsd/server/pydevd_hooks.py index 61b940a1..18e913cb 100644 --- a/src/ptvsd/server/pydevd_hooks.py +++ b/src/ptvsd/server/pydevd_hooks.py @@ -37,7 +37,7 @@ def start_server(daemon, host, port, **kwargs): return session except (DaemonClosedError, DaemonStoppedError): # Typically won't happen. - ptvsd.server.log.exception('Daemon stopped while waiting for session', category='D') + ptvsd.server.log.exception('Daemon stopped while waiting for session', level='debug') raise except Exception: ptvsd.server.log.exception() diff --git a/src/ptvsd/server/session.py b/src/ptvsd/server/session.py index 9d6776b6..57abc555 100644 --- a/src/ptvsd/server/session.py +++ b/src/ptvsd/server/session.py @@ -69,7 +69,7 @@ class DebugSession(Startable, Closeable): try: proc.wait_while_connected(10) # seconds except TimeoutError: - ptvsd.server.log.exception('timed out waiting for disconnect', category='D') + ptvsd.server.log.exception('timed out waiting for disconnect', level='debug') close_socket(self._sock) self.add_close_handler(handle_closing) diff --git a/src/ptvsd/server/wrapper.py b/src/ptvsd/server/wrapper.py index ffae74a8..753b8966 100644 --- a/src/ptvsd/server/wrapper.py +++ b/src/ptvsd/server/wrapper.py @@ -321,7 +321,7 @@ class PydevdSocket(object): assert data.endswith(b'\n') data = self._decode_and_unquote(data[:-1]) cmd_id, seq, args = data.split('\t', 2) - if ptvsd.server.log.is_enabled(): + if ptvsd.server.log.file: trace_fmt = '{cmd_name} {seq}\n{args}' except: ptvsd.server.log.exception(trace_prefix + trace_fmt, data=data) @@ -369,7 +369,7 @@ class PydevdSocket(object): seq = self.seq self.seq += 1 - if ptvsd.server.log.is_enabled(): + if ptvsd.server.log.file: try: cmd_name = pydevd_comm.ID_TO_MEANING[str(cmd_id)] except KeyError: @@ -696,14 +696,14 @@ class VSCodeMessageProcessorBase(ipcjson.SocketIO, ipcjson.IpcChannel): try: self.process_messages() except (EOFError, TimeoutError): - ptvsd.server.log.exception('Client socket closed', category='I') + ptvsd.server.log.exception('Client socket closed', level='info') with self._connlock: _util.lock_release(self._listening) _util.lock_release(self._connected) self.close() except socket.error as exc: if exc.errno == errno.ECONNRESET: - ptvsd.server.log.exception('Client socket forcibly closed', category='I') + ptvsd.server.log.exception('Client socket forcibly closed', level='info') with self._connlock: _util.lock_release(self._listening) _util.lock_release(self._connected) @@ -1199,7 +1199,7 @@ class VSCodeMessageProcessor(VSCLifecycleMsgProcessor): client_os_type = self.debug_options.get('CLIENT_OS_TYPE', '').upper().strip() if client_os_type and client_os_type not in ('WINDOWS', 'UNIX'): - ptvsd.server.log.warn('Invalid CLIENT_OS_TYPE passed: %s (must be either "WINDOWS" or "UNIX").' % (client_os_type,)) + ptvsd.server.log.warning('Invalid CLIENT_OS_TYPE passed: %s (must be either "WINDOWS" or "UNIX").' % (client_os_type,)) client_os_type = '' if not client_os_type: diff --git a/tests/__init__.py b/tests/__init__.py index 602b8cd1..2c957206 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,16 +2,50 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -__doc__ = """pytest-based ptvsd tests.""" +from __future__ import absolute_import, print_function, unicode_literals -import colorama +"""ptvsd tests +""" + +import pkgutil import pytest +import py.path + + +_tests_dir = py.path.local(__file__) / ".." + +test_data = _tests_dir / "test_data" +"""A py.path.local object for the tests/test_data/ directory. + +Idiomatic use is via from .. import:: + + from tests import test_data + f = open(str(test_data / "attach" / "attach1.py")) +""" + # This is only imported to ensure that the module is actually installed and the # timeout setting in pytest.ini is active, since otherwise most timeline-based -# tests will hang indefinitely. -import pytest_timeout # noqa +# tests will hang indefinitely if they time out. +__import__("pytest_timeout") +# We want pytest to rewrite asserts (for better error messages) in the common code +# code used by the tests, and in all the test helpers. This does not affect ptvsd +# inside debugged processes. -colorama.init() -pytest.register_assert_rewrite('tests.helpers') \ No newline at end of file +def _register_assert_rewrite(modname): + modname = str(modname) + # print("pytest.register_assert_rewrite({0!r})".format(modname)) + pytest.register_assert_rewrite(modname) + +_register_assert_rewrite("ptvsd.common") +tests_submodules = pkgutil.iter_modules([str(_tests_dir)]) +for _, submodule, _ in tests_submodules: + submodule = str("{0}.{1}".format(__name__, submodule)) + _register_assert_rewrite(submodule) + +# Enable full logging to stderr, and make timestamps shorter to match maximum test +# run time better. +from ptvsd.common import log +log.stderr_levels = set(log.LEVELS) +log.timestamp_format = "06.3f" diff --git a/tests/code.py b/tests/code.py new file mode 100644 index 00000000..6d87ac4c --- /dev/null +++ b/tests/code.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Helpers to work with Python code. +""" + +import re + + +def get_marked_line_numbers(path): + """Given a path to a Python source file, extracts line numbers for all lines + that are marked with #@. For example, given this file:: + + print(1) #@foo + print(2) + print(3) #@bar + + the function will return:: + + {'foo': 1, 'bar': 3} + """ + + with open(path) as f: + lines = {} + for i, line in enumerate(f): + match = re.search(r'#\s*@\s*(.*?)\s*$', line) + if match: + marker = match.group(1) + lines[marker] = i + 1 + return lines diff --git a/tests/conftest.py b/tests/conftest.py index a984aa62..fd9743c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,268 +2,9 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals -import inspect -import os -import platform -import pytest -import threading -import types -import sys -import site +"""pytest configuration. +""" -from . import helpers -from .helpers.printer import wait_for_output -import pytest_timeout -import tempfile - -_original_dump_stacks = pytest_timeout.dump_stacks - - -def _print_pydevd_log(reason): - sys.stderr.write('\n******************************************************************\n') - sys.stderr.write('pydevd log on %s\n' % (reason,)) - sys.stderr.write('******************************************************************\n') - current_pydevd_debug_file = os.environ.get('PYDEVD_DEBUG_FILE') - if current_pydevd_debug_file: - if os.path.exists(current_pydevd_debug_file): - with open(current_pydevd_debug_file, 'r') as stream: - sys.stderr.write(stream.read()) - sys.stderr.write('\n******************************************************************\n') - sys.stderr.write('******************************************************************\n') - - -def _on_dump_stack_print_pydevd_log(): - # On timeout we also want to print the pydev log. - _print_pydevd_log('timeout') - _original_dump_stacks() - - -pytest_timeout.dump_stacks = _on_dump_stack_print_pydevd_log - - -def pytest_report_header(config): - try: - import multiprocessing - except ImportError: - pass - else: - print('Number of processors: %s' % (multiprocessing.cpu_count(),)) - - import sysconfig - from os.path import realpath - - print('Relevant system paths:') - print('sys.prefix: %s (%s)' % (sys.prefix, realpath(sys.prefix))) - - if hasattr(sys, 'base_prefix'): - print('sys.base_prefix: %s (%s)' % ( - sys.base_prefix, realpath(sys.base_prefix))) - - if hasattr(sys, 'real_prefix'): - print('sys.real_prefix: %s (%s)' % ( - sys.real_prefix, realpath(sys.real_prefix))) - - if hasattr(site, 'getusersitepackages'): - paths = site.getusersitepackages() - if isinstance(paths, (list, tuple)): - real_paths = list(realpath(p) for p in paths) - else: - real_paths = realpath(paths) - print('site.getusersitepackages(): %s (%s)' % ( - site.getusersitepackages(), real_paths)) - - if hasattr(site, 'getsitepackages'): - paths = site.getsitepackages() - if isinstance(paths, (list, tuple)): - real_paths = list(realpath(p) for p in paths) - else: - real_paths = realpath(paths) - print('site.getsitepackages(): %s (%s)' % ( - site.getsitepackages(), real_paths)) - - for path in sys.path: - if os.path.exists(path) and os.path.basename(path) == 'site-packages': - print('Folder with "site-packages" in sys.path: %s (%s)' % ( - path, realpath(path))) - - for path_name in sorted(sysconfig.get_path_names()): - print('sysconfig: %s: %s (%s)' % ( - path_name, sysconfig.get_path(path_name), realpath(sysconfig.get_path(path_name)))) - - print('os module dir: %s (%s)' % ( - os.path.dirname(os.__file__), realpath(os.path.dirname(os.__file__)))) - - print('threading module dir: %s (%s)' % ( - os.path.dirname(threading.__file__), realpath(os.path.dirname(threading.__file__)))) - - -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_makereport(item, call): - # Adds attributes such as setup_result, call_result etc to the item after the - # corresponding scope finished running its tests. This can be used in function-level - # fixtures to detect failures, e.g.: - # - # if request.node.call_result.failed: ... - - outcome = yield - result = outcome.get_result() - setattr(item, result.when + '_result', result) - - -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_pyfunc_call(pyfuncitem): - # Resets the timestamp to zero for every new test, and ensures that - # all output is printed after the test. - helpers.timestamp_zero = helpers.clock() - yield - wait_for_output() - - -@pytest.fixture(autouse=True) -def _add_pydevd_output(request, tmpdir): - current_pydevd_debug_file = tempfile.mktemp(suffix='.log', prefix='pydevd_output_%s' % (os.getpid(),), dir=str(tmpdir)) - os.environ['PYDEVD_DEBUG'] = 'True' - os.environ['PYDEVD_DEBUG_FILE'] = current_pydevd_debug_file - yield - if request.node.setup_result.failed: - _print_pydevd_log('test failure') - elif request.node.setup_result.passed: - if request.node.call_result.failed: - _print_pydevd_log('test failure') - - -@pytest.fixture -def daemon(request): - """Provides a factory function for daemon threads. The returned thread is - started immediately, and it must not be alive by the time the test returns. - """ - - daemons = [] - - def factory(func, name_suffix=''): - name = func.__name__ + name_suffix - thread = threading.Thread(target=func, name=name) - thread.daemon = True - daemons.append(thread) - thread.start() - return thread - - yield factory - - try: - failed = request.node.call_result.failed - except AttributeError: - pass - else: - if not failed: - for thread in daemons: - assert not thread.is_alive() - - -@pytest.fixture -def pyfile(request, tmpdir): - """A fixture providing a factory function that generates .py files. - - The returned factory takes a single function with an empty argument list, - generates a temporary file that contains the code corresponding to the - function body, and returns the full path to the generated file. Idiomatic - use is as a decorator, e.g.: - - @pyfile - def script_file(): - print('fizz') - print('buzz') - - will produce a temporary file named script_file.py containing: - - print('fizz') - print('buzz') - - and the variable script_file will contain the path to that file. - - In order for the factory to be able to extract the function body properly, - function header ("def") must all be on a single line, with nothing after - the colon but whitespace. - """ - - def factory(source): - assert isinstance(source, types.FunctionType) - name = source.__name__ - source, _ = inspect.getsourcelines(source) - - # First, find the "def" line. - def_lineno = 0 - for line in source: - line = line.strip() - if line.startswith('def') and line.endswith(':'): - break - def_lineno += 1 - else: - raise ValueError('Failed to locate function header.') - - # Remove everything up to and including "def". - source = source[def_lineno + 1:] - assert source - - # Now we need to adjust indentation. Compute how much the first line of - # the body is indented by, then dedent all lines by that amount. Blank - # lines don't matter indentation-wise, and might not be indented to begin - # with, so just replace them with a simple newline. - line = source[0] - indent = len(line) - len(line.lstrip()) - source = [l[indent:] if l.strip() else '\n' for l in source] - - # Write it to file. - source = ''.join(source) - tmpfile = tmpdir.join(name + '.py') - assert not tmpfile.check() - # NOTE: This is a requirement with using pyfile. Adding this - # makes it easier to add import start method - assert 'import_and_enable_debugger' in source - tmpfile.write(source) - return tmpfile.strpath - - return factory - - -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_cmdline', - # 'attach_socket_import', - # 'attach_pid', - ] - _ATTACH_PARAMS += ['attach_socket_import'] if platform.system() == 'Windows' else [] - - _RUN_AS_PARAMS = [ - 'file', - 'module', - ] - - -@pytest.fixture( - name='run_as', - params=_RUN_AS_PARAMS -) -def _run_as(request): - return request.param - - -@pytest.fixture( - name='start_method', - params=_ATTACH_PARAMS -) -def start_method(request): - return request.param +pytest_plugins = ["tests.pytest_fixtures", "tests.pytest_hooks"] diff --git a/tests/helpers/session.py b/tests/debug.py similarity index 61% rename from tests/helpers/session.py rename to tests/debug.py index 08766231..453edc0d 100644 --- a/tests/helpers/session.py +++ b/tests/debug.py @@ -2,48 +2,59 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals from collections import namedtuple +import itertools import os import platform import psutil +import py.path import pytest import socket import subprocess import sys import threading import time -import traceback import ptvsd -import ptvsd.server.__main__ -from ptvsd.common.messaging import JsonIOStream, JsonMessageChannel, MessageHandlers +from ptvsd.common import fmt, log, messaging +from tests import net, test_data +from tests.patterns import some +from tests.timeline import Timeline, Event, Response -import tests.helpers -from . import colors, debuggee, print -from .messaging import LoggingJsonStream -from .pattern import ANY -from .printer import wait_for_output -from .timeline import Timeline, Event, Response - -PTVSD_PORT = tests.helpers.get_unique_port(5678) -PTVSD_ENABLE_KEY = 'PTVSD_ENABLE_ATTACH' -PTVSD_HOST_KEY = 'PTVSD_TEST_HOST' -PTVSD_PORT_KEY = 'PTVSD_TEST_PORT' +PTVSD_DIR = py.path.local(ptvsd.__file__) / ".." +PTVSD_PORT = net.get_test_server_port(5678, 5800) -class DebugSession(object): +# Code that is injected into the debuggee process when it does `import debug_me`, +# and start_method is attach_socket_* +PTVSD_DEBUG_ME = """ +import ptvsd +ptvsd.enable_attach(("localhost", {ptvsd_port})) +ptvsd.wait_for_attach() +""" + + +StopInfo = namedtuple('StopInfo', [ + 'body', + 'frames', + 'thread_id', + 'frame_id', +]) + + +class Session(object): WAIT_FOR_EXIT_TIMEOUT = 10 - BACKCHANNEL_TIMEOUT = 20 - StopInfo = namedtuple('StopInfo', 'thread_stopped, stacktrace, thread_id, frame_id') + _counter = itertools.count(1) def __init__(self, start_method='launch', ptvsd_port=None, pid=None): assert start_method in ('launch', 'attach_pid', 'attach_socket_cmdline', 'attach_socket_import', 'custom_client') assert ptvsd_port is None or start_method.startswith('attach_socket_') - print('New debug session with method %r' % str(start_method)) + self.id = next(self._counter) + log.info('Starting debug session {0} via {1!r}', self.id, start_method) self.target = ('code', 'print("OK")') self.start_method = start_method @@ -56,13 +67,15 @@ class DebugSession(object): self.path_mappings = [] self.success_exitcodes = None self.rules = [] - self.env = os.environ.copy() - self.env['PYTHONPATH'] = os.path.dirname(debuggee.__file__) self.cwd = None self.expected_returncode = 0 self.program_args = [] self.log_dir = None + self.env = os.environ.copy() + self.env['PYTHONPATH'] = str(test_data / "_PYTHONPATH") + self.env['PTVSD_SESSION_ID'] = str(self.id) + self.is_running = False self.process = None self.pid = pid @@ -72,19 +85,16 @@ class DebugSession(object): self.socket = None self.server_socket = None self.connected = threading.Event() - self.backchannel_socket = None - self.backchannel_port = None - self.backchannel_established = threading.Event() self._output_capture_threads = [] - self.output_data = {'OUT': [], 'ERR': []} + self.output_data = {'stdout': [], 'stderr': []} + self.backchannel = None self.timeline = Timeline(ignore_unobserved=[ Event('output'), - Event('thread', ANY.dict_with({'reason': 'exited'})) + Event('thread', some.dict.containing({'reason': 'exited'})) ]) self.timeline.freeze() self.perform_handshake = True - self.use_backchannel = False # Expose some common members of timeline directly - these should be the ones # that are the most straightforward to use, and are difficult to use incorrectly. @@ -103,6 +113,15 @@ class DebugSession(object): return self def __exit__(self, exc_type, exc_val, exc_tb): + # If we're exiting a failed test, make sure that all output from the debuggee + # process has been received and logged, before we close the sockets and kill + # the debuggee process. In success case, wait_for_exit() takes care of that. + if exc_type is not None: + # If it failed in the middle of the test, the debuggee process might still + # be alive, and waiting for the test to tell it to continue. In this case, + # it will never close its stdout/stderr, so use a reasonable timeout here. + self._wait_for_remaining_output(timeout=1) + was_final = self.timeline.is_final self.close() assert exc_type is not None or was_final, ( @@ -122,40 +141,37 @@ class DebugSession(object): def ignore_unobserved(self, value): self.timeline.ignore_unobserved = value + def __str__(self): + return fmt("ptvsd-{0}", self.id) + def close(self): if self.socket: try: self.socket.shutdown(socket.SHUT_RDWR) - print('Closed socket to ptvsd#%d' % self.ptvsd_port) - except socket.error as ex: - print('Error while closing socket to ptvsd#%d: %s' % (self.ptvsd_port, str(ex))) - self.socket = None + except Exception: + pass + self.socket = None + log.debug('Closed socket to {0}', self) if self.server_socket: try: self.server_socket.shutdown(socket.SHUT_RDWR) - print('Closed server socket to ptvsd#%d' % self.ptvsd_port) - except socket.error as ex: - print('Error while closing server socket to ptvsd#%d: %s' % (self.ptvsd_port, str(ex))) - self.server_socket = None + except Exception: + pass + self.server_socket = None + log.debug('Closed server socket for {0}', self) - if self.backchannel_socket: - try: - self.backchannel_socket.shutdown(socket.SHUT_RDWR) - print('Closed backchannel to ptvsd#%d' % self.backchannel_port) - except socket.error as ex: - print('Error while closing backchannel socket to ptvsd#%d: %s' % (self.ptvsd_port, str(ex))) - self.backchannel_socket = None + if self.backchannel: + self.backchannel.close() + self.backchannel = None if self.process: if self.kill_ptvsd: try: self._kill_process_tree() - print('Killed ptvsd process tree %d' % self.pid) except: - print('Error killing ptvsd process tree %d' % self.pid) - traceback.print_exc() - pass + log.exception('Error killing {0} (pid={1}) process tree', self, self.pid) + log.info('Killed {0} (pid={1}) process tree', self, self.pid) # Clean up pipes to avoid leaking OS handles. try: @@ -179,21 +195,21 @@ class DebugSession(object): def _get_argv_for_launch(self): argv = [sys.executable] - argv += [os.path.dirname(ptvsd.__file__)] + argv += [str(PTVSD_DIR)] argv += ['--client'] argv += ['--host', 'localhost', '--port', str(self.ptvsd_port)] return argv def _get_argv_for_attach_using_cmdline(self): argv = [sys.executable] - argv += [os.path.dirname(ptvsd.__file__)] + argv += [str(PTVSD_DIR)] argv += ['--wait'] argv += ['--host', 'localhost', '--port', str(self.ptvsd_port)] return argv def _get_argv_for_attach_using_pid(self): argv = [sys.executable] - argv += [os.path.dirname(ptvsd.__file__)] + argv += [str(PTVSD_DIR)] argv += ['--client', '--host', 'localhost', '--port', str(self.ptvsd_port)] # argv += ['--pid', ''] # pid value to be appended later return argv @@ -201,13 +217,26 @@ class DebugSession(object): def _get_argv_for_custom_client(self): return [sys.executable] + def _validate_pyfile(self, filename): + assert os.path.isfile(filename) + with open(filename) as f: + code = f.read() + if self.start_method != "custom_client": + assert 'debug_me' in code, ( + "Python source code that is run via tests.debug.Session must " + "import debug_me" + ) + return code + def _get_target(self): argv = [] run_as, path_or_code = self.target if run_as == 'file': - assert os.path.isfile(path_or_code) + self._validate_pyfile(path_or_code) argv += [path_or_code] elif run_as == 'module': + if os.path.isfile(path_or_code): + self._validate_pyfile(path_or_code) if os.path.isfile(path_or_code) or os.path.isdir(path_or_code): self.env['PYTHONPATH'] += os.pathsep + os.path.dirname(path_or_code) try: @@ -219,18 +248,15 @@ class DebugSession(object): argv += ['-m', path_or_code] elif run_as == 'code': if os.path.isfile(path_or_code): - with open(path_or_code, 'r') as f: - code = f.read() - argv += ['-c', code] - else: - argv += ['-c', path_or_code] + path_or_code = self._validate_pyfile(path_or_code) + argv += ['-c', path_or_code] else: pytest.fail() return argv def _setup_session(self, **kwargs): self.ignore_unobserved += [ - Event('thread', ANY.dict_with({'reason': 'started'})), + Event('thread', some.dict.containing({'reason': 'started'})), Event('module') ] + kwargs.pop('ignore_unobserved', []) @@ -249,6 +275,17 @@ class DebugSession(object): assert len(self.target) == 2 assert self.target[0] in ('file', 'module', 'code') + def setup_backchannel(self): + """Creates a BackChannel object associated with this Session, and returns it. + + The debuggee must import backchannel to establish the connection. + """ + assert self.process is None, ( + "setup_backchannel() must be called before initialize()" + ) + self.backchannel = BackChannel(self) + return self.backchannel + def before_connect(self): """Invoked by initialize() before connecting to the debuggee, or before waiting for an incoming connection, but after all the session parameters (port number etc) @@ -262,12 +299,10 @@ class DebugSession(object): provided Python file, module, or code, and establishes a message channel to it. - If use_backchannel is True, calls self.setup_backchannel() before returning. - If perform_handshake is True, calls self.handshake() before returning. """ self._setup_session(**kwargs) - print('Initializing debug session for ptvsd#%d' % self.ptvsd_port) + log.info('Initializing debug session for {0}', self) dbg_argv = [] usr_argv = [] if self.start_method == 'launch': @@ -277,12 +312,9 @@ class DebugSession(object): dbg_argv += self._get_argv_for_attach_using_cmdline() elif self.start_method == 'attach_socket_import': dbg_argv += self._get_argv_for_attach_using_import() - # TODO: Remove adding to python path after enabling TOX - ptvsd_path = os.path.dirname(os.path.dirname(ptvsd.server.__main__.__file__)) - self.env['PYTHONPATH'] = ptvsd_path + os.pathsep + self.env['PYTHONPATH'] - self.env[PTVSD_ENABLE_KEY] = '1' - self.env[PTVSD_HOST_KEY] = 'localhost' - self.env[PTVSD_PORT_KEY] = str(self.ptvsd_port) + # TODO: Remove adding to python path after enabling Tox + self.env['PYTHONPATH'] = str(PTVSD_DIR / "..") + os.pathsep + self.env['PYTHONPATH'] + self.env['PTVSD_DEBUG_ME'] = fmt(PTVSD_DEBUG_ME, ptvsd_port=self.ptvsd_port) elif self.start_method == 'attach_pid': self._listen() dbg_argv += self._get_argv_for_attach_using_pid() @@ -313,45 +345,56 @@ class DebugSession(object): if self.multiprocess and 'Multiprocess' not in self.debug_options: self.debug_options += ['Multiprocess'] - if self.use_backchannel: - self.setup_backchannel() - if self.backchannel_port: - self.env['PTVSD_BACKCHANNEL_PORT'] = str(self.backchannel_port) + if self.backchannel: + self.backchannel.listen() + self.env['PTVSD_BACKCHANNEL_PORT'] = str(self.backchannel.port) - print('ptvsd: %s' % ptvsd.__file__) - print('Start method: %s' % self.start_method) - print('Target: (%s) %s' % self.target) - print('Current directory: %s' % self.cwd) - print('PYTHONPATH: %s' % self.env['PYTHONPATH']) - if self.start_method == 'attach_pid': - print('Spawning %r' % usr_argv) - else: - print('Spawning %r' % dbg_argv) + log.info( + '{6} will have:\n\n' + 'ptvsd: {0}\n' + 'port: {7}\n' + 'start method: {1}\n' + 'target: ({2}) {3}\n' + 'current directory: {4}\n' + 'PYTHONPATH: {5}', + py.path.local(ptvsd.__file__).dirpath(), + self.start_method, + self.target[0], + self.target[1], + self.cwd, + self.env['PYTHONPATH'], + self, + self.ptvsd_port, + ) spawn_args = usr_argv if self.start_method == 'attach_pid' else dbg_argv + log.info('Spawning {0}: {1!r}', self, spawn_args) - # ensure env is all string, this is needed for python 2.7 on windows - temp_env = {} - for k, v in self.env.items(): - temp_env[str(k)] = str(v) + # Force env to use str everywhere - this is needed for Python 2.7 on Windows. + env = {str(k): str(v) for k, v in self.env.items()} - self.process = subprocess.Popen(spawn_args, env=temp_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.cwd) + self.process = subprocess.Popen( + spawn_args, + env=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.is_running = True # watchdog.create(self.pid) if not self.skip_capture: - self._capture_output(self.process.stdout, 'OUT') - self._capture_output(self.process.stderr, 'ERR') + self._capture_output(self.process.stdout, 'stdout') + self._capture_output(self.process.stderr, 'stderr') if self.start_method == 'attach_pid': - # This is a temp process spawned to inject debugger into the - # running process + # This is a temp process spawned to inject debugger into the debuggee. dbg_argv += ['--pid', str(self.pid)] - print('Spawning %r' % dbg_argv) - temp_process = subprocess.Popen(dbg_argv) - print('temp process has pid=%d' % temp_process.pid) + log.info('Spawning {0} attach helper: {1!r}', self, dbg_argv) + attach_helper = subprocess.Popen(dbg_argv) + log.info('Spawned {0} attach helper with pid={1}', self, attach_helper.pid) self.before_connect() @@ -360,15 +403,15 @@ class DebugSession(object): self.connected.wait() assert self.ptvsd_port assert self.socket - print('ptvsd#%d has pid=%d' % (self.ptvsd_port, self.pid)) + log.info('Spawned {0} with pid={1}', self, self.pid) - telemetry = self.timeline.wait_for_next(Event('output')) - assert telemetry == Event('output', { + telemetry = self.wait_for_next_event('output') + assert telemetry == { 'category': 'telemetry', 'output': 'ptvsd', - 'data': {'version': ANY}, + 'data': {'version': some.str}, #'data': {'version': ptvsd.__version__}, - }) + } if self.perform_handshake: return self.handshake() @@ -377,19 +420,15 @@ class DebugSession(object): """Waits for the connected ptvsd process to disconnect. """ - print(colors.LIGHT_MAGENTA + 'Waiting for ptvsd#%d to disconnect' % self.ptvsd_port + colors.RESET) - - # self.channel.wait() + log.info('Waiting for {0} to disconnect', self) + self._wait_for_remaining_output() self.channel.close() - self.timeline.finalize() if close: self.timeline.close() - wait_for_output() - def wait_for_termination(self): - print(colors.LIGHT_MAGENTA + 'Waiting for ptvsd#%d to terminate' % self.ptvsd_port + colors.RESET) + log.info('Waiting for {0} to terminate', self) # BUG: ptvsd sometimes exits without sending 'terminate' or 'exited', likely due to # https://github.com/Microsoft/ptvsd/issues/530. So rather than wait for them, wait until @@ -403,7 +442,7 @@ class DebugSession(object): # Due to https://github.com/Microsoft/ptvsd/issues/1278, exit code is not recorded # in the "exited" event correctly in attach scenarios on Windows. if self.start_method == 'attach_socket_import' and platform.system() == 'Windows': - expected_returncode = ANY.int + expected_returncode = some.int self.expect_realized(Event('exited', {'exitCode': expected_returncode})) @@ -411,7 +450,6 @@ class DebugSession(object): self.expect_realized(Event('exited') >> Event('terminated', {})) self.timeline.close() - wait_for_output() def wait_for_exit(self): """Waits for the spawned ptvsd process to exit. If it doesn't exit within @@ -427,14 +465,14 @@ class DebugSession(object): def kill(): time.sleep(self.WAIT_FOR_EXIT_TIMEOUT) if self.is_running: - print('ptvsd#%r (pid=%d) timed out, killing it' % (self.ptvsd_port, self.pid)) + log.warning('{0!r} (pid={1}) timed out, killing it', self, self.pid) self._kill_process_tree() - kill_thread = threading.Thread(target=kill, name='ptvsd#%r watchdog (pid=%d)' % (self.ptvsd_port, self.pid)) + kill_thread = threading.Thread(target=kill, name=fmt('{0} watchdog (pid={1})', self, self.pid)) kill_thread.daemon = True kill_thread.start() - print(colors.LIGHT_MAGENTA + 'Waiting for ptvsd#%d (pid=%d) to terminate' % (self.ptvsd_port, self.pid) + colors.RESET) + log.info('Waiting for {0} (pid={1}) to terminate', self, self.pid) returncode = self.psutil_process.wait() assert returncode == self.expected_returncode @@ -462,12 +500,13 @@ class DebugSession(object): self.server_socket.listen(0) def accept_worker(): - print('Listening for incoming connection from ptvsd#%d' % self.ptvsd_port) + log.info('Listening for incoming connection from {0} on port {1}...', self, self.ptvsd_port) self.socket, _ = self.server_socket.accept() - print('Incoming ptvsd#%d connection accepted' % self.ptvsd_port) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + log.info('Incoming connection from {0} accepted.', self) self._setup_channel() - accept_thread = threading.Thread(target=accept_worker, name='ptvsd#%d listener' % self.ptvsd_port) + accept_thread = threading.Thread(target=accept_worker, name=fmt('{0} listener', self)) accept_thread.daemon = True accept_thread.start() @@ -477,22 +516,22 @@ class DebugSession(object): while not self.socket: try: self._try_connect() - except socket.error as ex: - print('Error connecting to ptvsd#%d: %s' % (self.ptvsd_port, str(ex))) + except Exception: + log.exception('Error connecting to {0}; retrying ...', self, category="warning") time.sleep(0.1) - - def _try_connect(self): - print('Trying to connect to ptvsd#%d' % self.ptvsd_port) - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect(('localhost', self.ptvsd_port)) - print('Successfully connected to ptvsd#%d' % self.ptvsd_port) - self.socket = sock self._setup_channel() + def _try_connect(self): + log.info('Trying to connect to {0} on port {1}...', self, self.ptvsd_port) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', self.ptvsd_port)) + log.info('Connected to {0}.', self) + self.socket = sock + def _setup_channel(self): - self.stream = LoggingJsonStream(JsonIOStream.from_socket(self.socket), 'ptvsd#%d' % self.ptvsd_port) - handlers = MessageHandlers(request=self._process_request, event=self._process_event) - self.channel = JsonMessageChannel(self.stream, handlers) + self.stream = messaging.JsonIOStream.from_socket(self.socket, name=str(self)) + handlers = messaging.MessageHandlers(request=self._process_request, event=self._process_event) + self.channel = messaging.JsonMessageChannel(self.stream, handlers) self.channel.start() self.connected.set() @@ -500,19 +539,20 @@ class DebugSession(object): if self.timeline.is_frozen and proceed: self.proceed() - request = self.timeline.record_request(command, arguments) - request.sent = self.channel.send_request(command, arguments) - request.sent.on_response(lambda response: self._process_response(request, response)) - def causing(*expectations): for exp in expectations: (request >> exp).wait() return request + request = self.timeline.record_request(command, arguments) + request.sent = self.channel.send_request(command, arguments) + request.sent.on_response(lambda response: self._process_response(request, response)) request.causing = causing - return request + def request(self, *args, **kwargs): + return self.send_request(*args, **kwargs).wait_for_response().body + def handshake(self): """Performs the handshake that establishes the debug session ('initialized' and 'launch' or 'attach'). @@ -523,7 +563,7 @@ class DebugSession(object): to finalize the configuration stage, and start running code. """ - self.send_request('initialize', {'adapterID': 'test'}).wait_for_response() + self.request('initialize', {'adapterID': 'test'}) self.wait_for_next(Event('initialized', {})) request = 'launch' if self.start_method == 'launch' else 'attach' @@ -543,10 +583,10 @@ class DebugSession(object): # 'process' is expected right after 'launch' or 'attach'. self.expect_new(Event('process', { - 'name': ANY.str, + 'name': some.str, 'isLocalProcess': True, 'startMethod': 'launch' if self.start_method == 'launch' else 'attach', - 'systemProcessId': self.pid if self.pid is not None else ANY.int, + 'systemProcessId': self.pid if self.pid is not None else some.int, })) # Issue 'threads' so that we get the 'thread' event for the main thread now, @@ -582,109 +622,79 @@ class DebugSession(object): def _process_request(self, request): assert False, 'ptvsd should not be sending requests.' - def setup_backchannel(self): - self.backchannel_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.backchannel_socket.settimeout(self.BACKCHANNEL_TIMEOUT) - self.backchannel_socket.bind(('localhost', 0)) - _, 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.daemon = True - backchannel_thread.start() - - def _backchannel_worker(self): - print('Listening for incoming backchannel connection for bchan#%d' % self.ptvsd_port) - sock = None - - try: - sock, _ = self.backchannel_socket.accept() - except socket.timeout: - assert sock is not None, 'bchan#%r timed out!' % self.ptvsd_port - - print('Incoming bchan#%d backchannel connection accepted' % self.ptvsd_port) - sock.settimeout(None) - self._backchannel_stream = LoggingJsonStream(JsonIOStream.from_socket(sock), 'bchan#%d' % self.ptvsd_port) - self.backchannel_established.set() - - @property - def backchannel(self): - assert self.backchannel_port, 'backchannel() must be called after setup_backchannel()' - self.backchannel_established.wait() - return self._backchannel_stream - - def read_json(self): - return self.backchannel.read_json() - - def write_json(self, value): - self.timeline.unfreeze() - t = self.timeline.mark(('sending', value)) - self.backchannel.write_json(value) - return t - def _capture_output(self, pipe, name): - - def _output_worker(): - while True: - try: - line = pipe.readline() - if not line: - break - self.output_data[name].append(line) - except Exception: - break - else: - prefix = 'ptvsd#%d %s ' % (self.ptvsd_port, name) - line = colors.LIGHT_BLUE + prefix + colors.RESET + line.decode('utf-8') - print(line, end='') - - thread = threading.Thread(target=_output_worker, name='ptvsd#%r %s' % (self.ptvsd_port, name)) + thread = threading.Thread( + target=lambda: self._capture_output_worker(pipe, name), + name=fmt("{0} {1}", self, name) + ) thread.daemon = True thread.start() self._output_capture_threads.append(thread) - def _wait_for_remaining_output(self): + def _capture_output_worker(self, pipe, name): + while True: + try: + line = pipe.readline() + except Exception: + line = None + + if line: + log.info("{0} {1}> {2}", self, name, line.rstrip()) + self.output_data[name].append(line) + else: + break + + def _wait_for_remaining_output(self, timeout=None): for thread in self._output_capture_threads: - thread.join() + thread.join(timeout) + + def request_continue(self): + self.send_request('continue').wait_for_response(freeze=False) def set_breakpoints(self, path, lines=()): - return self.send_request('setBreakpoints', arguments={ - 'source': {'path': path}, - 'breakpoints': [{'line': bp_line} for bp_line in lines], - }).wait_for_response().body.get('breakpoints', None) + return self.request('setBreakpoints', arguments={ + 'source': {'path': path}, + 'breakpoints': [{'line': bp_line} for bp_line in lines], + }).get('breakpoints', {}) - def wait_for_thread_stopped(self, reason=ANY, text=None, description=None): - thread_stopped = self.wait_for_next(Event('stopped', ANY.dict_with({'reason': reason}))) + def wait_for_next_event(self, event, body=some.object): + return self.timeline.wait_for_next(Event(event, body)).body - if text is not None: - assert text == thread_stopped.body['text'] + def wait_for_stop(self, reason, expected_frames=None, expected_text=None, expected_description=None): + stopped_event = self.wait_for_next(Event('stopped', some.dict.containing({'reason': reason}))) + stopped = stopped_event.body - if description is not None: - assert description == thread_stopped.body['description'] + if expected_text is not None: + assert expected_text == stopped['text'] - tid = thread_stopped.body['threadId'] + if expected_description is not None: + assert expected_description == stopped['description'] - assert thread_stopped.body['allThreadsStopped'] - assert thread_stopped.body['preserveFocusHint'] == \ - (thread_stopped.body['reason'] not in ['step', 'exception', 'breakpoint', 'entry']) + tid = stopped['threadId'] + assert tid == some.int - assert tid is not None + assert stopped['allThreadsStopped'] + if stopped['reason'] not in ['step', 'exception', 'breakpoint', 'entry']: + assert stopped['preserveFocusHint'] - resp_stacktrace = self.send_request('stackTrace', arguments={ - 'threadId': tid, - }).wait_for_response() - assert resp_stacktrace.body['totalFrames'] > 0 - frames = resp_stacktrace.body['stackFrames'] + stack_trace = self.request('stackTrace', arguments={'threadId': tid}) + frames = stack_trace['stackFrames'] or [] + assert len(frames) == stack_trace['totalFrames'] + + if expected_frames: + assert len(expected_frames) <= len(frames) + assert expected_frames == frames[0:len(expected_frames)] fid = frames[0]['id'] + assert fid == some.int - return self.StopInfo(thread_stopped, resp_stacktrace, tid, fid) + return StopInfo(stopped, frames, tid, fid) def connect_to_child_session(self, ptvsd_subprocess): child_port = ptvsd_subprocess.body['port'] assert child_port != 0 - child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_port) + child_session = Session(start_method='attach_socket_cmdline', ptvsd_port=child_port) try: child_session.ignore_unobserved = self.ignore_unobserved child_session.debug_options = self.debug_options @@ -702,13 +712,13 @@ class DebugSession(object): return self.connect_to_child_session(ptvsd_subprocess) def get_stdout_as_string(self): - return b''.join(self.output_data['OUT']) + return b''.join(self.output_data['stdout']) def get_stderr_as_string(self): - return b''.join(self.output_data['ERR']) + return b''.join(self.output_data['stderr']) def connect_with_new_session(self, **kwargs): - ns = DebugSession(start_method='attach_socket_import', ptvsd_port=self.ptvsd_port) + ns = Session(start_method='attach_socket_import', ptvsd_port=self.ptvsd_port) try: ns._setup_session(**kwargs) ns.ignore_unobserved = self.ignore_unobserved @@ -727,3 +737,75 @@ class DebugSession(object): ns.close() else: return ns + + +class BackChannel(object): + TIMEOUT = 20 + + def __init__(self, session): + self.session = session + self.port = None + self._established = threading.Event() + self._socket = None + self._server_socket = None + + def __str__(self): + return fmt("backchannel-{0}", self.session.id) + + def listen(self): + self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_socket.settimeout(self.TIMEOUT) + self._server_socket.bind(('localhost', 0)) + _, self.port = self._server_socket.getsockname() + self._server_socket.listen(0) + + def accept_worker(): + log.info('Listening for incoming connection from {0} on port {1}...', self, self.port) + + try: + self._socket, _ = self._server_socket.accept() + except socket.timeout: + raise log.exception("Timed out waiting for {0} to connect", self) + + self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + log.info('Incoming connection from {0} accepted.', self) + self._setup_stream() + + accept_thread = threading.Thread( + target=accept_worker, + name=fmt('{0} listener', self) + ) + accept_thread.daemon = True + accept_thread.start() + + def _setup_stream(self): + self._stream = messaging.JsonIOStream.from_socket(self._socket, name=str(self)) + self._established.set() + + def receive(self): + self._established.wait() + return self._stream.read_json() + + def send(self, value): + self._established.wait() + self.session.timeline.unfreeze() + t = self.session.timeline.mark(('sending', value)) + self._stream.write_json(value) + return t + + def close(self): + if self._socket: + try: + self._socket.shutdown(socket.SHUT_RDWR) + except Exception: + pass + self._socket = None + log.debug('Closed socket for {0} to {1}', self, self.session) + + if self._server_socket: + try: + self._server_socket.shutdown(socket.SHUT_RDWR) + except Exception: + pass + self._server_socket = None + log.debug('Closed server socket for {0} to {1}', self, self.session) diff --git a/tests/func/test_run.py b/tests/func/test_run.py deleted file mode 100644 index cb684d48..00000000 --- a/tests/func/test_run.py +++ /dev/null @@ -1,117 +0,0 @@ -# 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 os -import pytest -import re - -import ptvsd - -from tests.helpers import print -from tests.helpers.pathutils import get_test_root -from tests.helpers.pattern import ANY, Regex -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event - - -@pytest.mark.parametrize('run_as', ['file', 'module', 'code']) -def test_run(pyfile, run_as, start_method): - @pyfile - def code_to_debug(): - import os - import sys - import backchannel - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() - - print('begin') - assert backchannel.read_json() == 'continue' - backchannel.write_json(os.path.abspath(sys.modules['ptvsd'].__file__)) - print('end') - - with DebugSession() as session: - session.initialize(target=(run_as, code_to_debug), start_method=start_method, use_backchannel=True) - session.start_debugging() - assert session.timeline.is_frozen - - process_event, = session.all_occurrences_of(Event('process')) - assert process_event == Event('process', ANY.dict_with({ - 'name': '-c' if run_as == 'code' else Regex(re.escape(code_to_debug) + r'(c|o)?$') - })) - - session.write_json('continue') - ptvsd_path = session.read_json() - expected_ptvsd_path = os.path.abspath(ptvsd.__file__) - assert re.match(re.escape(expected_ptvsd_path) + r'(c|o)?$', ptvsd_path) - - session.wait_for_exit() - - -def test_run_submodule(): - cwd = get_test_root('testpkgs') - with DebugSession() as session: - session.initialize( - target=('module', 'pkg1.sub'), - start_method='launch', - cwd=cwd, - ) - session.start_debugging() - session.wait_for_next(Event('output', ANY.dict_with({'category': 'stdout', 'output': 'three'}))) - session.wait_for_exit() - - -@pytest.mark.parametrize('run_as', ['file', 'module', 'code']) -def test_nodebug(pyfile, run_as): - @pyfile - def code_to_debug(): - # import_and_enable_debugger - import backchannel - backchannel.read_json() - print('ok') - - with DebugSession() as session: - session.no_debug = True - session.initialize(target=(run_as, code_to_debug), start_method='launch', use_backchannel=True) - breakpoints = session.set_breakpoints(code_to_debug, [3, 4]) - assert breakpoints == [{'verified': False}, {'verified': False}] - session.start_debugging() - - session.write_json(None) - - # Breakpoint shouldn't be hit. - session.wait_for_exit() - - session.expect_realized(Event('output', ANY.dict_with({ - 'category': 'stdout', - 'output': 'ok', - }))) - - -@pytest.mark.parametrize('run_as', ['script', 'module']) -def test_run_vs(pyfile, run_as): - @pyfile - def code_to_debug(): - # import_and_enable_debugger - import backchannel - backchannel.write_json('ok') - - @pyfile - def ptvsd_launcher(): - # import_and_enable_debugger - import ptvsd.debugger - import backchannel - args = tuple(backchannel.read_json()) - print('debug%r' % (args,)) - ptvsd.debugger.debug(*args) - - with DebugSession() as session: - filename = 'code_to_debug' if run_as == 'module' else code_to_debug - session.before_connect = lambda: session.write_json([filename, session.ptvsd_port, None, None, run_as]) - - session.initialize(target=('file', ptvsd_launcher), start_method='custom_client', use_backchannel=True) - session.start_debugging() - assert session.read_json() == 'ok' - session.wait_for_exit() diff --git a/tests/func/testfiles/bp/a&b/test.py b/tests/func/testfiles/bp/a&b/test.py deleted file mode 100644 index 5a235500..00000000 --- a/tests/func/testfiles/bp/a&b/test.py +++ /dev/null @@ -1,5 +0,0 @@ -from dbgimporter import import_and_enable_debugger -import_and_enable_debugger() -print('one') -print('two') -print('three') diff --git a/tests/func/testfiles/testpkgs/pkg1/sub/__init__.py b/tests/func/testfiles/testpkgs/pkg1/sub/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py deleted file mode 100644 index 3ebbea74..00000000 --- a/tests/helpers/__init__.py +++ /dev/null @@ -1,92 +0,0 @@ -# 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 os -import re -import sys -import threading -import time -import traceback - -if sys.version_info >= (3, 5): - clock = time.monotonic -else: - clock = time.clock - -timestamp_zero = clock() - - -def timestamp(): - return clock() - timestamp_zero - - -def dump_stacks(): - """Dump the stacks of all threads except the current thread""" - current_ident = threading.current_thread().ident - for thread_ident, frame in sys._current_frames().items(): - if thread_ident == current_ident: - continue - for t in threading.enumerate(): - if t.ident == thread_ident: - thread_name = t.name - thread_daemon = t.daemon - break - else: - thread_name = '' - print('Stack of %s (%s) in pid %s; daemon=%s' % (thread_name, thread_ident, os.getpid(), thread_daemon)) - print(''.join(traceback.format_stack(frame))) - - -def dump_stacks_in(secs): - """Invokes dump_stacks() on a background thread after waiting. - - Can be called from debugged code before the point after which it hangs, - to determine the cause of the hang while debugging a test. - """ - - def dumper(): - time.sleep(secs) - dump_stacks() - - thread = threading.Thread(target=dumper) - thread.daemon = True - thread.start() - - -def get_unique_port(base): - # Different worker processes need to use different ports, - # for those scenarios where one is specified explicitly. - try: - worker_id = os.environ['PYTEST_XDIST_WORKER'] - n = int(worker_id[2:]) # e.g. 'gw123' - except KeyError: - n = 0 - return base + n - - -# Given a path to a Python source file, extracts line numbers for -# all lines that are marked with #@. For example, given this file: -# -# print(1) #@foo -# print(2) -# print(3) #@bar -# -# the function will return: -# -# {'foo': 1, 'bar': 3} -# -def get_marked_line_numbers(path): - with open(path) as f: - lines = {} - for i, line in enumerate(f): - match = re.search(r'#\s*@\s*(.*?)\s*$', line) - if match: - marker = match.group(1) - lines[marker] = i + 1 - return lines - - -from .printer import print diff --git a/tests/helpers/colors.py b/tests/helpers/colors.py deleted file mode 100644 index 29433921..00000000 --- a/tests/helpers/colors.py +++ /dev/null @@ -1,93 +0,0 @@ -# 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 - - -if True: - # On Win32, colorama is not active when pytest-timeout dumps captured output - # on timeout, and ANSI sequences aren't properly interpreted. - # TODO: re-enable on Windows after enabling proper ANSI sequence handling: - # https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences - # - # Azure Pipelines doesn't support ANSI sequences at all. - # TODO: re-enable on all platforms after adding Azure Pipelines detection. - - RESET = '' - BLACK = '' - BLUE = '' - CYAN = '' - GREEN = '' - RED = '' - WHITE = '' - LIGHT_BLACK = '' - LIGHT_BLUE = '' - LIGHT_CYAN = '' - LIGHT_GREEN = '' - LIGHT_MAGENTA = '' - LIGHT_RED = '' - LIGHT_WHITE = '' - LIGHT_YELLOW = '' - - - def colorize_json(s): - return s - - - def color_repr(obj): - return repr(obj) - - -else: - from colorama import Fore - from pygments import highlight, lexers, formatters, token - - - # Colors that are commented out don't work with PowerShell. - RESET = Fore.RESET - BLACK = Fore.BLACK - BLUE = Fore.BLUE - CYAN = Fore.CYAN - GREEN = Fore.GREEN - # MAGENTA = Fore.MAGENTA - RED = Fore.RED - WHITE = Fore.WHITE - # YELLOW = Fore.YELLOW - LIGHT_BLACK = Fore.LIGHTBLACK_EX - LIGHT_BLUE = Fore.LIGHTBLUE_EX - LIGHT_CYAN = Fore.LIGHTCYAN_EX - LIGHT_GREEN = Fore.LIGHTGREEN_EX - LIGHT_MAGENTA = Fore.LIGHTMAGENTA_EX - LIGHT_RED = Fore.LIGHTRED_EX - LIGHT_WHITE = Fore.LIGHTWHITE_EX - LIGHT_YELLOW = Fore.LIGHTYELLOW_EX - - - color_scheme = { - token.Token: ('white', 'white'), - token.Punctuation: ('', ''), - token.Operator: ('', ''), - token.Literal: ('brown', 'brown'), - token.Keyword: ('brown', 'brown'), - token.Name: ('white', 'white'), - token.Name.Constant: ('brown', 'brown'), - token.Name.Attribute: ('brown', 'brown'), - # token.Name.Tag: ('white', 'white'), - # token.Name.Function: ('white', 'white'), - # token.Name.Variable: ('white', 'white'), - } - - formatter = formatters.TerminalFormatter(colorscheme=color_scheme) - json_lexer = lexers.JsonLexer() - python_lexer = lexers.PythonLexer() - - - def colorize_json(s): - return highlight(s, json_lexer, formatter).rstrip() - - - def color_repr(obj): - return highlight(repr(obj), python_lexer, formatter).rstrip() - - diff --git a/tests/helpers/debuggee/__init__.py b/tests/helpers/debuggee/__init__.py deleted file mode 100644 index ca4dcfa9..00000000 --- a/tests/helpers/debuggee/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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 - -# This dummy package contains modules that are only supposed to be imported from -# the code that is executed under debugger as part of the test (e.g. via @pyfile). -# PYTHONPATH has an entry appended to it that allows these modules to be imported -# directly from such code, i.e. "import backchannel". Consequently, these modules -# should not assume that any other code from tests/ is importable. - - -# Ensure that __file__ is always absolute. -import os -__file__ = os.path.abspath(__file__) diff --git a/tests/helpers/debuggee/backchannel.py b/tests/helpers/debuggee/backchannel.py deleted file mode 100644 index 899a9212..00000000 --- a/tests/helpers/debuggee/backchannel.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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 - -"""Imported from test code that runs under ptvsd, and allows that code -to communcate back to the test. Works in conjunction with debug_session -fixture and its backchannel method.""" - -import os -import socket - -import ptvsd.common.log as log -from ptvsd.common.messaging import JsonIOStream - - -port = int(os.getenv('PTVSD_BACKCHANNEL_PORT')) -log.debug('Connecting to bchan#{0}', port) - -sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -sock.connect(('localhost', port)) -stream = JsonIOStream.from_socket(sock, name='test') - - -def read_json(): - return stream.read_json() - - -def write_json(value): - stream.write_json(value) diff --git a/tests/helpers/debuggee/dbgimporter.py b/tests/helpers/debuggee/dbgimporter.py deleted file mode 100644 index b424cfa0..00000000 --- a/tests/helpers/debuggee/dbgimporter.py +++ /dev/null @@ -1,9 +0,0 @@ -import os - -def import_and_enable_debugger(**kwargs): - if os.getenv('PTVSD_ENABLE_ATTACH', False): - import ptvsd - host = os.getenv('PTVSD_TEST_HOST', 'localhost') - port = os.getenv('PTVSD_TEST_PORT', '5678') - ptvsd.enable_attach((host, port), **kwargs) - ptvsd.wait_for_attach() diff --git a/tests/helpers/messaging.py b/tests/helpers/messaging.py deleted file mode 100644 index 8dae75f8..00000000 --- a/tests/helpers/messaging.py +++ /dev/null @@ -1,65 +0,0 @@ -# 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 itertools -import json - -from . import print, colors - - -class JsonMemoryStream(object): - """Like JsonIOStream, but working directly with values stored in memory. - Values are round-tripped through JSON serialization. - - 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 close(self): - pass - - def read_json(self, decoder=None): - decoder = decoder if decoder is not None else json.JSONDecoder() - try: - value = next(self.input) - except StopIteration: - raise EOFError - return decoder.decode(json.dumps(value)) - - def write_json(self, value, encoder=None): - encoder = encoder if encoder is not None else json.JSONEncoder() - value = json.loads(encoder.encode(value)) - self.output.append(value) - - -class LoggingJsonStream(object): - """Wraps a JsonStream, and logs all values passing through. - """ - - id_iter = itertools.count() - - def __init__(self, stream, id=None): - self.stream = stream - self.id = id or next(self.id_iter) - self.name = self.id - - def close(self): - self.stream.close() - - def read_json(self, decoder=None): - value = self.stream.read_json(decoder) - s = colors.colorize_json(json.dumps(value)) - print('%s%s --> %s%s' % (colors.LIGHT_CYAN, self.id, colors.RESET, s)) - return value - - def write_json(self, value, encoder=None): - s = colors.colorize_json(json.dumps(value)) - print('%s%s <-- %s%s' % (colors.LIGHT_CYAN, self.id, colors.RESET, s)) - self.stream.write_json(value, encoder) diff --git a/tests/helpers/pathutils.py b/tests/helpers/pathutils.py deleted file mode 100644 index 71017ee2..00000000 --- a/tests/helpers/pathutils.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE in the project root -# for license information. - -import os.path -import sys - -from ptvsd.common.compat import unicode -from pydevd_file_utils import get_path_with_real_case - - -def get_test_root(name): - tests_dir = os.path.dirname(os.path.dirname(__file__)) - p = os.path.join(tests_dir, 'func', 'testfiles', name) - if os.path.exists(p): - return p - return None - - -def compare_path(left, right, show=True): - # If there's a unicode/bytes mismatch, make both unicode. - if isinstance(left, unicode): - if not isinstance(right, unicode): - right = right.decode(sys.getfilesystemencoding()) - elif isinstance(right, unicode): - right = right.encode(sys.getfilesystemencoding()) - - n_left = get_path_with_real_case(left) - n_right = get_path_with_real_case(right) - if show: - print('LEFT : ' + n_left) - print('RIGHT: ' + n_right) - return n_left == n_right diff --git a/tests/helpers/pattern.py b/tests/helpers/pattern.py deleted file mode 100644 index f519df05..00000000 --- a/tests/helpers/pattern.py +++ /dev/null @@ -1,174 +0,0 @@ -# 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 numbers -import re - -from ptvsd.common.compat import unicode -from tests.helpers.pathutils import compare_path - - -class BasePattern(object): - - def __repr__(self): - raise NotImplementedError() - - def __eq__(self, value): - raise NotImplementedError() - - def such_that(self, condition): - return Maybe(self, condition) - - -class Any(BasePattern): - """Represents a wildcard in a pattern as used by json_matches(), - and matches any single object in the same place in input data. - """ - - def __init__(self): - pass - - def __repr__(self): - return 'ANY' - - def __eq__(self, other): - return True - - @staticmethod - def dict_with(items): - """A pattern that matches any dict that contains the specified key-value pairs. - - d1 = {'a': 1, 'b': 2, 'c': 3} - d2 = {'a': 1, 'b': 2} - - d1 == Pattern(d2) # False (need exact match) - d1 == ANY.dict_with(d2) # True (subset matches) - """ - - class AnyDictWith(object): - - def __repr__(self): - return repr(items)[:-1] + ', ...}' - - def __eq__(self, other): - if not isinstance(other, dict): - return NotImplemented - d = {key: ANY for key in other} - d.update(items) - return d == other - - def __ne__(self, other): - return not (self == other) - - items = dict(items) - return AnyDictWith() - - -class Maybe(BasePattern): - """A pattern that matches if condition is True. - """ - - name = None - - def __init__(self, pattern, condition): - self.pattern = pattern - self.condition = condition - - def __repr__(self): - return self.name or 'Maybe(%r)' % self.pattern - - def __eq__(self, value): - return self.condition(value) and value == self.pattern - - -class Success(BasePattern): - """A pattern that matches a response body depending on whether the request succeeded or failed. - """ - - def __init__(self, success): - self.success = success - - def __repr__(self): - return 'SUCCESS' if self.success else 'FAILURE' - - def __eq__(self, response_body): - return self.success != isinstance(response_body, Exception) - - -class Is(BasePattern): - """A pattern that matches a specific object only (i.e. uses operator 'is' rather than '=='). - """ - - def __init__(self, obj): - self.obj = obj - - def __repr__(self): - return 'Is(%r)' % self.obj - - def __eq__(self, value): - return self.obj is value - - -class Path(object): - """A pattern that matches strings as path, using os.path.normcase before comparison, - and sys.getfilesystemencoding() to compare Unicode and non-Unicode strings. - """ - - def __init__(self, s): - self.s = s - - def __repr__(self): - return 'Path(%r)' % (self.s,) - - def __eq__(self, other): - if not (isinstance(other, bytes) or isinstance(other, unicode)): - return NotImplemented - return compare_path(self.s, other, show=False) - - def __ne__(self, other): - return not (self == other) - - -class Regex(object): - """A pattern that matches strings against regex, as if with re.match(). - """ - - def __init__(self, regex): - self.regex = regex - - def __repr__(self): - return '/%s/' % (self.regex,) - - def __eq__(self, other): - if not (isinstance(other, bytes) or isinstance(other, unicode)): - return NotImplemented - return re.match(self.regex, other) - - def __ne__(self, other): - return not (self == other) - - -SUCCESS = Success(True) -FAILURE = Success(False) - -ANY = Any() - -ANY.bool = ANY.such_that(lambda x: x is True or x is False) -ANY.bool.name = 'ANY.bool' - -ANY.str = ANY.such_that(lambda x: isinstance(x, unicode)) -ANY.str.name = 'ANY.str' - -ANY.num = ANY.such_that(lambda x: isinstance(x, numbers.Real)) -ANY.num.name = 'ANY.num' - -ANY.int = ANY.such_that(lambda x: isinstance(x, numbers.Integral)) -ANY.int.name = 'ANY.int' - -# Note: in practice it could be any int32, but as in those cases we expect the number to be -# incremented sequentially, this should be reasonable for tests. -ANY.dap_id = ANY.such_that(lambda x: isinstance(x, numbers.Integral) and 0 <= x < 10000) -ANY.dap_id.name = 'ANY.dap_id' diff --git a/tests/helpers/printer.py b/tests/helpers/printer.py deleted file mode 100644 index 2abb74fd..00000000 --- a/tests/helpers/printer.py +++ /dev/null @@ -1,43 +0,0 @@ -# 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 - -__all__ = ['print', 'wait_for_output'] - -import threading -from ptvsd.common.compat import queue -from tests.helpers import timestamp, colors - - -real_print = print -print_queue = queue.Queue() - - -def print(*args, **kwargs): - """Like builtin print(), but synchronized across multiple threads, - and adds a timestamp. - """ - timestamped = kwargs.pop('timestamped', True) - t = timestamp() if timestamped else None - print_queue.put((t, args, kwargs)) - - -def wait_for_output(): - print_queue.join() - - -def print_worker(): - while True: - t, args, kwargs = print_queue.get() - if t is not None: - t = colors.LIGHT_BLACK + ('@%09.6f:' % t) + colors.RESET - args = (t,) + args - real_print(*args, **kwargs) - print_queue.task_done() - - -print_thread = threading.Thread(target=print_worker, name='printer') -print_thread.daemon = True -print_thread.start() diff --git a/tests/helpers/test_pattern.py b/tests/helpers/test_pattern.py deleted file mode 100644 index 768f355c..00000000 --- a/tests/helpers/test_pattern.py +++ /dev/null @@ -1,81 +0,0 @@ -# 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 pytest - -from .pattern import ANY, SUCCESS, FAILURE - - -VALUES = [ - None, - True, False, - 0, -1, -1.0, 1.23, - b'abc', b'abcd', - u'abc', u'abcd', - (), (1, 2, 3), - [], [1, 2, 3], - {}, {'a': 1, 'b': 2}, -] - - -@pytest.mark.parametrize('x', VALUES) -def test_any(x): - assert x == ANY - - -def test_lists(): - assert [1, 2, 3] == [1, ANY, 3] - assert [1, 2, 3, 4] != [1, ANY, 4] - - -def test_dicts(): - assert {'a': 1, 'b': 2} == {'a': ANY, 'b': 2} - assert {'a': 1, 'b': 2} == ANY.dict_with({'a': 1}) - - -def test_maybe(): - def nonzero(x): - return x != 0 - - pattern = ANY.such_that(nonzero) - assert 0 != pattern - assert 1 == pattern - assert 2 == pattern - - -def test_success(): - assert {} == SUCCESS - assert {} != FAILURE - - -def test_failure(): - error = Exception('error!') - assert error != SUCCESS - assert error == FAILURE - - -def test_recursive(): - assert [ - 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/tests/helpers/webhelper.py b/tests/helpers/webhelper.py deleted file mode 100644 index ffeee778..00000000 --- a/tests/helpers/webhelper.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE in the project root -# for license information. - -import threading -import requests -import re -import socket -import time - -def get_web_string(path, obj): - r = requests.get(path) - content = r.text - if obj is not None: - obj.content = content - return content - - -def get_web_string_no_error(path, obj): - try: - return get_web_string(path, obj) - except Exception: - pass - - -re_link = r"(http(s|)\:\/\/[\w\.]*\:[0-9]{4,6}(\/|))" -def get_url_from_str(s): - matches = re.findall(re_link, s) - if matches and matches[0]and matches[0][0].strip(): - return matches[0][0] - return None - - -def get_web_content(link, web_result=None, timeout=1): - class WebResponse(object): - def __init__(self): - self.content = None - - def wait_for_response(self, timeout=1): - self._web_client_thread.join(timeout) - return self.content - - response = WebResponse() - response._web_client_thread = threading.Thread( - target=get_web_string_no_error, - args=(link, response), - name='test.webClient' - ) - response._web_client_thread.start() - print('Opening link: ' + link) - return response - - -def wait_for_connection(port, interval=1, attempts=10): - count = 0 - while count < attempts: - count += 1 - try: - print('Waiting to connect to port: %s' % port) - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect(('localhost', port)) - return - except socket.error: - pass - finally: - sock.close() - time.sleep(interval) diff --git a/tests/net.py b/tests/net.py new file mode 100644 index 00000000..fda91ccd --- /dev/null +++ b/tests/net.py @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Test helpers for networking. +""" + +import os +import re +import requests +import socket +import threading +import time + +from ptvsd.common import compat, fmt, log +from tests.patterns import some + + +def get_test_server_port(start, stop): + """Returns a server port number that can be safely used for listening without + clashing with another test worker process, when running with pytest-xdist. + + If multiple test workers invoke this function with the same min value, each of + them will receive a different number that is not lower than start (but may be + higher). If the resulting value is >=stop, it is a fatal error. + + Note that if multiple test workers invoke this function with different ranges + that overlap, conflicts are possible! + """ + + try: + worker_id = os.environ['PYTEST_XDIST_WORKER'] + except KeyError: + n = 0 + else: + assert worker_id == some.str.matching(r"gw(\d+)"), ( + "Unrecognized PYTEST_XDIST_WORKER format" + ) + n = int(worker_id[2:]) + + port = start + n + assert port <= stop + return port + + +def find_http_url(text): + match = re.search(r"https?://[-.0-9A-Za-z]+(:\d+)/?", text) + return match.group() if match else None + + +def wait_until_port_is_listening(port, interval=1, max_attempts=1000): + """Blocks until the specified TCP port on localhost is listening, and can be + connected to. + + Tries to connect to the port periodically, and repeats until connection succeeds. + Connection is immediately closed before returning. + """ + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + for i in compat.xrange(0, max_attempts): + try: + log.info("Trying to connect to port {0} (attempt {1})", port, i) + sock.connect(("localhost", port)) + return + except socket.error: + time.sleep(interval) + finally: + sock.close() + + +class WebRequest(object): + """An async wrapper around requests. + """ + + @staticmethod + def get(*args, **kwargs): + return WebRequest("get", *args, **kwargs) + + @staticmethod + def post(*args, **kwargs): + return WebRequest("post", *args, **kwargs) + + def __init__(self, method, url, *args, **kwargs): + """Invokes requests.method(url, *args, **kwargs) on a background thread, + and immediately returns. + """ + + self.request = None + """The underlying Request object. Not set until wait_for_response() returns. + """ + + method = getattr(requests, method) + self._worker.thread = threading.Thread( + target=lambda: self._worker(method, url, *args, **kwargs), + name=fmt("WebRequest({0!r})", url) + ) + + def _worker(self, method, url, *args, **kwargs): + self.request = method(url, *args, **kwargs) + + def wait_for_response(self, timeout=None): + """Blocks until the request completes, and returns self.request. + """ + self._worker.thread.join(timeout) + return self.request + + def response_text(self): + """Blocks until the request completes, and returns the response body. + """ + return self.wait_for_response().text + + +class WebServer(object): + """Interacts with a web server listening on localhost on the specified port. + """ + + def __init__(self, port): + self.port = port + self.url = fmt("http://localhost:{0}", port) + + def __enter__(self): + """Blocks until the server starts listening on self.port. + """ + wait_until_port_is_listening(self.port) + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + """Sends an HTTP /exit POST request to the server. + """ + self.post("exit").wait_for_response() + + def get(self, path, *args, **kwargs): + return WebRequest.get(self.url + path, *args, **kwargs) + + def post(self, path, *args, **kwargs): + return WebRequest.post(self.url + path, *args, **kwargs) diff --git a/tests/pattern.py b/tests/pattern.py new file mode 100644 index 00000000..3f84f766 --- /dev/null +++ b/tests/pattern.py @@ -0,0 +1,4 @@ +ANY = None +Path = None +Regex = None +Is = None diff --git a/tests/patterns/__init__.py b/tests/patterns/__init__.py new file mode 100644 index 00000000..49aadcbc --- /dev/null +++ b/tests/patterns/__init__.py @@ -0,0 +1,281 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Do not import this package directly - import tests.patterns.some instead. +""" + +# The actual patterns are defined here, so that tests.patterns.some can redefine +# builtin names like str, int etc without affecting the implementations in this +# file - some.* then provides shorthand aliases. + +import re +import sys + +from ptvsd.common import compat, fmt +from ptvsd.common.compat import unicode +import pydevd_file_utils + + +class Some(object): + """A pattern that can be tested against a value with == to see if it matches. + """ + + def __repr__(self): + try: + return self.name + except AttributeError: + raise NotImplementedError + + def __eq__(self, value): + raise NotImplementedError + + def __ne__(self, other): + return not (self == other) + + def __invert__(self): + """The inverse pattern - matches everything that this one doesn't. + """ + return Not(self) + + def __or__(self, pattern): + """Union pattern - matches if either of the two patterns match. + """ + return Either(self, pattern) + + def such_that(self, condition): + """Same pattern, but it only matches if condition() is true. + """ + return SuchThat(self, condition) + + def in_range(self, start, stop): + """Same pattern, but it only matches if the start <= value < stop. + """ + return InRange(self, start, stop) + + +class Not(Some): + """Matches the inverse of the pattern. + """ + + def __init__(self, pattern): + self.pattern = pattern + + def __repr__(self): + return fmt("~{0!r}", self.pattern) + + def __eq__(self, value): + return value != self.pattern + + +class Either(Some): + """Matches either of the patterns. + """ + + def __init__(self, *patterns): + assert len(patterns) > 0 + self.patterns = tuple(patterns) + + def __repr__(self): + try: + return self.name + except AttributeError: + return fmt("({0})", " | ".join(repr(pat) for pat in self.patterns)) + + def __eq__(self, value): + return any(pattern == value for pattern in self.patterns) + + def __or__(self, pattern): + return Either(*(self.patterns + (pattern,))) + + +class SuchThat(Some): + """Matches only if condition is true. + """ + + def __init__(self, pattern, condition): + self.pattern = pattern + self.condition = condition + + def __repr__(self): + try: + return self.name + except AttributeError: + return fmt("({0!r} if {1})", self.pattern, compat.nameof(self.condition)) + + def __eq__(self, value): + return self.condition(value) and value == self.pattern + + +class InRange(Some): + """Matches only if the value is within the specified range. + """ + + def __init__(self, pattern, start, stop): + self.pattern = pattern + self.start = start + self.stop = stop + + def __repr__(self): + try: + return self.name + except AttributeError: + return fmt("({0!r} <= {1!r} < {2!r})", self.start, self.pattern, self.stop) + + def __eq__(self, value): + return self.start <= value < self.stop and value == self.pattern + + +class Object(Some): + """Matches anything. + """ + + name = "" + + def __eq__(self, value): + return True + + def equal_to(self, obj): + return EqualTo(obj) + + def same_as(self, obj): + return SameAs(obj) + + +class Thing(Some): + """Matches anything that is not None. + """ + + name = "<>" + + def __eq__(self, value): + return value is not None + + +class InstanceOf(Some): + """Matches any object that is an instance of the specified type. + """ + + def __init__(self, classinfo, name=None): + if isinstance(classinfo, type): + classinfo = (classinfo,) + assert ( + len(classinfo) > 0 and + all((isinstance(cls, type) for cls in classinfo)) + ), "classinfo must be a type or a tuple of types" + + self.name = name + self.classinfo = classinfo + + def __repr__(self): + if self.name: + name = self.name + else: + name = " | ".join(cls.__name__ for cls in self.classinfo) + return fmt("<{0}>", name) + + def __eq__(self, value): + return isinstance(value, self.classinfo) + + +class EqualTo(Some): + """Matches any object that is equal to the specified object. + """ + + def __init__(self, obj): + self.obj = obj + + def __repr__(self): + return repr(self.obj) + + def __eq__(self, value): + return self.obj == value + + +class SameAs(Some): + """Matches one specific object only (i.e. makes '==' behave like 'is'). + """ + + def __init__(self, obj): + self.obj = obj + + def __repr__(self): + return fmt("is {0!r}", self.obj) + + def __eq__(self, value): + return self.obj is value + + +class StrMatching(Some): + """Matches any string that matches the specified regular expression. + """ + + def __init__(self, regex): + self.regex = regex + + def __repr__(self): + return fmt("/{0}/", self.regex) + + def __eq__(self, other): + if not (isinstance(other, bytes) or isinstance(other, unicode)): + return NotImplemented + return re.match(self.regex, other) is not None + + +class Path(Some): + """Matches any string that matches the specified path. + + Uses os.path.normcase() to normalize both strings before comparison. + + If one string is unicode, but the other one is not, both strings are normalized + to unicode using sys.getfilesystemencoding(). + """ + + def __init__(self, path): + self.path = path + + def __repr__(self): + return fmt("some.path({0!r})", self.path) + + def __eq__(self, other): + if not (isinstance(other, bytes) or isinstance(other, unicode)): + return NotImplemented + + left, right = self.path, other + + # If there's a unicode/bytes mismatch, make both unicode. + if isinstance(left, unicode): + if not isinstance(right, unicode): + right = right.decode(sys.getfilesystemencoding()) + elif isinstance(right, unicode): + right = right.encode(sys.getfilesystemencoding()) + + left = pydevd_file_utils.get_path_with_real_case(left) + right = pydevd_file_utils.get_path_with_real_case(right) + return left == right + + +class DictContaining(Some): + """Matches any dict that contains the specified key-value pairs:: + + d1 = {'a': 1, 'b': 2, 'c': 3} + d2 = {'a': 1, 'b': 2} + assert d1 == some.dict.containing(d2) + assert d2 != some.dict.containing(d1) + """ + + def __init__(self, items): + self.items = dict(items) + + def __repr__(self): + return repr(self.items)[:-1] + ', ...}' + + def __eq__(self, other): + if not isinstance(other, dict): + return NotImplemented + any = Object() + d = {key: any for key in other} + d.update(self.items) + return d == other diff --git a/tests/patterns/some.py b/tests/patterns/some.py new file mode 100644 index 00000000..2ef2ddf4 --- /dev/null +++ b/tests/patterns/some.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Pattern matching for recursive Python data structures. + +Usage:: + + from tests.patterns import some + + assert object() == some.object + assert None == some.object + assert None != some.thing + assert None == ~some.thing # inverse + assert None == some.thing | None + + assert 123 == some.thing.in_range(0, 200) + assert "abc" == some.thing.such_that(lambda s: s.startswith("ab")) + + xs = [] + assert xs == some.specific_object(xs) # xs is xs + assert xs != some.specific_object([]) # xs is not [] + + assert Exception() == some.instanceof(Exception) + assert 123 == some.instanceof((int, str)) + assert "abc" == some.instanceof((int, str)) + + assert True == some.bool + assert 123.456 == some.number + assert 123 == some.int + assert Exception() == some.error + + assert u"abc" == some.str + if sys.version_info < (3,): + assert b"abc" == some.str + else: + assert b"abc" != some.str + + assert "abbbc" == some.str.matching(r".(b+).") + assert "abbbc" != some.str.matching(r"bbb") + + if platform.system() == "Windows": + assert "\\Foo\\Bar" == some.path("/foo/bar") + else: + assert "/Foo/Bar" != some.path("/foo/bar") + + assert { + "bool": True, + "list": [None, True, 123], + "dict": { + "int": 123, + "str": "abc", + }, + } == some.dict.containing({ + "list": [None, some.bool, some.int | some.str], + "dict": some.dict.containing({ + "int": some.int.in_range(100, 200), + }) + }) +""" + +__all__ = [ + "bool", + "dap_id", + "error", + "instanceof", + "int", + "number", + "path", + "source", + "str", + "such_that", + "thing", +] + +import numbers +import sys + +from ptvsd.common.compat import builtins +from tests import patterns as some + + +such_that = some.SuchThat +object = some.Object() +thing = some.Thing() +instanceof = some.InstanceOf +path = some.Path + + +bool = instanceof(builtins.bool) +number = instanceof(numbers.Real, "number") +int = instanceof(numbers.Integral, "int") +error = instanceof(Exception) + + +str = None +"""In Python 2, matches both str and unicode. In Python 3, only matches str. +""" +if sys.version_info < (3,): + str = instanceof((builtins.str, builtins.unicode), "str") +else: + str = instanceof(builtins.str) +str.matching = some.StrMatching + + +dict = instanceof(builtins.dict) +dict.containing = some.DictContaining + + +dap_id = int.in_range(0, 10000) +"""Matches a DAP "id", assuming some reasonable range for an implementation that +generates those ids sequentially. +""" + + +def source(path): + """Matches "source": {"path": ...} values in DAP. + """ + return dict.containing({"path": path}) diff --git a/tests/print.py b/tests/print.py new file mode 100644 index 00000000..bcefef3e --- /dev/null +++ b/tests/print.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""When imported using from, this module effectively overrides the print() built-in +with a synchronized version that also adds a timestamp. + +Because tests can run in parallel, all modules that can be invoked from test code, +and that need to print, should do:: + + from tests import print + +Each call to print() is then atomic - i.e. it will not interleave with any other print. +If a sequence of several print calls must be atomic, lock explicitly:: + + with print: + print('fizz') + print('bazz') +""" + +import sys +import types + +from ptvsd.common import fmt, singleton, timestamp + + +# The class of the module object for this module. +class Printer(singleton.ThreadSafeSingleton, types.ModuleType): + def __init__(self): + # Set self up as a proper module, and copy globals. + # types must be re-imported, because globals aren't there yet at this point. + import types + types.ModuleType.__init__(self, __name__) + self.__dict__.update(sys.modules[__name__].__dict__) + + @singleton.autolocked_method + def __call__(self, *args, **kwargs): + """Like builtin print(), but synchronized across multiple threads, + and adds a timestamp. + """ + with self: + timestamped = kwargs.pop('timestamped', True) + t = timestamp.current() if timestamped else None + if t is not None: + t = '@%09.6f:' % t + args = (t,) + args + print(*args, **kwargs) + + def f(self, format_string, *args, **kwargs) : + """Same as print(fmt(...)). + """ + return self(fmt(format_string, *args, **kwargs)) + + +# Replace the standard module object for this module with a Printer object. +sys.modules[__name__] = Printer() diff --git a/tests/func/__init__.py b/tests/ptvsd/__init__.py similarity index 61% rename from tests/func/__init__.py rename to tests/ptvsd/__init__.py index 22b212ac..f87c379c 100644 --- a/tests/func/__init__.py +++ b/tests/ptvsd/__init__.py @@ -2,4 +2,6 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -__doc__ = """ptvsd functional tests.""" +from __future__ import absolute_import, print_function, unicode_literals + +"""Product tests.""" diff --git a/tests/ptvsd/adapter/__init__.py b/tests/ptvsd/adapter/__init__.py new file mode 100644 index 00000000..059014ca --- /dev/null +++ b/tests/ptvsd/adapter/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Tests for the debug adapter. +""" diff --git a/tests/ptvsd/common/__init__.py b/tests/ptvsd/common/__init__.py new file mode 100644 index 00000000..c5bd0ff7 --- /dev/null +++ b/tests/ptvsd/common/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Tests for product code that is shared between the adapter and the server. +""" diff --git a/tests/test_messaging.py b/tests/ptvsd/common/test_messaging.py similarity index 92% rename from tests/test_messaging.py rename to tests/ptvsd/common/test_messaging.py index 67875932..a5d24cfd 100644 --- a/tests/test_messaging.py +++ b/tests/ptvsd/common/test_messaging.py @@ -2,7 +2,10 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals + +"""Tests for JSON message streams and channels. +""" import json import io @@ -14,13 +17,43 @@ import threading import time from ptvsd.common import fmt, messaging -from tests.helpers.messaging import JsonMemoryStream, LoggingJsonStream -from tests.helpers.pattern import Regex +from tests.patterns import some +# Default timeout for tests in this file. pytestmark = pytest.mark.timeout(5) +class JsonMemoryStream(object): + """Like JsonIOStream, but working directly with values stored in memory. + Values are round-tripped through JSON serialization. + + 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, name="memory"): + self.name = name + self.input = iter(input) + self.output = output + + def close(self): + pass + + def read_json(self, decoder=None): + decoder = decoder if decoder is not None else json.JSONDecoder() + try: + value = next(self.input) + except StopIteration: + raise EOFError + return decoder.decode(json.dumps(value)) + + def write_json(self, value, encoder=None): + encoder = encoder if encoder is not None else json.JSONEncoder() + value = json.loads(encoder.encode(value)) + self.output.append(value) + + class TestJsonIOStream(object): MESSAGE_BODY_TEMPLATE = u'{"arguments": {"threadId": 3}, "command": "next", "seq": %d, "type": "request"}' MESSAGES = [] @@ -38,7 +71,7 @@ class TestJsonIOStream(object): def test_read(self): data = io.BytesIO(self.SERIALIZED_MESSAGES) - stream = messaging.JsonIOStream(data, data) + stream = messaging.JsonIOStream(data, data, "data") for expected_message in self.MESSAGES: message = stream.read_json() assert message == expected_message @@ -47,7 +80,7 @@ class TestJsonIOStream(object): def test_write(self): data = io.BytesIO() - stream = messaging.JsonIOStream(data, data) + stream = messaging.JsonIOStream(data, data, "data") for message in self.MESSAGES: stream.write_json(message) data = data.getvalue() @@ -106,7 +139,7 @@ class TestJsonMessageChannel(object): events_received.append((event.channel, event.event, event.body)) input, input_exhausted = self.iter_with_event(EVENTS) - stream = LoggingJsonStream(JsonMemoryStream(input, [])) + stream = JsonMemoryStream(input, []) channel = messaging.JsonMessageChannel(stream, Handlers()) channel.start() input_exhausted.wait() @@ -142,7 +175,7 @@ class TestJsonMessageChannel(object): input, input_exhausted = self.iter_with_event(REQUESTS) output = [] - stream = LoggingJsonStream(JsonMemoryStream(input, output)) + stream = JsonMemoryStream(input, output) channel = messaging.JsonMessageChannel(stream, Handlers()) channel.start() input_exhausted.wait() @@ -183,7 +216,7 @@ class TestJsonMessageChannel(object): 'success': True, 'body': {'threadId': 5}, } - stream = LoggingJsonStream(JsonMemoryStream(iter_responses(), [])) + stream = JsonMemoryStream(iter_responses(), []) channel = messaging.JsonMessageChannel(stream, None) channel.start() @@ -377,7 +410,7 @@ class TestJsonMessageChannel(object): input, input_exhausted = self.iter_with_event(REQUESTS) output = [] - stream = LoggingJsonStream(JsonMemoryStream(input, output)) + stream = JsonMemoryStream(input, output) channel = messaging.JsonMessageChannel(stream, Handlers()) channel.start() input_exhausted.wait() @@ -433,13 +466,13 @@ class TestJsonMessageChannel(object): input, input_exhausted = self.iter_with_event(REQUESTS) output = [] - stream = LoggingJsonStream(JsonMemoryStream(input, output)) + stream = JsonMemoryStream(input, output) channel = messaging.JsonMessageChannel(stream, Handlers()) channel.start() input_exhausted.wait() def missing_property(name): - return Regex("^Invalid message:.*" + re.escape(name)) + return some.str.matching("Invalid message:.*" + re.escape(name)) assert output == [ { @@ -585,12 +618,12 @@ class TestJsonMessageChannel(object): io1 = socket1.makefile('rwb', 0) io2 = socket2.makefile('rwb', 0) - stream1 = LoggingJsonStream(messaging.JsonIOStream(io1, io1)) + stream1 = messaging.JsonIOStream(io1, io1, "socket1") channel1 = messaging.JsonMessageChannel(stream1, fuzzer1) channel1.start() fuzzer1.start(channel1) - stream2 = LoggingJsonStream(messaging.JsonIOStream(io2, io2)) + stream2 = messaging.JsonIOStream(io2, io2, "socket2") channel2 = messaging.JsonMessageChannel(stream2, fuzzer2) channel2.start() fuzzer2.start(channel2) diff --git a/tests/test_socket.py b/tests/ptvsd/common/test_socket.py similarity index 100% rename from tests/test_socket.py rename to tests/ptvsd/common/test_socket.py diff --git a/tests/ptvsd/server/__init__.py b/tests/ptvsd/server/__init__.py new file mode 100644 index 00000000..c395291e --- /dev/null +++ b/tests/ptvsd/server/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Tests for the debug server. +""" diff --git a/tests/func/test_args.py b/tests/ptvsd/server/test_args.py similarity index 69% rename from tests/func/test_args.py rename to tests/ptvsd/server/test_args.py index 407bc048..91b55997 100644 --- a/tests/func/test_args.py +++ b/tests/ptvsd/server/test_args.py @@ -2,24 +2,27 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import -from tests.helpers.session import DebugSession +from __future__ import absolute_import, print_function, unicode_literals + import pytest +from tests import debug + + @pytest.mark.parametrize('run_as', ['file', 'module', 'code']) -def test_args(pyfile, run_as, start_method): +def test_args(pyfile, start_method, run_as): @pyfile def code_to_debug(): + import debug_me # noqa import sys - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + print(sys.argv) assert sys.argv[1] == '--arg1' assert sys.argv[2] == 'arg2' assert sys.argv[3] == '-arg3' args = ['--arg1', 'arg2', '-arg3'] - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_attach.py b/tests/ptvsd/server/test_attach.py similarity index 83% rename from tests/func/test_attach.py rename to tests/ptvsd/server/test_attach.py index 61444164..f48b3709 100644 --- a/tests/func/test_attach.py +++ b/tests/ptvsd/server/test_attach.py @@ -2,23 +2,21 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals -import os import pytest -from tests.helpers.session import DebugSession -from tests.helpers.pathutils import get_test_root -from tests.helpers.timeline import Event -from tests.helpers.pattern import ANY + +from tests import debug, test_data +from tests.patterns import some +from tests.timeline import Event @pytest.mark.parametrize('wait_for_attach', ['waitOn', 'waitOff']) @pytest.mark.parametrize('is_attached', ['attachCheckOn', 'attachCheckOff']) @pytest.mark.parametrize('break_into', ['break', 'pause']) def test_attach(run_as, wait_for_attach, is_attached, break_into): - testfile = os.path.join(get_test_root('attach'), 'attach1.py') - - with DebugSession() as session: + attach1_py = str(test_data / 'attach' / 'attach1.py') + with debug.Session() as session: env = { 'PTVSD_TEST_HOST': 'localhost', 'PTVSD_TEST_PORT': str(session.ptvsd_port), @@ -31,7 +29,7 @@ def test_attach(run_as, wait_for_attach, is_attached, break_into): env['PTVSD_BREAK_INTO_DBG'] = '1' session.initialize( - target=(run_as, testfile), + target=(run_as, attach1_py), start_method='launch', env=env, use_backchannel=True, @@ -64,14 +62,13 @@ def test_attach(run_as, wait_for_attach, is_attached, break_into): @pytest.mark.parametrize('start_method', ['attach_socket_cmdline', 'attach_socket_import']) -def test_reattach(pyfile, run_as, start_method): +def test_reattach(pyfile, start_method, run_as): @pyfile def code_to_debug(): + from debug_me import ptvsd import time - import ptvsd import backchannel - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + ptvsd.break_into_debugger() print('first') backchannel.write_json('continued') @@ -80,7 +77,7 @@ def test_reattach(pyfile, run_as, start_method): ptvsd.break_into_debugger() print('second') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -108,24 +105,24 @@ def test_reattach(pyfile, run_as, start_method): session2.wait_for_disconnect() -@pytest.mark.parametrize('start_method', ['attach_pid']) @pytest.mark.parametrize('run_as', ['file', 'module', 'code']) @pytest.mark.skip(reason='Enable after #846, #863 and #1144 are fixed') -def test_attaching_by_pid(pyfile, run_as, start_method): +def test_attaching_by_pid(pyfile, run_as): @pyfile def code_to_debug(): - # import_and_enable_debugger() + import debug_me # noqa import time def do_something(i): time.sleep(0.1) print(i) for i in range(100): do_something(i) + bp_line = 5 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), - start_method=start_method, + start_method='attach_pid', ) session.set_breakpoints(code_to_debug, [bp_line]) session.start_debugging() diff --git a/tests/func/test_break_into_dbg.py b/tests/ptvsd/server/test_break_into_dbg.py similarity index 77% rename from tests/func/test_break_into_dbg.py rename to tests/ptvsd/server/test_break_into_dbg.py index 23a8d41a..3ddcfd62 100644 --- a/tests/func/test_break_into_dbg.py +++ b/tests/ptvsd/server/test_break_into_dbg.py @@ -2,26 +2,25 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import pytest -from tests.helpers.session import DebugSession + +from tests import debug @pytest.mark.parametrize('run_as', ['file', 'module', 'code']) -def test_with_wait_for_attach(pyfile, run_as, start_method): +def test_with_wait_for_attach(pyfile, start_method, run_as): @pyfile def code_to_debug(): # NOTE: These tests verify break_into_debugger for launch # and attach cases. For attach this is always after wait_for_attach - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() - import ptvsd + from debug_me import ptvsd ptvsd.break_into_debugger() print('break here') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -37,19 +36,18 @@ def test_with_wait_for_attach(pyfile, run_as, start_method): @pytest.mark.parametrize('run_as', ['file', 'module', 'code']) @pytest.mark.skip(reason='https://github.com/microsoft/ptvsd/issues/1505') -def test_breakpoint_function(pyfile, run_as, start_method): +def test_breakpoint_function(pyfile, start_method, run_as): @pyfile def code_to_debug(): # NOTE: These tests verify break_into_debugger for launch # and attach cases. For attach this is always after wait_for_attach - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa # TODO: use ptvsd.break_into_debugger() on <3.7 breakpoint() # noqa print('break here') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_breakpoints.py b/tests/ptvsd/server/test_breakpoints.py similarity index 85% rename from tests/func/test_breakpoints.py rename to tests/ptvsd/server/test_breakpoints.py index 433e606a..daefea8e 100644 --- a/tests/func/test_breakpoints.py +++ b/tests/ptvsd/server/test_breakpoints.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import os.path import platform @@ -11,32 +11,28 @@ import pytest import re import sys -from tests.helpers import get_marked_line_numbers -from tests.helpers.pathutils import get_test_root -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from tests.helpers.pattern import ANY, Path +from tests import code, debug, test_data +from tests.patterns import some +from tests.timeline import Event -BP_TEST_ROOT = get_test_root('bp') +BP_TEST_ROOT = test_data / "bp" -def test_path_with_ampersand(run_as, start_method): - bp_line = 4 - testfile = os.path.join(BP_TEST_ROOT, 'a&b', 'test.py') +def test_path_with_ampersand(start_method, run_as): + test_py = str(BP_TEST_ROOT / 'a&b' / 'test.py') + lines = code.get_marked_line_numbers(test_py) - with DebugSession() as session: - session.initialize( - target=(run_as, testfile), - start_method=start_method, - ) - session.set_breakpoints(testfile, [bp_line]) + with debug.Session(start_method) as session: + session.initialize(target=(run_as, test_py)) + session.set_breakpoints(test_py, [lines["two"]]) session.start_debugging() - hit = session.wait_for_thread_stopped('breakpoint') - frames = hit.stacktrace.body['stackFrames'] - assert frames[0]['source']['path'] == Path(testfile) - session.send_request('continue').wait_for_response(freeze=False) + session.wait_for_stop('breakpoint', expected_frames=[ + ANY.dict_with({"source": ANY.source(test_py)}), + ]) + + session.request_continue() session.wait_for_exit() @@ -44,11 +40,11 @@ def test_path_with_ampersand(run_as, start_method): @pytest.mark.skipif( platform.system() == 'Windows' and sys.version_info < (3, 6), reason='https://github.com/Microsoft/ptvsd/issues/1124#issuecomment-459506802') -def test_path_with_unicode(run_as, start_method): +def test_path_with_unicode(start_method, run_as): bp_line = 6 testfile = os.path.join(BP_TEST_ROOT, u'ನನ್ನ_ಸ್ಕ್ರಿಪ್ಟ್.py') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, testfile), start_method=start_method, @@ -74,11 +70,10 @@ def test_path_with_unicode(run_as, start_method): 'hitCondition_le', 'hitCondition_mod', ]) -def test_conditional_breakpoint(pyfile, run_as, start_method, condition_key): +def test_conditional_breakpoint(pyfile, start_method, run_as, condition_key): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa for i in range(0, 10): print(i) @@ -95,7 +90,7 @@ def test_conditional_breakpoint(pyfile, run_as, start_method, condition_key): condition_type, condition, value, hits = expected[condition_key] bp_line = 4 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -131,24 +126,23 @@ def test_conditional_breakpoint(pyfile, run_as, start_method, condition_key): session.wait_for_exit() -def test_crossfile_breakpoint(pyfile, run_as, start_method): +def test_crossfile_breakpoint(pyfile, start_method, run_as): @pyfile def script1(): - from dbgimporter import import_and_enable_debugger # noqa + import debug_me # noqa def do_something(): print('do something') @pyfile def script2(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa import script1 script1.do_something() print('Done') bp_script1_line = 3 bp_script2_line = 4 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, script2), start_method=start_method, @@ -176,11 +170,10 @@ def test_crossfile_breakpoint(pyfile, run_as, start_method): 'NameError', 'OtherError', ]) -def test_error_in_condition(pyfile, run_as, start_method, error_name): +def test_error_in_condition(pyfile, start_method, run_as, error_name): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa def do_something_bad(): raise ArithmeticError() for i in range(1, 10): @@ -195,7 +188,7 @@ def test_error_in_condition(pyfile, run_as, start_method, error_name): } bp_line = 5 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -217,11 +210,10 @@ def test_error_in_condition(pyfile, run_as, start_method, error_name): assert session.get_stderr_as_string().find(b'ArithmeticError') > 0 -def test_log_point(pyfile, run_as, start_method): +def test_log_point(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa a = 10 for i in range(1, a): print('value: %d' % i) @@ -231,7 +223,7 @@ def test_log_point(pyfile, run_as, start_method): bp_line = 5 end_bp_line = 8 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -264,11 +256,10 @@ def test_log_point(pyfile, run_as, start_method): assert values == list(range(1, 10)) -def test_condition_with_log_point(pyfile, run_as, start_method): +def test_condition_with_log_point(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa a = 10 for i in range(1, a): print('value: %d' % i) @@ -278,7 +269,7 @@ def test_condition_with_log_point(pyfile, run_as, start_method): bp_line = 5 end_bp_line = 8 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -338,7 +329,7 @@ def test_package_launch(): cwd = get_test_root('testpkgs') testfile = os.path.join(cwd, 'pkg1', '__main__.py') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=('module', 'pkg1'), start_method='launch', @@ -355,18 +346,16 @@ def test_package_launch(): session.wait_for_exit() -def test_add_and_remove_breakpoint(pyfile, run_as, start_method): +def test_add_and_remove_breakpoint(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + from debug_me import backchannel for i in range(0, 10): print(i) - import backchannel backchannel.read_json() bp_line = 4 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -392,11 +381,10 @@ def test_add_and_remove_breakpoint(pyfile, run_as, start_method): assert list(range(0, 10)) == output -def test_invalid_breakpoints(pyfile, run_as, start_method): +def test_invalid_breakpoints(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa b = True while b: #@bp1-expected @@ -415,10 +403,9 @@ def test_invalid_breakpoints(pyfile, run_as, start_method): 4, 5, 6) line_numbers = get_marked_line_numbers(code_to_debug) - from tests.helpers import print print(line_numbers) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -472,21 +459,21 @@ def test_invalid_breakpoints(pyfile, run_as, start_method): session.wait_for_exit() -def test_deep_stacks(pyfile, run_as, start_method): +def test_deep_stacks(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa def deep_stack(level): if level <= 0: print('done') #@bp return level deep_stack(level - 1) + deep_stack(100) line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_completions.py b/tests/ptvsd/server/test_completions.py similarity index 86% rename from tests/func/test_completions.py rename to tests/ptvsd/server/test_completions.py index 87b34b51..61eb6a41 100644 --- a/tests/func/test_completions.py +++ b/tests/ptvsd/server/test_completions.py @@ -2,14 +2,15 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import pytest -from tests.helpers.pattern import ANY -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from ptvsd.common.messaging import MessageHandlingError -from tests.helpers import get_marked_line_numbers + +from ptvsd.common import messaging +from tests import debug +from tests.patterns import some +from tests.timeline import Event + expected_at_line = { 'in_do_something': [ @@ -31,12 +32,10 @@ expected_at_line = { @pytest.mark.parametrize('bp_line', sorted(expected_at_line.keys())) -def test_completions_scope(pyfile, bp_line, run_as, start_method): - +def test_completions_scope(pyfile, bp_line, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa class SomeClass(): @@ -56,7 +55,7 @@ def test_completions_scope(pyfile, bp_line, run_as, start_method): expected = expected_at_line[bp_line] - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -95,12 +94,10 @@ def test_completions_scope(pyfile, bp_line, run_as, start_method): session.wait_for_exit() -def test_completions_cases(pyfile, run_as, start_method): - +def test_completions_cases(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa a = 1 b = {"one": 1, "two": 2} c = 3 @@ -110,7 +107,7 @@ def test_completions_cases(pyfile, run_as, start_method): bp_line = line_numbers['break'] bp_file = code_to_debug - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, bp_file), start_method=start_method, @@ -146,7 +143,7 @@ def test_completions_cases(pyfile, run_as, start_method): assert not response.body['targets'] # Check errors - with pytest.raises(MessageHandlingError) as error: + with pytest.raises(messaging.MessageHandlingError) as error: response = session.send_request('completions', arguments={ 'frameId': 9999999, # frameId not available. 'text': 'not_there', diff --git a/tests/func/test_disconnect.py b/tests/ptvsd/server/test_disconnect.py similarity index 76% rename from tests/func/test_disconnect.py rename to tests/ptvsd/server/test_disconnect.py index 9b86f8a4..1dad39f7 100644 --- a/tests/func/test_disconnect.py +++ b/tests/ptvsd/server/test_disconnect.py @@ -2,25 +2,25 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import os.path import pytest -from tests.helpers.pattern import ANY -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event + +from tests import debug +from tests.patterns import some +from tests.timeline import Event @pytest.mark.parametrize('start_method', ['attach_socket_cmdline', 'attach_socket_import']) -def test_continue_on_disconnect_for_attach(pyfile, run_as, start_method): +def test_continue_on_disconnect_for_attach(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() - import backchannel + from debug_me import backchannel backchannel.write_json('continued') + bp_line = 4 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -39,18 +39,19 @@ def test_continue_on_disconnect_for_attach(pyfile, run_as, start_method): @pytest.mark.parametrize('start_method', ['launch']) @pytest.mark.skip(reason='Bug #1052') -def test_exit_on_disconnect_for_launch(pyfile, run_as, start_method): +def test_exit_on_disconnect_for_launch(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa import os.path + fp = os.join(os.path.dirname(os.path.abspath(__file__)), 'here.txt') # should not execute this with open(fp, 'w') as f: print('Should not continue after disconnect on launch', file=f) + bp_line = 4 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_django.py b/tests/ptvsd/server/test_django.py similarity index 67% rename from tests/func/test_django.py rename to tests/ptvsd/server/test_django.py index 5506d5f7..a048bd68 100644 --- a/tests/func/test_django.py +++ b/tests/ptvsd/server/test_django.py @@ -2,36 +2,34 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals -import os.path import pytest -import tests.helpers -from tests.helpers.pattern import ANY, Path -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from tests.helpers.pathutils import get_test_root -from tests.helpers.webhelper import get_url_from_str, get_web_content, wait_for_connection +from tests import debug, net, test_data +from tests.patterns import some +from tests.timeline import Event -DJANGO1_ROOT = get_test_root('django1') -DJANGO1_MANAGE = os.path.join(DJANGO1_ROOT, 'app.py') -DJANGO1_TEMPLATE = os.path.join(DJANGO1_ROOT, 'templates', 'hello.html') -DJANGO1_BAD_TEMPLATE = os.path.join(DJANGO1_ROOT, 'templates', 'bad.html') -DJANGO_PORT = tests.helpers.get_unique_port(8000) -DJANGO_LINK = 'http://127.0.0.1:{}/'.format(DJANGO_PORT) + +DJANGO1_ROOT = test_data / "django1" +DJANGO1_MANAGE = DJANGO1_ROOT / 'app.py' +DJANGO1_TEMPLATE = DJANGO1_ROOT / 'templates' / 'hello.html' +DJANGO1_BAD_TEMPLATE = DJANGO1_ROOT / 'templates' / 'bad.html' +DJANGO_PORT = net.get_test_server_port(8000, 8100) + +django = net.WebServer(DJANGO_PORT) @pytest.mark.parametrize('bp_target', ['code', 'template']) @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline']) @pytest.mark.timeout(60) -def test_django_breakpoint_no_multiproc(bp_target, start_method): +def test_django_breakpoint_no_multiproc(start_method, bp_target): bp_file, bp_line, bp_name = { 'code': (DJANGO1_MANAGE, 40, 'home'), 'template': (DJANGO1_TEMPLATE, 8, 'Django Template'), }[bp_target] - with DebugSession() as session: + with debug.Session() as session: session.initialize( start_method=start_method, target=('file', DJANGO1_MANAGE), @@ -44,36 +42,27 @@ def test_django_breakpoint_no_multiproc(bp_target, start_method): bp_var_content = 'Django-Django-Test' session.set_breakpoints(bp_file, [bp_line]) session.start_debugging() + with django: + home_request = django.get('home') + stop = session.wait_for_stop('breakpoint', [{ + 'id': ANY.dap_id, + 'name': bp_name, + 'source': { + 'sourceReference': ANY, + 'path': Path(bp_file), + }, + 'line': bp_line, + 'column': 1, + }]) - # wait for Django server to start - wait_for_connection(DJANGO_PORT) - web_request = get_web_content(DJANGO_LINK + 'home', {}) + scopes = session.request('scopes', arguments={'frameId': stop.frame_id}) + assert len(scopes) > 0 - hit = session.wait_for_thread_stopped() - frames = hit.stacktrace.body['stackFrames'] - assert frames[0] == { - 'id': ANY.dap_id, - 'name': bp_name, - 'source': { - 'sourceReference': ANY, - 'path': Path(bp_file), - }, - 'line': bp_line, - 'column': 1, - } - - fid = frames[0]['id'] - resp_scopes = session.send_request('scopes', arguments={ - 'frameId': fid - }).wait_for_response() - scopes = resp_scopes.body['scopes'] - assert len(scopes) > 0 - - resp_variables = session.send_request('variables', arguments={ - 'variablesReference': scopes[0]['variablesReference'] - }).wait_for_response() - variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'content') - assert variables == [{ + variables = session.request('variables', arguments={ + 'variablesReference': scopes[0]['variablesReference'] + }) + variables = [v for v in variables['variables'] if v['name'] == 'content'] + assert variables == [{ 'name': 'content', 'type': 'str', 'value': repr(bp_var_content), @@ -82,14 +71,8 @@ def test_django_breakpoint_no_multiproc(bp_target, start_method): 'variablesReference': 0, }] - session.send_request('continue').wait_for_response(freeze=False) - - web_content = web_request.wait_for_response() - assert web_content.find(bp_var_content) != -1 - - # shutdown to web server - link = DJANGO_LINK + 'exit' - get_web_content(link).wait_for_response() + session.send_continue() + assert bp_var_content in home_request.response_text() session.wait_for_exit() @@ -97,7 +80,7 @@ def test_django_breakpoint_no_multiproc(bp_target, start_method): @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline']) @pytest.mark.timeout(60) def test_django_template_exception_no_multiproc(start_method): - with DebugSession() as session: + with debug.Session() as session: session.initialize( start_method=start_method, target=('file', DJANGO1_MANAGE), @@ -112,53 +95,46 @@ def test_django_template_exception_no_multiproc(start_method): }).wait_for_response() session.start_debugging() + with django: + web_request = django.get('badtemplate') - wait_for_connection(DJANGO_PORT) - - link = DJANGO_LINK + 'badtemplate' - web_request = get_web_content(link, {}) - - hit = session.wait_for_thread_stopped(reason='exception') - frames = hit.stacktrace.body['stackFrames'] - assert frames[0] == ANY.dict_with({ - 'id': ANY.dap_id, - 'name': 'Django TemplateSyntaxError', - 'source': ANY.dict_with({ - 'sourceReference': ANY.dap_id, - 'path': Path(DJANGO1_BAD_TEMPLATE), - }), - 'line': 8, - 'column': 1, - }) - - # Will stop once in the plugin - resp_exception_info = session.send_request( - 'exceptionInfo', - arguments={'threadId': hit.thread_id, } - ).wait_for_response() - exception = resp_exception_info.body - assert exception == ANY.dict_with({ - 'exceptionId': ANY.such_that(lambda s: s.endswith('TemplateSyntaxError')), - 'breakMode': 'always', - 'description': ANY.such_that(lambda s: s.find('doesnotexist') > -1), - 'details': ANY.dict_with({ - 'message': ANY.such_that(lambda s: s.endswith('doesnotexist') > -1), - 'typeName': ANY.such_that(lambda s: s.endswith('TemplateSyntaxError')), + hit = session.wait_for_thread_stopped(reason='exception') + frames = hit.stacktrace.body['stackFrames'] + assert frames[0] == ANY.dict_with({ + 'id': ANY.dap_id, + 'name': 'Django TemplateSyntaxError', + 'source': ANY.dict_with({ + 'sourceReference': ANY.dap_id, + 'path': Path(DJANGO1_BAD_TEMPLATE), + }), + 'line': 8, + 'column': 1, }) - }) - session.send_request('continue').wait_for_response(freeze=False) + # Will stop once in the plugin + resp_exception_info = session.send_request( + 'exceptionInfo', + arguments={'threadId': hit.thread_id, } + ).wait_for_response() + exception = resp_exception_info.body + assert exception == ANY.dict_with({ + 'exceptionId': ANY.such_that(lambda s: s.endswith('TemplateSyntaxError')), + 'breakMode': 'always', + 'description': ANY.such_that(lambda s: s.find('doesnotexist') > -1), + 'details': ANY.dict_with({ + 'message': ANY.such_that(lambda s: s.endswith('doesnotexist') > -1), + 'typeName': ANY.such_that(lambda s: s.endswith('TemplateSyntaxError')), + }) + }) - # And a second time when the exception reaches the user code. - hit = session.wait_for_thread_stopped(reason='exception') - session.send_request('continue').wait_for_response(freeze=False) + session.send_request('continue').wait_for_response(freeze=False) - # ignore response for exception tests - web_request.wait_for_response() + # And a second time when the exception reaches the user code. + hit = session.wait_for_thread_stopped(reason='exception') + session.send_request('continue').wait_for_response(freeze=False) - # shutdown to web server - link = DJANGO_LINK + 'exit' - get_web_content(link).wait_for_response() + # ignore response for exception tests + web_request.wait_for_response() session.wait_for_exit() @@ -172,7 +148,7 @@ def test_django_exception_no_multiproc(ex_type, start_method): 'unhandled': 64, }[ex_type] - with DebugSession() as session: + with debug.Session() as session: session.initialize( start_method=start_method, target=('file', DJANGO1_MANAGE), @@ -250,7 +226,7 @@ def test_django_exception_no_multiproc(ex_type, start_method): @pytest.mark.timeout(120) @pytest.mark.parametrize('start_method', ['launch']) def test_django_breakpoint_multiproc(start_method): - with DebugSession() as parent_session: + with debug.Session() as parent_session: parent_session.initialize( start_method=start_method, target=('file', DJANGO1_MANAGE), diff --git a/tests/func/test_evaluate.py b/tests/ptvsd/server/test_evaluate.py similarity index 92% rename from tests/func/test_evaluate.py rename to tests/ptvsd/server/test_evaluate.py index 156cfd00..341a9100 100644 --- a/tests/func/test_evaluate.py +++ b/tests/ptvsd/server/test_evaluate.py @@ -2,21 +2,18 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import sys -from tests.helpers import get_marked_line_numbers, print -from tests.helpers.pattern import ANY -from tests.helpers.session import DebugSession +from tests import debug +from tests.patterns import some -def test_variables_and_evaluate(pyfile, run_as, start_method): - +def test_variables_and_evaluate(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa a = 1 b = {"one": 1, "two": 2} c = 3 @@ -25,7 +22,7 @@ def test_variables_and_evaluate(pyfile, run_as, start_method): bp_line = 6 bp_file = code_to_debug - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, bp_file), start_method=start_method, @@ -109,19 +106,15 @@ def test_variables_and_evaluate(pyfile, run_as, start_method): session.wait_for_exit() -def test_set_variable(pyfile, run_as, start_method): - +def test_set_variable(pyfile, start_method, run_as): @pyfile def code_to_debug(): - import backchannel - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() - import ptvsd + from debug_me import backchannel, ptvsd a = 1 ptvsd.break_into_debugger() backchannel.write_json(a) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -166,12 +159,11 @@ def test_set_variable(pyfile, run_as, start_method): session.wait_for_exit() -def test_variable_sort(pyfile, run_as, start_method): +def test_variable_sort(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa b_test = {"spam": "A", "eggs": "B", "abcd": "C"} # noqa _b_test = 12 # noqa __b_test = 13 # noqa @@ -190,7 +182,7 @@ def test_variable_sort(pyfile, run_as, start_method): bp_line = 15 bp_file = code_to_debug - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, bp_file), start_method=start_method, @@ -244,15 +236,13 @@ def test_variable_sort(pyfile, run_as, start_method): session.wait_for_exit() -def test_return_values(pyfile, run_as, start_method): +def test_return_values(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa class MyClass(object): - def do_something(self): return 'did something' @@ -284,7 +274,7 @@ def test_return_values(pyfile, run_as, start_method): }), }) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -331,22 +321,20 @@ def test_return_values(pyfile, run_as, start_method): session.wait_for_exit() -def test_unicode(pyfile, run_as, start_method): +def test_unicode(pyfile, start_method, run_as): # On Python 3, variable names can contain Unicode characters. # On Python 2, they must be ASCII, but using a Unicode character in an expression should not crash debugger. @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() - import ptvsd + from debug_me import ptvsd # Since Unicode variable name is a SyntaxError at parse time in Python 2, # this needs to do a roundabout way of setting it to avoid parse issues. globals()[u'\u16A0'] = 123 ptvsd.break_into_debugger() print('break') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -372,12 +360,10 @@ def test_unicode(pyfile, run_as, start_method): session.wait_for_exit() -def test_hex_numbers(pyfile, run_as, start_method): - +def test_hex_numbers(pyfile, start_method, run_as): @pyfile def code_to_debug(): - from dbgimporter import import_and_enable_debugger - import_and_enable_debugger() + import debug_me # noqa a = 100 b = [1, 10, 100] c = {10: 10, 100: 100, 1000: 1000} @@ -387,7 +373,7 @@ def test_hex_numbers(pyfile, run_as, start_method): line_numbers = get_marked_line_numbers(code_to_debug) print(line_numbers) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_exception.py b/tests/ptvsd/server/test_exception.py similarity index 93% rename from tests/func/test_exception.py rename to tests/ptvsd/server/test_exception.py index 0eb5c424..2d04dd07 100644 --- a/tests/func/test_exception.py +++ b/tests/ptvsd/server/test_exception.py @@ -2,19 +2,18 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import pytest -from tests.helpers import print, get_marked_line_numbers -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from tests.helpers.pattern import ANY, Path, Regex +from tests import debug +from tests.patterns import some +from tests.timeline import Event @pytest.mark.parametrize('raised', ['raisedOn', 'raisedOff']) @pytest.mark.parametrize('uncaught', ['uncaughtOn', 'uncaughtOff']) -def test_vsc_exception_options_raise_with_except(pyfile, run_as, start_method, raised, uncaught): +def test_vsc_exception_options_raise_with_except(pyfile, start_method, run_as, raised, uncaught): @pyfile def code_to_debug(): @@ -34,7 +33,7 @@ def test_vsc_exception_options_raise_with_except(pyfile, run_as, start_method, r filters = [] filters += ['raised'] if raised == 'raisedOn' else [] filters += ['uncaught'] if uncaught == 'uncaughtOn' else [] - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -78,7 +77,7 @@ def test_vsc_exception_options_raise_with_except(pyfile, run_as, start_method, r @pytest.mark.parametrize('raised', ['raisedOn', 'raisedOff']) @pytest.mark.parametrize('uncaught', ['uncaughtOn', 'uncaughtOff']) -def test_vsc_exception_options_raise_without_except(pyfile, run_as, start_method, raised, uncaught): +def test_vsc_exception_options_raise_without_except(pyfile, start_method, run_as, raised, uncaught): @pyfile def code_to_debug(): @@ -95,7 +94,7 @@ def test_vsc_exception_options_raise_without_except(pyfile, run_as, start_method filters = [] filters += ['raised'] if raised == 'raisedOn' else [] filters += ['uncaught'] if uncaught == 'uncaughtOn' else [] - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -167,7 +166,7 @@ def test_vsc_exception_options_raise_without_except(pyfile, run_as, start_method @pytest.mark.parametrize('uncaught', ['uncaught', '']) @pytest.mark.parametrize('zero', ['zero', '']) @pytest.mark.parametrize('exit_code', [0, 1, 'nan']) -def test_systemexit(pyfile, run_as, start_method, raised, uncaught, zero, exit_code): +def test_systemexit(pyfile, start_method, run_as, raised, uncaught, zero, exit_code): @pyfile def code_to_debug(): @@ -190,7 +189,7 @@ def test_systemexit(pyfile, run_as, start_method, raised, uncaught, zero, exit_c if uncaught: filters += ['uncaught'] - with DebugSession() as session: + with debug.Session() as session: session.program_args = [repr(exit_code)] if zero: session.debug_options += ['BreakOnSystemExitZero'] @@ -239,7 +238,7 @@ def test_systemexit(pyfile, run_as, start_method, raised, uncaught, zero, exit_c ['RuntimeError', 'AssertionError'], [], # Add the whole Python Exceptions category. ]) -def test_raise_exception_options(pyfile, run_as, start_method, exceptions, break_mode): +def test_raise_exception_options(pyfile, start_method, run_as, exceptions, break_mode): if break_mode in ('never', 'unhandled', 'userUnhandled'): @@ -284,7 +283,7 @@ def test_raise_exception_options(pyfile, run_as, start_method, exceptions, break line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -316,7 +315,7 @@ def test_raise_exception_options(pyfile, run_as, start_method, exceptions, break @pytest.mark.parametrize('exit_code', [0, 3]) -def test_success_exitcodes(pyfile, run_as, start_method, exit_code): +def test_success_exitcodes(pyfile, start_method, run_as, exit_code): @pyfile def code_to_debug(): @@ -327,7 +326,7 @@ def test_success_exitcodes(pyfile, run_as, start_method, exit_code): print('sys.exit(%r)' % (exit_code,)) sys.exit(exit_code) - with DebugSession() as session: + with debug.Session() as session: session.program_args = [repr(exit_code)] session.success_exitcodes = [3] session.initialize( @@ -348,7 +347,7 @@ def test_success_exitcodes(pyfile, run_as, start_method, exit_code): @pytest.mark.parametrize('max_frames', ['default', 'all', 10]) -def test_exception_stack(pyfile, run_as, start_method, max_frames): +def test_exception_stack(pyfile, start_method, run_as, max_frames): @pyfile def code_to_debug(): @@ -381,7 +380,7 @@ def test_exception_stack(pyfile, run_as, start_method, max_frames): args = {'maxExceptionStackFrames': 10} line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_exclude_rules.py b/tests/ptvsd/server/test_exclude_rules.py similarity index 86% rename from tests/func/test_exclude_rules.py rename to tests/ptvsd/server/test_exclude_rules.py index bcb350f4..3f97b2ad 100644 --- a/tests/func/test_exclude_rules.py +++ b/tests/ptvsd/server/test_exclude_rules.py @@ -2,14 +2,13 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals -from tests.helpers import print, get_marked_line_numbers -from tests.helpers.session import DebugSession -from tests.helpers.pattern import ANY, Path -from os.path import os, basename +from os import path import pytest -from tests.helpers.pathutils import get_test_root + +from tests import debug +from tests.patterns import some @pytest.mark.parametrize('scenario', [ @@ -20,7 +19,7 @@ from tests.helpers.pathutils import get_test_root 'RuntimeError', 'SysExit' ]) -def test_exceptions_and_exclude_rules(pyfile, run_as, start_method, scenario, exception_type): +def test_exceptions_and_exclude_rules(pyfile, start_method, run_as, scenario, exception_type): if exception_type == 'RuntimeError': @@ -43,13 +42,13 @@ def test_exceptions_and_exclude_rules(pyfile, run_as, start_method, scenario, ex raise AssertionError('Unexpected exception_type: %s' % (exception_type,)) if scenario == 'exclude_by_name': - rules = [{'path': '**/' + os.path.basename(code_to_debug), 'include': False}] + rules = [{'path': '**/' + path.basename(code_to_debug), 'include': False}] elif scenario == 'exclude_by_dir': rules = [{'path': os.path.dirname(code_to_debug), 'include': False}] else: raise AssertionError('Unexpected scenario: %s' % (scenario,)) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -73,7 +72,7 @@ def test_exceptions_and_exclude_rules(pyfile, run_as, start_method, scenario, ex 'exclude_code_to_debug', 'exclude_callback_dir', ]) -def test_exceptions_and_partial_exclude_rules(pyfile, run_as, start_method, scenario): +def test_exceptions_and_partial_exclude_rules(pyfile, start_method, run_as, scenario): @pyfile def code_to_debug(): @@ -99,7 +98,7 @@ def test_exceptions_and_partial_exclude_rules(pyfile, run_as, start_method, scen if scenario == 'exclude_code_to_debug': rules = [ - {'path': '**/' + os.path.basename(code_to_debug), 'include': False} + {'path': '**/' + path.basename(code_to_debug), 'include': False} ] elif scenario == 'exclude_callback_dir': rules = [ @@ -108,7 +107,7 @@ def test_exceptions_and_partial_exclude_rules(pyfile, run_as, start_method, scen else: raise AssertionError('Unexpected scenario: %s' % (scenario,)) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -154,7 +153,7 @@ def test_exceptions_and_partial_exclude_rules(pyfile, run_as, start_method, scen # Stop at handled raise_line hit = session.wait_for_thread_stopped(reason='exception') frames = hit.stacktrace.body['stackFrames'] - assert [(frame['name'], basename(frame['source']['path'])) for frame in frames] == [ + assert [(frame['name'], path.basename(frame['source']['path'])) for frame in frames] == [ ('call_func', 'code_to_debug.py'), # ('call_me_back', 'call_me_back.py'), -- filtered out ('', 'code_to_debug.py'), @@ -170,7 +169,7 @@ def test_exceptions_and_partial_exclude_rules(pyfile, run_as, start_method, scen # Stop at handled call_me_back_line hit = session.wait_for_thread_stopped(reason='exception') frames = hit.stacktrace.body['stackFrames'] - assert [(frame['name'], basename(frame['source']['path'])) for frame in frames] == [ + assert [(frame['name'], path.basename(frame['source']['path'])) for frame in frames] == [ ('', 'code_to_debug.py'), ] assert frames[0] == ANY.dict_with({ @@ -184,7 +183,7 @@ def test_exceptions_and_partial_exclude_rules(pyfile, run_as, start_method, scen # Stop at unhandled hit = session.wait_for_thread_stopped(reason='exception') frames = hit.stacktrace.body['stackFrames'] - assert [(frame['name'], basename(frame['source']['path'])) for frame in frames] == [ + assert [(frame['name'], path.basename(frame['source']['path'])) for frame in frames] == [ ('call_func', 'code_to_debug.py'), # ('call_me_back', 'call_me_back.py'), -- filtered out ('', 'code_to_debug.py'), diff --git a/tests/func/test_flask.py b/tests/ptvsd/server/test_flask.py similarity index 93% rename from tests/func/test_flask.py rename to tests/ptvsd/server/test_flask.py index d99245c8..0f94f839 100644 --- a/tests/func/test_flask.py +++ b/tests/ptvsd/server/test_flask.py @@ -2,26 +2,24 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals -import os.path import platform import pytest import sys -import tests.helpers -from tests.helpers.pattern import ANY, Path -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from tests.helpers.webhelper import get_web_content, wait_for_connection -from tests.helpers.pathutils import get_test_root +from tests import debug, net, test_data +from tests.patterns import some +from tests.timeline import Event -FLASK1_ROOT = get_test_root('flask1') -FLASK1_APP = os.path.join(FLASK1_ROOT, 'app.py') -FLASK1_TEMPLATE = os.path.join(FLASK1_ROOT, 'templates', 'hello.html') -FLASK1_BAD_TEMPLATE = os.path.join(FLASK1_ROOT, 'templates', 'bad.html') -FLASK_PORT = tests.helpers.get_unique_port(5000) -FLASK_LINK = 'http://127.0.0.1:{}/'.format(FLASK_PORT) + +FLASK1_ROOT = test_data / 'flask1' +FLASK1_APP = FLASK1_ROOT / 'app.py' +FLASK1_TEMPLATE = FLASK1_ROOT / 'templates' / 'hello.html' +FLASK1_BAD_TEMPLATE = FLASK1_ROOT / 'templates' / 'bad.html' +FLASK_PORT = net.get_test_server_port(7000, 7100) + +flask_server = net.WebServer(FLASK_PORT) def _initialize_flask_session_no_multiproc(session, start_method): @@ -58,7 +56,7 @@ def test_flask_breakpoint_no_multiproc(bp_target, start_method): 'template': (FLASK1_TEMPLATE, 8, 'template') }[bp_target] - with DebugSession() as session: + with debug.Session() as session: _initialize_flask_session_no_multiproc(session, start_method) bp_var_content = 'Flask-Jinja-Test' @@ -126,7 +124,7 @@ def test_flask_breakpoint_no_multiproc(bp_target, start_method): @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline']) @pytest.mark.timeout(60) def test_flask_template_exception_no_multiproc(start_method): - with DebugSession() as session: + with debug.Session() as session: _initialize_flask_session_no_multiproc(session, start_method) session.send_request('setExceptionBreakpoints', arguments={ @@ -191,7 +189,7 @@ def test_flask_exception_no_multiproc(ex_type, start_method): 'unhandled': 33, }[ex_type] - with DebugSession() as session: + with debug.Session() as session: _initialize_flask_session_no_multiproc(session, start_method) session.send_request('setExceptionBreakpoints', arguments={ @@ -275,7 +273,7 @@ def test_flask_breakpoint_multiproc(start_method): 'LANG': locale, }) - with DebugSession() as parent_session: + with debug.Session() as parent_session: parent_session.initialize( start_method=start_method, target=('module', 'flask'), diff --git a/tests/test_internals_filter.py b/tests/ptvsd/server/test_internals_filter.py similarity index 91% rename from tests/test_internals_filter.py rename to tests/ptvsd/server/test_internals_filter.py index 80cf3625..89f8407d 100644 --- a/tests/test_internals_filter.py +++ b/tests/ptvsd/server/test_internals_filter.py @@ -2,13 +2,18 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. +from __future__ import absolute_import, print_function, unicode_literals + import os import pytest import ptvsd from ptvsd.server.wrapper import InternalsFilter + INTERNAL_DIR = os.path.dirname(os.path.abspath(ptvsd.__file__)) + + @pytest.mark.parametrize('path', [ os.path.abspath(ptvsd.__file__), # File used by VS/VSC to launch ptvsd @@ -16,10 +21,13 @@ INTERNAL_DIR = os.path.dirname(os.path.abspath(ptvsd.__file__)) # Any file under ptvsd os.path.join(INTERNAL_DIR, 'somefile.py'), ]) + + def test_internal_paths(path): int_filter = InternalsFilter() assert int_filter.is_internal_path(path) + @pytest.mark.parametrize('path', [ __file__, os.path.join('somepath', 'somefile.py'), diff --git a/tests/func/test_justmycode.py b/tests/ptvsd/server/test_justmycode.py similarity index 85% rename from tests/func/test_justmycode.py rename to tests/ptvsd/server/test_justmycode.py index 6d70a0a2..aa0ec322 100644 --- a/tests/func/test_justmycode.py +++ b/tests/ptvsd/server/test_justmycode.py @@ -2,16 +2,16 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import pytest -from tests.helpers import print, get_marked_line_numbers -from tests.helpers.session import DebugSession -from tests.helpers.pattern import ANY, Path +from tests import debug +from tests.patterns import some + @pytest.mark.parametrize('jmc', ['jmcOn', 'jmcOff']) -def test_justmycode_frames(pyfile, run_as, start_method, jmc): +def test_justmycode_frames(pyfile, start_method, run_as, jmc): @pyfile def code_to_debug(): from dbgimporter import import_and_enable_debugger @@ -19,7 +19,7 @@ def test_justmycode_frames(pyfile, run_as, start_method, jmc): print('break here') #@bp line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_log.py b/tests/ptvsd/server/test_log.py similarity index 86% rename from tests/func/test_log.py rename to tests/ptvsd/server/test_log.py index 065c22de..549b2962 100644 --- a/tests/func/test_log.py +++ b/tests/ptvsd/server/test_log.py @@ -2,12 +2,12 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import contextlib import pytest -from tests.helpers.session import DebugSession +from tests import debug @contextlib.contextmanager @@ -20,7 +20,7 @@ def check_logs(tmpdir, session): @pytest.mark.parametrize('cli', ['arg', 'env']) -def test_log_cli(pyfile, tmpdir, run_as, start_method, cli): +def test_log_cli(pyfile, tmpdir, start_method, run_as, cli): if cli == 'arg' and start_method == 'attach_socket_import': pytest.skip() @@ -29,7 +29,7 @@ def test_log_cli(pyfile, tmpdir, run_as, start_method, cli): from dbgimporter import import_and_enable_debugger import_and_enable_debugger() - with DebugSession() as session: + with debug.Session() as session: with check_logs(tmpdir, session): if cli == 'arg': session.log_dir = str(tmpdir) @@ -47,7 +47,7 @@ def test_log_api(pyfile, tmpdir, run_as): from dbgimporter import import_and_enable_debugger import_and_enable_debugger(log_dir=str(sys.argv[1])) - with DebugSession() as session: + with debug.Session() as session: with check_logs(tmpdir, session): session.program_args += [str(tmpdir)] session.initialize(target=(run_as, code_to_debug), start_method='attach_socket_import') diff --git a/tests/func/test_multiproc.py b/tests/ptvsd/server/test_multiproc.py similarity index 94% rename from tests/func/test_multiproc.py rename to tests/ptvsd/server/test_multiproc.py index 07bb5626..d53bfad7 100644 --- a/tests/func/test_multiproc.py +++ b/tests/ptvsd/server/test_multiproc.py @@ -2,22 +2,22 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import platform import pytest import sys -from tests.helpers.pattern import ANY -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event, Request +from tests import debug +from tests.patterns import some +from tests.timeline import Event, Request @pytest.mark.timeout(30) @pytest.mark.skipif(platform.system() != 'Windows', reason='Debugging multiprocessing module only works on Windows') @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline']) -def test_multiprocessing(pyfile, run_as, start_method): +def test_multiprocessing(pyfile, start_method, run_as): @pyfile def code_to_debug(): import multiprocessing @@ -67,7 +67,7 @@ def test_multiprocessing(pyfile, run_as, start_method): q.close() backchannel.write_json('done') - with DebugSession() as parent_session: + with debug.Session() as parent_session: parent_session.initialize(multiprocess=True, target=(run_as, code_to_debug), start_method=start_method, use_backchannel=True) parent_session.start_debugging() @@ -126,7 +126,7 @@ def test_multiprocessing(pyfile, run_as, start_method): @pytest.mark.skipif(sys.version_info < (3, 0) and (platform.system() != 'Windows'), reason='Bug #935') @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline']) -def test_subprocess(pyfile, run_as, start_method): +def test_subprocess(pyfile, start_method, run_as): @pyfile def child(): import sys @@ -147,7 +147,7 @@ def test_subprocess(pyfile, run_as, start_method): process = subprocess.Popen(argv, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) process.wait() - with DebugSession() as parent_session: + with debug.Session() as parent_session: parent_session.program_args += [child] parent_session.initialize(multiprocess=True, target=(run_as, parent), start_method=start_method, use_backchannel=True) parent_session.start_debugging() @@ -185,7 +185,7 @@ def test_subprocess(pyfile, run_as, start_method): @pytest.mark.skipif(sys.version_info < (3, 0) and (platform.system() != 'Windows'), reason='Bug #935') @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline']) -def test_autokill(pyfile, run_as, start_method): +def test_autokill(pyfile, start_method, run_as): @pyfile def child(): from dbgimporter import import_and_enable_debugger @@ -206,7 +206,7 @@ def test_autokill(pyfile, run_as, start_method): subprocess.Popen(argv, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) backchannel.read_json() - with DebugSession() as parent_session: + with debug.Session() as parent_session: parent_session.program_args += [child] parent_session.initialize(multiprocess=True, target=(run_as, parent), start_method=start_method, use_backchannel=True) parent_session.start_debugging() @@ -230,7 +230,7 @@ def test_autokill(pyfile, run_as, start_method): @pytest.mark.skipif(sys.version_info < (3, 0) and (platform.system() != 'Windows'), reason='Bug #935') -def test_argv_quoting(pyfile, run_as, start_method): +def test_argv_quoting(pyfile, start_method, run_as): @pyfile def args(): # import_and_enable_debugger @@ -271,7 +271,7 @@ def test_argv_quoting(pyfile, run_as, start_method): actual_args = sys.argv[1:] backchannel.write_json(actual_args) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, parent), start_method=start_method, diff --git a/tests/func/test_output.py b/tests/ptvsd/server/test_output.py similarity index 84% rename from tests/func/test_output.py rename to tests/ptvsd/server/test_output.py index b3b0e0b0..8e68eef6 100644 --- a/tests/func/test_output.py +++ b/tests/ptvsd/server/test_output.py @@ -2,16 +2,16 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import pytest -from tests.helpers import get_marked_line_numbers -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from tests.helpers.pattern import ANY + +from tests import debug +from tests.patterns import some +from tests.timeline import Event -def test_with_no_output(pyfile, run_as, start_method): +def test_with_no_output(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -19,7 +19,7 @@ def test_with_no_output(pyfile, run_as, start_method): import_and_enable_debugger() # Do nothing, and check if there is any output - with DebugSession() as session: + with debug.Session() as session: session.initialize(target=(run_as, code_to_debug), start_method=start_method) session.start_debugging() session.wait_for_exit() @@ -27,7 +27,7 @@ def test_with_no_output(pyfile, run_as, start_method): assert b'' == session.get_stderr_as_string() -def test_with_tab_in_output(pyfile, run_as, start_method): +def test_with_tab_in_output(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -39,7 +39,7 @@ def test_with_tab_in_output(pyfile, run_as, start_method): a = 1 # @bp1 line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -59,7 +59,7 @@ def test_with_tab_in_output(pyfile, run_as, start_method): @pytest.mark.parametrize('redirect', ['RedirectOutput', '']) -def test_redirect_output(pyfile, run_as, start_method, redirect): +def test_redirect_output(pyfile, start_method, run_as, redirect): @pyfile def code_to_debug(): from dbgimporter import import_and_enable_debugger @@ -71,7 +71,7 @@ def test_redirect_output(pyfile, run_as, start_method, redirect): print() # @bp1 line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: # By default 'RedirectOutput' is always set. So using this way # to override the default in session. session.debug_options = [redirect] if bool(redirect) else [] diff --git a/tests/test_parse_args.py b/tests/ptvsd/server/test_parse_args.py similarity index 74% rename from tests/test_parse_args.py rename to tests/ptvsd/server/test_parse_args.py index 4173c54f..faba7ba4 100644 --- a/tests/test_parse_args.py +++ b/tests/ptvsd/server/test_parse_args.py @@ -2,17 +2,14 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. +from __future__ import absolute_import, print_function, unicode_literals + import pytest -try: - from importlib import reload -except ImportError: - pass +from ptvsd.common.compat import reload +from ptvsd.server import options, __main__ +from tests.patterns import some -import ptvsd.server.options -from ptvsd.server.__main__ import parse - -from tests.helpers.pattern import ANY EXPECTED_EXTRA = ['--'] @@ -55,10 +52,10 @@ def test_targets(target_kind, client, wait, nodebug, multiproc, extra): extra = [] print(args) - reload(ptvsd.server.options) - rest = parse(args) + reload(options) + rest = __main__.parse(args) assert list(rest) == extra - assert vars(ptvsd.server.options) == ANY.dict_with({ + assert vars(options) == ANY.dict_with({ 'target_kind': target_kind, 'target': target, 'host': 'localhost', @@ -70,9 +67,9 @@ def test_targets(target_kind, client, wait, nodebug, multiproc, extra): def test_unsupported_arg(): - reload(ptvsd.server.options) + reload(options) with pytest.raises(Exception): - parse([ + __main__.parse([ '--port', '8888', '--xyz', '123', 'spam.py', @@ -80,21 +77,21 @@ def test_unsupported_arg(): def test_host_required(): - reload(ptvsd.server.options) + reload(options) with pytest.raises(Exception): - parse([ + __main__.parse([ '--port', '8888', '-m', 'spam', ]) def test_host_empty(): - reload(ptvsd.server.options) - parse(['--host', '', '--port', '8888', 'spam.py']) - assert ptvsd.server.options.host == '' + reload(options) + __main__.parse(['--host', '', '--port', '8888', 'spam.py']) + assert options.host == '' def test_port_default(): - reload(ptvsd.server.options) - parse(['--host', 'localhost', 'spam.py']) - assert ptvsd.server.options.port == 5678 + reload(options) + __main__.parse(['--host', 'localhost', 'spam.py']) + assert options.port == 5678 diff --git a/tests/func/test_path_mapping.py b/tests/ptvsd/server/test_path_mapping.py similarity index 88% rename from tests/func/test_path_mapping.py rename to tests/ptvsd/server/test_path_mapping.py index e889f5fc..1f0c54d0 100644 --- a/tests/func/test_path_mapping.py +++ b/tests/ptvsd/server/test_path_mapping.py @@ -2,22 +2,21 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import os -import traceback -from shutil import copyfile -from tests.helpers.pattern import Path -from tests.helpers.session import DebugSession -from tests.helpers.pathutils import get_test_root -from tests.helpers import get_marked_line_numbers import pytest +import shutil import sys +import traceback + +from tests import debug +from tests.patterns import some @pytest.mark.skipif(sys.platform == 'win32', reason='Linux/Mac only test.') @pytest.mark.parametrize('invalid_os_type', [True]) -def test_client_ide_from_path_mapping_linux_backend(pyfile, tmpdir, run_as, start_method, invalid_os_type): +def test_client_ide_from_path_mapping_linux_backend(pyfile, tmpdir, start_method, run_as, invalid_os_type): ''' Test simulating that the backend is on Linux and the client is on Windows (automatically detect it from the path mapping). @@ -32,7 +31,7 @@ def test_client_ide_from_path_mapping_linux_backend(pyfile, tmpdir, run_as, star backchannel.write_json({'ide_os': pydevd_file_utils._ide_os}) print('done') # @break_here - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -58,7 +57,7 @@ def test_client_ide_from_path_mapping_linux_backend(pyfile, tmpdir, run_as, star session.wait_for_exit() -def test_with_dot_remote_root(pyfile, tmpdir, run_as, start_method): +def test_with_dot_remote_root(pyfile, tmpdir, start_method, run_as): @pyfile def code_to_debug(): @@ -76,10 +75,10 @@ def test_with_dot_remote_root(pyfile, tmpdir, run_as, start_method): dir_local = os.path.dirname(path_local) dir_remote = os.path.dirname(path_remote) - copyfile(code_to_debug, path_local) - copyfile(code_to_debug, path_remote) + shutil.copyfile(code_to_debug, path_local) + shutil.copyfile(code_to_debug, path_remote) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, path_remote), start_method=start_method, @@ -105,7 +104,7 @@ def test_with_dot_remote_root(pyfile, tmpdir, run_as, start_method): session.wait_for_exit() -def test_with_path_mappings(pyfile, tmpdir, run_as, start_method): +def test_with_path_mappings(pyfile, tmpdir, start_method, run_as): @pyfile def code_to_debug(): @@ -134,12 +133,12 @@ def test_with_path_mappings(pyfile, tmpdir, run_as, start_method): dir_local = os.path.dirname(path_local) dir_remote = os.path.dirname(path_remote) - copyfile(code_to_debug, path_local) - copyfile(code_to_debug, path_remote) + shutil.copyfile(code_to_debug, path_local) + shutil.copyfile(code_to_debug, path_remote) call_me_back_dir = get_test_root('call_me_back') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, path_remote), start_method=start_method, diff --git a/tests/ptvsd/server/test_run.py b/tests/ptvsd/server/test_run.py new file mode 100644 index 00000000..748d6850 --- /dev/null +++ b/tests/ptvsd/server/test_run.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +from os import path +import pytest +import re + +import ptvsd +from tests import debug, test_data +from tests.patterns import some +from tests.timeline import Event + + +@pytest.mark.parametrize('run_as', ['file', 'module', 'code']) +def test_run(pyfile, start_method, run_as): + @pyfile + def code_to_debug(): + from debug_me import backchannel + from os import path + import sys + + print('begin') + assert backchannel.receive() == 'continue' + backchannel.send(path.abspath(sys.modules['ptvsd'].__file__)) + print('end') + + with debug.Session(start_method) as session: + backchannel = session.setup_backchannel() + session.initialize(target=(run_as, code_to_debug)) + session.start_debugging() + assert session.timeline.is_frozen + + process_event, = session.all_occurrences_of(Event('process')) + expected_name = ( + '-c' if run_as == 'code' + else some.str.matching(re.escape(code_to_debug) + r'(c|o)?$') + ) + assert process_event == Event('process', some.dict.containing({ + 'name': expected_name + })) + + backchannel.send('continue') + ptvsd_path = backchannel.receive() + expected_ptvsd_path = path.abspath(ptvsd.__file__) + assert re.match(re.escape(expected_ptvsd_path) + r'(c|o)?$', ptvsd_path) + + session.wait_for_exit() + + +def test_run_submodule(): + cwd = str(test_data / 'testpkgs') + with debug.Session('launch') as session: + session.initialize(target=('module', 'pkg1.sub'), cwd=cwd) + session.start_debugging() + session.wait_for_next(Event('output', some.dict.containing({ + 'category': 'stdout', + 'output': 'three' + }))) + session.wait_for_exit() + + +@pytest.mark.parametrize('run_as', ['file', 'module', 'code']) +def test_nodebug(pyfile, run_as): + @pyfile + def code_to_debug(): + from debug_me import backchannel + backchannel.receive() #@ bp1 + print('ok') #@ bp2 + + with debug.Session('launch') as session: + session.no_debug = True + backchannel = session.setup_backchannel() + session.initialize(target=(run_as, code_to_debug)) + + breakpoints = session.set_breakpoints(code_to_debug, [ + code_to_debug.lines["bp1"], + code_to_debug.lines["bp2"], + ]) + assert breakpoints == [ + {'verified': False}, + {'verified': False}, + ] + + session.start_debugging() + backchannel.send(None) + + # Breakpoint shouldn't be hit. + session.wait_for_exit() + + session.expect_realized(Event('output', some.dict.containing({ + 'category': 'stdout', + 'output': 'ok', + }))) + + +@pytest.mark.parametrize('run_as', ['script', 'module']) +def test_run_vs(pyfile, run_as): + @pyfile + def code_to_debug(): + from debug_me import backchannel + print('ok') + backchannel.send('ok') + + @pyfile + def ptvsd_launcher(): + from debug_me import backchannel + import ptvsd.debugger + + args = tuple(backchannel.receive()) + print('debug{0!r}'.format(args)) + ptvsd.debugger.debug(*args) + + filename = 'code_to_debug' if run_as == 'module' else code_to_debug + with debug.Session('custom_client') as session: + backchannel = session.setup_backchannel() + + session.before_connect = lambda: backchannel.send([ + filename, session.ptvsd_port, None, None, run_as + ]) + + session.initialize(target=('file', ptvsd_launcher)) + session.start_debugging() + + assert backchannel.receive() == 'ok' + session.wait_for_exit() diff --git a/tests/func/test_set_expression.py b/tests/ptvsd/server/test_set_expression.py similarity index 81% rename from tests/func/test_set_expression.py rename to tests/ptvsd/server/test_set_expression.py index 3ca02180..9d082168 100644 --- a/tests/func/test_set_expression.py +++ b/tests/ptvsd/server/test_set_expression.py @@ -1,10 +1,14 @@ -from __future__ import print_function, with_statement, absolute_import +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. -from tests.helpers.pattern import ANY -from tests.helpers.session import DebugSession +from __future__ import absolute_import, print_function, unicode_literals + +from tests import debug +from tests.patterns import some -def test_set_expression(pyfile, run_as, start_method): +def test_set_expression(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -16,7 +20,7 @@ def test_set_expression(pyfile, run_as, start_method): ptvsd.break_into_debugger() backchannel.write_json(a) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_start_stop.py b/tests/ptvsd/server/test_start_stop.py similarity index 89% rename from tests/func/test_start_stop.py rename to tests/ptvsd/server/test_start_stop.py index b096f8a2..55383d3e 100644 --- a/tests/func/test_start_stop.py +++ b/tests/ptvsd/server/test_start_stop.py @@ -2,20 +2,20 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import platform import pytest import sys -from tests.helpers.pattern import ANY -from tests.helpers.session import DebugSession +from tests import debug +from tests.patterns import some @pytest.mark.parametrize('start_method', ['launch']) @pytest.mark.skipif(sys.version_info < (3, 0) and platform.system() == 'Windows', reason="On Win32 Python2.7, unable to send key strokes to test.") -def test_wait_on_normal_exit_enabled(pyfile, run_as, start_method): +def test_wait_on_normal_exit_enabled(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -26,7 +26,7 @@ def test_wait_on_normal_exit_enabled(pyfile, run_as, start_method): ptvsd.break_into_debugger() backchannel.write_json('done') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -55,7 +55,7 @@ def test_wait_on_normal_exit_enabled(pyfile, run_as, start_method): @pytest.mark.parametrize('start_method', ['launch']) @pytest.mark.skipif(sys.version_info < (3, 0) and platform.system() == 'Windows', reason="On windows py2.7 unable to send key strokes to test.") -def test_wait_on_abnormal_exit_enabled(pyfile, run_as, start_method): +def test_wait_on_abnormal_exit_enabled(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -68,7 +68,7 @@ def test_wait_on_abnormal_exit_enabled(pyfile, run_as, start_method): backchannel.write_json('done') sys.exit(12345) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -98,7 +98,7 @@ def test_wait_on_abnormal_exit_enabled(pyfile, run_as, start_method): @pytest.mark.parametrize('start_method', ['launch']) -def test_exit_normally_with_wait_on_abnormal_exit_enabled(pyfile, run_as, start_method): +def test_exit_normally_with_wait_on_abnormal_exit_enabled(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -109,7 +109,7 @@ def test_exit_normally_with_wait_on_abnormal_exit_enabled(pyfile, run_as, start_ ptvsd.break_into_debugger() backchannel.write_json('done') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_step.py b/tests/ptvsd/server/test_step.py similarity index 87% rename from tests/func/test_step.py rename to tests/ptvsd/server/test_step.py index 471fc1d8..38357a51 100644 --- a/tests/func/test_step.py +++ b/tests/ptvsd/server/test_step.py @@ -2,17 +2,16 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import absolute_import, print_function, with_statement, unicode_literals +from __future__ import absolute_import, print_function, unicode_literals import pytest -from tests.helpers import get_marked_line_numbers, print -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from tests.helpers.pattern import ANY +from tests import debug +from tests.patterns import some +from tests.timeline import Event -def test_set_next_statement(pyfile, run_as, start_method): +def test_set_next_statement(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -29,7 +28,7 @@ def test_set_next_statement(pyfile, run_as, start_method): line_numbers = get_marked_line_numbers(code_to_debug) print(line_numbers) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_stop_on_entry.py b/tests/ptvsd/server/test_stop_on_entry.py similarity index 86% rename from tests/func/test_stop_on_entry.py rename to tests/ptvsd/server/test_stop_on_entry.py index e2ccf799..5bcff453 100644 --- a/tests/func/test_stop_on_entry.py +++ b/tests/ptvsd/server/test_stop_on_entry.py @@ -2,17 +2,17 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals -from tests.helpers import get_marked_line_numbers -from tests.helpers.pattern import Path -from tests.helpers.session import DebugSession import pytest +from tests import debug +from tests.patterns import some + @pytest.mark.parametrize('start_method', ['launch']) @pytest.mark.parametrize('with_bp', ['with_breakpoint', '']) -def test_stop_on_entry(pyfile, run_as, start_method, with_bp): +def test_stop_on_entry(pyfile, start_method, run_as, with_bp): @pyfile def code_to_debug(): @@ -20,7 +20,7 @@ def test_stop_on_entry(pyfile, run_as, start_method, with_bp): # import_and_enable_debugger() backchannel.write_json('done') - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_threads.py b/tests/ptvsd/server/test_threads.py similarity index 90% rename from tests/func/test_threads.py rename to tests/ptvsd/server/test_threads.py index ee05ac01..8ffc2c61 100644 --- a/tests/func/test_threads.py +++ b/tests/ptvsd/server/test_threads.py @@ -2,17 +2,16 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import platform import pytest -from tests.helpers import get_marked_line_numbers -from tests.helpers.session import DebugSession +from tests import debug @pytest.mark.parametrize('count', [1, 3]) -def test_thread_count(pyfile, run_as, start_method, count): +def test_thread_count(pyfile, start_method, run_as, count): @pyfile def code_to_debug(): @@ -40,7 +39,7 @@ def test_thread_count(pyfile, run_as, start_method, count): stop = True line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -60,7 +59,7 @@ def test_thread_count(pyfile, run_as, start_method, count): @pytest.mark.skipif( platform.system() not in ['Windows', 'Linux', 'Darwin'], reason='Test not implemented on ' + platform.system()) -def test_debug_this_thread(pyfile, run_as, start_method): +def test_debug_this_thread(pyfile, start_method, run_as): @pyfile def code_to_debug(): @@ -97,7 +96,7 @@ def test_debug_this_thread(pyfile, run_as, start_method): line_numbers = get_marked_line_numbers(code_to_debug) - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, diff --git a/tests/func/test_vs_specific.py b/tests/ptvsd/server/test_vs_specific.py similarity index 87% rename from tests/func/test_vs_specific.py rename to tests/ptvsd/server/test_vs_specific.py index 9c03ee99..d0b24c59 100644 --- a/tests/func/test_vs_specific.py +++ b/tests/ptvsd/server/test_vs_specific.py @@ -2,17 +2,18 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import pytest -from tests.helpers.session import DebugSession -from tests.helpers.timeline import Event -from tests.helpers.pattern import Path + +from tests import debug +from tests.patterns import some +from tests.timeline import Event @pytest.mark.parametrize('module', [True, False]) @pytest.mark.parametrize('line', [True, False]) -def test_stack_format(pyfile, run_as, start_method, module, line): +def test_stack_format(pyfile, start_method, run_as, module, line): @pyfile def code_to_debug(): from dbgimporter import import_and_enable_debugger @@ -27,7 +28,7 @@ def test_stack_format(pyfile, run_as, start_method, module, line): print('break here') bp_line = 3 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, code_to_debug), start_method=start_method, @@ -52,7 +53,7 @@ def test_stack_format(pyfile, run_as, start_method, module, line): session.wait_for_exit() -def test_module_events(pyfile, run_as, start_method): +def test_module_events(pyfile, start_method, run_as): @pyfile def module2(): # import_and_enable_debugger() @@ -74,7 +75,7 @@ def test_module_events(pyfile, run_as, start_method): do_something() bp_line = 3 - with DebugSession() as session: + with debug.Session() as session: session.initialize( target=(run_as, test_code), start_method=start_method, diff --git a/tests/pydevd_log.py b/tests/pydevd_log.py new file mode 100644 index 00000000..68fb19c2 --- /dev/null +++ b/tests/pydevd_log.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +import contextlib +import os + + +@contextlib.contextmanager +def enabled(filename): + os.environ['PYDEVD_DEBUG'] = 'True' + os.environ['PYDEVD_DEBUG_FILE'] = filename + + yield + + del os.environ['PYDEVD_DEBUG'] + del os.environ['PYDEVD_DEBUG_FILE'] + + +def dump(why): + assert why + + pydevd_debug_file = os.environ.get('PYDEVD_DEBUG_FILE') + if not pydevd_debug_file: + return + + try: + f = open(pydevd_debug_file) + except Exception: + print('Test {0}, but no ptvsd log found'.format(why)) + return + + with f: + print('Test {0}; dumping pydevd log:'.format(why)) + print(f.read()) diff --git a/tests/pytest_fixtures.py b/tests/pytest_fixtures.py new file mode 100644 index 00000000..6bd62290 --- /dev/null +++ b/tests/pytest_fixtures.py @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +import inspect +import os +import platform +import pytest +import tempfile +import threading +import types + +from tests import code, pydevd_log + +__all__ = ['run_as', 'start_method', 'with_pydevd_log', 'daemon', 'pyfile'] + + +# Set up the test matrix for various code types and attach methods. Most tests will +# use both run_as and start_method, so the matrix is a cross product of them. + +RUN_AS = ['file'] +START_METHODS = ['launch'] + +if os.environ.get('PTVSD_SIMPLE_TESTS', '').lower() not in ('1', 'true'): + RUN_AS += ['module'] + START_METHODS += ['attach_socket_cmdline'] + #START_METHODS += ['attach_pid'] + if platform.system() == 'Windows': + START_METHODS += ['attach_socket_import'] + + +@pytest.fixture(params=RUN_AS) +def run_as(request): + return request.param + + +@pytest.fixture(params=START_METHODS) +def start_method(request): + return request.param + + +@pytest.fixture(autouse=True) +def with_pydevd_log(request, tmpdir): + """Enables pydevd logging during the test run, and dumps the log if the test fails. + """ + + prefix = 'pydevd_debug_file-{0}'.format(os.getpid()) + filename = tempfile.mktemp(suffix='.log', prefix=prefix, dir=str(tmpdir)) + + with pydevd_log.enabled(filename): + yield + + if request.node.setup_result.passed: + if not request.node.call_result.failed: + return + elif not request.node.setup_result.failed: + return + + pydevd_log.dump("failed") + + +@pytest.fixture +def daemon(request): + """Provides a factory function for daemon threads. The returned thread is + started immediately, and it must not be alive by the time the test returns. + """ + + daemons = [] + + def factory(func, name_suffix=''): + name = func.__name__ + name_suffix + thread = threading.Thread(target=func, name=name) + thread.daemon = True + daemons.append(thread) + thread.start() + return thread + + yield factory + + try: + failed = request.node.call_result.failed + except AttributeError: + pass + else: + if not failed: + for thread in daemons: + assert not thread.is_alive() + + +@pytest.fixture +def pyfile(request, tmpdir): + """A fixture providing a factory function that generates .py files. + + The returned factory takes a single function with an empty argument list, + generates a temporary file that contains the code corresponding to the + function body, and returns the full path to the generated file. Idiomatic + use is as a decorator, e.g.: + + @pyfile + def script_file(): + print('fizz') + print('buzz') + + will produce a temporary file named script_file.py containing: + + print('fizz') + print('buzz') + + and the variable script_file will contain the path to that file. + + In order for the factory to be able to extract the function body properly, + function header ("def") must all be on a single line, with nothing after + the colon but whitespace. + + Note that because the code is physically in a separate file when it runs, + it cannot reuse top-level module imports - it must import all the modules + that it uses locally. When linter complains, use #noqa. + + The returned object is a subclass of str that has an additional attribute "lines". + After the source is writen to disk, tests.code.get_marked_line_numbers() is + invoked on the resulting file to compute the value of that attribute. + """ + + def factory(source): + assert isinstance(source, types.FunctionType) + name = source.__name__ + source, _ = inspect.getsourcelines(source) + + # First, find the "def" line. + def_lineno = 0 + for line in source: + line = line.strip() + if line.startswith('def') and line.endswith(':'): + break + def_lineno += 1 + else: + raise ValueError('Failed to locate function header.') + + # Remove everything up to and including "def". + source = source[def_lineno + 1:] + assert source + + # Now we need to adjust indentation. Compute how much the first line of + # the body is indented by, then dedent all lines by that amount. Blank + # lines don't matter indentation-wise, and might not be indented to begin + # with, so just replace them with a simple newline. + line = source[0] + indent = len(line) - len(line.lstrip()) + source = [l[indent:] if l.strip() else '\n' for l in source] + + # Write it to file. + source = ''.join(source) + tmpfile = tmpdir.join(name + '.py') + assert not tmpfile.check() + tmpfile.write(source) + + class PyFile(str): + lines = code.get_marked_line_numbers(tmpfile.strpath) + + return PyFile(tmpfile.strpath) + + return factory diff --git a/tests/pytest_hooks.py b/tests/pytest_hooks.py new file mode 100644 index 00000000..d7301314 --- /dev/null +++ b/tests/pytest_hooks.py @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +import multiprocessing +import os +from os import path +import pytest +import pytest_timeout +import site # noqa +import sys +import sysconfig +import threading # noqa + +from ptvsd.common import fmt, log, timestamp + + +def pytest_report_header(config): + result = ["Test environment:\n\n"] + + def report(*args, **kwargs): + result.append(fmt(*args, **kwargs)) + + def report_paths(expr, label=None): + prefix = fmt(" {0}: ", label or expr) + + try: + paths = expr() if callable(expr) else eval(expr, globals()) + except AttributeError: + report("{0}\n", prefix) + return + + if not isinstance(paths, (list, tuple)): + paths = [paths] + + for p in sorted(paths): + report("{0}{1}", prefix, p) + rp = path.realpath(p) + if p != rp: + report("({0})", rp) + report("\n") + + prefix = " " * len(prefix) + + report("CPU count: {0}\n\n", multiprocessing.cpu_count()) + report("System paths:\n") + report_paths("sys.prefix") + report_paths("sys.base_prefix") + report_paths("sys.real_prefix") + report_paths("site.getsitepackages()") + report_paths("site.getusersitepackages()") + + site_packages = [ + p for p in sys.path + if os.path.exists(p) and os.path.basename(p) == 'site-packages' + ] + report_paths(lambda: site_packages, "sys.path (site-packages)") + + for name in sysconfig.get_path_names(): + expr = fmt("sysconfig.get_path({0!r})", name) + report_paths(expr) + + report_paths("os.__file__") + report_paths("threading.__file__") + + result = "".join(result).rstrip("\n") + log.info("{0}", result) + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_runtest_makereport(item, call): + # Adds attributes such as setup_result, call_result etc to the item after the + # corresponding scope finished running its tests. This can be used in function-level + # fixtures to detect failures, e.g.: + # + # if request.node.call_result.failed: ... + + outcome = yield + result = outcome.get_result() + setattr(item, result.when + '_result', result) + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_pyfunc_call(pyfuncitem): + # Resets the timestamp to zero for every new test. + timestamp.reset() + yield + + +# If a test times out and pytest tries to print the stacks of where it was hanging, +# we want to print the pydevd log as well. This is not a normal pytest hook - we +# just detour pytest_timeout.dump_stacks directly. + +def print_pydevd_log(what): + assert what + + pydevd_debug_file = os.environ.get('PYDEVD_DEBUG_FILE') + if not pydevd_debug_file: + return + + try: + f = open(pydevd_debug_file) + except Exception: + print('Test {0}, but no ptvsd log found'.format(what)) + return + + with f: + print('Test {0}; dumping pydevd log:'.format(what)) + print(f.read()) + + +def dump_stacks_and_print_pydevd_log(): + print_pydevd_log('timed out') + dump_stacks() + + +dump_stacks = pytest_timeout.dump_stacks +pytest_timeout.dump_stacks = dump_stacks_and_print_pydevd_log diff --git a/tests/requirements.txt b/tests/requirements.txt index ca1a9b93..ebcbdf64 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,10 +1,20 @@ -colorama -django -requests -flask -psutil -pygments +## Used to run the tests: + +# pytest>=5 does not support Python 2.7 pytest<5 + pytest-timeout pytest-xdist tox + +## Used by test helpers: + +colorama +psutil +pygments + +## Used in Python code that is run/debugged by the tests: + +django +flask +requests diff --git a/tests/stacks.py b/tests/stacks.py new file mode 100644 index 00000000..37d2804e --- /dev/null +++ b/tests/stacks.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Provides facilities to dump all stacks of all threads in the process. +""" + +import os +import sys +import threading +import traceback + +from ptvsd.common import log + + +def dump(): + """Dump the stacks of all threads except the current thread. + """ + + tid = threading.current_thread().ident + pid = os.getpid() + + log.info("Dumping stacks for process {0}...", pid) + + for t_ident, frame in sys._current_frames().items(): + if t_ident == tid: + continue + + for t in threading.enumerate(): + if t.ident == tid: + t_name = t.name + t_daemon = t.daemon + break + else: + t_name = t_daemon = "" + + stack = ''.join(traceback.format_stack(frame)) + log.info( + "Stack of thread {0} (tid={1}, pid={2}, daemon={3}):\n\n{4}", + t_name, + t_ident, + pid, + t_daemon, + stack, + ) + + log.info("Finished dumping stacks for process {0}.", pid) + + +def dump_after(secs): + """Invokes dump() on a background thread after waiting for the specified time. + + Can be called from debugged code before the point after which it hangs, + to determine the cause of the hang when debugging a test. + """ + + def dumper(): + time.sleep(secs) + dump_stacks() + + thread = threading.Thread(target=dumper) + thread.daemon = True + thread.start() diff --git a/tests/test_data/NOT_A_PACKAGE b/tests/test_data/NOT_A_PACKAGE new file mode 100644 index 00000000..fd0738d3 --- /dev/null +++ b/tests/test_data/NOT_A_PACKAGE @@ -0,0 +1,3 @@ +This is not a subpackage! + +It's a folder for scripts and other data files that are used by tests. diff --git a/tests/test_data/_PYTHONPATH/NOT_A_PACKAGE b/tests/test_data/_PYTHONPATH/NOT_A_PACKAGE new file mode 100644 index 00000000..aa27e331 --- /dev/null +++ b/tests/test_data/_PYTHONPATH/NOT_A_PACKAGE @@ -0,0 +1,10 @@ +This is not a subpackage! + +PYTHONPATH has an entry for this directory automatically appended for all Python code +that is executed via tests.debug.Session. + +Thus, it should be used for modules that are meant to be importable by such debugged +code, and that are not test-specific - for example, backchannel. + +Because this code runs in the debuggee process, it cannot import anything from the +top-level tests package. It can, however, import ptvsd and pydevd. diff --git a/tests/test_data/_PYTHONPATH/backchannel.py b/tests/test_data/_PYTHONPATH/backchannel.py new file mode 100644 index 00000000..f46745a2 --- /dev/null +++ b/tests/test_data/_PYTHONPATH/backchannel.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Imported from test code that runs under ptvsd, and allows that code +to communcate back to the test. Works in conjunction with debug_session +fixture and its backchannel method.""" + +__all__ = ["port", "receive", "send"] + +import atexit +import os +import socket +import sys + +assert "debug_me" in sys.modules +import debug_me + +from ptvsd.common import fmt, messaging + + +port = int(os.getenv('PTVSD_BACKCHANNEL_PORT', 0)) +if port: + print(fmt('Connecting backchannel-{0} to port {1}...', debug_me.session_id, port)) + + _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + _socket.connect(('localhost', port)) + _stream = messaging.JsonIOStream.from_socket(_socket, name='backchannel') + + receive = _stream.read_json + send = _stream.write_json + + @atexit.register + def _atexit_handler(): + print(fmt('Shutting down backchannel-{0}...', debug_me.session_id)) + try: + _socket.shutdown(socket.SHUT_RDWR) + except Exception: + pass diff --git a/tests/test_data/_PYTHONPATH/debug_me.py b/tests/test_data/_PYTHONPATH/debug_me.py new file mode 100644 index 00000000..1e762688 --- /dev/null +++ b/tests/test_data/_PYTHONPATH/debug_me.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Makes sure that the code is run under debugger, using the appropriate method +to establish connection back to DebugSession in the test process, depending on +DebugSession.start_method used by the test. + +This module MUST be imported by all code that is executed via DebugSession, unless +it is launched with start_method="custom_client", for tests that need to set up +ptvsd and establish the connection themselves in some special manner. + +If the code needs to access ptvsd and/or pydevd, this module additionally exports +both as global variables, specifically so that it is possible to write:: + + from debug_me import ptvsd, pydevd, backchannel +""" + +__all__ = ["backchannel", "ptvsd", "pydevd", "session_id"] + +import os + +# Needs to be set before backchannel can set things up. +session_id = int(os.getenv('PTVSD_SESSION_ID')) + +# For `from debug_me import ...`. +import backchannel # noqa +import ptvsd # noqa +import pydevd # noqa + + +# For all start methods except for "attach_socket_import", DebugSession itself +# will take care of starting the debuggee process correctly. + +# For "attach_socket_import", DebugSession will supply the code that needs to +# be executed in the debuggee to enable debugging and establish connection back +# to DebugSession - the debuggee simply needs to execute it as is. +_code = os.getenv("PTVSD_DEBUG_ME") +if _code: + _code = compile(_code, "", "exec") + eval(_code, {}) diff --git a/tests/func/testfiles/attach/attach1.py b/tests/test_data/attach/attach1.py similarity index 100% rename from tests/func/testfiles/attach/attach1.py rename to tests/test_data/attach/attach1.py diff --git a/tests/test_data/bp/a&b/test.py b/tests/test_data/bp/a&b/test.py new file mode 100644 index 00000000..556d2e07 --- /dev/null +++ b/tests/test_data/bp/a&b/test.py @@ -0,0 +1,4 @@ +import debug_me # noqa +print('one') # @one +print('two') # @two +print('three') # @three diff --git a/tests/func/testfiles/bp/ನನ್ನ_ಸ್ಕ್ರಿಪ್ಟ್.py b/tests/test_data/bp/ನನ್ನ_ಸ್ಕ್ರಿಪ್ಟ್.py similarity index 100% rename from tests/func/testfiles/bp/ನನ್ನ_ಸ್ಕ್ರಿಪ್ಟ್.py rename to tests/test_data/bp/ನನ್ನ_ಸ್ಕ್ರಿಪ್ಟ್.py diff --git a/tests/func/testfiles/__init__.py b/tests/test_data/call_me_back/__init__.py similarity index 100% rename from tests/func/testfiles/__init__.py rename to tests/test_data/call_me_back/__init__.py diff --git a/tests/func/testfiles/call_me_back/call_me_back.py b/tests/test_data/call_me_back/call_me_back.py similarity index 100% rename from tests/func/testfiles/call_me_back/call_me_back.py rename to tests/test_data/call_me_back/call_me_back.py diff --git a/tests/func/testfiles/call_me_back/__init__.py b/tests/test_data/django1/__init__.py similarity index 100% rename from tests/func/testfiles/call_me_back/__init__.py rename to tests/test_data/django1/__init__.py diff --git a/tests/func/testfiles/django1/app.py b/tests/test_data/django1/app.py similarity index 100% rename from tests/func/testfiles/django1/app.py rename to tests/test_data/django1/app.py diff --git a/tests/func/testfiles/django1/templates/bad.html b/tests/test_data/django1/templates/bad.html similarity index 100% rename from tests/func/testfiles/django1/templates/bad.html rename to tests/test_data/django1/templates/bad.html diff --git a/tests/func/testfiles/django1/templates/hello.html b/tests/test_data/django1/templates/hello.html similarity index 100% rename from tests/func/testfiles/django1/templates/hello.html rename to tests/test_data/django1/templates/hello.html diff --git a/tests/func/testfiles/django1/__init__.py b/tests/test_data/flask1/__init__.py similarity index 100% rename from tests/func/testfiles/django1/__init__.py rename to tests/test_data/flask1/__init__.py diff --git a/tests/func/testfiles/flask1/app.py b/tests/test_data/flask1/app.py similarity index 100% rename from tests/func/testfiles/flask1/app.py rename to tests/test_data/flask1/app.py diff --git a/tests/func/testfiles/flask1/templates/bad.html b/tests/test_data/flask1/templates/bad.html similarity index 100% rename from tests/func/testfiles/flask1/templates/bad.html rename to tests/test_data/flask1/templates/bad.html diff --git a/tests/func/testfiles/flask1/templates/hello.html b/tests/test_data/flask1/templates/hello.html similarity index 100% rename from tests/func/testfiles/flask1/templates/hello.html rename to tests/test_data/flask1/templates/hello.html diff --git a/tests/func/testfiles/flask1/__init__.py b/tests/test_data/testpkgs/pkg1/__init__.py similarity index 100% rename from tests/func/testfiles/flask1/__init__.py rename to tests/test_data/testpkgs/pkg1/__init__.py diff --git a/tests/func/testfiles/testpkgs/pkg1/__main__.py b/tests/test_data/testpkgs/pkg1/__main__.py similarity index 100% rename from tests/func/testfiles/testpkgs/pkg1/__main__.py rename to tests/test_data/testpkgs/pkg1/__main__.py diff --git a/tests/func/testfiles/testpkgs/pkg1/__init__.py b/tests/test_data/testpkgs/pkg1/sub/__init__.py similarity index 100% rename from tests/func/testfiles/testpkgs/pkg1/__init__.py rename to tests/test_data/testpkgs/pkg1/sub/__init__.py diff --git a/tests/func/testfiles/testpkgs/pkg1/sub/__main__.py b/tests/test_data/testpkgs/pkg1/sub/__main__.py similarity index 100% rename from tests/func/testfiles/testpkgs/pkg1/sub/__main__.py rename to tests/test_data/testpkgs/pkg1/sub/__main__.py diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py new file mode 100644 index 00000000..340e19cc --- /dev/null +++ b/tests/tests/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +"""Tests for the testing infrastructure. +""" diff --git a/tests/tests/test_patterns.py b/tests/tests/test_patterns.py new file mode 100644 index 00000000..93a08c93 --- /dev/null +++ b/tests/tests/test_patterns.py @@ -0,0 +1,173 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +from __future__ import absolute_import, print_function, unicode_literals + +import pytest +import sys + +from ptvsd.common import log +from tests.patterns import some + + +NONE = None +NAN = float("nan") + + +def log_repr(x): + s = repr(x) + log.info("{0}", s) + + +VALUES = [ + object(), + True, False, + 0, -1, -1.0, 1.23, + b'abc', b'abcd', + u'abc', u'abcd', + (), (1, 2, 3), + [], [1, 2, 3], + {}, {'a': 1, 'b': 2}, +] + +@pytest.mark.parametrize('x', VALUES) +def test_value(x): + log_repr(some.object) + assert x == some.object + + log_repr(some.object.equal_to(x)) + assert x == some.object.equal_to(x) + + log_repr(some.object.same_as(x)) + assert x == some.object.same_as(x) + + log_repr(some.thing) + assert x == some.thing + + log_repr(~some.thing) + assert x != ~some.thing + + log_repr(~some.object) + assert x != ~some.object + + log_repr(~some.object | x) + assert x == ~some.object | x + + +def test_none(): + assert NONE == some.object + assert NONE == some.object.equal_to(None) + assert NONE == some.object.same_as(None) + assert NONE != some.thing + assert NONE == some.thing | None + + +def test_equal(): + assert 123.0 == some.object.equal_to(123) + assert NAN != some.object.equal_to(NAN) + + +def test_same(): + assert 123.0 != some.object.same_as(123) + assert NAN == some.object.same_as(NAN) + + +def test_inverse(): + pattern = ~some.object.equal_to(2) + log_repr(pattern) + + assert pattern == 1 + assert pattern != 2 + assert pattern == 3 + assert pattern == "2" + assert pattern == NONE + + +def test_either(): + pattern = some.number | some.str + log_repr(pattern) + assert pattern == 123 + + pattern = some.str | 123 | some.bool + log_repr(pattern) + assert pattern == 123 + + +def test_in_range(): + pattern = some.int.in_range(-5, 5) + log_repr(pattern) + + assert all([pattern == x for x in range(-5, 5)]) + assert pattern != -6 + assert pattern != 5 + + +def test_str(): + log_repr(some.str) + assert some.str == u"abc" + + if sys.version_info < (3,): + assert b"abc" == some.str + else: + assert b"abc" != some.str + + +def test_str_matching(): + pattern = some.str.matching(r".(b+).") + log_repr(pattern) + assert pattern == "abbbc" + + pattern = some.str.matching(r"bbb") + log_repr(pattern) + assert pattern != "abbbc" + + +def test_list(): + assert [1, 2, 3] == [1, some.thing, 3] + assert [1, 2, 3, 4] != [1, some.thing, 4] + + +def test_dict(): + pattern = {'a': some.thing, 'b': 2} + log_repr(pattern) + assert pattern == {'a': 1, 'b': 2} + + pattern = some.dict.containing({'a': 1}) + log_repr(pattern) + assert pattern == {'a': 1, 'b': 2} + + +def test_such_that(): + pattern = some.thing.such_that(lambda x: x != 1) + log_repr(pattern) + + assert 0 == pattern + assert 1 != pattern + assert 2 == pattern + + +def test_error(): + log_repr(some.error) + assert some.error == Exception('error!') + assert some.error != {} + + +def test_recursive(): + pattern = some.dict.containing({ + "dict": some.dict.containing({ + "int": some.int.in_range(100, 200), + }), + "list": [None, ~some.error, some.number | some.str], + }) + + log_repr(pattern) + + assert pattern == { + "list": [None, False, 123], + "bool": True, + "dict": { + "int": 123, + "str": "abc", + }, + } diff --git a/tests/helpers/test_timeline.py b/tests/tests/test_timeline.py similarity index 98% rename from tests/helpers/test_timeline.py rename to tests/tests/test_timeline.py index 4ac14969..a3ca2150 100644 --- a/tests/helpers/test_timeline.py +++ b/tests/tests/test_timeline.py @@ -2,14 +2,14 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import pytest import threading import time -from .pattern import ANY, SUCCESS, FAILURE, Is -from .timeline import Timeline, Mark, Event, Request, Response +from tests.patterns import some +from tests.timeline import Timeline, Mark, Event, Request, Response @pytest.fixture diff --git a/tests/helpers/timeline.md b/tests/timeline.md similarity index 100% rename from tests/helpers/timeline.md rename to tests/timeline.md diff --git a/tests/helpers/timeline.py b/tests/timeline.py similarity index 92% rename from tests/helpers/timeline.py rename to tests/timeline.py index 10b5bf96..6221ce24 100644 --- a/tests/helpers/timeline.py +++ b/tests/timeline.py @@ -2,21 +2,22 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import contextlib import itertools +import pytest import threading +from ptvsd.common import fmt, log, timestamp from ptvsd.common.compat import queue -from tests.helpers import colors, pattern, print, timestamp +from tests.patterns import some class Timeline(object): def __init__(self, ignore_unobserved=None): self._ignore_unobserved = ignore_unobserved or [] - self._index_iter = itertools.count(1) self._accepting_new = threading.Event() self._finalized = threading.Event() @@ -120,7 +121,7 @@ class Timeline(object): if self.is_final: return - print(colors.LIGHT_MAGENTA + 'Finalizing' + colors.RESET) + log.info('Finalizing timeline ...') with self.unfrozen(): self.mark('finalized') @@ -189,7 +190,7 @@ class Timeline(object): def wait_until_realized(self, expectation, freeze=None, explain=True, observe=True): if explain: - print(colors.LIGHT_MAGENTA + 'Waiting for ' + colors.RESET + colors.color_repr(expectation)) + log.info('Waiting for {0!r}', expectation) return self._wait_until_realized(expectation, freeze, explain, observe) def wait_for(self, expectation, freeze=None, explain=True): @@ -200,12 +201,12 @@ class Timeline(object): 'frozen, or wait_until_realized() when a lower bound is really not necessary.' ) if explain: - print(colors.LIGHT_MAGENTA + 'Waiting for ' + colors.RESET + colors.color_repr(expectation)) + log.info('Waiting for {0!r}', expectation) return self._wait_until_realized(expectation, freeze, explain=explain) def wait_for_next(self, expectation, freeze=True, explain=True, observe=True): if explain: - print(colors.LIGHT_MAGENTA + 'Waiting for next ' + colors.RESET + colors.color_repr(expectation)) + log.info('Waiting for next {0!r}', expectation) return self._wait_until_realized(self._proceeding_from >> expectation, freeze, explain, observe) def new(self): @@ -228,10 +229,10 @@ class Timeline(object): try: reasons = next(expectation.test(first, self.last)) except StopIteration: - print(colors.LIGHT_RED + 'No matching ' + colors.RESET + colors.color_repr(expectation)) - # The weird always-false assert is to make pytest print occurrences nicely. + log.info('No matching {0!r}', expectation) occurrences = list(first.and_following()) - assert occurrences is ('not matching expectation', expectation) + log.info("Occurrences considered: {0!r}", occurrences) + pytest.fail("Expectation not matched") occs = tuple(reasons.values()) assert occs @@ -257,10 +258,7 @@ class Timeline(object): assert expectation not in self.new() def _explain_how_realized(self, expectation, reasons): - message = ( - colors.LIGHT_MAGENTA + 'Realized ' + colors.RESET + - colors.color_repr(expectation) - ) + message = fmt("Realized {0}", expectation) # For the breakdown, we want to skip any expectations that were exact occurrences, # since there's no point explaining that occurrence was realized by itself. @@ -269,14 +267,11 @@ class Timeline(object): reasons.pop(exp, None) if reasons: - message += colors.LIGHT_MAGENTA + ':' + colors.RESET + message += ":" for exp, reason in reasons.items(): - message += ( - '\n ' + colors.color_repr(exp) + - colors.LIGHT_MAGENTA + ' by ' + colors.RESET + - colors.color_repr(reason) - ) - print(message) + message += fmt("\n\n{0!r} == {1!r}", exp, reason) + + log.info("{0}", message) def _record(self, occurrence, block=True): assert isinstance(occurrence, Occurrence) @@ -300,7 +295,7 @@ class Timeline(object): self._accepting_new.wait() with self._recorded_new: occ.timeline = self - occ.timestamp = timestamp() + occ.timestamp = timestamp.current() occ.index = next(self._index_iter) if self._last is None: @@ -328,7 +323,7 @@ class Timeline(object): occ.observed = True def wait_for_response(freeze=True, raise_if_failed=True): - response = Response(occ, pattern.ANY).wait_until_realized(freeze) + response = Response(occ, some.object).wait_until_realized(freeze) assert response.observed if raise_if_failed and not response.success: raise response.body @@ -402,7 +397,6 @@ class Interval(tuple): if not self: return - # print('Checking for unobserved since %s' % colors.color_repr(self[0])) unobserved = [ occ for occ in self if not occ.observed and all( @@ -412,10 +406,10 @@ class Interval(tuple): if not unobserved: return - print(colors.LIGHT_RED + 'Unobserved occurrences detected:' + colors.RESET) - for occ in unobserved: - print(' ' + colors.color_repr(occ)) - raise Exception('Unobserved occurrences detected') + raise log.error( + "Unobserved occurrences detected:\n{0}", + ''.join(' ' + repr(occ) for occ in unobserved) + ) class Expectation(object): @@ -480,11 +474,12 @@ class DerivativeExpectation(Expectation): timelines = {id(exp.timeline): exp.timeline for exp in expectations} timelines.pop(id(None), None) if len(timelines) > 1: - print(colors.RED + 'Cannot mix expectations from multiple timelines:' + colors.RESET) + offending_expectations = "" for tl_id, tl in timelines.items(): - print('\n %d: %r' % (tl_id, tl)) - print() - raise ValueError('Cannot mix expectations from multiple timelines') + offending_expectations += fmt('\n {0}: {1!r}\n', tl_id, tl) + raise log.error( + 'Cannot mix expectations from multiple timelines:\n{0}', + offending_expectations) for tl in timelines.values(): self.timeline = tl @@ -644,11 +639,11 @@ def Mark(id): return PatternExpectation('Mark', id) -def Request(command, arguments=pattern.ANY): +def Request(command, arguments=some.object): return PatternExpectation('Request', command, arguments) -def Response(request, body=pattern.ANY): +def Response(request, body=some.object): assert isinstance(request, Expectation) or isinstance(request, Occurrence) exp = PatternExpectation('Response', request, body) exp.timeline = request.timeline @@ -656,7 +651,7 @@ def Response(request, body=pattern.ANY): return exp -def Event(event, body=pattern.ANY): +def Event(event, body=some.object): return PatternExpectation('Event', event, body) diff --git a/tests/helpers/watchdog.py b/tests/watchdog.py similarity index 95% rename from tests/helpers/watchdog.py rename to tests/watchdog.py index f8516761..acfdd0ea 100644 --- a/tests/helpers/watchdog.py +++ b/tests/watchdog.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. -from __future__ import print_function, with_statement, absolute_import +from __future__ import absolute_import, print_function, unicode_literals import multiprocessing import os diff --git a/tox.ini b/tox.ini index a4f074ee..eae10c90 100644 --- a/tox.ini +++ b/tox.ini @@ -3,5 +3,6 @@ envlist = py{27,34,35,36,37} [testenv] deps = -rtests/requirements.txt -commands = pytest {posargs:-vv} passenv = PTVSD_LOG_DIR +commands = + pytest {posargs:-n32}