Coverage for Applications/PyCharm.app/Contents/plugins/python/helpers/pycharm/_jb_runner_tools.py: 73%
201 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-12 16:26 -0700
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-12 16:26 -0700
1# coding=utf-8
2"""
3Tools to implement runners (https://confluence.jetbrains.com/display/~link/PyCharm+test+runners+protocol)
4"""
5import os
6import re
7import sys
8from collections import OrderedDict
10import _jb_utils
11from teamcity import teamcity_presence_env_var, messages
13# Some runners need it to "detect" TC and start protocol
14if teamcity_presence_env_var not in os.environ:
15 os.environ[teamcity_presence_env_var] = "LOCAL"
17# Providing this env variable disables output buffering.
18# anything sent to stdout/stderr goes to IDE directly, not after test is over like it is done by default.
19# out and err are not in sync, so output may go to wrong test
20JB_DISABLE_BUFFERING = "JB_DISABLE_BUFFERING" in os.environ
21# getcwd resolves symlinks, but PWD is not supported by some shells
22PROJECT_DIR = os.getenv('PWD', os.getcwd())
25def _parse_parametrized(part):
26 """
28 Support nose generators / pytest parameters and other functions that provides names like foo(1,2)
29 Until https://github.com/JetBrains/teamcity-messages/issues/121, all such tests are provided
30 with parentheses.
32 Tests with docstring are reported in similar way but they have space before parenthesis and should be ignored
33 by this function
35 """
36 match = re.match("^([^\\s)(]+)(\\(.+\\))$", part)
37 if not match:
38 return [part]
39 else:
40 return [match.group(1), match.group(2)]
43class _TreeManagerHolder(object):
44 def __init__(self):
45 self.parallel = "JB_USE_PARALLEL_TREE_MANAGER" in os.environ
46 self.offset = 0
47 self._manager_imp = None
49 @property
50 def manager(self):
51 if not self._manager_imp:
52 self._fill_manager()
53 return self._manager_imp
55 def _fill_manager(self):
56 if self.parallel:
57 from _jb_parallel_tree_manager import ParallelTreeManager
58 self._manager_imp = ParallelTreeManager(self.offset)
59 else:
60 from _jb_serial_tree_manager import SerialTreeManager
61 self._manager_imp = SerialTreeManager(self.offset)
64_TREE_MANAGER_HOLDER = _TreeManagerHolder()
67def set_parallel_mode():
68 _TREE_MANAGER_HOLDER.parallel = True
71def is_parallel_mode():
72 return _TREE_MANAGER_HOLDER.parallel
75# Monkeypatching TC
76_old_service_messages = messages.TeamcityServiceMessages
78PARSE_FUNC = None
81class NewTeamcityServiceMessages(_old_service_messages):
82 _latest_subtest_result = None
83 # [full_test_name] = (test_name, node_id, parent_node_id)
84 _test_suites = OrderedDict()
85 INSTANCE = None
87 def __init__(self, *args, **kwargs):
88 super(NewTeamcityServiceMessages, self).__init__(*args, **kwargs)
89 NewTeamcityServiceMessages.INSTANCE = self
91 def message(self, messageName, **properties):
92 if messageName in {"enteredTheMatrix", "testCount"}:
93 if "_jb_do_not_call_enter_matrix" not in os.environ:
94 _old_service_messages.message(self, messageName, **properties)
95 return
97 full_name = properties["name"]
98 try:
99 # Report directory so Java site knows which folder to resolve names against
101 # tests with docstrings are reported in format "test.name (some test here)".
102 # text should be part of name, but not location.
103 possible_location = str(full_name)
104 loc = possible_location.find("(")
105 if loc > 0:
106 possible_location = possible_location[:loc].strip()
107 properties["locationHint"] = "python<{0}>://{1}".format(PROJECT_DIR,
108 possible_location)
109 except KeyError:
110 # If message does not have name, then it is not test
111 # Simply pass it
112 _old_service_messages.message(self, messageName, **properties)
113 return
115 current, parent = _TREE_MANAGER_HOLDER.manager.get_node_ids(full_name)
116 if not current and not parent:
117 return
118 # Shortcut for name
119 try:
120 properties["name"] = str(full_name).split(".")[-1]
121 except IndexError:
122 pass
124 properties["nodeId"] = str(current)
125 properties["parentNodeId"] = str(parent)
127 is_test = messageName == "testStarted"
128 if messageName == "testSuiteStarted" or is_test:
129 self._test_suites[full_name] = (full_name, current, parent, is_test)
130 _old_service_messages.message(self, messageName, **properties)
132 def _test_to_list(self, test_name):
133 """
134 Splits test name to parts to use it as list.
135 It most cases dot is used, but runner may provide custom function
136 """
137 parts = test_name.split(".")
138 result = []
139 for part in parts:
140 result += _parse_parametrized(part)
141 return result
143 def _fix_setup_teardown_name(self, test_name):
144 """
146 Hack to rename setup and teardown methods to much real python signatures
147 """
148 try:
149 return {"test setup": "setUpClass", "test teardown": "tearDownClass"}[test_name]
150 except KeyError:
151 return test_name
153 # Blocks are used for 2 cases now:
154 # 1) Unittest subtests (only closed, opened by subTestBlockOpened)
155 # 2) setup/teardown (does not work, see https://github.com/JetBrains/teamcity-messages/issues/114)
156 # def blockOpened(self, name, flowId=None):
157 # self.testStarted(".".join(TREE_MANAGER.current_branch + [self._fix_setup_teardown_name(name)]))
159 def blockClosed(self, name, flowId=None):
161 # If _latest_subtest_result is not set or does not exist we closing setup method, not a subtest
162 try:
163 if not self._latest_subtest_result:
164 return
165 except AttributeError:
166 return
168 # closing subtest
169 test_name = ".".join(_TREE_MANAGER_HOLDER.manager.current_branch)
170 if self._latest_subtest_result in {"Failure", "Error"}:
171 self.testFailed(test_name)
172 if self._latest_subtest_result == "Skip":
173 self.testIgnored(test_name)
175 self.testFinished(test_name)
176 self._latest_subtest_result = None
178 def subTestBlockOpened(self, name, subTestResult, flowId=None):
179 self.testStarted(".".join(_TREE_MANAGER_HOLDER.manager.current_branch + [name]))
180 self._latest_subtest_result = subTestResult
182 def testStarted(self, testName, captureStandardOutput=None, flowId=None, is_suite=False, metainfo=None):
183 test_name_as_list = self._test_to_list(testName)
184 testName = ".".join(test_name_as_list)
186 def _write_start_message():
187 # testName, captureStandardOutput, flowId
188 args = {"name": testName, "captureStandardOutput": captureStandardOutput, "metainfo": metainfo}
189 if is_suite:
190 self.message("testSuiteStarted", **args)
191 else:
192 self.message("testStarted", **args)
194 commands = _TREE_MANAGER_HOLDER.manager.level_opened(self._test_to_list(testName), _write_start_message)
195 if commands:
196 self.do_commands(commands)
197 self.testStarted(testName, captureStandardOutput, metainfo=metainfo)
199 def testFailed(self, testName, message='', details='', flowId=None, comparison_failure=None):
200 testName = ".".join(self._test_to_list(testName))
201 _old_service_messages.testFailed(self, testName, message, details, comparison_failure=comparison_failure)
203 def testFinished(self, testName, testDuration=None, flowId=None, is_suite=False):
204 test_parts = self._test_to_list(testName)
205 testName = ".".join(test_parts)
207 def _write_finished_message():
208 # testName, captureStandardOutput, flowId
209 current, parent = _TREE_MANAGER_HOLDER.manager.get_node_ids(testName)
210 if not current and not parent:
211 return
212 args = {"nodeId": current, "parentNodeId": parent, "name": testName}
214 # TODO: Doc copy/paste with parent, extract
215 if testDuration is not None:
216 duration_ms = testDuration.days * 86400000 + \
217 testDuration.seconds * 1000 + \
218 int(testDuration.microseconds / 1000)
219 args["duration"] = str(duration_ms)
221 if is_suite:
222 del self._test_suites[testName]
223 if is_parallel_mode():
224 del args["duration"]
225 self.message("testSuiteFinished", **args)
226 else:
227 self.message("testFinished", **args)
228 del self._test_suites[testName]
230 commands = _TREE_MANAGER_HOLDER.manager.level_closed(
231 self._test_to_list(testName), _write_finished_message)
232 if commands:
233 self.do_commands(commands)
234 self.testFinished(testName, testDuration)
236 def do_commands(self, commands):
237 """
239 Executes commands, returned by level_closed and level_opened
240 """
241 for command, test in commands:
242 test_name = ".".join(test)
243 # By executing commands we open or close suites(branches) since tests(leaves) are always reported by runner
244 if command == "open":
245 self.testStarted(test_name, is_suite=True)
246 else:
247 self.testFinished(test_name, is_suite=True)
249 def _repose_suite_closed(self, suite):
250 name = suite.full_name
251 if name:
252 _old_service_messages.testSuiteFinished(self, ".".join(name))
253 for child in suite.children.values():
254 self._repose_suite_closed(child)
256 def close_suites(self):
257 # Go in reverse order and close all suites
258 for (test_suite, node_id, parent_node_id, is_test) in \
259 reversed(list(self._test_suites.values())):
260 # suits are explicitly closed, but if test can't been finished, it is skipped
261 message = "testIgnored" if is_test else "testSuiteFinished"
262 _old_service_messages.message(self, message, **{
263 "name": test_suite,
264 "nodeId": str(node_id),
265 "parentNodeId": str(parent_node_id)
266 })
267 self._test_suites = OrderedDict()
270messages.TeamcityServiceMessages = NewTeamcityServiceMessages
273# Monkeypatched
275def jb_patch_separator(targets, fs_glue, python_glue, fs_to_python_glue):
276 """
277 Converts python target if format "/path/foo.py::parts.to.python" provided by Java to
278 python specific format
280 :param targets: list of dot-separated targets
281 :param fs_glue: how to glue fs parts of target. I.e.: module "eggs" in "spam" package is "spam[fs_glue]eggs"
282 :param python_glue: how to glue python parts (glue between class and function etc)
283 :param fs_to_python_glue: between last fs-part and first python part
284 :return: list of targets with patched separators
285 """
286 if not targets:
287 return []
289 def _patch_target(target):
290 # /path/foo.py::parts.to.python
291 match = re.match("^(:?(.+)[.]py::)?(.+)$", target)
292 assert match, "unexpected string: {0}".format(target)
293 fs_part = match.group(2)
294 python_part = match.group(3).replace(".", python_glue)
295 if fs_part:
296 return fs_part.replace("/", fs_glue) + fs_to_python_glue + python_part
297 else:
298 return python_part
300 return map(_patch_target, targets)
303def jb_start_tests():
304 """
305 Parses arguments, starts protocol and fixes syspath and returns tuple of arguments
306 """
307 path, targets, additional_args = parse_arguments()
308 start_protocol()
309 return path, targets, additional_args
312def jb_finish_tests():
313 # To be called before process exist to close all suites
314 instance = NewTeamcityServiceMessages.INSTANCE
316 # instance may not be set if you run like pytest --version
317 if instance:
318 instance.close_suites()
321def start_protocol():
322 properties = {"durationStrategy": "manual"} if is_parallel_mode() else dict()
323 NewTeamcityServiceMessages().message('enteredTheMatrix', **properties)
326def parse_arguments():
327 """
328 Parses arguments, fixes syspath and returns tuple of arguments
330 :return: (string with path or None, list of targets or None, list of additional arguments)
331 It may return list with only one element (name itself) if name is the same or split names to several parts
332 """
333 # Handle additional args after --
334 additional_args = []
335 try:
336 index = sys.argv.index("--")
337 additional_args = sys.argv[index + 1:]
338 del sys.argv[index:]
339 except ValueError:
340 pass
341 utils = _jb_utils.VersionAgnosticUtils()
342 namespace = utils.get_options(
343 _jb_utils.OptionDescription('--path', 'Path to file or folder to run'),
344 _jb_utils.OptionDescription('--offset', 'Root node offset'),
345 _jb_utils.OptionDescription('--target', 'Python target to run', "append"))
346 del sys.argv[1:] # Remove all args
348 # PyCharm helpers dir is first dir in sys.path because helper is launched.
349 # But sys.path should be same as when launched with test runner directly
350 try:
351 if os.path.abspath(sys.path[0]) == os.path.abspath(
352 os.environ["PYCHARM_HELPERS_DIR"]):
353 path = sys.path.pop(0)
354 if path not in sys.path:
355 sys.path.append(path)
356 except KeyError:
357 pass
358 _TREE_MANAGER_HOLDER.offset = int(namespace.offset if namespace.offset else 0)
359 return namespace.path, namespace.target, additional_args
362def jb_doc_args(framework_name, args):
363 """
364 Runner encouraged to report its arguments to user with aid of this function
366 """
367 print("Launching {0} with arguments {1} in {2}\n".format(framework_name,
368 " ".join(args),
369 PROJECT_DIR))