From 6e09980157ee2e0b7d9fe1ee2c71bf26baf7191e Mon Sep 17 00:00:00 2001 From: "Ilya.Kazakevich" Date: Sat, 14 Jan 2017 05:10:03 +0300 Subject: [PATCH] New python test runners API based on Leonid s TC runners * See https://confluence.jetbrains.com/display/~link/PyCharm+test+runners+protocol * See "universalTests" package --- .../actions/RunConfigurationProducer.java | 12 +- .../util/resources/misc/registry.properties | 4 + python/helpers/pycharm/_jb_nosetest_runner.py | 13 + python/helpers/pycharm/_jb_pytest_runner.py | 17 + python/helpers/pycharm/_jb_runner_tools.py | 99 +++++ python/helpers/pycharm/_jb_unittest_runner.py | 26 ++ python/helpers/pycharm/teamcity/README.txt | 4 + python/helpers/pycharm/teamcity/__init__.py | 12 + python/helpers/pycharm/teamcity/common.py | 51 +++ .../pycharm/teamcity/context_managers.py | 53 +++ python/helpers/pycharm/teamcity/django.py | 8 + .../helpers/pycharm/teamcity/flake8_plugin.py | 6 + .../pycharm/teamcity/flake8_v2_plugin.py | 78 ++++ .../pycharm/teamcity/flake8_v3_plugin.py | 55 +++ python/helpers/pycharm/teamcity/messages.py | 189 ++++++++ .../helpers/pycharm/teamcity/nose_report.py | 185 ++++++++ .../helpers/pycharm/teamcity/pytest_plugin.py | 309 ++++++++++++++ python/helpers/pycharm/teamcity/unittestpy.py | 171 ++++++++ .../com/jetbrains/python/psi/PyPsiFacade.java | 1 + python/src/META-INF/python-core-common.xml | 4 +- .../python/PyFileChooserDescriptor.java | 33 ++ .../com/jetbrains/python/PythonHelper.java | 6 + .../python/psi/impl/PyPsiFacadeImpl.java | 1 + .../run/AbstractPythonRunConfiguration.java | 45 +- .../run/PythonRunConfigurationForm.java | 8 +- ...AbstractPythonOldTestRunConfiguration.java | 392 +++++++++++++++++ .../AbstractPythonTestRunConfiguration.java | 382 ++--------------- ...tractPythonTestRunConfigurationParams.java | 4 +- .../testing/PyRerunAwareConfiguration.java | 39 ++ .../testing/PyRerunFailedTestsAction.java | 42 +- .../PythonTestCommandLineStateBase.java | 8 +- .../testing/PythonTestConfigurationType.java | 106 +---- ...> PythonTestOldConfigurationProducer.java} | 55 +-- .../PythonTestOldConfigurationType.java | 134 ++++++ .../PythonTestRunConfigurationForm.java | 6 +- .../PythonTestRunConfigurationsConverter.java | 4 +- .../PythonDocTestConfigurationProducer.java | 14 +- .../PythonDocTestRunConfiguration.java | 4 +- .../PythonNoseTestConfigurationProducer.java | 6 +- .../PythonNoseTestRunConfiguration.java | 6 +- .../pytest/PyTestConfigurationProducer.java | 8 +- .../pytest/PyTestRunConfiguration.java | 5 +- .../testing/tox/PyToxConfiguration.java | 4 +- .../PythonUnitTestCommandLineState.java | 8 +- .../PythonUnitTestConfigurationProducer.java | 8 +- .../PythonUnitTestRunConfiguration.java | 5 +- .../PythonUnitTestRunConfigurationForm.java | 4 +- .../universalTests/PyUniversalNoseTest.kt | 55 +++ .../universalTests/PyUniversalPyTest.kt | 60 +++ .../universalTests/PyUniversalTestForm.form | 79 ++++ .../universalTests/PyUniversalTestForm.java | 207 +++++++++ .../universalTests/PyUniversalTests.kt | 404 ++++++++++++++++++ .../universalTests/PyUniversalUnitTest.kt | 55 +++ .../testing/universalTests/package-info.java | 21 + .../jetbrains/reflection/ReflectionUtils.kt | 155 +++++++ .../testing/CreateConfigurationTestTask.java | 10 +- .../env/ut/PyDocTestProcessRunner.java | 4 +- .../env/ut/PyNoseTestProcessRunner.java | 4 +- .../env/ut/PyTestTestProcessRunner.java | 4 +- .../env/ut/PyUnitTestProcessRunner.java | 4 +- .../com/jetbrains/env/ut/PyUnitTestTask.java | 10 +- .../python/PythonRunConfigurationTest.java | 4 +- 62 files changed, 3102 insertions(+), 608 deletions(-) create mode 100644 python/helpers/pycharm/_jb_nosetest_runner.py create mode 100644 python/helpers/pycharm/_jb_pytest_runner.py create mode 100644 python/helpers/pycharm/_jb_runner_tools.py create mode 100644 python/helpers/pycharm/_jb_unittest_runner.py create mode 100644 python/helpers/pycharm/teamcity/README.txt create mode 100644 python/helpers/pycharm/teamcity/__init__.py create mode 100644 python/helpers/pycharm/teamcity/common.py create mode 100644 python/helpers/pycharm/teamcity/context_managers.py create mode 100644 python/helpers/pycharm/teamcity/django.py create mode 100644 python/helpers/pycharm/teamcity/flake8_plugin.py create mode 100644 python/helpers/pycharm/teamcity/flake8_v2_plugin.py create mode 100644 python/helpers/pycharm/teamcity/flake8_v3_plugin.py create mode 100644 python/helpers/pycharm/teamcity/messages.py create mode 100644 python/helpers/pycharm/teamcity/nose_report.py create mode 100644 python/helpers/pycharm/teamcity/pytest_plugin.py create mode 100644 python/helpers/pycharm/teamcity/unittestpy.py create mode 100644 python/src/com/jetbrains/python/PyFileChooserDescriptor.java create mode 100644 python/src/com/jetbrains/python/testing/AbstractPythonOldTestRunConfiguration.java create mode 100644 python/src/com/jetbrains/python/testing/PyRerunAwareConfiguration.java rename python/src/com/jetbrains/python/testing/{PythonTestConfigurationProducer.java => PythonTestOldConfigurationProducer.java} (81%) create mode 100644 python/src/com/jetbrains/python/testing/PythonTestOldConfigurationType.java create mode 100644 python/src/com/jetbrains/python/testing/universalTests/PyUniversalNoseTest.kt create mode 100644 python/src/com/jetbrains/python/testing/universalTests/PyUniversalPyTest.kt create mode 100644 python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.form create mode 100644 python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.java create mode 100644 python/src/com/jetbrains/python/testing/universalTests/PyUniversalTests.kt create mode 100644 python/src/com/jetbrains/python/testing/universalTests/PyUniversalUnitTest.kt create mode 100644 python/src/com/jetbrains/python/testing/universalTests/package-info.java create mode 100644 python/src/com/jetbrains/reflection/ReflectionUtils.kt diff --git a/platform/lang-api/src/com/intellij/execution/actions/RunConfigurationProducer.java b/platform/lang-api/src/com/intellij/execution/actions/RunConfigurationProducer.java index ebc517c2787a..8c0a551238ee 100644 --- a/platform/lang-api/src/com/intellij/execution/actions/RunConfigurationProducer.java +++ b/platform/lang-api/src/com/intellij/execution/actions/RunConfigurationProducer.java @@ -215,11 +215,17 @@ public abstract class RunConfigurationProducer { } protected RunnerAndConfigurationSettings cloneTemplateConfiguration(@NotNull final ConfigurationContext context) { - final RunConfiguration original = context.getOriginalConfiguration(myConfigurationFactory.getType()); + return cloneTemplateConfigurationStatic(context, myConfigurationFactory); + } + + @NotNull + protected static RunnerAndConfigurationSettings cloneTemplateConfigurationStatic(@NotNull final ConfigurationContext context, + @NotNull final ConfigurationFactory configurationFactory) { + final RunConfiguration original = context.getOriginalConfiguration(configurationFactory.getType()); if (original != null) { - return RunManager.getInstance(context.getProject()).createConfiguration(original.clone(), myConfigurationFactory); + return RunManager.getInstance(context.getProject()).createConfiguration(original.clone(), configurationFactory); } - return RunManager.getInstance(context.getProject()).createRunConfiguration("", myConfigurationFactory); + return RunManager.getInstance(context.getProject()).createRunConfiguration("", configurationFactory); } @NotNull diff --git a/platform/util/resources/misc/registry.properties b/platform/util/resources/misc/registry.properties index 1b837b0d243a..bc703565a9a1 100644 --- a/platform/util/resources/misc/registry.properties +++ b/platform/util/resources/misc/registry.properties @@ -482,6 +482,10 @@ completion.show.intention.icon=true tabs.alphabetical=false remote.changelist.management.available=false + +# Enables https://confluence.jetbrains.com/display/~link/PyCharm+test+runners+protocol +python.tests.enableUniversalTests=true + python.import.always.ask=false python.django.timeout=30 python.django.timeout.description=Number of seconds to wait for manage.py list commands diff --git a/python/helpers/pycharm/_jb_nosetest_runner.py b/python/helpers/pycharm/_jb_nosetest_runner.py new file mode 100644 index 000000000000..ca07bdafe17d --- /dev/null +++ b/python/helpers/pycharm/_jb_nosetest_runner.py @@ -0,0 +1,13 @@ +# coding=utf-8 +import nose +import sys + +from _jb_runner_tools import jb_start_tests, jb_patch_separator, jb_doc_args +from teamcity.nose_report import TeamcityReport + +if __name__ == '__main__': + path, targets, additional_args = jb_start_tests() + sys.argv += [path] if path else jb_patch_separator(targets, fs_glue=".", python_glue=".", fs_to_python_glue=":") + sys.argv += additional_args + jb_doc_args("Nosetest", sys.argv) + nose.main(addplugins=[TeamcityReport()]) diff --git a/python/helpers/pycharm/_jb_pytest_runner.py b/python/helpers/pycharm/_jb_pytest_runner.py new file mode 100644 index 000000000000..a5bd94a56e8e --- /dev/null +++ b/python/helpers/pycharm/_jb_pytest_runner.py @@ -0,0 +1,17 @@ +# coding=utf-8 +import sys + +import pytest + +from _jb_runner_tools import jb_start_tests, jb_patch_separator, jb_doc_args +from teamcity import pytest_plugin + +if __name__ == '__main__': + path, targets, additional_args = jb_start_tests() + sys.argv += additional_args + joined_targets = jb_patch_separator(targets, fs_glue=":", python_glue="::", fs_to_python_glue=".py::") + # When file is launched in py.test it should be file.py: you can't provide it as bare module + joined_targets = [t + ".py" if ":" not in t else t for t in joined_targets] + sys.argv += [path] if path else joined_targets + jb_doc_args("py.test", sys.argv[1:]) + pytest.main(sys.argv[1:], [pytest_plugin]) diff --git a/python/helpers/pycharm/_jb_runner_tools.py b/python/helpers/pycharm/_jb_runner_tools.py new file mode 100644 index 000000000000..cde91b21a047 --- /dev/null +++ b/python/helpers/pycharm/_jb_runner_tools.py @@ -0,0 +1,99 @@ +# coding=utf-8 +""" +Tools to implement runners (https://confluence.jetbrains.com/display/~link/PyCharm+test+runners+protocol) +""" +import argparse + +import sys, os + +import imp + +from teamcity import teamcity_presence_env_var, messages + +# Some runners need it to "detect" TC and start protocol +if teamcity_presence_env_var not in os.environ: + os.environ[teamcity_presence_env_var] = "LOCAL" + +# Monkeypatching TC to pass location hint +old_started = messages.TeamcityServiceMessages.message + + +def __jb_message(self, messageName, **properties): + try: + properties["locationHint"] = "python://{0}".format(properties["name"]) + except KeyError: + pass + print("\n") + old_started(self, messageName, **properties) + + +messages.TeamcityServiceMessages.message = __jb_message + + +# Monkeypatched + + +def jb_patch_separator(targets, fs_glue, python_glue, fs_to_python_glue): + """ + Targets are always dot separated according to manual. + How ever, some runners may need different separators. + This function splits target to file/symbol parts and glues them using provided glues. + + :param targets: list of dot-separated targets + :param fs_glue: how to glue fs parts of target. I.e.: module "eggs" in "spam" package is "spam[fs_glue]eggs" + :param python_glue: how to glue python parts (glue between class and function etc) + :param fs_to_python_glue: between last fs-part and first python part + :return: list of targets with patched separators + """ + if not targets: + return [] + + def _patch_target(target): + path = None + parts = target.split(".") + for i in range(0, len(parts)): + m = parts[i] + try: + (fil, path, desc) = imp.find_module(m, path) + except ImportError: + fs_part = fs_glue.join(parts[:i]) + python_path = python_glue.join(parts[i:]) + return fs_part + fs_to_python_glue + python_path if python_path else fs_part + if desc[2] == imp.PKG_DIRECTORY: + # Package + path = imp.load_module(m, fil, path, desc).__path__ + return target + + return map(_patch_target, targets) + + +def jb_start_tests(): + """ + Parses arguments, starts protocol and returns tuple of arguments + + :return: (string with path or None, list of targets or None, list of additional arguments) + """ + + # Handle additional args after -- + additional_args = [] + try: + index = sys.argv.index("--") + additional_args = sys.argv[index + 1:] + del sys.argv[index:] + except ValueError: + pass + parser = argparse.ArgumentParser(description='PyCharm test runner') + parser.add_argument('--path', help='Path to file or folder to run') + parser.add_argument('--target', help='Python target to run', action="append") + namespace = parser.parse_args() + del sys.argv[1:] # Remove all args + messages.TeamcityServiceMessages().message('enteredTheMatrix') + return namespace.path, namespace.target, additional_args + + +def jb_doc_args(framework_name, args): + """ + Runner encouraged to report its arguments to user with aid of this function + + """ + print("Launching {0} with arguments {1}".format(framework_name, " ".join(args))) diff --git a/python/helpers/pycharm/_jb_unittest_runner.py b/python/helpers/pycharm/_jb_unittest_runner.py new file mode 100644 index 000000000000..ea74e3de663b --- /dev/null +++ b/python/helpers/pycharm/_jb_unittest_runner.py @@ -0,0 +1,26 @@ +# coding=utf-8 +import os +from unittest import main + +from _jb_runner_tools import jb_start_tests, jb_doc_args +from teamcity import unittestpy + +if __name__ == '__main__': + path, targets, additional_args = jb_start_tests() + + args = ["python -m unittest"] + if path: + # Unittest does not support script directly, but it can use "discover" to find all tests in some folder + # filtering by script + additional_args.append("discover") + additional_args.append("-s") + assert os.path.exists(path), "{0}: No such file or directory".format(path) + if os.path.isfile(path): + additional_args += [os.path.dirname(path), "-p", os.path.basename(path)] + else: + additional_args.append(path) + else: + additional_args += targets + args += additional_args + jb_doc_args("unittests", args) + main(argv=args, module=None, testRunner=unittestpy.TeamcityTestRunner()) diff --git a/python/helpers/pycharm/teamcity/README.txt b/python/helpers/pycharm/teamcity/README.txt new file mode 100644 index 000000000000..9e24110cfb9e --- /dev/null +++ b/python/helpers/pycharm/teamcity/README.txt @@ -0,0 +1,4 @@ +Test runners wraps python framework and provides teamcity protocol. +They are are distributed separately. +Copy latest version from https://github.com/JetBrains/teamcity-messages.git/teamcity +See https://confluence.jetbrains.com/display/~link/PyCharm+test+runners+protocol \ No newline at end of file diff --git a/python/helpers/pycharm/teamcity/__init__.py b/python/helpers/pycharm/teamcity/__init__.py new file mode 100644 index 000000000000..56fd76dcffb7 --- /dev/null +++ b/python/helpers/pycharm/teamcity/__init__.py @@ -0,0 +1,12 @@ +# coding=utf-8 +import os + +__all__ = ['is_running_under_teamcity'] + +__version__ = "1.21" + +teamcity_presence_env_var = "TEAMCITY_VERSION" + + +def is_running_under_teamcity(): + return os.getenv(teamcity_presence_env_var) is not None diff --git a/python/helpers/pycharm/teamcity/common.py b/python/helpers/pycharm/teamcity/common.py new file mode 100644 index 000000000000..8f2c68e2cad3 --- /dev/null +++ b/python/helpers/pycharm/teamcity/common.py @@ -0,0 +1,51 @@ +# coding=utf-8 + +import sys +import traceback +import inspect + + +_max_reported_output_size = 1 * 1024 * 1024 +_reported_output_chunk_size = 50000 + + +def limit_output(data): + return data[:_max_reported_output_size] + + +def split_output(data): + while len(data) > 0: + if len(data) <= _reported_output_chunk_size: + yield data + data = '' + else: + yield data[:_reported_output_chunk_size] + data = data[_reported_output_chunk_size:] + + +def is_string(obj): + if sys.version_info >= (3, 0): + return isinstance(obj, str) + else: + return isinstance(obj, basestring) + + +def get_class_fullname(something): + if inspect.isclass(something): + cls = something + else: + cls = something.__class__ + + module = cls.__module__ + if module is None or module == str.__class__.__module__: + return cls.__name__ + return module + '.' + cls.__name__ + + +def convert_error_to_string(err): + try: + exctype, value, tb = err + return ''.join(traceback.format_exception(exctype, value, tb)) + except: + tb = traceback.format_exc() + return "*FAILED TO GET TRACEBACK*: " + tb diff --git a/python/helpers/pycharm/teamcity/context_managers.py b/python/helpers/pycharm/teamcity/context_managers.py new file mode 100644 index 000000000000..93cf6db966e3 --- /dev/null +++ b/python/helpers/pycharm/teamcity/context_managers.py @@ -0,0 +1,53 @@ +try: + from contextlib2 import contextmanager +except: + from contextlib import contextmanager + + +@contextmanager +def block(messages, name, flowId=None): + messages.blockOpened(name, flowId) + yield + messages.blockClosed(name, flowId) + + +@contextmanager +def compilation(messages, compiler): + messages.compilationStarted(compiler) + yield + messages.compilationFinished(compiler) + + +@contextmanager +def testSuite(messages, name): + messages.testSuiteStarted(name) + yield + messages.testSuiteFinished(name) + + +@contextmanager +def test(messages, testName, captureStandardOutput=None, flowId=None, testDuration=None): + messages.testStarted(testName=testName, captureStandardOutput=captureStandardOutput, flowId=flowId) + yield + messages.testFinished(testName=testName, testDuration=testDuration, flowId=flowId) + + +@contextmanager +def progress(messages, message): + messages.progressStart(message) + yield + messages.progressFinish(message) + + +@contextmanager +def serviceMessagesDisabled(messages, flowId=None): + messages.disableServiceMessages(flowId=flowId) + yield + messages.enableServiceMessages(flowId=flowId) + + +@contextmanager +def serviceMessagesEnabled(messages, flowId=None): + messages.enableServiceMessages(flowId=flowId) + yield + messages.disableServiceMessages(flowId=flowId) diff --git a/python/helpers/pycharm/teamcity/django.py b/python/helpers/pycharm/teamcity/django.py new file mode 100644 index 000000000000..a9dc1a9cd40b --- /dev/null +++ b/python/helpers/pycharm/teamcity/django.py @@ -0,0 +1,8 @@ +from __future__ import absolute_import +from django.test.runner import DiscoverRunner +from teamcity.unittestpy import TeamcityTestRunner + + +class TeamcityDjangoRunner(DiscoverRunner): + def run_suite(self, suite, **kwargs): + return TeamcityTestRunner().run(suite) diff --git a/python/helpers/pycharm/teamcity/flake8_plugin.py b/python/helpers/pycharm/teamcity/flake8_plugin.py new file mode 100644 index 000000000000..5ab8dbcde1f3 --- /dev/null +++ b/python/helpers/pycharm/teamcity/flake8_plugin.py @@ -0,0 +1,6 @@ +try: + from flake8.formatting import base # noqa +except ImportError: + from teamcity.flake8_v2_plugin import * # noqa +else: + from teamcity.flake8_v3_plugin import * # noqa diff --git a/python/helpers/pycharm/teamcity/flake8_v2_plugin.py b/python/helpers/pycharm/teamcity/flake8_v2_plugin.py new file mode 100644 index 000000000000..e60937f4c798 --- /dev/null +++ b/python/helpers/pycharm/teamcity/flake8_v2_plugin.py @@ -0,0 +1,78 @@ +try: + import pycodestyle as pep8 +except ImportError: + import pep8 + +import re + +from teamcity.messages import TeamcityServiceMessages +from teamcity import __version__, is_running_under_teamcity + + +enable_teamcity = is_running_under_teamcity() + + +class TeamcityReport(pep8.StandardReport): + name = 'teamcity' + version = __version__ + options_added = False + + @classmethod + def add_options(cls, parser): + if not cls.options_added: + parser.add_option('--teamcity', default=False, + action='callback', callback=cls.set_option_callback, + help="Enable teamcity messages") + cls.options_added = True + + @classmethod + def set_option_callback(cls, option, opt, value, parser): + global enable_teamcity + enable_teamcity = True + + @classmethod + def parse_options(cls, options): + if not enable_teamcity: + return + + options.reporter = TeamcityReport + options.report = TeamcityReport(options) + options.jobs = None # needs to be disabled, flake8 overrides the report if enabled + + def get_file_results(self): + self._deferred_print.sort() + + messages = TeamcityServiceMessages() + + normalized_filename = self.filename.replace("\\", "/") + + for line_number, offset, code, text, doc in self._deferred_print: + position = '%(path)s:%(row)d:%(col)d' % { + 'path': normalized_filename, + 'row': self.line_offset + line_number, + 'col': offset + 1, + } + + error_message = '%s %s' % (code, text) + test_name = 'pep8: %s: %s' % (position, error_message) + + messages.testStarted(test_name) + + if line_number > len(self.lines): + line = '' + else: + line = self.lines[line_number - 1] + + details = [ + line.rstrip(), + re.sub(r'\S', ' ', line[:offset]) + '^', + ] + + if doc: + details.append(doc.strip()) + + details = '\n'.join(details) + + messages.testFailed(test_name, error_message, details) + messages.testFinished(test_name) + return self.file_errors diff --git a/python/helpers/pycharm/teamcity/flake8_v3_plugin.py b/python/helpers/pycharm/teamcity/flake8_v3_plugin.py new file mode 100644 index 000000000000..a15b0da132a7 --- /dev/null +++ b/python/helpers/pycharm/teamcity/flake8_v3_plugin.py @@ -0,0 +1,55 @@ +import re +from io import BytesIO + +from flake8.formatting import base + +from teamcity.messages import TeamcityServiceMessages +from teamcity import __version__, is_running_under_teamcity + + +class TeamcityReport(base.BaseFormatter): + name = 'teamcity-messages' + version = __version__ + + options_added = False + + @classmethod + def add_options(cls, parser): + if not cls.options_added: + parser.add_option('--teamcity', + default=is_running_under_teamcity(), + help="Force output of JetBrains TeamCity service messages") + parser.add_option('--no-teamcity', + default=False, + help="Disable output of JetBrains TeamCity service messages (even under TeamCity build)") + cls.options_added = True + + @classmethod + def parse_options(cls, options): + if not options.no_teamcity: + if options.teamcity or is_running_under_teamcity(): + options.format = 'teamcity-messages' + + def format(self, error): + normalized_filename = error.filename.replace("\\", "/") + position = '%s:%d:%d' % ( + normalized_filename, error.line_number, error.column_number) + error_message = '%s %s' % (error.code, error.text) + test_name = 'pep8: %s: %s' % (position, error_message) + + line = error.physical_line + offset = error.column_number + details = [ + line.rstrip(), + re.sub(r'\S', ' ', line[:offset]) + '^', + ] + details = '\n'.join(details) + + bytesio = BytesIO() + messages = TeamcityServiceMessages(output=bytesio) + + messages.testStarted(test_name) + messages.testFailed(test_name, error_message, details) + messages.testFinished(test_name) + + return bytesio.getvalue().decode('UTF-8') diff --git a/python/helpers/pycharm/teamcity/messages.py b/python/helpers/pycharm/teamcity/messages.py new file mode 100644 index 000000000000..e358baf53139 --- /dev/null +++ b/python/helpers/pycharm/teamcity/messages.py @@ -0,0 +1,189 @@ +# coding=utf-8 +import sys +import datetime + +if sys.version_info < (3, ): + # Python 2 + text_type = unicode # flake8: noqa +else: + # Python 3 + text_type = str + + +_quote = {"'": "|'", "|": "||", "\n": "|n", "\r": "|r", '[': '|[', ']': '|]'} + +def escape_value(value): + return "".join(_quote.get(x, x) for x in value) + + +class TeamcityServiceMessages(object): + def __init__(self, output=sys.stdout, now=datetime.datetime.now, encoding='auto'): + if sys.version_info < (3, ) or not hasattr(output, 'buffer'): + self.output = output + else: + self.output = output.buffer + self.now = now + + if encoding and encoding != 'auto': + self.encoding = encoding + elif getattr(output, 'encoding', None) or encoding == 'auto': + # Default encoding to 'utf-8' because it sucks if we fail with a + # `UnicodeEncodeError` simply because LANG didn't get propagated to + # a subprocess or something and sys.stdout.encoding is None + self.encoding = getattr(output, 'encoding', None) or 'utf-8' + else: + self.encoding = None + + def encode(self, value): + if self.encoding and isinstance(value, text_type): + value = value.encode(self.encoding) + return value + + def decode(self, value): + if self.encoding and not isinstance(value, text_type): + value = value.decode(self.encoding) + return value + + if sys.version_info < (3, ): + def escapeValue(self, value): + return escape_value(self.encode(value)) + else: + def escapeValue(self, value): + return escape_value(self.decode(value)) + + def message(self, messageName, **properties): + timestamp = self.now().strftime("%Y-%m-%dT%H:%M:%S.") + "%03d" % (self.now().microsecond / 1000) + message = ("##teamcity[%s timestamp='%s'" % (messageName, timestamp)) + + for k in sorted(properties.keys()): + value = properties[k] + if value is None: + continue + + message += (" %s='%s'" % (k, self.escapeValue(value))) + + message += ("]\n") + + # Python may buffer it for a long time, flushing helps to see real-time result + self.output.write(self.encode(message)) + self.output.flush() + + def _single_value_message(self, messageName, value): + message = ("##teamcity[%s '%s']\n" % (messageName, self.escapeValue(value))) + + # Python may buffer it for a long time, flushing helps to see real-time result + self.output.write(self.encode(message)) + self.output.flush() + + def blockOpened(self, name, flowId=None): + self.message('blockOpened', name=name, flowId=flowId) + + def blockClosed(self, name, flowId=None): + self.message('blockClosed', name=name, flowId=flowId) + + def block(self, name, flowId=None): + import teamcity.context_managers as cm + return cm.block(self, name=name, flowId=flowId) + + def compilationStarted(self, compiler): + self.message('compilationStarted', compiler=compiler) + + def compilationFinished(self, compiler): + self.message('compilationFinished', compiler=compiler) + + def compilation(self, compiler): + import teamcity.context_managers as cm + return cm.compilation(self, compiler=compiler) + + def testSuiteStarted(self, suiteName, flowId=None): + self.message('testSuiteStarted', name=suiteName, flowId=flowId) + + def testSuiteFinished(self, suiteName, flowId=None): + self.message('testSuiteFinished', name=suiteName, flowId=flowId) + + def testSuite(self, name): + import teamcity.context_managers as cm + return cm.testSuite(self, name=name) + + def testStarted(self, testName, captureStandardOutput=None, flowId=None): + self.message('testStarted', name=testName, captureStandardOutput=captureStandardOutput, flowId=flowId) + + def testFinished(self, testName, testDuration=None, flowId=None): + if testDuration is not None: + duration_ms = testDuration.days * 86400000 + \ + testDuration.seconds * 1000 + \ + int(testDuration.microseconds / 1000) + self.message('testFinished', name=testName, duration=str(duration_ms), flowId=flowId) + else: + self.message('testFinished', name=testName, flowId=flowId) + + def test(self, testName, captureStandardOutput=None, testDuration=None, flowId=None): + import teamcity.context_managers as cm + return cm.test(self, testName=testName, captureStandardOutput=captureStandardOutput, testDuration=testDuration, flowId=flowId) + + def testIgnored(self, testName, message='', flowId=None): + self.message('testIgnored', name=testName, message=message, flowId=flowId) + + def testFailed(self, testName, message='', details='', flowId=None): + self.message('testFailed', name=testName, message=message, details=details, flowId=flowId) + + def testStdOut(self, testName, out, flowId=None): + self.message('testStdOut', name=testName, out=out, flowId=flowId) + + def testStdErr(self, testName, out, flowId=None): + self.message('testStdErr', name=testName, out=out, flowId=flowId) + + def publishArtifacts(self, path, flowId=None): + self._single_value_message('publishArtifacts', path) + + def progressMessage(self, message): + self._single_value_message('progressMessage', message) + + def progressStart(self, message): + self._single_value_message('progressStart', message) + + def progressFinish(self, message): + self._single_value_message('progressFinish', message) + + def progress(self, message): + import teamcity.context_managers as cm + return cm.progress(self, message=message) + + def buildProblem(self, description, identity): + self.message('buildProblem', description=description, identity=identity) + + def buildStatus(self, status, text): + self.message('buildStatus', status=status, text=text) + + def setParameter(self, name, value): + self.message('setParameter', name=name, value=value) + + def buildStatisticLinesCovered(self, linesCovered): + self.message('buildStatisticValue', key='CodeCoverageAbsLCovered', value=str(linesCovered)) + + def buildStatisticTotalLines(self, totalLines): + self.message('buildStatisticValue', key='CodeCoverageAbsLTotal', value=str(totalLines)) + + def buildStatisticLinesUncovered(self, linesUncovered): + self.message('buildStatisticValue', key='CodeCoverageAbsLUncovered', value=str(linesUncovered)) + + def enableServiceMessages(self, flowId=None): + self.message('enableServiceMessages', flowId=flowId) + + def disableServiceMessages(self, flowId=None): + self.message('disableServiceMessages', flowId=flowId) + + def serviceMessagesDisabled(self, flowId=None): + import teamcity.context_managers as cm + return cm.serviceMessagesDisabled(self, flowId=flowId) + + def serviceMessagesEnabled(self, flowId=None): + import teamcity.context_managers as cm + return cm.serviceMessagesEnabled(self, flowId=flowId) + + def importData(self, typeID, pathToXMLFile): + self.message('importData', type=typeID, path=pathToXMLFile) + + def customMessage(self, text, status, errorDetails='', flowId=None): + self.message('message', text=text, status=status, errorDetails=errorDetails, flowId=flowId) + diff --git a/python/helpers/pycharm/teamcity/nose_report.py b/python/helpers/pycharm/teamcity/nose_report.py new file mode 100644 index 000000000000..9ed71c85dd8d --- /dev/null +++ b/python/helpers/pycharm/teamcity/nose_report.py @@ -0,0 +1,185 @@ +# coding=utf-8 +import os +import sys +import datetime +import inspect + +from teamcity import is_running_under_teamcity +from teamcity.common import is_string, split_output, limit_output, get_class_fullname, convert_error_to_string +from teamcity.messages import TeamcityServiceMessages + +from nose.exc import SkipTest, DeprecatedTest + + +CONTEXT_SUITE_FQN = "nose.suite.ContextSuite" + + +# from nose.util.ln +def _ln(label): + label_len = len(label) + 2 + chunk = (70 - label_len) // 2 + out = '%s %s %s' % ('-' * chunk, label, '-' * chunk) + pad = 70 - len(out) + if pad > 0: + out = out + ('-' * pad) + return out + + +_captured_output_start_marker = _ln('>> begin captured stdout <<') + "\n" +_captured_output_end_marker = "\n" + _ln('>> end captured stdout <<') + +_real_stdout = sys.stdout + + +class TeamcityReport(object): + name = 'teamcity-report' + score = 10000 + + def __init__(self): + super(TeamcityReport, self).__init__() + + self.messages = TeamcityServiceMessages(_real_stdout) + self.test_started_datetime_map = {} + self.enabled = False + + def get_test_id(self, test): + if is_string(test): + return test + + # Handle special "tests" + test_class_name = get_class_fullname(test) + if test_class_name == CONTEXT_SUITE_FQN: + if inspect.ismodule(test.context): + module_name = test.context.__name__ + return module_name + "." + test.error_context + elif inspect.isclass(test.context): + class_name = get_class_fullname(test.context) + return class_name + "." + test.error_context + + test_id = test.id() + + real_test = getattr(test, "test", test) + real_test_class_name = get_class_fullname(real_test) + + test_arg = getattr(real_test, "arg", tuple()) + if (type(test_arg) is tuple or type(test_arg) is list) and len(test_arg) > 0: + # As written in nose.case.FunctionTestCase#__str__ or nose.case.MethodTestCase#__str__ + test_arg_str = "%s" % (test_arg,) + if test_id.endswith(test_arg_str): + # Replace '.' in test args with '_' to preserve test hierarchy on TeamCity + test_id = test_id[:len(test_id) - len(test_arg_str)] + test_arg_str.replace('.', '_') + + # Force test_id for doctests + if real_test_class_name != "doctest.DocTestCase" and real_test_class_name != "nose.plugins.doctests.DocTestCase": + desc = test.shortDescription() + if desc and desc != test.id(): + return "%s (%s)" % (test_id, desc.replace('.', '_')) + + return test_id + + def configure(self, options, conf): + self.enabled = is_running_under_teamcity() + + def options(self, parser, env=os.environ): + pass + + def _get_capture_plugin(self, test): + """ + :type test: nose.case.Test + :rtype: nose.plugins.base.Plugin + """ + for plugin in test.config.plugins.plugins: + if plugin.name == "capture": + return plugin + return None + + def _capture_plugin_enabled(self, test): + """ + :type test: nose.case.Test + """ + plugin = self._get_capture_plugin(test) + return plugin is not None and plugin.enabled + + def _capture_plugin_buffer(self, test): + """ + :type test: nose.case.Test + """ + plugin = self._get_capture_plugin(test) + if plugin is None: + return None + return getattr(plugin, "buffer", None) + + def _captureStandardOutput_value(self, test): + """ + :type test: nose.case.Test + """ + if self._capture_plugin_enabled(test): + return 'false' + else: + return 'true' + + def report_fail(self, test, fail_type, err): + # workaround nose bug on python 3 + if is_string(err[1]): + err = (err[0], Exception(err[1]), err[2]) + + test_id = self.get_test_id(test) + + details = convert_error_to_string(err) + + start_index = details.find(_captured_output_start_marker) + end_index = details.find(_captured_output_end_marker) + + if 0 <= start_index < end_index: + # do not log test output twice, see report_finish for actual output handling + details = details[:start_index] + details[end_index + len(_captured_output_end_marker):] + + self.messages.testFailed(test_id, message=fail_type, details=details, flowId=test_id) + + def report_finish(self, test): + test_id = self.get_test_id(test) + + captured_output = getattr(test, "capturedOutput", None) + if captured_output is None and self._capture_plugin_enabled(test): + # nose capture does not fill 'capturedOutput' property on successful tests + captured_output = self._capture_plugin_buffer(test) + if captured_output: + for chunk in split_output(limit_output(captured_output)): + self.messages.testStdOut(test_id, chunk, flowId=test_id) + + if test_id in self.test_started_datetime_map: + time_diff = datetime.datetime.now() - self.test_started_datetime_map[test_id] + self.messages.testFinished(test_id, testDuration=time_diff, flowId=test_id) + else: + self.messages.testFinished(test_id, flowId=test_id) + + def addError(self, test, err): + test_class_name = get_class_fullname(test) + test_id = self.get_test_id(test) + + if issubclass(err[0], SkipTest): + self.messages.testIgnored(test_id, message=("SKIPPED: %s" % str(err[1])), flowId=test_id) + self.report_finish(test) + elif issubclass(err[0], DeprecatedTest): + self.messages.testIgnored(test_id, message="Deprecated", flowId=test_id) + self.report_finish(test) + elif test_class_name == CONTEXT_SUITE_FQN: + self.messages.testStarted(test_id, captureStandardOutput=self._captureStandardOutput_value(test), flowId=test_id) + self.report_fail(test, 'error in ' + test.error_context + ' context', err) + self.messages.testFinished(test_id, flowId=test_id) + else: + self.report_fail(test, 'Error', err) + self.report_finish(test) + + def addFailure(self, test, err): + self.report_fail(test, 'Failure', err) + self.report_finish(test) + + def startTest(self, test): + test_id = self.get_test_id(test) + + self.test_started_datetime_map[test_id] = datetime.datetime.now() + self.messages.testStarted(test_id, captureStandardOutput=self._captureStandardOutput_value(test), flowId=test_id) + + def addSuccess(self, test): + self.report_finish(test) diff --git a/python/helpers/pycharm/teamcity/pytest_plugin.py b/python/helpers/pycharm/teamcity/pytest_plugin.py new file mode 100644 index 000000000000..4076d561b7f4 --- /dev/null +++ b/python/helpers/pycharm/teamcity/pytest_plugin.py @@ -0,0 +1,309 @@ +# coding=utf-8 +""" +Aaron Buchanan +Nov. 2012 + +Plug-in for py.test for reporting to TeamCity server +Report results to TeamCity during test execution for immediate reporting + when using TeamCity. + +This should be installed as a py.test plugin and will be automatically enabled by running +tests under TeamCity build. +""" + +import os +import sys +import re +import traceback +from datetime import timedelta + +from teamcity.messages import TeamcityServiceMessages +from teamcity.common import limit_output, split_output, convert_error_to_string +from teamcity import is_running_under_teamcity + + +def pytest_addoption(parser): + group = parser.getgroup("terminal reporting", "reporting", after="general") + + group._addoption('--teamcity', action="count", + dest="teamcity", default=0, help="force output of JetBrains TeamCity service messages") + group._addoption('--no-teamcity', action="count", + dest="no_teamcity", default=0, help="disable output of JetBrains TeamCity service messages") + + +def pytest_configure(config): + if config.option.no_teamcity >= 1: + enabled = False + elif config.option.teamcity >= 1: + enabled = True + else: + enabled = is_running_under_teamcity() + + if enabled: + output_capture_enabled = getattr(config.option, 'capture', 'fd') != 'no' + coverage_controller = _get_coverage_controller(config) + + config._teamcityReporting = EchoTeamCityMessages(output_capture_enabled, coverage_controller) + config.pluginmanager.register(config._teamcityReporting) + + +def pytest_unconfigure(config): + teamcity_reporting = getattr(config, '_teamcityReporting', None) + if teamcity_reporting: + del config._teamcityReporting + config.pluginmanager.unregister(teamcity_reporting) + + +def _get_coverage_controller(config): + cov_plugin = config.pluginmanager.getplugin('_cov') + if not cov_plugin: + return None + + return cov_plugin.cov_controller + + +class EchoTeamCityMessages(object): + def __init__(self, output_capture_enabled, coverage_controller): + self.coverage_controller = coverage_controller + self.output_capture_enabled = output_capture_enabled + + self.teamcity = TeamcityServiceMessages() + self.test_start_reported_mark = set() + + self.max_reported_output_size = 1 * 1024 * 1024 + self.reported_output_chunk_size = 50000 + + def get_id_from_location(self, location): + if type(location) is not tuple or len(location) != 3 or not hasattr(location[2], "startswith"): + return None + + def convert_file_to_id(filename): + filename = re.sub(r"\.pyc?$", "", filename) + return filename.replace(os.sep, ".").replace("/", ".") + + def add_prefix_to_filename_id(filename_id, prefix): + dot_location = filename_id.rfind('.') + if dot_location <= 0 or dot_location >= len(filename_id) - 1: + return None + + return filename_id[:dot_location + 1] + prefix + filename_id[dot_location + 1:] + + pylint_prefix = '[pylint] ' + if location[2].startswith(pylint_prefix): + id_from_file = convert_file_to_id(location[2][len(pylint_prefix):]) + return id_from_file + ".Pylint" + + if location[2] == "PEP8-check": + id_from_file = convert_file_to_id(location[0]) + return id_from_file + ".PEP8" + + return None + + def format_test_id(self, nodeid, location): + id_from_location = self.get_id_from_location(location) + + if id_from_location is not None: + return id_from_location + + test_id = nodeid + + if test_id.find("::") < 0: + test_id += "::top_level" + + test_id = test_id.replace("::()::", "::") + test_id = re.sub(r"\.pyc?::", r"::", test_id) + test_id = test_id.replace(".", "_").replace(os.sep, ".").replace("/", ".").replace('::', '.') + + return test_id + + def format_location(self, location): + if type(location) is tuple and len(location) == 3: + return "%s:%s (%s)" % (str(location[0]), str(location[1]), str(location[2])) + return str(location) + + def pytest_runtest_logstart(self, nodeid, location): + self.ensure_test_start_reported(self.format_test_id(nodeid, location)) + + def ensure_test_start_reported(self, test_id): + if test_id not in self.test_start_reported_mark: + if self.output_capture_enabled: + capture_standard_output = "false" + else: + capture_standard_output = "true" + self.teamcity.testStarted(test_id, flowId=test_id, captureStandardOutput=capture_standard_output) + self.test_start_reported_mark.add(test_id) + + def report_has_output(self, report): + for (secname, data) in report.sections: + if report.when in secname and ('stdout' in secname or 'stderr' in secname): + return True + return False + + def report_test_output(self, report, test_id): + for (secname, data) in report.sections: + # https://github.com/JetBrains/teamcity-messages/issues/112 + # CollectReport doesn't have 'when' property + if hasattr(report, "when") and report.when not in secname: + continue + if not data: + continue + + if 'stdout' in secname: + for chunk in split_output(limit_output(data)): + self.teamcity.testStdOut(test_id, out=chunk, flowId=test_id) + elif 'stderr' in secname: + for chunk in split_output(limit_output(data)): + self.teamcity.testStdErr(test_id, out=chunk, flowId=test_id) + + def report_test_finished(self, test_id, duration=None): + self.teamcity.testFinished(test_id, testDuration=duration, flowId=test_id) + self.test_start_reported_mark.remove(test_id) + + def report_test_failure(self, test_id, report, message=None, report_output=True): + if hasattr(report, 'duration'): + duration = timedelta(seconds=report.duration) + else: + duration = None + + if message is None: + message = self.format_location(report.location) + + self.ensure_test_start_reported(test_id) + if report_output: + self.report_test_output(report, test_id) + self.teamcity.testFailed(test_id, message, str(report.longrepr), flowId=test_id) + self.report_test_finished(test_id, duration) + + def report_test_skip(self, test_id, report): + if type(report.longrepr) is tuple and len(report.longrepr) == 3: + reason = report.longrepr[2] + else: + reason = str(report.longrepr) + + if hasattr(report, 'duration'): + duration = timedelta(seconds=report.duration) + else: + duration = None + + self.ensure_test_start_reported(test_id) + self.report_test_output(report, test_id) + self.teamcity.testIgnored(test_id, reason, flowId=test_id) + self.report_test_finished(test_id, duration) + + def pytest_runtest_logreport(self, report): + """ + :type report: _pytest.runner.TestReport + """ + test_id = self.format_test_id(report.nodeid, report.location) + + duration = timedelta(seconds=report.duration) + + if report.passed: + # Do not report passed setup/teardown if no output + if report.when == 'call': + self.ensure_test_start_reported(test_id) + self.report_test_output(report, test_id) + self.report_test_finished(test_id, duration) + else: + if self.report_has_output(report): + block_name = "test " + report.when + self.teamcity.blockOpened(block_name, flowId=test_id) + self.report_test_output(report, test_id) + self.teamcity.blockClosed(block_name, flowId=test_id) + elif report.failed: + if report.when == 'call': + self.report_test_failure(test_id, report) + elif report.when == 'setup': + if self.report_has_output(report): + self.teamcity.blockOpened("test setup", flowId=test_id) + self.report_test_output(report, test_id) + self.teamcity.blockClosed("test setup", flowId=test_id) + + self.report_test_failure(test_id, report, message="test setup failed", report_output=False) + elif report.when == 'teardown': + # Report failed teardown as a separate test as original test is already finished + self.report_test_failure(test_id + "_teardown", report) + elif report.skipped: + self.report_test_skip(test_id, report) + + def pytest_collectreport(self, report): + test_id = self.format_test_id(report.nodeid, report.location) + "_collect" + + if report.failed: + self.report_test_failure(test_id, report) + elif report.skipped: + self.report_test_skip(test_id, report) + + def pytest_terminal_summary(self): + if self.coverage_controller is not None: + try: + self._report_coverage() + except: + tb = traceback.format_exc() + self.teamcity.customMessage("Coverage statistics reporting failed", "ERROR", errorDetails=tb) + + def _report_coverage(self): + from coverage.misc import NotPython + from coverage.report import Reporter + from coverage.results import Numbers + + class _CoverageReporter(Reporter): + def __init__(self, coverage, config, messages): + super(_CoverageReporter, self).__init__(coverage, config) + + self.branches = coverage.data.has_arcs() + self.messages = messages + + def report(self, morfs, outfile=None): + if hasattr(self, 'find_code_units'): + self.find_code_units(morfs) + else: + self.find_file_reporters(morfs) + + total = Numbers() + + if hasattr(self, 'code_units'): + units = self.code_units + else: + units = self.file_reporters + + for cu in units: + try: + analysis = self.coverage._analyze(cu) + nums = analysis.numbers + total += nums + except KeyboardInterrupt: + raise + except: + if self.config.ignore_errors: + continue + + err = sys.exc_info() + typ, msg = err[:2] + if typ is NotPython and not cu.should_be_python(): + continue + + test_id = cu.name + details = convert_error_to_string(err) + + self.messages.testStarted(test_id, flowId=test_id) + self.messages.testFailed(test_id, message="Coverage analysis failed", details=details, flowId=test_id) + self.messages.testFinished(test_id, flowId=test_id) + + if total.n_files > 0: + covered = total.n_executed + total_statements = total.n_statements + + if self.branches: + covered += total.n_executed_branches + total_statements += total.n_branches + + self.messages.buildStatisticLinesCovered(covered) + self.messages.buildStatisticTotalLines(total_statements) + self.messages.buildStatisticLinesUncovered(total_statements - covered) + reporter = _CoverageReporter( + self.coverage_controller.cov, + self.coverage_controller.cov.config, + self.teamcity, + ) + reporter.report(None) diff --git a/python/helpers/pycharm/teamcity/unittestpy.py b/python/helpers/pycharm/teamcity/unittestpy.py new file mode 100644 index 000000000000..046b035f3a36 --- /dev/null +++ b/python/helpers/pycharm/teamcity/unittestpy.py @@ -0,0 +1,171 @@ +# coding=utf-8 +import sys +from unittest import TestResult, TextTestRunner +import datetime +import re + +from teamcity.messages import TeamcityServiceMessages +from teamcity.common import is_string, get_class_fullname, convert_error_to_string + +_real_stdout = sys.stdout + + +class TeamcityTestResult(TestResult): + separator2 = "\n" + + def __init__(self, stream=_real_stdout, descriptions=None, verbosity=None): + super(TeamcityTestResult, self).__init__() + + self.test_started_datetime_map = {} + self.failed_tests = set() + self.subtest_failures = {} + self.messages = TeamcityServiceMessages(stream) + + def get_test_id(self, test): + if is_string(test): + return test + + # Force test_id for doctests + if get_class_fullname(test) != "doctest.DocTestCase": + desc = test.shortDescription() + test_method_name = getattr(test, "_testMethodName", "") + if desc and desc != test.id() and desc != test_method_name: + return "%s (%s)" % (test.id(), desc.replace('.', '_')) + + return test.id() + + def addSuccess(self, test): + super(TeamcityTestResult, self).addSuccess(test) + + def addExpectedFailure(self, test, err): + super(TeamcityTestResult, self).addExpectedFailure(test, err) + + err = convert_error_to_string(err) + test_id = self.get_test_id(test) + + self.messages.testIgnored(test_id, message="Expected failure: " + err, flowId=test_id) + + def addSkip(self, test, reason=""): + if sys.version_info >= (2, 7): + super(TeamcityTestResult, self).addSkip(test, reason) + + test_id = self.get_test_id(test) + + if reason: + reason_str = ": " + str(reason) + else: + reason_str = "" + self.messages.testIgnored(test_id, message="Skipped" + reason_str, flowId=test_id) + + def addUnexpectedSuccess(self, test): + super(TeamcityTestResult, self).addUnexpectedSuccess(test) + + test_id = self.get_test_id(test) + self.messages.testFailed(test_id, message='Failure', + details="Test should not succeed since it's marked with @unittest.expectedFailure", + flowId=test_id) + + def addError(self, test, err, *k): + super(TeamcityTestResult, self).addError(test, err) + + if get_class_fullname(test) == "unittest.suite._ErrorHolder": + # This is a standalone error + + test_name = test.id() + # patch setUpModule (__main__) -> __main__.setUpModule + test_name = re.sub(r'^(.*) \((.*)\)$', r'\2.\1', test_name) + + self.messages.testStarted(test_name, flowId=test_name) + self.report_fail(test_name, 'Failure', err) + self.messages.testFinished(test_name, flowId=test_name) + elif get_class_fullname(err[0]) == "unittest2.case.SkipTest": + message = "" + if hasattr(err[1], "message"): + message = getattr(err[1], "message", "") + elif hasattr(err[1], "args"): + message = getattr(err[1], "args", [""])[0] + self.addSkip(test, message) + else: + self.report_fail(test, 'Error', err) + + def addFailure(self, test, err, *k): + super(TeamcityTestResult, self).addFailure(test, err) + + self.report_fail(test, 'Failure', err) + + def addSubTest(self, test, subtest, err): + super(TeamcityTestResult, self).addSubTest(test, subtest, err) + + test_id = self.get_test_id(test) + + if err is not None: + if issubclass(err[0], test.failureException): + self.add_subtest_failure(test_id, self.get_test_id(subtest), err) + self.messages.testStdErr(test_id, out="%s: failure\n" % self.get_test_id(subtest), flowId=test_id) + else: + self.add_subtest_failure(test_id, self.get_test_id(subtest), err) + self.messages.testStdErr(test_id, out="%s: error\n" % self.get_test_id(subtest), flowId=test_id) + else: + self.messages.testStdOut(test_id, out="%s: ok\n" % self.get_test_id(subtest), flowId=test_id) + + def add_subtest_failure(self, test_id, subtest_id, err): + fail_array = self.subtest_failures.get(test_id, []) + fail_array.append("%s:\n%s" % (subtest_id, convert_error_to_string(err))) + self.subtest_failures[test_id] = fail_array + + def get_subtest_failure(self, test_id): + fail_array = self.subtest_failures.get(test_id, []) + return "\n".join(fail_array) + + def report_fail(self, test, fail_type, err): + test_id = self.get_test_id(test) + + if is_string(err): + details = err + elif get_class_fullname(err) == "twisted.python.failure.Failure": + details = err.getTraceback() + else: + details = convert_error_to_string(err) + + subtest_failure = self.get_subtest_failure(test_id) + if subtest_failure: + details = subtest_failure + "\n" + details + + self.messages.testFailed(test_id, message=fail_type, details=details, flowId=test_id) + self.failed_tests.add(test_id) + + def startTest(self, test): + super(TeamcityTestResult, self).startTest(test) + + test_id = self.get_test_id(test) + + self.test_started_datetime_map[test_id] = datetime.datetime.now() + self.messages.testStarted(test_id, captureStandardOutput='true', flowId=test_id) + + def stopTest(self, test): + super(TeamcityTestResult, self).stopTest(test) + + test_id = self.get_test_id(test) + + if test_id not in self.failed_tests and self.subtest_failures.get(test_id, []): + self.report_fail(test, "Subtest failed", "") + + time_diff = datetime.datetime.now() - self.test_started_datetime_map[test_id] + self.messages.testFinished(test_id, testDuration=time_diff, flowId=test_id) + + def printErrors(self): + pass + + +class TeamcityTestRunner(TextTestRunner): + resultclass = TeamcityTestResult + + if sys.version_info < (2, 7): + def _makeResult(self): + return TeamcityTestResult(self.stream, self.descriptions, self.verbosity) + + +if __name__ == '__main__': + from unittest import main + + main(module=None, testRunner=TeamcityTestRunner()) diff --git a/python/psi-api/src/com/jetbrains/python/psi/PyPsiFacade.java b/python/psi-api/src/com/jetbrains/python/psi/PyPsiFacade.java index c27881c65887..cf9f4db172f2 100644 --- a/python/psi-api/src/com/jetbrains/python/psi/PyPsiFacade.java +++ b/python/psi-api/src/com/jetbrains/python/psi/PyPsiFacade.java @@ -37,6 +37,7 @@ public abstract class PyPsiFacade { return ServiceManager.getService(project, PyPsiFacade.class); } + @NotNull public abstract QualifiedNameResolver qualifiedNameResolver(String qNameString); public abstract QualifiedNameResolver qualifiedNameResolver(QualifiedName qualifiedName); diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml index c440388b5be3..07e88d0b468b 100644 --- a/python/src/META-INF/python-core-common.xml +++ b/python/src/META-INF/python-core-common.xml @@ -151,7 +151,8 @@ - + + @@ -159,6 +160,7 @@ + diff --git a/python/src/com/jetbrains/python/PyFileChooserDescriptor.java b/python/src/com/jetbrains/python/PyFileChooserDescriptor.java new file mode 100644 index 000000000000..962bbde1a2e2 --- /dev/null +++ b/python/src/com/jetbrains/python/PyFileChooserDescriptor.java @@ -0,0 +1,33 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.python; + +import com.intellij.openapi.fileChooser.FileChooserDescriptor; +import com.intellij.openapi.util.Comparing; +import com.intellij.openapi.vfs.VirtualFile; + +/** + * Descriptor to choose only .py files and (probably) folders. + * @author Ilya.Kazakevich + */ +public final class PyFileChooserDescriptor extends FileChooserDescriptor { + public PyFileChooserDescriptor(final boolean chooseFolders) {super(true, chooseFolders, false, false, false, false);} + + @Override + public boolean isFileVisible(final VirtualFile file, final boolean showHiddenFiles) { + return file.isDirectory() || file.getExtension() == null || Comparing.equal(file.getExtension(), "py"); + } +} diff --git a/python/src/com/jetbrains/python/PythonHelper.java b/python/src/com/jetbrains/python/PythonHelper.java index 9c88ffe9b44d..49a9f50f125c 100644 --- a/python/src/com/jetbrains/python/PythonHelper.java +++ b/python/src/com/jetbrains/python/PythonHelper.java @@ -56,6 +56,12 @@ public enum PythonHelper implements HelperPackage { PYTEST("pycharm", "pytestrunner"), DOCSTRING("pycharm", "docrunner"), + // Runners for new test runner API. + + UNIVERSAL_UT("pycharm", "_jb_unittest_runner"), + UNIVERSAL_PYTEST("pycharm", "_jb_pytest_runner"), + UNIVERSAL_NOSE("pycharm", "_jb_nosetest_runner"), + BEHAVE("pycharm", "behave_runner"), LETTUCE("pycharm", "lettuce_runner"), diff --git a/python/src/com/jetbrains/python/psi/impl/PyPsiFacadeImpl.java b/python/src/com/jetbrains/python/psi/impl/PyPsiFacadeImpl.java index 8c82802fee87..a4ea26f79c79 100644 --- a/python/src/com/jetbrains/python/psi/impl/PyPsiFacadeImpl.java +++ b/python/src/com/jetbrains/python/psi/impl/PyPsiFacadeImpl.java @@ -44,6 +44,7 @@ public class PyPsiFacadeImpl extends PyPsiFacade { } @Override + @NotNull public QualifiedNameResolver qualifiedNameResolver(String qNameString) { return new QualifiedNameResolverImpl(qNameString); } diff --git a/python/src/com/jetbrains/python/run/AbstractPythonRunConfiguration.java b/python/src/com/jetbrains/python/run/AbstractPythonRunConfiguration.java index 8e4a4bc51466..3e8944a73d93 100644 --- a/python/src/com/jetbrains/python/run/AbstractPythonRunConfiguration.java +++ b/python/src/com/jetbrains/python/run/AbstractPythonRunConfiguration.java @@ -18,11 +18,9 @@ package com.jetbrains.python.run; import com.google.common.collect.Lists; import com.intellij.diagnostic.logging.LogConfigurationPanel; import com.intellij.execution.ExecutionBundle; -import com.intellij.execution.Location; import com.intellij.execution.configuration.AbstractRunConfiguration; import com.intellij.execution.configuration.EnvironmentVariablesComponent; import com.intellij.execution.configurations.*; -import com.intellij.execution.testframework.AbstractTestProxy; import com.intellij.openapi.module.Module; import com.intellij.openapi.module.ModuleManager; import com.intellij.openapi.module.ModuleType; @@ -37,17 +35,12 @@ import com.intellij.openapi.util.JDOMExternalizerUtil; import com.intellij.openapi.util.WriteExternalException; import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiElement; -import com.intellij.psi.util.PsiTreeUtil; import com.intellij.util.PathMappingSettings; import com.intellij.util.PlatformUtils; import com.jetbrains.python.PyBundle; import com.jetbrains.python.PythonModuleTypeBase; -import com.jetbrains.python.psi.PyClass; -import com.jetbrains.python.psi.PyFunction; import com.jetbrains.python.sdk.PythonEnvUtil; import com.jetbrains.python.sdk.PythonSdkType; -import com.jetbrains.python.testing.PyPsiLocationWithFixedClass; import org.jdom.Element; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -61,12 +54,7 @@ import java.util.Map; * @author Leonid Shalupov */ public abstract class AbstractPythonRunConfiguration> extends AbstractRunConfiguration - implements LocatableConfiguration, AbstractPythonRunConfigurationParams, CommandLinePatcher { - /** - * When passing path to test to runners, you should join parts with this char. - * I.e.: file.py::PyClassTest::test_method - */ - public static final String TEST_NAME_PARTS_SPLITTER = "::"; + implements AbstractPythonRunConfigurationParams, CommandLinePatcher { private String myInterpreterOptions = ""; private String myWorkingDirectory = ""; private String mySdkHome = ""; @@ -231,7 +219,7 @@ public abstract class AbstractPythonRunConfiguration location, @NotNull final AbstractTestProxy failedTest) { - PsiElement element = location.getPsiElement(); - PyClass pyClass = PsiTreeUtil.getParentOfType(element, PyClass.class, false); - if (location instanceof PyPsiLocationWithFixedClass) { - pyClass = ((PyPsiLocationWithFixedClass)location).getFixedClass(); - } - PyFunction pyFunction = PsiTreeUtil.getParentOfType(element, PyFunction.class, false); - final VirtualFile virtualFile = location.getVirtualFile(); - if (virtualFile != null) { - String path = virtualFile.getCanonicalPath(); - if (pyClass != null) { - path += TEST_NAME_PARTS_SPLITTER + pyClass.getName(); - } - if (pyFunction != null) { - path += TEST_NAME_PARTS_SPLITTER + pyFunction.getName(); - } - return path; - } - return null; - } /** * Note to inheritors: Always check {@link #getWorkingDirectory()} first. You should return it, if it is not empty since diff --git a/python/src/com/jetbrains/python/run/PythonRunConfigurationForm.java b/python/src/com/jetbrains/python/run/PythonRunConfigurationForm.java index bf9ca13ba021..b83aef4e59f0 100644 --- a/python/src/com/jetbrains/python/run/PythonRunConfigurationForm.java +++ b/python/src/com/jetbrains/python/run/PythonRunConfigurationForm.java @@ -28,6 +28,7 @@ import com.intellij.ui.PanelWithAnchor; import com.intellij.ui.RawCommandLineEditor; import com.intellij.ui.components.JBCheckBox; import com.intellij.ui.components.JBLabel; +import com.jetbrains.python.PyFileChooserDescriptor; import com.jetbrains.python.debugger.PyDebuggerOptionsProvider; import org.jetbrains.annotations.NotNull; @@ -57,12 +58,7 @@ public class PythonRunConfigurationForm implements PythonRunConfigurationParams, myProject = configuration.getProject(); - FileChooserDescriptor chooserDescriptor = new FileChooserDescriptor(true, false, false, false, false, false) { - @Override - public boolean isFileVisible(VirtualFile file, boolean showHiddenFiles) { - return file.isDirectory() || file.getExtension() == null || Comparing.equal(file.getExtension(), "py"); - } - }; + FileChooserDescriptor chooserDescriptor = new PyFileChooserDescriptor(false); //chooserDescriptor.setRoot(s.getProject().getBaseDir()); ComponentWithBrowseButton.BrowseFolderActionListener listener = diff --git a/python/src/com/jetbrains/python/testing/AbstractPythonOldTestRunConfiguration.java b/python/src/com/jetbrains/python/testing/AbstractPythonOldTestRunConfiguration.java new file mode 100644 index 000000000000..01d326473b28 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/AbstractPythonOldTestRunConfiguration.java @@ -0,0 +1,392 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.python.testing; + +import com.intellij.execution.configurations.ConfigurationFactory; +import com.intellij.execution.configurations.RefactoringListenerProvider; +import com.intellij.execution.configurations.RuntimeConfigurationError; +import com.intellij.execution.configurations.RuntimeConfigurationException; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Comparing; +import com.intellij.openapi.util.InvalidDataException; +import com.intellij.openapi.util.JDOMExternalizerUtil; +import com.intellij.openapi.util.WriteExternalException; +import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiDirectory; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.refactoring.listeners.RefactoringElementAdapter; +import com.intellij.refactoring.listeners.RefactoringElementListener; +import com.jetbrains.python.PyBundle; +import com.jetbrains.python.codeInsight.controlflow.ScopeOwner; +import com.jetbrains.python.psi.PyClass; +import com.jetbrains.python.psi.PyFile; +import com.jetbrains.python.psi.PyFunction; +import com.jetbrains.python.run.AbstractPythonRunConfiguration; +import com.jetbrains.python.run.AbstractPythonRunConfigurationParams; +import org.jdom.Element; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; + +/** + * Parent of all python test old-style test runners. + * For new style see {@link com.jetbrains.python.testing.universalTests} + * User: catherine + */ +public abstract class AbstractPythonOldTestRunConfiguration> + extends AbstractPythonTestRunConfiguration + implements AbstractPythonRunConfigurationParams, + AbstractPythonTestRunConfigurationParams, + RefactoringListenerProvider { + protected String myClassName = ""; + protected String myScriptName = ""; + protected String myMethodName = ""; + protected String myFolderName = ""; + protected TestType myTestType = TestType.TEST_SCRIPT; + + private String myPattern = ""; // pattern for modules in folder to match against + private boolean usePattern = false; + + protected AbstractPythonOldTestRunConfiguration(Project project, ConfigurationFactory configurationFactory) { + super(project, configurationFactory); + } + + @NotNull + @Override + public String getWorkingDirectorySafe() { + final String workingDirectoryFromConfig = getWorkingDirectory(); + if (StringUtil.isNotEmpty(workingDirectoryFromConfig)) { + return workingDirectoryFromConfig; + } + + final String folderName = myFolderName; + if (!StringUtil.isEmptyOrSpaces(folderName)) { + return folderName; + } + final String scriptName = myScriptName; + if (!StringUtil.isEmptyOrSpaces(scriptName)) { + final VirtualFile script = LocalFileSystem.getInstance().findFileByPath(scriptName); + if (script != null) { + return script.getParent().getPath(); + } + } + return super.getWorkingDirectorySafe(); + } + + @Override + public void readExternal(Element element) throws InvalidDataException { + super.readExternal(element); + myScriptName = JDOMExternalizerUtil.readField(element, "SCRIPT_NAME"); + myClassName = JDOMExternalizerUtil.readField(element, "CLASS_NAME"); + myMethodName = JDOMExternalizerUtil.readField(element, "METHOD_NAME"); + myFolderName = JDOMExternalizerUtil.readField(element, "FOLDER_NAME"); + + myPattern = JDOMExternalizerUtil.readField(element, "PATTERN"); + usePattern = Boolean.parseBoolean(JDOMExternalizerUtil.readField(element, "USE_PATTERN")); + + try { + final String testType = JDOMExternalizerUtil.readField(element, "TEST_TYPE"); + myTestType = testType != null ? TestType.valueOf(testType) : TestType.TEST_SCRIPT; + } + catch (IllegalArgumentException e) { + myTestType = TestType.TEST_SCRIPT; // safe default + } + } + + @Override + public void writeExternal(Element element) throws WriteExternalException { + super.writeExternal(element); + + JDOMExternalizerUtil.writeField(element, "SCRIPT_NAME", myScriptName); + JDOMExternalizerUtil.writeField(element, "CLASS_NAME", myClassName); + JDOMExternalizerUtil.writeField(element, "METHOD_NAME", myMethodName); + JDOMExternalizerUtil.writeField(element, "FOLDER_NAME", myFolderName); + JDOMExternalizerUtil.writeField(element, "TEST_TYPE", myTestType.toString()); + JDOMExternalizerUtil.writeField(element, "PATTERN", myPattern); + JDOMExternalizerUtil.writeField(element, "USE_PATTERN", String.valueOf(usePattern)); + } + + public AbstractPythonRunConfigurationParams getBaseParams() { + return this; + } + + public String getClassName() { + return myClassName; + } + + public void setClassName(String className) { + myClassName = className; + } + + public String getFolderName() { + return myFolderName; + } + + public void setFolderName(String folderName) { + myFolderName = folderName; + } + + public String getScriptName() { + return myScriptName; + } + + public void setScriptName(String scriptName) { + myScriptName = scriptName; + } + + public String getMethodName() { + return myMethodName; + } + + public void setMethodName(String methodName) { + myMethodName = methodName; + } + + public TestType getTestType() { + return myTestType; + } + + public void setTestType(TestType testType) { + myTestType = testType; + } + + public String getPattern() { + return myPattern; + } + + public void setPattern(String pattern) { + myPattern = pattern; + } + + public boolean usePattern() { + return usePattern; + } + + public void usePattern(boolean usePattern) { + this.usePattern = usePattern; + } + + public enum TestType { + TEST_FOLDER, + TEST_SCRIPT, + TEST_CLASS, + TEST_METHOD, + TEST_FUNCTION, + } + + @Override + public void checkConfiguration() throws RuntimeConfigurationException { + super.checkConfiguration(); + + if (StringUtil.isEmptyOrSpaces(myFolderName) && myTestType == TestType.TEST_FOLDER) { + throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_folder_name")); + } + + if (StringUtil.isEmptyOrSpaces(getScriptName()) && myTestType != TestType.TEST_FOLDER) { + throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_script_name")); + } + + if (StringUtil.isEmptyOrSpaces(myClassName) && (myTestType == TestType.TEST_METHOD || myTestType == TestType.TEST_CLASS)) { + throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_class_name")); + } + + if (StringUtil.isEmptyOrSpaces(myMethodName) && (myTestType == TestType.TEST_METHOD || myTestType == TestType.TEST_FUNCTION)) { + throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_method_name")); + } + } + + public boolean compareSettings(AbstractPythonOldTestRunConfiguration cfg) { + if (cfg == null) return false; + + if (getTestType() != cfg.getTestType()) return false; + + switch (getTestType()) { + case TEST_FOLDER: + return getFolderName().equals(cfg.getFolderName()); + case TEST_SCRIPT: + return getScriptName().equals(cfg.getScriptName()) && + getWorkingDirectory().equals(cfg.getWorkingDirectory()); + case TEST_CLASS: + return getScriptName().equals(cfg.getScriptName()) && + getWorkingDirectory().equals(cfg.getWorkingDirectory()) && + getClassName().equals(cfg.getClassName()); + case TEST_METHOD: + return getScriptName().equals(cfg.getScriptName()) && + getWorkingDirectory().equals(cfg.getWorkingDirectory()) && + getClassName().equals(cfg.getClassName()) && + getMethodName().equals(cfg.getMethodName()); + case TEST_FUNCTION: + return getScriptName().equals(cfg.getScriptName()) && + getWorkingDirectory().equals(cfg.getWorkingDirectory()) && + getMethodName().equals(cfg.getMethodName()); + default: + throw new IllegalStateException("Unknown test type: " + getTestType()); + } + } + + public static void copyParams(AbstractPythonTestRunConfigurationParams source, AbstractPythonTestRunConfigurationParams target) { + AbstractPythonRunConfiguration.copyParams(source.getBaseParams(), target.getBaseParams()); + target.setScriptName(source.getScriptName()); + target.setClassName(source.getClassName()); + target.setFolderName(source.getFolderName()); + target.setMethodName(source.getMethodName()); + target.setTestType(source.getTestType()); + target.setPattern(source.getPattern()); + target.usePattern(source.usePattern()); + target.setAddContentRoots(source.shouldAddContentRoots()); + target.setAddSourceRoots(source.shouldAddSourceRoots()); + } + + public AbstractPythonTestRunConfigurationParams getTestRunConfigurationParams() { + return this; + } + + @Override + public String suggestedName() { + switch (myTestType) { + case TEST_CLASS: + return getPluralTitle() + " in " + myClassName; + case TEST_METHOD: + return getTitle() + " " + myClassName + "." + myMethodName; + case TEST_SCRIPT: + String name = new File(getScriptName()).getName(); + name = StringUtil.trimEnd(name, ".py"); + return getPluralTitle() + " in " + name; + case TEST_FOLDER: + String folderName = new File(myFolderName).getName(); + return getPluralTitle() + " in " + folderName; + case TEST_FUNCTION: + return getTitle() + " " + myMethodName; + default: + throw new IllegalStateException("Unknown test type: " + myTestType); + } + } + + @Nullable + @Override + public String getActionName() { + if (TestType.TEST_METHOD.equals(myTestType)) { + return getTitle() + " " + myMethodName; + } + return suggestedName(); + } + + protected abstract String getTitle(); + + protected abstract String getPluralTitle(); + + @Override + public RefactoringElementListener getRefactoringElementListener(PsiElement element) { + if (element instanceof PsiDirectory) { + VirtualFile vFile = ((PsiDirectory)element).getVirtualFile(); + if ((myTestType == TestType.TEST_FOLDER && pathsEqual(vFile, myFolderName)) || pathsEqual(vFile, getWorkingDirectory())) { + return new RefactoringElementAdapter() { + @Override + protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { + String newPath = FileUtil.toSystemDependentName(((PsiDirectory)newElement).getVirtualFile().getPath()); + setWorkingDirectory(newPath); + if (myTestType == TestType.TEST_FOLDER) { + myFolderName = newPath; + } + } + + @Override + public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { + final String systemDependant = FileUtil.toSystemDependentName(oldQualifiedName); + setWorkingDirectory(systemDependant); + if (myTestType == TestType.TEST_FOLDER) { + myFolderName = systemDependant; + } + } + }; + } + return null; + } + if (myTestType == TestType.TEST_FOLDER) { + return null; + } + File scriptFile = new File(myScriptName); + if (!scriptFile.isAbsolute()) { + scriptFile = new File(getWorkingDirectory(), myScriptName); + } + PsiFile containingFile = element.getContainingFile(); + VirtualFile vFile = containingFile == null ? null : containingFile.getVirtualFile(); + if (vFile != null && Comparing.equal(new File(vFile.getPath()).getAbsolutePath(), scriptFile.getAbsolutePath())) { + if (element instanceof PsiFile) { + return new RefactoringElementAdapter() { + @Override + protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { + VirtualFile virtualFile = ((PsiFile)newElement).getVirtualFile(); + if (virtualFile != null) { + myScriptName = FileUtil.toSystemDependentName(virtualFile.getPath()); + } + } + + @Override + public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { + myScriptName = FileUtil.toSystemDependentName(oldQualifiedName); + } + }; + } + if (element instanceof PyClass && (myTestType == TestType.TEST_CLASS || myTestType == TestType.TEST_METHOD) && + Comparing.equal(((PyClass)element).getName(), myClassName)) { + return new RefactoringElementAdapter() { + @Override + protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { + myClassName = ((PyClass)newElement).getName(); + } + + @Override + public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { + myClassName = oldQualifiedName; + } + }; + } + if (element instanceof PyFunction && + Comparing.equal(((PyFunction)element).getName(), myMethodName)) { + ScopeOwner scopeOwner = PsiTreeUtil.getParentOfType(element, ScopeOwner.class); + if ((myTestType == TestType.TEST_FUNCTION && scopeOwner instanceof PyFile) || + (myTestType == TestType.TEST_METHOD && scopeOwner instanceof PyClass && Comparing.equal(scopeOwner.getName(), myClassName))) { + return new RefactoringElementAdapter() { + @Override + protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { + myMethodName = ((PyFunction)newElement).getName(); + } + + @Override + public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { + final int methodIdx = oldQualifiedName.indexOf("#") + 1; + if (methodIdx > 0 && methodIdx < oldQualifiedName.length()) { + myMethodName = oldQualifiedName.substring(methodIdx); + } + } + }; + } + } + } + return null; + } + + private static boolean pathsEqual(VirtualFile vFile, final String folderName) { + return Comparing.equal(new File(vFile.getPath()).getAbsolutePath(), new File(folderName).getAbsolutePath()); + } +} diff --git a/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfiguration.java b/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfiguration.java index 58fe5bab6c1b..8b4e82a25c1d 100644 --- a/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfiguration.java +++ b/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2000-2015 JetBrains s.r.o. + * Copyright 2000-2017 JetBrains s.r.o. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,372 +15,62 @@ */ package com.jetbrains.python.testing; +import com.intellij.execution.Location; import com.intellij.execution.configurations.ConfigurationFactory; -import com.intellij.execution.configurations.RefactoringListenerProvider; -import com.intellij.execution.configurations.RuntimeConfigurationError; -import com.intellij.execution.configurations.RuntimeConfigurationException; +import com.intellij.execution.testframework.AbstractTestProxy; import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.Comparing; -import com.intellij.openapi.util.InvalidDataException; -import com.intellij.openapi.util.JDOMExternalizerUtil; -import com.intellij.openapi.util.WriteExternalException; -import com.intellij.openapi.util.io.FileUtil; -import com.intellij.openapi.util.text.StringUtil; -import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiDirectory; import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; import com.intellij.psi.util.PsiTreeUtil; -import com.intellij.refactoring.listeners.RefactoringElementAdapter; -import com.intellij.refactoring.listeners.RefactoringElementListener; -import com.jetbrains.python.PyBundle; -import com.jetbrains.python.codeInsight.controlflow.ScopeOwner; import com.jetbrains.python.psi.PyClass; -import com.jetbrains.python.psi.PyFile; import com.jetbrains.python.psi.PyFunction; import com.jetbrains.python.run.AbstractPythonRunConfiguration; -import com.jetbrains.python.run.AbstractPythonRunConfigurationParams; -import org.jdom.Element; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.io.File; - /** - * User: catherine + * Parent of all test configurations + * + * @author Ilya.Kazakevich */ -public abstract class AbstractPythonTestRunConfiguration> extends AbstractPythonRunConfiguration - implements AbstractPythonRunConfigurationParams, - AbstractPythonTestRunConfigurationParams, - RefactoringListenerProvider { - protected String myClassName = ""; - protected String myScriptName = ""; - protected String myMethodName = ""; - protected String myFolderName = ""; - protected TestType myTestType = TestType.TEST_SCRIPT; +public abstract class AbstractPythonTestRunConfiguration> + extends AbstractPythonRunConfiguration { + /** + * When passing path to test to runners, you should join parts with this char. + * I.e.: file.py::PyClassTest::test_method + */ + protected static final String TEST_NAME_PARTS_SPLITTER = "::"; - private String myPattern = ""; // pattern for modules in folder to match against - private boolean usePattern = false; - - protected AbstractPythonTestRunConfiguration(Project project, ConfigurationFactory configurationFactory) { - super(project, configurationFactory); - } - - @NotNull - @Override - public String getWorkingDirectorySafe() { - final String workingDirectoryFromConfig = getWorkingDirectory(); - if (StringUtil.isNotEmpty(workingDirectoryFromConfig)) { - return workingDirectoryFromConfig; - } - - final String folderName = myFolderName; - if (!StringUtil.isEmptyOrSpaces(folderName)) { - return folderName; - } - final String scriptName = myScriptName; - if (!StringUtil.isEmptyOrSpaces(scriptName)) { - final VirtualFile script = LocalFileSystem.getInstance().findFileByPath(scriptName); - if (script != null) { - return script.getParent().getPath(); - } - } - return super.getWorkingDirectorySafe(); - } - - @Override - public void readExternal(Element element) throws InvalidDataException { - super.readExternal(element); - myScriptName = JDOMExternalizerUtil.readField(element, "SCRIPT_NAME"); - myClassName = JDOMExternalizerUtil.readField(element, "CLASS_NAME"); - myMethodName = JDOMExternalizerUtil.readField(element, "METHOD_NAME"); - myFolderName = JDOMExternalizerUtil.readField(element, "FOLDER_NAME"); - - myPattern = JDOMExternalizerUtil.readField(element, "PATTERN"); - usePattern = Boolean.parseBoolean(JDOMExternalizerUtil.readField(element, "USE_PATTERN")); - - try { - final String testType = JDOMExternalizerUtil.readField(element, "TEST_TYPE"); - myTestType = testType != null ? TestType.valueOf(testType) : TestType.TEST_SCRIPT; - } - catch (IllegalArgumentException e) { - myTestType = TestType.TEST_SCRIPT; // safe default - } - } - - @Override - public void writeExternal(Element element) throws WriteExternalException { - super.writeExternal(element); - - JDOMExternalizerUtil.writeField(element, "SCRIPT_NAME", myScriptName); - JDOMExternalizerUtil.writeField(element, "CLASS_NAME", myClassName); - JDOMExternalizerUtil.writeField(element, "METHOD_NAME", myMethodName); - JDOMExternalizerUtil.writeField(element, "FOLDER_NAME", myFolderName); - JDOMExternalizerUtil.writeField(element, "TEST_TYPE", myTestType.toString()); - JDOMExternalizerUtil.writeField(element, "PATTERN", myPattern); - JDOMExternalizerUtil.writeField(element, "USE_PATTERN", String.valueOf(usePattern)); - } - - public AbstractPythonRunConfigurationParams getBaseParams() { - return this; - } - - public String getClassName() { - return myClassName; - } - - public void setClassName(String className) { - myClassName = className; - } - public String getFolderName() { - return myFolderName; - } - - public void setFolderName(String folderName) { - myFolderName = folderName; - } - - public String getScriptName() { - return myScriptName; - } - - public void setScriptName(String scriptName) { - myScriptName = scriptName; - } - - public String getMethodName() { - return myMethodName; - } - - public void setMethodName(String methodName) { - myMethodName = methodName; - } - - public TestType getTestType() { - return myTestType; - } - - public void setTestType(TestType testType) { - myTestType = testType; - } - - public String getPattern() { - return myPattern; - } - - public void setPattern(String pattern) { - myPattern = pattern; - } - - public boolean usePattern() { - return usePattern; - } - - public void usePattern(boolean usePattern) { - this.usePattern = usePattern; - } - - public enum TestType { - TEST_FOLDER, - TEST_SCRIPT, - TEST_CLASS, - TEST_METHOD, - TEST_FUNCTION,} - - @Override - public void checkConfiguration() throws RuntimeConfigurationException { - super.checkConfiguration(); - - if (StringUtil.isEmptyOrSpaces(myFolderName) && myTestType == TestType.TEST_FOLDER) { - throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_folder_name")); - } - - if (StringUtil.isEmptyOrSpaces(getScriptName()) && myTestType != TestType.TEST_FOLDER) { - throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_script_name")); - } - - if (StringUtil.isEmptyOrSpaces(myClassName) && (myTestType == TestType.TEST_METHOD || myTestType == TestType.TEST_CLASS)) { - throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_class_name")); - } - - if (StringUtil.isEmptyOrSpaces(myMethodName) && (myTestType == TestType.TEST_METHOD || myTestType == TestType.TEST_FUNCTION)) { - throw new RuntimeConfigurationError(PyBundle.message("runcfg.unittest.no_method_name")); - } - } - - public boolean compareSettings(AbstractPythonTestRunConfiguration cfg) { - if (cfg == null) return false; - - if (getTestType() != cfg.getTestType()) return false; - - switch (getTestType()) { - case TEST_FOLDER: - return getFolderName().equals(cfg.getFolderName()); - case TEST_SCRIPT: - return getScriptName().equals(cfg.getScriptName()) && - getWorkingDirectory().equals(cfg.getWorkingDirectory()); - case TEST_CLASS: - return getScriptName().equals(cfg.getScriptName()) && - getWorkingDirectory().equals(cfg.getWorkingDirectory()) && - getClassName().equals(cfg.getClassName()); - case TEST_METHOD: - return getScriptName().equals(cfg.getScriptName()) && - getWorkingDirectory().equals(cfg.getWorkingDirectory()) && - getClassName().equals(cfg.getClassName()) && - getMethodName().equals(cfg.getMethodName()); - case TEST_FUNCTION: - return getScriptName().equals(cfg.getScriptName()) && - getWorkingDirectory().equals(cfg.getWorkingDirectory()) && - getMethodName().equals(cfg.getMethodName()); - default: - throw new IllegalStateException("Unknown test type: " + getTestType()); - } - } - - public static void copyParams(AbstractPythonTestRunConfigurationParams source, AbstractPythonTestRunConfigurationParams target) { - AbstractPythonRunConfiguration.copyParams(source.getBaseParams(), target.getBaseParams()); - target.setScriptName(source.getScriptName()); - target.setClassName(source.getClassName()); - target.setFolderName(source.getFolderName()); - target.setMethodName(source.getMethodName()); - target.setTestType(source.getTestType()); - target.setPattern(source.getPattern()); - target.usePattern(source.usePattern()); - target.setAddContentRoots(source.shouldAddContentRoots()); - target.setAddSourceRoots(source.shouldAddSourceRoots()); - } - - public AbstractPythonTestRunConfigurationParams getTestRunConfigurationParams() { - return this; - } - - @Override - public String suggestedName() { - switch (myTestType) { - case TEST_CLASS: - return getPluralTitle() + " in " + myClassName; - case TEST_METHOD: - return getTitle() + " " + myClassName + "." + myMethodName; - case TEST_SCRIPT: - String name = new File(getScriptName()).getName(); - name = StringUtil.trimEnd(name, ".py"); - return getPluralTitle() + " in " + name; - case TEST_FOLDER: - String folderName = new File(myFolderName).getName(); - return getPluralTitle() + " in " + folderName; - case TEST_FUNCTION: - return getTitle() + " " + myMethodName; - default: - throw new IllegalStateException("Unknown test type: " + myTestType); - } + protected AbstractPythonTestRunConfiguration(Project project, ConfigurationFactory factory) { + super(project, factory); } + /** + * Create test spec (string to be passed to runner, probably glued with {@link AbstractPythonOldTestRunConfiguration#TEST_NAME_PARTS_SPLITTER}) + * + * @param location test location as reported by runner + * @param failedTest failed test + * @return string spec or null if spec calculation is impossible + */ @Nullable - @Override - public String getActionName() { - if (TestType.TEST_METHOD.equals(myTestType)) - return getTitle() + " " + myMethodName; - return suggestedName(); - } - - protected abstract String getTitle(); - - protected abstract String getPluralTitle(); - - @Override - public RefactoringElementListener getRefactoringElementListener(PsiElement element) { - if (element instanceof PsiDirectory) { - VirtualFile vFile = ((PsiDirectory)element).getVirtualFile(); - if ((myTestType == TestType.TEST_FOLDER && pathsEqual(vFile, myFolderName)) || pathsEqual(vFile, getWorkingDirectory())) { - return new RefactoringElementAdapter() { - @Override - protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { - String newPath = FileUtil.toSystemDependentName(((PsiDirectory)newElement).getVirtualFile().getPath()); - setWorkingDirectory(newPath); - if (myTestType == TestType.TEST_FOLDER) { - myFolderName = newPath; - } - } - - @Override - public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { - final String systemDependant = FileUtil.toSystemDependentName(oldQualifiedName); - setWorkingDirectory(systemDependant); - if (myTestType == TestType.TEST_FOLDER) { - myFolderName = systemDependant; - } - } - }; - } - return null; + public String getTestSpec(@NotNull final Location location, @NotNull final AbstractTestProxy failedTest) { + PsiElement element = location.getPsiElement(); + PyClass pyClass = PsiTreeUtil.getParentOfType(element, PyClass.class, false); + if (location instanceof PyPsiLocationWithFixedClass) { + pyClass = ((PyPsiLocationWithFixedClass)location).getFixedClass(); } - if (myTestType == TestType.TEST_FOLDER) { - return null; - } - File scriptFile = new File(myScriptName); - if (!scriptFile.isAbsolute()) { - scriptFile = new File(getWorkingDirectory(), myScriptName); - } - PsiFile containingFile = element.getContainingFile(); - VirtualFile vFile = containingFile == null ? null : containingFile.getVirtualFile(); - if (vFile != null && Comparing.equal(new File(vFile.getPath()).getAbsolutePath(), scriptFile.getAbsolutePath())) { - if (element instanceof PsiFile) { - return new RefactoringElementAdapter() { - @Override - protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { - VirtualFile virtualFile = ((PsiFile)newElement).getVirtualFile(); - if (virtualFile != null) { - myScriptName = FileUtil.toSystemDependentName(virtualFile.getPath()); - } - } - - @Override - public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { - myScriptName = FileUtil.toSystemDependentName(oldQualifiedName); - } - }; + PyFunction pyFunction = PsiTreeUtil.getParentOfType(element, PyFunction.class, false); + final VirtualFile virtualFile = location.getVirtualFile(); + if (virtualFile != null) { + String path = virtualFile.getCanonicalPath(); + if (pyClass != null) { + path += TEST_NAME_PARTS_SPLITTER + pyClass.getName(); } - if (element instanceof PyClass && (myTestType == TestType.TEST_CLASS || myTestType == TestType.TEST_METHOD) && - Comparing.equal(((PyClass)element).getName(), myClassName)) { - return new RefactoringElementAdapter() { - @Override - protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { - myClassName = ((PyClass) newElement).getName(); - } - - @Override - public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { - myClassName = oldQualifiedName; - } - }; - } - if (element instanceof PyFunction && - Comparing.equal(((PyFunction) element).getName(), myMethodName)) { - ScopeOwner scopeOwner = PsiTreeUtil.getParentOfType(element, ScopeOwner.class); - if ((myTestType == TestType.TEST_FUNCTION && scopeOwner instanceof PyFile) || - (myTestType == TestType.TEST_METHOD && scopeOwner instanceof PyClass && Comparing.equal(scopeOwner.getName(), myClassName))) { - return new RefactoringElementAdapter() { - @Override - protected void elementRenamedOrMoved(@NotNull PsiElement newElement) { - myMethodName = ((PyFunction) newElement).getName(); - } - - @Override - public void undoElementMovedOrRenamed(@NotNull PsiElement newElement, @NotNull String oldQualifiedName) { - final int methodIdx = oldQualifiedName.indexOf("#") + 1; - if (methodIdx > 0 && methodIdx < oldQualifiedName.length()) { - myMethodName = oldQualifiedName.substring(methodIdx); - } - } - }; - } + if (pyFunction != null) { + path += TEST_NAME_PARTS_SPLITTER + pyFunction.getName(); } + return path; } return null; } - - private static boolean pathsEqual(VirtualFile vFile, final String folderName) { - return Comparing.equal(new File(vFile.getPath()).getAbsolutePath(), new File(folderName).getAbsolutePath()); - } } diff --git a/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfigurationParams.java b/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfigurationParams.java index 6e543b0155e8..9a42af3cafe4 100644 --- a/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfigurationParams.java +++ b/python/src/com/jetbrains/python/testing/AbstractPythonTestRunConfigurationParams.java @@ -35,8 +35,8 @@ public interface AbstractPythonTestRunConfigurationParams { String getMethodName(); void setMethodName(String methodName); - AbstractPythonTestRunConfiguration.TestType getTestType(); - void setTestType(AbstractPythonTestRunConfiguration.TestType testType); + AbstractPythonOldTestRunConfiguration.TestType getTestType(); + void setTestType(AbstractPythonOldTestRunConfiguration.TestType testType); boolean usePattern(); void usePattern(boolean isPureUnittest); diff --git a/python/src/com/jetbrains/python/testing/PyRerunAwareConfiguration.java b/python/src/com/jetbrains/python/testing/PyRerunAwareConfiguration.java new file mode 100644 index 000000000000..dd6280e31f96 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/PyRerunAwareConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.python.testing; + +import com.intellij.execution.Location; +import com.intellij.execution.testframework.AbstractTestProxy; +import com.intellij.openapi.util.Pair; +import com.intellij.psi.search.GlobalSearchScope; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * {@link AbstractPythonTestRunConfiguration} that handles failed test rerun by itself + * + * @author Ilya.Kazakevich + */ +@FunctionalInterface +public interface PyRerunAwareConfiguration { + + /** + * Lunched each time user clicks "rerun". Must return new test specs. + */ + @NotNull + List getTestSpecsForRerun(@NotNull GlobalSearchScope scope, @NotNull List, AbstractTestProxy>> locations); +} diff --git a/python/src/com/jetbrains/python/testing/PyRerunFailedTestsAction.java b/python/src/com/jetbrains/python/testing/PyRerunFailedTestsAction.java index a3b6681d8c6c..2b7e5c1f9653 100644 --- a/python/src/com/jetbrains/python/testing/PyRerunFailedTestsAction.java +++ b/python/src/com/jetbrains/python/testing/PyRerunFailedTestsAction.java @@ -31,6 +31,7 @@ import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.module.Module; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.ComponentContainer; +import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.text.StringUtil; import com.intellij.psi.PsiElement; import com.intellij.psi.search.GlobalSearchScope; @@ -74,7 +75,7 @@ public class PyRerunFailedTestsAction extends AbstractRerunFailedTestsAction { @Nullable @Override public RunProfileState getState(@NotNull Executor executor, @NotNull ExecutionEnvironment env) throws ExecutionException { - final AbstractPythonRunConfiguration configuration = ((AbstractPythonRunConfiguration)getPeer()); + final AbstractPythonTestRunConfiguration configuration = ((AbstractPythonTestRunConfiguration)getPeer()); // If configuration wants to take care about rerun itself if (configuration instanceof TestRunConfigurationReRunResponsible) { @@ -93,13 +94,13 @@ public class PyRerunFailedTestsAction extends AbstractRerunFailedTestsAction { } } - private class FailedPythonTestCommandLineStateBase extends PythonTestCommandLineStateBase { - private final PythonTestCommandLineStateBase myState; + private class FailedPythonTestCommandLineStateBase extends PythonTestCommandLineStateBase> { + private final PythonTestCommandLineStateBase myState; private final Project myProject; - public FailedPythonTestCommandLineStateBase(AbstractPythonRunConfiguration configuration, + public FailedPythonTestCommandLineStateBase(AbstractPythonTestRunConfiguration configuration, ExecutionEnvironment env, - PythonTestCommandLineStateBase state) { + PythonTestCommandLineStateBase state) { super(configuration, env); myState = state; myProject = configuration.getProject(); @@ -128,25 +129,38 @@ public class PyRerunFailedTestsAction extends AbstractRerunFailedTestsAction { @NotNull @Override protected List getTestSpecs() { - List specs = new ArrayList<>(); - List failedTests = getFailedTests(myProject); - for (AbstractTestProxy failedTest : failedTests) { + + final List, AbstractTestProxy>> failedTestLocations = new ArrayList<>(); + final List failedTests = getFailedTests(myProject); + for (final AbstractTestProxy failedTest : failedTests) { if (failedTest.isLeaf()) { final Location location = failedTest.getLocation(myProject, myConsoleProperties.getScope()); if (location != null) { - final String spec = getConfiguration().getTestSpec(location, failedTest); - if (spec != null && !specs.contains(spec)) { - specs.add(spec); - } + failedTestLocations.add(Pair.create(location, failedTest)); } } } - if (specs.isEmpty()) { + + final List result; + final AbstractPythonTestRunConfiguration configuration = getConfiguration(); + if (configuration instanceof PyRerunAwareConfiguration) { + result = ((PyRerunAwareConfiguration)configuration).getTestSpecsForRerun(myConsoleProperties.getScope(), failedTestLocations); + } + else { + final Collection locations = new LinkedHashSet<>(); + locations.addAll(failedTestLocations.stream() + .map(o -> configuration.getTestSpec(o.first, o.second)) + .filter(o -> o != null) + .collect(Collectors.toList())); + result = new ArrayList<>(locations); + } + + if (result.isEmpty()) { final List locations = failedTests.stream().map(AbstractTestProxy::getLocationUrl).collect(Collectors.toList()); Logger.getInstance(FailedPythonTestCommandLineStateBase.class).warn( String.format("Can't resolve specs for the following tests: %s", StringUtil.join(locations, ", "))); } - return specs; + return result; } @Override diff --git a/python/src/com/jetbrains/python/testing/PythonTestCommandLineStateBase.java b/python/src/com/jetbrains/python/testing/PythonTestCommandLineStateBase.java index 49b74c2c0798..782ef167fdbe 100644 --- a/python/src/com/jetbrains/python/testing/PythonTestCommandLineStateBase.java +++ b/python/src/com/jetbrains/python/testing/PythonTestCommandLineStateBase.java @@ -47,14 +47,14 @@ import java.util.Map; /** * @author yole */ -public abstract class PythonTestCommandLineStateBase extends PythonCommandLineState { - protected final AbstractPythonRunConfiguration myConfiguration; +public abstract class PythonTestCommandLineStateBase> extends PythonCommandLineState { + protected final T myConfiguration; - public AbstractPythonRunConfiguration getConfiguration() { + public T getConfiguration() { return myConfiguration; } - public PythonTestCommandLineStateBase(AbstractPythonRunConfiguration configuration, ExecutionEnvironment env) { + public PythonTestCommandLineStateBase(T configuration, ExecutionEnvironment env) { super(configuration, env); myConfiguration = configuration; setRunWithPty(false); diff --git a/python/src/com/jetbrains/python/testing/PythonTestConfigurationType.java b/python/src/com/jetbrains/python/testing/PythonTestConfigurationType.java index 4aef83b4c273..1522f359f50a 100644 --- a/python/src/com/jetbrains/python/testing/PythonTestConfigurationType.java +++ b/python/src/com/jetbrains/python/testing/PythonTestConfigurationType.java @@ -1,5 +1,5 @@ /* - * Copyright 2000-2014 JetBrains s.r.o. + * Copyright 2000-2017 JetBrains s.r.o. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,125 +15,25 @@ */ package com.jetbrains.python.testing; -import com.intellij.execution.configurations.ConfigurationFactory; import com.intellij.execution.configurations.ConfigurationType; -import com.intellij.execution.configurations.ConfigurationTypeUtil; -import com.intellij.execution.configurations.RunConfiguration; -import com.intellij.openapi.project.Project; import com.jetbrains.python.PyBundle; -import com.jetbrains.python.run.PythonConfigurationFactoryBase; -import com.jetbrains.python.testing.doctest.PythonDocTestRunConfiguration; -import com.jetbrains.python.testing.nosetest.PythonNoseTestRunConfiguration; -import com.jetbrains.python.testing.pytest.PyTestRunConfiguration; -import com.jetbrains.python.testing.unittest.PythonUnitTestRunConfiguration; import icons.PythonIcons; -import org.jetbrains.annotations.NotNull; import javax.swing.*; /** - * User : catherine + * @author Ilya.Kazakevich */ -public class PythonTestConfigurationType implements ConfigurationType { - public static final String ID = "tests"; - - public final PythonDocTestConfigurationFactory PY_DOCTEST_FACTORY = new PythonDocTestConfigurationFactory(this); - public final PythonUnitTestConfigurationFactory PY_UNITTEST_FACTORY = new PythonUnitTestConfigurationFactory(this); - public final PythonNoseTestConfigurationFactory PY_NOSETEST_FACTORY = new PythonNoseTestConfigurationFactory(this); - public final PythonPyTestConfigurationFactory PY_PYTEST_FACTORY = new PythonPyTestConfigurationFactory(this); - - public static PythonTestConfigurationType getInstance() { - return ConfigurationTypeUtil.findConfigurationType(PythonTestConfigurationType.class); - } - - private static class PythonUnitTestConfigurationFactory extends PythonConfigurationFactoryBase { - protected PythonUnitTestConfigurationFactory(ConfigurationType configurationType) { - super(configurationType); - } - - @Override - public RunConfiguration createTemplateConfiguration(Project project) { - return new PythonUnitTestRunConfiguration(project, this); - } - - @Override - public String getName() { - return PyBundle.message("runcfg.unittest.display_name"); - } - } - - private static class PythonDocTestConfigurationFactory extends PythonConfigurationFactoryBase { - protected PythonDocTestConfigurationFactory(ConfigurationType configurationType) { - super(configurationType); - } - - @Override - public RunConfiguration createTemplateConfiguration(Project project) { - return new PythonDocTestRunConfiguration(project, this); - } - - @Override - public String getName() { - return PyBundle.message("runcfg.doctest.display_name"); - } - } - - private static class PythonPyTestConfigurationFactory extends PythonConfigurationFactoryBase { - protected PythonPyTestConfigurationFactory(ConfigurationType configurationType) { - super(configurationType); - } - - @Override - public RunConfiguration createTemplateConfiguration(Project project) { - return new PyTestRunConfiguration(project, this); - } - - @Override - public String getName() { - return PyBundle.message("runcfg.pytest.display_name"); - } - } - - private static class PythonNoseTestConfigurationFactory extends PythonConfigurationFactoryBase { - protected PythonNoseTestConfigurationFactory(ConfigurationType configurationType) { - super(configurationType); - } - - @Override - public RunConfiguration createTemplateConfiguration(Project project) { - return new PythonNoseTestRunConfiguration(project, this); - } - - @Override - public String getName() { - return PyBundle.message("runcfg.nosetests.display_name"); - } - } - - @Override +public abstract class PythonTestConfigurationType implements ConfigurationType { public String getDisplayName() { return PyBundle.message("runcfg.test.display_name"); } - @Override public String getConfigurationTypeDescription() { return PyBundle.message("runcfg.test.description"); } - @Override public Icon getIcon() { return PythonIcons.Python.PythonTests; } - - @NotNull - @Override - public String getId() { - return ID; - } - - @Override - public ConfigurationFactory[] getConfigurationFactories() { - return new ConfigurationFactory[] {PY_UNITTEST_FACTORY, PY_DOCTEST_FACTORY, PY_NOSETEST_FACTORY, - PY_PYTEST_FACTORY}; - } } diff --git a/python/src/com/jetbrains/python/testing/PythonTestConfigurationProducer.java b/python/src/com/jetbrains/python/testing/PythonTestOldConfigurationProducer.java similarity index 81% rename from python/src/com/jetbrains/python/testing/PythonTestConfigurationProducer.java rename to python/src/com/jetbrains/python/testing/PythonTestOldConfigurationProducer.java index bcf8510ee49a..4b79c98e53cd 100644 --- a/python/src/com/jetbrains/python/testing/PythonTestConfigurationProducer.java +++ b/python/src/com/jetbrains/python/testing/PythonTestOldConfigurationProducer.java @@ -39,9 +39,9 @@ import com.jetbrains.python.PythonModuleTypeBase; import com.jetbrains.python.facet.PythonFacetSettings; import com.jetbrains.python.psi.*; import com.jetbrains.python.psi.types.TypeEvalContext; -import com.jetbrains.python.run.AbstractPythonRunConfiguration; import com.jetbrains.python.run.PythonRunConfigurationProducer; import com.jetbrains.python.testing.unittest.PythonUnitTestRunConfiguration; +import com.jetbrains.python.testing.universalTests.PyUniversalTestsKt; import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -54,14 +54,14 @@ import java.util.List; /** * User: ktisha */ -abstract public class PythonTestConfigurationProducer> extends RunConfigurationProducer> { +abstract public class PythonTestOldConfigurationProducer> extends RunConfigurationProducer> { - public PythonTestConfigurationProducer(final ConfigurationFactory configurationFactory) { + public PythonTestOldConfigurationProducer(final ConfigurationFactory configurationFactory) { super(configurationFactory); } @Override - public boolean isConfigurationFromContext(AbstractPythonTestRunConfiguration configuration, ConfigurationContext context) { + public boolean isConfigurationFromContext(AbstractPythonOldTestRunConfiguration configuration, ConfigurationContext context) { final Location location = context.getLocation(); if (location == null || !isAvailable(location)) return false; final PsiElement element = location.getPsiElement(); @@ -73,12 +73,12 @@ abstract public class PythonTestConfigurationProducer configuration, + protected boolean setupConfigurationFromContext(AbstractPythonOldTestRunConfiguration configuration, ConfigurationContext context, Ref sourceElement) { if (context == null) return false; @@ -147,12 +147,12 @@ abstract public class PythonTestConfigurationProducer +public class PythonDocTestRunConfiguration extends AbstractPythonOldTestRunConfiguration implements PythonDocTestRunConfigurationParams { protected String myPluralTitle = "Doctests"; protected String myTitle = "Doctest"; diff --git a/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestConfigurationProducer.java b/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestConfigurationProducer.java index f78792e8db46..093d536098a4 100644 --- a/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestConfigurationProducer.java +++ b/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestConfigurationProducer.java @@ -27,9 +27,9 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class PythonNoseTestConfigurationProducer extends - PythonTestConfigurationProducer { + PythonTestOldConfigurationProducer { public PythonNoseTestConfigurationProducer() { - super(PythonTestConfigurationType.getInstance().PY_NOSETEST_FACTORY); + super(PythonTestOldConfigurationType.getInstance().PY_NOSETEST_FACTORY); } protected boolean isAvailable(@NotNull final Location location) { @@ -45,7 +45,7 @@ public class PythonNoseTestConfigurationProducer extends } @Override - protected boolean isTestFunction(@NotNull final PyFunction pyFunction, @Nullable final AbstractPythonTestRunConfiguration configuration) { + protected boolean isTestFunction(@NotNull final PyFunction pyFunction, @Nullable final AbstractPythonOldTestRunConfiguration configuration) { return PythonUnitTestUtil.isTestCaseFunction(pyFunction, true); } } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestRunConfiguration.java b/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestRunConfiguration.java index 70cd7c4d4479..4a6ea8e2a716 100644 --- a/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestRunConfiguration.java +++ b/python/src/com/jetbrains/python/testing/nosetest/PythonNoseTestRunConfiguration.java @@ -26,7 +26,7 @@ import com.intellij.openapi.util.JDOMExternalizerUtil; import com.intellij.openapi.util.WriteExternalException; import com.jetbrains.python.PyBundle; import com.jetbrains.python.sdk.PythonSdkType; -import com.jetbrains.python.testing.AbstractPythonTestRunConfiguration; +import com.jetbrains.python.testing.AbstractPythonOldTestRunConfiguration; import com.jetbrains.python.testing.VFSTestFrameworkListener; import org.jdom.Element; import org.jetbrains.annotations.NotNull; @@ -34,7 +34,7 @@ import org.jetbrains.annotations.NotNull; /** * User: catherine */ -public class PythonNoseTestRunConfiguration extends AbstractPythonTestRunConfiguration +public class PythonNoseTestRunConfiguration extends AbstractPythonOldTestRunConfiguration implements PythonNoseTestRunConfigurationParams { private String myParams = ""; // parameters for nosetests protected String myTitle = "Nosetest"; @@ -80,7 +80,7 @@ public class PythonNoseTestRunConfiguration extends AbstractPythonTestRunConfigu } public static void copyParams(PythonNoseTestRunConfigurationParams source, PythonNoseTestRunConfigurationParams target) { - AbstractPythonTestRunConfiguration.copyParams(source.getTestRunConfigurationParams(), target.getTestRunConfigurationParams()); + AbstractPythonOldTestRunConfiguration.copyParams(source.getTestRunConfigurationParams(), target.getTestRunConfigurationParams()); target.setParams(source.getParams()); target.useParam(source.useParam()); } diff --git a/python/src/com/jetbrains/python/testing/pytest/PyTestConfigurationProducer.java b/python/src/com/jetbrains/python/testing/pytest/PyTestConfigurationProducer.java index 502421091823..6d09344f56bb 100644 --- a/python/src/com/jetbrains/python/testing/pytest/PyTestConfigurationProducer.java +++ b/python/src/com/jetbrains/python/testing/pytest/PyTestConfigurationProducer.java @@ -44,14 +44,14 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.util.List; -public class PyTestConfigurationProducer extends PythonTestConfigurationProducer { +public class PyTestConfigurationProducer extends PythonTestOldConfigurationProducer { public PyTestConfigurationProducer() { - super(PythonTestConfigurationType.getInstance().PY_PYTEST_FACTORY); + super(PythonTestOldConfigurationType.getInstance().PY_PYTEST_FACTORY); } @Override - protected boolean setupConfigurationFromContext(AbstractPythonTestRunConfiguration configuration, + protected boolean setupConfigurationFromContext(AbstractPythonOldTestRunConfiguration configuration, ConfigurationContext context, Ref sourceElement) { final PsiElement element = sourceElement.get(); @@ -144,7 +144,7 @@ public class PyTestConfigurationProducer extends PythonTestConfigurationProducer } @Override - public boolean isConfigurationFromContext(AbstractPythonTestRunConfiguration configuration, ConfigurationContext context) { + public boolean isConfigurationFromContext(AbstractPythonOldTestRunConfiguration configuration, ConfigurationContext context) { final Location location = context.getLocation(); if (location == null) return false; if (!(configuration instanceof PyTestRunConfiguration)) return false; diff --git a/python/src/com/jetbrains/python/testing/pytest/PyTestRunConfiguration.java b/python/src/com/jetbrains/python/testing/pytest/PyTestRunConfiguration.java index 1a7b826f4d31..614ac77245ab 100644 --- a/python/src/com/jetbrains/python/testing/pytest/PyTestRunConfiguration.java +++ b/python/src/com/jetbrains/python/testing/pytest/PyTestRunConfiguration.java @@ -33,7 +33,7 @@ import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.jetbrains.python.PyBundle; import com.jetbrains.python.sdk.PythonSdkType; -import com.jetbrains.python.testing.AbstractPythonTestRunConfiguration; +import com.jetbrains.python.testing.AbstractPythonOldTestRunConfiguration; import com.jetbrains.python.testing.VFSTestFrameworkListener; import org.jdom.Element; import org.jetbrains.annotations.NotNull; @@ -45,7 +45,8 @@ import java.util.List; /** * @author yole */ -public class PyTestRunConfiguration extends AbstractPythonTestRunConfiguration implements PyTestRunConfigurationParams { +public class PyTestRunConfiguration extends AbstractPythonOldTestRunConfiguration + implements PyTestRunConfigurationParams { private String myTestToRun = ""; private String myKeywords = ""; private String myParams = ""; diff --git a/python/src/com/jetbrains/python/testing/tox/PyToxConfiguration.java b/python/src/com/jetbrains/python/testing/tox/PyToxConfiguration.java index 0082c5b00e8b..330aa1381234 100644 --- a/python/src/com/jetbrains/python/testing/tox/PyToxConfiguration.java +++ b/python/src/com/jetbrains/python/testing/tox/PyToxConfiguration.java @@ -31,7 +31,7 @@ import com.intellij.util.ArrayUtil; import com.intellij.util.xmlb.SkipEmptySerializationFilter; import com.intellij.util.xmlb.XmlSerializer; import com.intellij.util.xmlb.annotations.Tag; -import com.jetbrains.python.run.AbstractPythonRunConfiguration; +import com.jetbrains.python.testing.AbstractPythonTestRunConfiguration; import com.jetbrains.serialization.AnnotationSerializationFilter; import com.jetbrains.serialization.CompoundFilter; import org.jdom.Element; @@ -43,7 +43,7 @@ import java.util.List; /** * @author Ilya.Kazakevich */ -public final class PyToxConfiguration extends AbstractPythonRunConfiguration { +public final class PyToxConfiguration extends AbstractPythonTestRunConfiguration { @NotNull private final Project myProject; diff --git a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestCommandLineState.java b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestCommandLineState.java index 5f1949431698..540fd1b05ad6 100644 --- a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestCommandLineState.java +++ b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestCommandLineState.java @@ -23,7 +23,7 @@ import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.util.text.StringUtil; import com.jetbrains.python.PyNames; import com.jetbrains.python.PythonHelper; -import com.jetbrains.python.testing.AbstractPythonTestRunConfiguration; +import com.jetbrains.python.testing.AbstractPythonOldTestRunConfiguration; import com.jetbrains.python.testing.PythonTestCommandLineStateBase; import com.jetbrains.python.testing.PythonUnitTestTestIdUrlProvider; import org.jetbrains.annotations.NotNull; @@ -45,8 +45,8 @@ public class PythonUnitTestCommandLineState extends PythonTestCommandLineStateBa @Override protected PythonHelper getRunner() { - if (myConfig.getTestType() == AbstractPythonTestRunConfiguration.TestType.TEST_SCRIPT && - myConfig.getScriptName().endsWith(PyNames.SETUP_DOT_PY)) + if (myConfig.getTestType() == AbstractPythonOldTestRunConfiguration.TestType.TEST_SCRIPT && + myConfig.getScriptName().endsWith(PyNames.SETUP_DOT_PY)) return PythonHelper.SETUPPY; return PythonHelper.UT; } @@ -98,7 +98,7 @@ public class PythonUnitTestCommandLineState extends PythonTestCommandLineStateBa if (myConfig.useParam() && !StringUtil.isEmptyOrSpaces(myConfig.getParams())) script_params.addParameter(myConfig.getParams()); - if (myConfig.getTestType() != AbstractPythonTestRunConfiguration.TestType.TEST_SCRIPT || + if (myConfig.getTestType() != AbstractPythonOldTestRunConfiguration.TestType.TEST_SCRIPT || !myConfig.getScriptName().endsWith(PyNames.SETUP_DOT_PY)) script_params.addParameter(String.valueOf(myConfig.isPureUnittest())); } diff --git a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestConfigurationProducer.java b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestConfigurationProducer.java index 368933c1a4f3..7c79c9ccb341 100644 --- a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestConfigurationProducer.java +++ b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestConfigurationProducer.java @@ -36,9 +36,9 @@ import org.jetbrains.annotations.Nullable; import java.util.List; -public class PythonUnitTestConfigurationProducer extends PythonTestConfigurationProducer { +public class PythonUnitTestConfigurationProducer extends PythonTestOldConfigurationProducer { public PythonUnitTestConfigurationProducer() { - super(PythonTestConfigurationType.getInstance().PY_UNITTEST_FACTORY); + super(PythonTestOldConfigurationType.getInstance().PY_UNITTEST_FACTORY); } protected boolean isAvailable(@NotNull final Location location) { @@ -54,7 +54,7 @@ public class PythonUnitTestConfigurationProducer extends PythonTestConfiguration @Override protected boolean isTestFunction(@NotNull final PyFunction pyFunction, - @Nullable final AbstractPythonTestRunConfiguration configuration) { + @Nullable final AbstractPythonOldTestRunConfiguration configuration) { final boolean isTestFunction = super.isTestFunction(pyFunction, configuration); return isTestFunction || (configuration instanceof PythonUnitTestRunConfiguration && !((PythonUnitTestRunConfiguration)configuration).isPureUnittest()); @@ -62,7 +62,7 @@ public class PythonUnitTestConfigurationProducer extends PythonTestConfiguration @Override protected boolean isTestClass(@NotNull PyClass pyClass, - @Nullable final AbstractPythonTestRunConfiguration configuration, + @Nullable final AbstractPythonOldTestRunConfiguration configuration, TypeEvalContext context) { final boolean isTestClass = super.isTestClass(pyClass, configuration, context); return isTestClass || (configuration instanceof PythonUnitTestRunConfiguration && diff --git a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfiguration.java b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfiguration.java index a3a7cf248f47..de7fe6fc1900 100644 --- a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfiguration.java +++ b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfiguration.java @@ -18,14 +18,13 @@ package com.jetbrains.python.testing.unittest; import com.intellij.execution.ExecutionException; import com.intellij.execution.Executor; import com.intellij.execution.configurations.ConfigurationFactory; -import com.intellij.execution.configurations.RunConfiguration; import com.intellij.execution.configurations.RunProfileState; import com.intellij.execution.runners.ExecutionEnvironment; import com.intellij.openapi.options.SettingsEditor; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.JDOMExternalizerUtil; import com.intellij.openapi.util.WriteExternalException; -import com.jetbrains.python.testing.AbstractPythonTestRunConfiguration; +import com.jetbrains.python.testing.AbstractPythonOldTestRunConfiguration; import org.jdom.Element; import org.jetbrains.annotations.NotNull; @@ -33,7 +32,7 @@ import org.jetbrains.annotations.NotNull; * @author Leonid Shalupov */ public class PythonUnitTestRunConfiguration extends - AbstractPythonTestRunConfiguration + AbstractPythonOldTestRunConfiguration implements PythonUnitTestRunConfigurationParams { private boolean myIsPureUnittest = true; protected String myTitle = "Unittest"; diff --git a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfigurationForm.java b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfigurationForm.java index 15eff28dd52e..31e064060cab 100644 --- a/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfigurationForm.java +++ b/python/src/com/jetbrains/python/testing/unittest/PythonUnitTestRunConfigurationForm.java @@ -17,7 +17,7 @@ package com.jetbrains.python.testing.unittest; import com.intellij.openapi.project.Project; import com.jetbrains.python.PyBundle; -import com.jetbrains.python.testing.AbstractPythonTestRunConfiguration; +import com.jetbrains.python.testing.AbstractPythonOldTestRunConfiguration; import com.jetbrains.python.testing.AbstractPythonTestRunConfigurationParams; import com.jetbrains.python.testing.PythonTestRunConfigurationForm; @@ -45,7 +45,7 @@ public class PythonUnitTestRunConfigurationForm implements PythonUnitTestRunConf final ActionListener testTypeListener = new ActionListener() { public void actionPerformed(final ActionEvent e) { - myIsPureUnittest.setVisible(myTestRunConfigurationForm.getTestType() != AbstractPythonTestRunConfiguration.TestType.TEST_FUNCTION); + myIsPureUnittest.setVisible(myTestRunConfigurationForm.getTestType() != AbstractPythonOldTestRunConfiguration.TestType.TEST_FUNCTION); } }; myTestRunConfigurationForm.addTestTypeListener(testTypeListener); diff --git a/python/src/com/jetbrains/python/testing/universalTests/PyUniversalNoseTest.kt b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalNoseTest.kt new file mode 100644 index 000000000000..831486ab15c3 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalNoseTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.jetbrains.python.testing.universalTests + +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.jetbrains.python.PythonHelper +import com.jetbrains.python.testing.PythonTestConfigurationsModel + +/** + * Nose runner + */ + +class PyUniversalNoseTestSettingsEditor(configuration: PyUniversalTestConfiguration) : + PyUniversalTestSettingsEditor(PyUniversalTestForm.create(configuration)) + +class PyUniversalNoseTestExecutionEnvironment(configuration: PyUniversalNoseTestConfiguration, environment: ExecutionEnvironment) : + PyUniversalTestExecutionEnvironment(configuration, environment) { + override fun getRunner() = PythonHelper.UNIVERSAL_NOSE +} + + +class PyUniversalNoseTestConfiguration(project: Project, factory: PyUniversalTestFactory) : PyUniversalTestConfiguration(project, factory) { + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState? = + PyUniversalNoseTestExecutionEnvironment(this, environment) + + override fun createConfigurationEditor(): SettingsEditor = + PyUniversalNoseTestSettingsEditor(this) + + +} + +object PyUniversalNoseTestFactory : PyUniversalTestFactory() { + override fun createTemplateConfiguration(project: Project) = PyUniversalNoseTestConfiguration(project, this) + + override fun getName(): String = PythonTestConfigurationsModel.PYTHONS_NOSETEST_NAME +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/universalTests/PyUniversalPyTest.kt b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalPyTest.kt new file mode 100644 index 000000000000..f47d30f42fc7 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalPyTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.python.testing.universalTests + +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.jetbrains.python.PythonHelper +import com.jetbrains.python.testing.PythonTestConfigurationsModel + +/** + * Py.test runner + */ + +class PyUniversalPyTestSettingsEditor(configuration: PyUniversalTestConfiguration) : + PyUniversalTestSettingsEditor(PyUniversalTestForm.create(configuration, PyUniversalPyTestConfiguration::keywords.name)) + +class PyUniversalPyTestExecutionEnvironment(configuration: PyUniversalPyTestConfiguration, environment: ExecutionEnvironment) : + PyUniversalTestExecutionEnvironment(configuration, environment) { + override fun getRunner() = PythonHelper.UNIVERSAL_PYTEST +} + + +class PyUniversalPyTestConfiguration(project: Project, factory: PyUniversalTestFactory) : PyUniversalTestConfiguration(project, factory) { + @ConfigField + var keywords = "" + + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState? = + PyUniversalPyTestExecutionEnvironment(this, environment) + + override fun createConfigurationEditor(): SettingsEditor = + PyUniversalPyTestSettingsEditor(this) + + override fun getCustomRawArgumentsString(): String = + when { + keywords.isEmpty() -> "" + else -> "-k $keywords" + } +} + +object PyUniversalPyTestFactory : PyUniversalTestFactory() { + override fun createTemplateConfiguration(project: Project) = PyUniversalPyTestConfiguration(project, this) + + override fun getName(): String = PythonTestConfigurationsModel.PY_TEST_NAME +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.form b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.form new file mode 100644 index 000000000000..93c6216a5787 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.form @@ -0,0 +1,79 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.java b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.java new file mode 100644 index 000000000000..e793752537f0 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTestForm.java @@ -0,0 +1,207 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.python.testing.universalTests; + +import com.intellij.openapi.ui.TextFieldWithBrowseButton; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBRadioButton; +import com.intellij.ui.components.JBTextField; +import com.intellij.uiDesigner.core.GridConstraints; +import com.intellij.uiDesigner.core.GridLayoutManager; +import com.intellij.util.ArrayUtil; +import com.jetbrains.python.PyFileChooserDescriptor; +import com.jetbrains.python.run.AbstractPyCommonOptionsForm; +import com.jetbrains.python.run.PyCommonOptionsFormFactory; +import com.jetbrains.reflection.ReflectionUtilsKt; +import com.jetbrains.reflection.SimplePropertiesProvider; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.util.*; +import java.util.regex.Pattern; + +/** + * Form to display run configuration. + * It displays target type, target, additional arguments, custom options (if provided) and environment options + * Create with {@link #create(PyUniversalTestConfiguration, String...)} + * + * @author Ilya.Kazakevich + */ +public final class PyUniversalTestForm implements SimplePropertiesProvider { + + /** + * Regex to convert additionalArgumentNames to "Additional Argument Names" + */ + private static final Pattern CAPITAL_LETTER = Pattern.compile("(?=\\p{Upper})"); + private JPanel myPanel; + /** + * Panel for test targets + */ + private JPanel myTargets; + /** + * Panel for environment options + */ + private JPanel myOptionsPanel; + private TextFieldWithBrowseButton myTargetText; + /** + * Test label + */ + private JBLabel myLabel; + /** + * Panel for custom options, specific for runner and for "Additional Arguments"al;sop + */ + private JPanel myCustomOptionsPanel; + private final ButtonGroup myButtonGroup = new ButtonGroup(); + private AbstractPyCommonOptionsForm myOptionsForm; + + private final Map myCustomOptions = new LinkedHashMap<>(); // TDO: Linked -- order + + @NotNull + JPanel getPanel() { + return myPanel; + } + + @NotNull + @Override + public List getPropertyNames() { + return new ArrayList<>(myCustomOptions.keySet()); + } + + @Override + public void setPropertyValue(@NotNull final String propertyName, @Nullable final String propertyValue) { + myCustomOptions.get(propertyName).setText(propertyValue != null ? propertyValue : ""); + } + + @Nullable + @Override + public String getPropertyValue(@NotNull final String propertyName) { + return myCustomOptions.get(propertyName).getText(); + } + + private PyUniversalTestForm() { + } + + /** + * @param configuration configuration to configure form on creation + * @param customOptions additional option names this form shall support. Make sure your configuration has appropriate properties. + */ + @NotNull + public static PyUniversalTestForm create(@NotNull final PyUniversalTestConfiguration configuration, + @NotNull final String... customOptions) { // TODO: DOC + + + final PyUniversalTestForm form = new PyUniversalTestForm(); + form.myTargetText.addBrowseFolderListener("Choose File or Folder", null, configuration.getProject(), + new PyFileChooserDescriptor(true)); + + for (final TestTargetType testTargetType : TestTargetType.values()) { + final JBRadioButton button = new JBRadioButton(StringUtil.capitalize(testTargetType.name().toLowerCase(Locale.getDefault()))); + button.setActionCommand(testTargetType.name()); + button.addActionListener(o -> form.configureElementsVisibility()); + form.myButtonGroup.add(button); + form.myTargets.add(button); + } + form.myButtonGroup.getElements().nextElement().setSelected(true); + + form.myOptionsForm = PyCommonOptionsFormFactory.getInstance().createForm(configuration.getCommonOptionsFormData()); + final GridConstraints constraints = new GridConstraints(); + constraints.setFill(GridConstraints.FILL_BOTH); + form.myOptionsPanel.add(form.myOptionsForm.getMainPanel(), constraints); + + + form.myLabel.setText(configuration.getTestFrameworkName()); + + form.addCustomOptions(ArrayUtil.mergeArrays(customOptions, PyUniversalTestsKt.getAdditionalArgumentsPropertyName())); + configuration.copyTo(ReflectionUtilsKt.getProperties(form, null, true)); + return form; + } + + private void addCustomOptions(@NotNull final String... optionNames) { + if (optionNames.length == 0) { + return; + } + for (final String optionName : optionNames) { + final JBTextField textField = new JBTextField(); + myCustomOptions.put(optionName, textField); + } + myCustomOptionsPanel.setLayout(new GridLayoutManager(optionNames.length, 2)); + + for (int i = 0; i < optionNames.length; i++) { + final String optionName = optionNames[i]; + final JBTextField textField = myCustomOptions.get(optionName); + + final GridConstraints labelConstraints = new GridConstraints(); + labelConstraints.setFill(GridConstraints.FILL_VERTICAL); + labelConstraints.setRow(i); + labelConstraints.setColumn(0); + labelConstraints.setHSizePolicy(GridConstraints.SIZEPOLICY_CAN_SHRINK); + + final JLabel label = new JLabel(StringUtil.capitalize(CAPITAL_LETTER.matcher(optionName).replaceAll(" "))); + label.setHorizontalAlignment(SwingConstants.LEFT); + myCustomOptionsPanel.add(label, labelConstraints); + + + final GridConstraints textConstraints = new GridConstraints(); + textConstraints.setFill(GridConstraints.FILL_BOTH); + textConstraints.setRow(i); + textConstraints.setColumn(1); + textConstraints.setHSizePolicy(GridConstraints.SIZEPOLICY_CAN_GROW); + myCustomOptionsPanel.add(textField, textConstraints); + } + } + + @NotNull + AbstractPyCommonOptionsForm getOptionsForm() { + return myOptionsForm; + } + + @NotNull + public String getTarget() { + return myTargetText.getText(); + } + + + public void setTarget(@NotNull final String targetText) { + myTargetText.setText(targetText); + } + + private void configureElementsVisibility() { + final TestTargetType targetType = getTargetType(); + myTargetText.setVisible(targetType != TestTargetType.CUSTOM); + myTargetText.getButton().setVisible(targetType == TestTargetType.FOLDER); + } + + @SuppressWarnings("WeakerAccess") // Accessor for property + @NotNull + public TestTargetType getTargetType() { + return TestTargetType.valueOf(myButtonGroup.getSelection().getActionCommand()); + } + + @SuppressWarnings("unused") // Mutator for property + public void setTargetType(@NotNull final TestTargetType target) { + final Enumeration elements = myButtonGroup.getElements(); + while (elements.hasMoreElements()) { + final AbstractButton button = elements.nextElement(); + if (TestTargetType.valueOf(button.getActionCommand()) == target) { + myButtonGroup.setSelected(button.getModel(), true); + break; + } + } + configureElementsVisibility(); + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTests.kt b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTests.kt new file mode 100644 index 000000000000..5c836a43db15 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalTests.kt @@ -0,0 +1,404 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.jetbrains.python.testing.universalTests + +import com.google.gson.Gson +import com.intellij.execution.Location +import com.intellij.execution.PsiLocation +import com.intellij.execution.RunnerAndConfigurationSettings +import com.intellij.execution.actions.ConfigurationContext +import com.intellij.execution.actions.ConfigurationFromContext +import com.intellij.execution.actions.RunConfigurationProducer +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.testframework.AbstractTestProxy +import com.intellij.execution.testframework.sm.runner.SMTestLocator +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.module.impl.scopes.ModuleWithDependenciesScope +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.JDOMExternalizerUtil +import com.intellij.openapi.util.Pair +import com.intellij.openapi.util.Ref +import com.intellij.openapi.util.registry.Registry +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.QualifiedName +import com.jetbrains.python.psi.PyClass +import com.jetbrains.python.psi.PyFile +import com.jetbrains.python.psi.PyFunction +import com.jetbrains.python.psi.PyQualifiedNameOwner +import com.jetbrains.python.psi.impl.PyPsiFacadeImpl +import com.jetbrains.python.psi.types.TypeEvalContext +import com.jetbrains.python.run.AbstractPythonRunConfiguration +import com.jetbrains.python.run.CommandLinePatcher +import com.jetbrains.python.run.PythonConfigurationFactoryBase +import com.jetbrains.python.testing.* +import com.jetbrains.reflection.DelegationProperty +import com.jetbrains.reflection.Properties +import com.jetbrains.reflection.getProperties +import org.jdom.Element +import java.io.File +import java.util.* +import javax.swing.JComponent + + +/** + * New (universal) API for test runners. + * + * @author Ilya.Kazakevich + */ + + +fun isUniversalModeEnabled(): Boolean = Registry.`is`("python.tests.enableUniversalTests") + +internal fun getAdditionalArgumentsPropertyName() = PyUniversalTestConfiguration::additionalArguments.name + +/** + * Resolves qname of any symbol to appropriate PSI element. + */ +private fun findElementByQualifiedName(name: QualifiedName, module: Module, context: TypeEvalContext): PsiElement? { + val facade = PyPsiFacadeImpl.getInstance(module.project) + var currentName = name + + + var element: PsiElement? = null + + // Drill as deep, as we can + var lastElement: String? = null + while (currentName.componentCount > 0 && element == null) { + + element = facade.qualifiedNameResolver(currentName).fromModule(module).withMembers().firstResult() + if (element != null) { + break + } + lastElement = name.lastComponent!! + currentName = name.removeLastComponent() + } + + if (lastElement != null && element is PyClass) { + // Drill in class + val method = element.findMethodByName(lastElement, true, context) + if (method != null) { + return method + } + + } + return element +} + +/** + * @return factory chosen by user in "test runner" settings + */ +private fun findConfigurationFactoryFromSettings(module: Module): ConfigurationFactory { + val name = TestRunnerService.getInstance(module).projectConfiguration + val factories = PyUniversalTestsConfigurationType.configurationFactories + val configurationFactory = factories.find { it.name == name } + return configurationFactory ?: factories.first() +} + + +private object PyUniversalTestsLocator : SMTestLocator { + override fun getLocation(protocol: String, path: String, project: Project, scope: GlobalSearchScope): MutableList> { + if (scope !is ModuleWithDependenciesScope) { + return ArrayList() + } + val result = findElementByQualifiedName(QualifiedName.fromDottedString(path), scope.module, + TypeEvalContext.userInitiated(project, null)) + if (result != null) { + return arrayListOf(PsiLocation.fromPsiElement(result)) + } + else { + return ArrayList() + } + } +} + +abstract class PyUniversalTestExecutionEnvironment(configuration: T, environment: ExecutionEnvironment) + : PythonTestCommandLineStateBase(configuration, environment) { + + override fun getTestLocator(): SMTestLocator = PyUniversalTestsLocator + + override fun getTestSpecs(): MutableList = ArrayList(configuration.getTestSpec()) + + override fun generateCommandLine(patchers: Array?): GeneralCommandLine { + val line = super.generateCommandLine(patchers) + line.workDirectory = File(configuration.workingDirectorySafe) + return line + } +} + + +abstract class PyUniversalTestSettingsEditor(private val form: PyUniversalTestForm) + : SettingsEditor() { + + + override fun resetEditorFrom(s: PyUniversalTestConfiguration) { + // usePojoProperties is true because we know that Form is java-based + AbstractPythonRunConfiguration.copyParams(s, form.optionsForm) + s.copyTo(getProperties(form, usePojoProperties = true)) + } + + override fun applyEditorTo(s: PyUniversalTestConfiguration) { + AbstractPythonRunConfiguration.copyParams(form.optionsForm, s) + s.copyFrom(getProperties(form, usePojoProperties = true)) + } + + override fun createEditor(): JComponent = form.panel +} + +enum class TestTargetType(val optionName: String) { + PYTHON("--target"), FOLDER("--path"), CUSTOM("") +} + + +/** + * Target depends on target type. It could be path to file/folder or python target + */ +data class ConfigurationTarget(@ConfigField var target: String, @ConfigField var targetType: TestTargetType) { + fun copyTo(dst: ConfigurationTarget) { + // TODO: do we have such method it in Kotlin? + dst.target = target + dst.targetType = targetType + } +} + +/** + * Parent of all new test configurations. + * All config-specific fields are implemented as properties. They are saved/restored automatically and passed to GUI form. + * + * @param runBareFunctions if config supports running functions directly in modules or only class methods + */ +abstract class PyUniversalTestConfiguration(project: Project, + configurationFactory: ConfigurationFactory, + private val runBareFunctions: Boolean = true) + : AbstractPythonTestRunConfiguration(project, configurationFactory), PyRerunAwareConfiguration { + @DelegationProperty + val target = ConfigurationTarget(".", TestTargetType.FOLDER) + @ConfigField + var additionalArguments = "" + + val testFrameworkName = configurationFactory.name!! + + + private fun getTestSpecForPythonTarget(location: Location<*>): List { + if (location !is PsiLocation) { + return emptyList() + } + if (location.psiElement !is PyQualifiedNameOwner) { + return emptyList() + } + val qualifiedName = (location.psiElement as PyQualifiedNameOwner).qualifiedName ?: return emptyList() + return listOf(TestTargetType.PYTHON.optionName, qualifiedName) + } + + override fun getTestSpec(location: Location<*>, failedTest: AbstractTestProxy): String? { + val list = getTestSpecForPythonTarget(location) + if (list.isEmpty()) { + return null + } + else { + return list.joinToString(" ") + } + } + + override fun getTestSpecsForRerun(scope: GlobalSearchScope, + locations: MutableList, AbstractTestProxy>>): List { + val result = ArrayList() + locations.map { getTestSpecForPythonTarget(it.first) }.filterNotNull().forEach { result.addAll(it) } + return result + generateRawArguments() + } + + fun getTestSpec() = listOf(target.targetType.optionName, target.target) + generateRawArguments() + + /** + * raw arguments to be added after "--" and passed to runner directly + */ + private fun generateRawArguments(): List { + val rawArguments = additionalArguments + " " + getCustomRawArgumentsString() + if (rawArguments.isNotBlank()) { + return listOf("--") + rawArguments.trim().split(" ") + } + return emptyList() + } + + /** + * @return configuration-specific arguments + */ + protected open fun getCustomRawArgumentsString() = "" + + fun reset() { + target.target = "." + target.targetType = TestTargetType.FOLDER + additionalArguments = "" + } + + fun copyFrom(src: Properties) { + src.copyTo(getConfigFields()) + } + + fun copyTo(dst: Properties) { + getConfigFields().copyTo(dst) + } + + + override fun writeExternal(element: Element) { + super.writeExternal(element) + + val gson = Gson() + + getConfigFields().properties.forEach { + JDOMExternalizerUtil.writeField(element, it.getName(), gson.toJson(it.get())) + } + } + + override fun readExternal(element: Element) { + super.readExternal(element) + + val gson = Gson() + + getConfigFields().properties.forEach { + val fromJson: Any? = gson.fromJson(JDOMExternalizerUtil.readField(element, it.getName()), it.getType()) + if (fromJson != null) { + it.set(fromJson) + } + } + } + + + private fun getConfigFields() = getProperties(this, ConfigField::class) + + /** + * Checks if element could be test target for this config. + * Function is used to create tests by context. + * + * If yes, and element is [PsiElement] then it is [TestTargetType.PYTHON]. + * If file then [TestTargetType.FOLDER] + */ + fun couldBeTestTarget(element: PsiElement) = + // TODO: PythonUnitTestUtil logic is weak. We should give user ability to launch test on symbol since user knows better if folder + // contains tests etc + when (element) { + is PyFile -> PythonUnitTestUtil.isUnitTestFile(element) + is PyFunction -> PythonUnitTestUtil.isTestCaseFunction(element, runBareFunctions) + is PyClass -> PythonUnitTestUtil.isTestCaseClass(element, TypeEvalContext.userInitiated(element.project, element.containingFile)) + else -> false + } +} + +object PyUniversalTestsConfigurationType : PythonTestConfigurationType() { + override fun getId() = "py_universal_tests" + + override fun getConfigurationFactories(): Array { + if (isUniversalModeEnabled()) { + return arrayOf(PyUniversalUnitTestFactory, + PyUniversalPyTestFactory, + PyUniversalNoseTestFactory) + } + // Array can't be empty according to contract (type is fetched from first element) + return arrayOf(PyUniversalUnitTestFactory) + } +} + +abstract class PyUniversalTestFactory : PythonConfigurationFactoryBase(PyUniversalTestsConfigurationType) + +/** + * Only one producer is registered with EP, but it uses factory configured by user to prdouce different configs + */ +object PyUniversalTestsConfigurationProducer : RunConfigurationProducer(PyUniversalTestsConfigurationType) { + + override fun cloneTemplateConfiguration(context: ConfigurationContext): RunnerAndConfigurationSettings { + return cloneTemplateConfigurationStatic(context, findConfigurationFactoryFromSettings(context.module)) + } + + override fun findOrCreateConfigurationFromContext(context: ConfigurationContext?): ConfigurationFromContext? { + if (!isUniversalModeEnabled()) { + return null + } + return super.findOrCreateConfigurationFromContext(context) + } + + override fun setupConfigurationFromContext(configuration: PyUniversalTestConfiguration?, + context: ConfigurationContext?, + sourceElement: Ref?): Boolean { + + if (sourceElement == null || configuration == null) { + return false + } + val nameAndTarget = getNameAndTargetForConfig(configuration, sourceElement.get()) ?: return false + configuration.name = nameAndTarget.first + nameAndTarget.second.copyTo(configuration.target) + return true + } + + + // TODO: DOC + private fun getNameAndTargetForConfig(configuration: PyUniversalTestConfiguration, + baseElement: PsiElement): Pair? { + var element = baseElement + // Go up until we reach top of the file + // asking configuration about each element if it is supported or not + // If element is supported -- set it as configuration target + do { + if (configuration.couldBeTestTarget(element)) { + when (element) { + is PyQualifiedNameOwner -> { // Function, class, method + val qualifiedName = element.qualifiedName + if (qualifiedName == null) { + Logger.getInstance(PyUniversalTestConfiguration::class.java).warn("$element has no qualified name") + return null + } + return Pair(qualifiedName, ConfigurationTarget(qualifiedName, TestTargetType.PYTHON)) + } + is PsiFile -> return Pair(element.virtualFile.name, ConfigurationTarget(element.virtualFile.path, TestTargetType.FOLDER)) + } + } + element = element.parent + } + while (element !is PsiDirectory) // if parent is folder, then we are at file level + return null + } + + + override fun isConfigurationFromContext(configuration: PyUniversalTestConfiguration?, context: ConfigurationContext?): Boolean { + val psiElement = context?.psiLocation ?: return false + val nameAndTarget = getNameAndTargetForConfig(configuration!!, psiElement) ?: return false + return configuration.target == nameAndTarget.second + } + + override fun isPreferredConfiguration(self: ConfigurationFromContext?, other: ConfigurationFromContext?): Boolean { + if (self == null || other == null) { + return false + } + val module = ModuleUtil.findModuleForPsiElement(self.sourceElement) ?: return false + return self.configuration.factory == findConfigurationFactoryFromSettings(module) + } +} + + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.PROPERTY) +/** + * Mark run configuration field with it to enable saving, resotring and form iteraction + */ +annotation class ConfigField diff --git a/python/src/com/jetbrains/python/testing/universalTests/PyUniversalUnitTest.kt b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalUnitTest.kt new file mode 100644 index 000000000000..89f7862dc021 --- /dev/null +++ b/python/src/com/jetbrains/python/testing/universalTests/PyUniversalUnitTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.jetbrains.python.testing.universalTests + +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.jetbrains.python.PythonHelper +import com.jetbrains.python.testing.PythonTestConfigurationsModel + +/** + * unittest + */ + +class PyUniversalUnitTestSettingsEditor(configuration: PyUniversalTestConfiguration) : + PyUniversalTestSettingsEditor(PyUniversalTestForm.create(configuration)) + +class PyUniversalUnitTestExecutionEnvironment(configuration: PyUniversalUnitTestConfiguration, environment: ExecutionEnvironment) : + PyUniversalTestExecutionEnvironment(configuration, environment) { + override fun getRunner() = PythonHelper.UNIVERSAL_UT +} + + +class PyUniversalUnitTestConfiguration(project: Project, factory: PyUniversalTestFactory) : + PyUniversalTestConfiguration(project, factory, runBareFunctions = false) { // Bare functions not supported in unittest: classes only + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState? = + PyUniversalUnitTestExecutionEnvironment(this, environment) + + override fun createConfigurationEditor(): SettingsEditor = + PyUniversalUnitTestSettingsEditor(this) + +} + +object PyUniversalUnitTestFactory : PyUniversalTestFactory() { + override fun createTemplateConfiguration(project: Project) = PyUniversalUnitTestConfiguration(project, this) + + override fun getName(): String = PythonTestConfigurationsModel.PYTHONS_UNITTEST_NAME +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/testing/universalTests/package-info.java b/python/src/com/jetbrains/python/testing/universalTests/package-info.java new file mode 100644 index 000000000000..15bf720d086d --- /dev/null +++ b/python/src/com/jetbrains/python/testing/universalTests/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * New (universal) test runners for Python based on this + * Protocol. + */ +package com.jetbrains.python.testing.universalTests; \ No newline at end of file diff --git a/python/src/com/jetbrains/reflection/ReflectionUtils.kt b/python/src/com/jetbrains/reflection/ReflectionUtils.kt new file mode 100644 index 000000000000..0b3508952fae --- /dev/null +++ b/python/src/com/jetbrains/reflection/ReflectionUtils.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2000-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.jetbrains.reflection + +import com.intellij.util.containers.HashMap +import java.beans.Introspector +import java.beans.PropertyDescriptor +import java.util.* +import kotlin.reflect.KClass +import kotlin.reflect.KMutableProperty +import kotlin.reflect.KProperty +import kotlin.reflect.jvm.javaType +import kotlin.reflect.memberProperties + +/** + * Tools to fetch properties both from Java and Kotlin code and to copy them from one object to another. + * To be a property container class should have kotlin properties, java bean properties or implement [SimplePropertiesProvider]. + * + * Properties should be writable except [DelegationProperty] + * @author Ilya.Kazakevich + */ + +interface Property { + fun getName(): String + fun getType(): java.lang.reflect.Type + fun get(): Any? + fun set(value: Any?) +} + +private class KotlinProperty(val property: KMutableProperty<*>, val instance: Any?) : Property { + override fun getName() = property.name + override fun getType() = property.returnType.javaType + override fun get() = property.getter.call(instance) + override fun set(value: Any?) = property.setter.call(instance, value) +} + +private class JavaProperty(val property: PropertyDescriptor, val instance: Any?) : Property { + override fun getName() = property.name!! + override fun getType() = property.propertyType!! + override fun get() = property.readMethod.invoke(instance)!! + override fun set(value: Any?) { + property.writeMethod.invoke(instance, value) + } +} + +private class SimpleProperty(private val propertyName: String, + private val provider: SimplePropertiesProvider) : Property { + override fun getName() = propertyName + override fun getType() = String::class.java + override fun get() = provider.getPropertyValue(propertyName) + override fun set(value: Any?) = provider.setPropertyValue(propertyName, value?.toString()) +} + +/** + * Implement to handle properties manually + */ +interface SimplePropertiesProvider { + val propertyNames: List + fun setPropertyValue(propertyName: String, propertyValue: String?) + fun getPropertyValue(propertyName: String): String? +} + +class Properties(val properties: List, val instance: Any) { + val propertiesMap: MutableMap = HashMap(properties.map { Pair(it.getName(), it) }.toMap()) + + init { + if (instance is SimplePropertiesProvider) { + instance.propertyNames.forEach { propertiesMap.put(it, SimpleProperty(it, instance)) } + } + } + + fun copyTo(dst: Properties) { + propertiesMap.values.forEach { + val dstProperty = dst.propertiesMap[it.getName()] + if (dstProperty != null) { + val value = it.get() + dstProperty.set(value) + } + } + } +} + + +private fun KProperty<*>.isAnnotated(annotation: KClass<*>): Boolean { + return this.annotations.find { annotation.java.isAssignableFrom(it.javaClass) } != null +} + +/** + * @param instance object with properties (see module doc) + * @param annotationToFilterBy optional annotation class to fetch only kotlin properties annotated with it. Only supported in Kotlin + * @param usePojoProperties search for java-style properties (kotlin otherwise) + * @return properties of some object + */ +fun getProperties(instance: Any, annotationToFilterBy: KClass<*>? = null, usePojoProperties: Boolean = false): Properties { + + if (usePojoProperties) { + // Java props + val javaProperties = Introspector.getBeanInfo(instance.javaClass).propertyDescriptors + assert(annotationToFilterBy == null, { "Filtering java properties is not supported" }) + return Properties(javaProperties.map { JavaProperty(it, instance) }, instance) + } + else { + // Kotlin props + val klass = instance.javaClass.kotlin + val allKotlinProperties = LinkedHashSet(klass.memberProperties.filterIsInstance(KProperty::class.java)) + + var delegatedProperties = listOf() // See DelegationProperty doc + allKotlinProperties.filter { it.isAnnotated(DelegationProperty::class) }.forEach { + val delegatedInstance = it.getter.call(instance) + if (delegatedInstance != null) { + delegatedProperties = getProperties(delegatedInstance, annotationToFilterBy, false).properties + allKotlinProperties.remove(it) + } + } + val firstLevelProperties = allKotlinProperties.filterIsInstance(KMutableProperty::class.java) + + if (annotationToFilterBy == null) { + return Properties(firstLevelProperties.map { KotlinProperty(it, instance) } + delegatedProperties, instance) + } + return Properties( + firstLevelProperties.filter { it.isAnnotated(annotationToFilterBy) }.map { KotlinProperty(it, instance) } + delegatedProperties, instance) + } +} + + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.PROPERTY) +/** + * Property marked with it is not considered to be [Property] by itself, but class with properties instead. + * Following structure is example: + * class User: + * +familyName: String + * +lastName: String + * +credentials: Credentials + * + * class Credentials: + * +login: String + * +password: String + * + * Property credentials here is [DelegationProperty]. It can be val, but all other properties should be var + */ +annotation class DelegationProperty \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/env/python/testing/CreateConfigurationTestTask.java b/python/testSrc/com/jetbrains/env/python/testing/CreateConfigurationTestTask.java index c63a60b32866..ef63ddcd88c9 100644 --- a/python/testSrc/com/jetbrains/env/python/testing/CreateConfigurationTestTask.java +++ b/python/testSrc/com/jetbrains/env/python/testing/CreateConfigurationTestTask.java @@ -23,7 +23,7 @@ import com.intellij.psi.PsiElement; import com.jetbrains.env.PyExecutionFixtureTestTask; import com.jetbrains.python.sdk.InvalidSdkException; import com.jetbrains.python.sdkTools.SdkCreationType; -import com.jetbrains.python.testing.PythonTestConfigurationProducer; +import com.jetbrains.python.testing.PythonTestOldConfigurationProducer; import com.jetbrains.python.testing.TestRunnerService; import org.jetbrains.annotations.NotNull; import org.junit.Assert; @@ -43,13 +43,13 @@ class CreateConfigurationTestTask extends PyExecutionFixtureTestTask { @NotNull private final String myTestRunnerName; @NotNull - private final Class myProducer; + private final Class myProducer; /** * @param producer class of configuration producer to check * @param testRunnerName test runner name (to set as default to make sure producer launched) */ - CreateConfigurationTestTask(@NotNull final Class producer, + CreateConfigurationTestTask(@NotNull final Class producer, @NotNull final String testRunnerName) { super("/testRunner/env/createConfigurationTest/"); myProducer = producer; @@ -74,7 +74,7 @@ class CreateConfigurationTestTask extends PyExecutionFixtureTestTask { private void checkConfigurationCreatedFrom(@NotNull final PsiElement element) { - final PythonTestConfigurationProducer producer = createProducer(); + final PythonTestOldConfigurationProducer producer = createProducer(); final ConfigurationFromContext context = producer.createConfigurationFromContext(new ConfigurationContext(element)); Assert.assertNotNull(String.format("Failed to create context for %s", myTestRunnerName), context); @@ -82,7 +82,7 @@ class CreateConfigurationTestTask extends PyExecutionFixtureTestTask { } @NotNull - private PythonTestConfigurationProducer createProducer() { + private PythonTestOldConfigurationProducer createProducer() { try { return myProducer.getConstructor().newInstance(); } diff --git a/python/testSrc/com/jetbrains/env/ut/PyDocTestProcessRunner.java b/python/testSrc/com/jetbrains/env/ut/PyDocTestProcessRunner.java index 0e4ca2cd8cea..2ff7cc6a7d9b 100644 --- a/python/testSrc/com/jetbrains/env/ut/PyDocTestProcessRunner.java +++ b/python/testSrc/com/jetbrains/env/ut/PyDocTestProcessRunner.java @@ -16,7 +16,7 @@ package com.jetbrains.env.ut; import com.jetbrains.env.ProcessWithConsoleRunner; -import com.jetbrains.python.testing.PythonTestConfigurationType; +import com.jetbrains.python.testing.PythonTestOldConfigurationType; import com.jetbrains.python.testing.doctest.PythonDocTestRunConfiguration; import org.jetbrains.annotations.NotNull; @@ -27,7 +27,7 @@ import org.jetbrains.annotations.NotNull; */ public class PyDocTestProcessRunner extends PyScriptTestProcessRunner { public PyDocTestProcessRunner(@NotNull final String scriptName, final int timesToRerunFailedTests) { - super(PythonTestConfigurationType.getInstance().PY_DOCTEST_FACTORY, + super(PythonTestOldConfigurationType.getInstance().PY_DOCTEST_FACTORY, PythonDocTestRunConfiguration.class, scriptName, timesToRerunFailedTests); } } diff --git a/python/testSrc/com/jetbrains/env/ut/PyNoseTestProcessRunner.java b/python/testSrc/com/jetbrains/env/ut/PyNoseTestProcessRunner.java index 39f5ce6ed3da..cc7586fd5151 100644 --- a/python/testSrc/com/jetbrains/env/ut/PyNoseTestProcessRunner.java +++ b/python/testSrc/com/jetbrains/env/ut/PyNoseTestProcessRunner.java @@ -16,7 +16,7 @@ package com.jetbrains.env.ut; import com.jetbrains.env.ProcessWithConsoleRunner; -import com.jetbrains.python.testing.PythonTestConfigurationType; +import com.jetbrains.python.testing.PythonTestOldConfigurationType; import com.jetbrains.python.testing.nosetest.PythonNoseTestRunConfiguration; import org.jetbrains.annotations.NotNull; @@ -27,7 +27,7 @@ import org.jetbrains.annotations.NotNull; */ public class PyNoseTestProcessRunner extends PyScriptTestProcessRunner { public PyNoseTestProcessRunner(@NotNull final String scriptName, final int timesToRerunFailedTests) { - super(PythonTestConfigurationType.getInstance().PY_NOSETEST_FACTORY, + super(PythonTestOldConfigurationType.getInstance().PY_NOSETEST_FACTORY, PythonNoseTestRunConfiguration.class, scriptName, timesToRerunFailedTests); } } diff --git a/python/testSrc/com/jetbrains/env/ut/PyTestTestProcessRunner.java b/python/testSrc/com/jetbrains/env/ut/PyTestTestProcessRunner.java index 414c10f8fdec..35ac7bd27e4e 100644 --- a/python/testSrc/com/jetbrains/env/ut/PyTestTestProcessRunner.java +++ b/python/testSrc/com/jetbrains/env/ut/PyTestTestProcessRunner.java @@ -16,7 +16,7 @@ package com.jetbrains.env.ut; import com.jetbrains.env.ProcessWithConsoleRunner; -import com.jetbrains.python.testing.PythonTestConfigurationType; +import com.jetbrains.python.testing.PythonTestOldConfigurationType; import com.jetbrains.python.testing.pytest.PyTestRunConfiguration; import org.jetbrains.annotations.NotNull; @@ -29,7 +29,7 @@ import java.io.IOException; */ public class PyTestTestProcessRunner extends PyScriptTestProcessRunner { public PyTestTestProcessRunner(@NotNull final String scriptName, final int timesToRerunFailedTests) { - super(PythonTestConfigurationType.getInstance().PY_PYTEST_FACTORY, + super(PythonTestOldConfigurationType.getInstance().PY_PYTEST_FACTORY, PyTestRunConfiguration.class, scriptName, timesToRerunFailedTests); } diff --git a/python/testSrc/com/jetbrains/env/ut/PyUnitTestProcessRunner.java b/python/testSrc/com/jetbrains/env/ut/PyUnitTestProcessRunner.java index 2612a12f3456..c10387467f8d 100644 --- a/python/testSrc/com/jetbrains/env/ut/PyUnitTestProcessRunner.java +++ b/python/testSrc/com/jetbrains/env/ut/PyUnitTestProcessRunner.java @@ -16,7 +16,7 @@ package com.jetbrains.env.ut; import com.jetbrains.env.ProcessWithConsoleRunner; -import com.jetbrains.python.testing.PythonTestConfigurationType; +import com.jetbrains.python.testing.PythonTestOldConfigurationType; import com.jetbrains.python.testing.unittest.PythonUnitTestRunConfiguration; import org.jetbrains.annotations.NotNull; @@ -27,7 +27,7 @@ import org.jetbrains.annotations.NotNull; */ public class PyUnitTestProcessRunner extends PyScriptTestProcessRunner { public PyUnitTestProcessRunner(@NotNull final String scriptName, final int timesToRerunFailedTests) { - super(PythonTestConfigurationType.getInstance().PY_UNITTEST_FACTORY, + super(PythonTestOldConfigurationType.getInstance().PY_UNITTEST_FACTORY, PythonUnitTestRunConfiguration.class, scriptName, timesToRerunFailedTests); } } diff --git a/python/testSrc/com/jetbrains/env/ut/PyUnitTestTask.java b/python/testSrc/com/jetbrains/env/ut/PyUnitTestTask.java index a4be41f4b5c4..80a33a31e14d 100644 --- a/python/testSrc/com/jetbrains/env/ut/PyUnitTestTask.java +++ b/python/testSrc/com/jetbrains/env/ut/PyUnitTestTask.java @@ -40,8 +40,8 @@ import com.jetbrains.python.PyNames; import com.jetbrains.python.sdk.PythonEnvUtil; import com.jetbrains.python.sdk.flavors.JythonSdkFlavor; import com.jetbrains.python.sdk.flavors.PythonSdkFlavor; -import com.jetbrains.python.testing.AbstractPythonTestRunConfiguration; -import com.jetbrains.python.testing.PythonTestConfigurationType; +import com.jetbrains.python.testing.AbstractPythonOldTestRunConfiguration; +import com.jetbrains.python.testing.PythonTestOldConfigurationType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.junit.Assert; @@ -154,7 +154,7 @@ public abstract class PyUnitTestTask extends PyExecutionFixtureTestTask { @Override public void runTestOn(String sdkHome) throws Exception { final Project project = getProject(); - final ConfigurationFactory factory = PythonTestConfigurationType.getInstance().PY_UNITTEST_FACTORY; + final ConfigurationFactory factory = PythonTestOldConfigurationType.getInstance().PY_UNITTEST_FACTORY; runConfiguration(factory, sdkHome, project); } @@ -162,7 +162,7 @@ public abstract class PyUnitTestTask extends PyExecutionFixtureTestTask { final RunnerAndConfigurationSettings settings = RunManager.getInstance(project).createRunConfiguration("test", factory); - AbstractPythonTestRunConfiguration config = (AbstractPythonTestRunConfiguration)settings.getConfiguration(); + AbstractPythonOldTestRunConfiguration config = (AbstractPythonOldTestRunConfiguration)settings.getConfiguration(); config.setSdkHome(sdkHome); @@ -271,7 +271,7 @@ public abstract class PyUnitTestTask extends PyExecutionFixtureTestTask { return 60000; } - protected void configure(AbstractPythonTestRunConfiguration config) { + protected void configure(AbstractPythonOldTestRunConfiguration config) { } /** diff --git a/python/testSrc/com/jetbrains/python/PythonRunConfigurationTest.java b/python/testSrc/com/jetbrains/python/PythonRunConfigurationTest.java index 420d9bc4b8ce..40afc8f1c850 100644 --- a/python/testSrc/com/jetbrains/python/PythonRunConfigurationTest.java +++ b/python/testSrc/com/jetbrains/python/PythonRunConfigurationTest.java @@ -18,7 +18,7 @@ package com.jetbrains.python; import com.jetbrains.python.fixtures.PyCommandLineTestCase; import com.jetbrains.python.run.PythonConfigurationType; import com.jetbrains.python.run.PythonRunConfiguration; -import com.jetbrains.python.testing.PythonTestConfigurationType; +import com.jetbrains.python.testing.PythonTestOldConfigurationType; import com.jetbrains.python.testing.unittest.PythonUnitTestRunConfiguration; import junit.framework.Assert; @@ -31,7 +31,7 @@ public class PythonRunConfigurationTest extends PyCommandLineTestCase { private static final String PY_SCRIPT = "foo.py"; public void testUnitTestCommandLine() { - PythonUnitTestRunConfiguration configuration = createConfiguration(PythonTestConfigurationType.getInstance(), + PythonUnitTestRunConfiguration configuration = createConfiguration(PythonTestOldConfigurationType.getInstance(), PythonUnitTestRunConfiguration.class); configuration.setScriptName(PY_SCRIPT); final List params = buildRunCommandLine(configuration);