Coverage for /Applications/PyCharm.app/Contents/plugins/python/helpers/pycharm/_jb_runner_tools.py: 35%
204 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-06 12:04 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-06 12:04 +0200
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: 37 ↛ 40line 37 didn't jump to line 40 because the condition on line 37 was always true
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: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
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: 93 ↛ 95line 93 didn't jump to line 95 because the condition on line 93 was always true
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: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
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: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
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: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 return
212 args = {"nodeId": current, "parentNodeId": parent, "name": testName}
214 # TODO: Doc copy/paste with parent, extract
215 if testDuration is not None: 215 ↛ 221line 215 didn't jump to line 221 because the condition on line 215 was always true
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: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
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: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
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": 244 ↛ 247line 244 didn't jump to line 247 because the condition on line 244 was always true
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_targets(targets, fs_glue, old_python_glue, new_python_glue, fs_to_python_glue, python_parts_action=None):
276 """
277 Converts python targets format provided by Java to python-specific format
279 :param targets: list of separated by [old_python_glue] or dots targets
280 :param fs_glue: how to glue fs parts of target. I.e.: module "eggs" in "spam" package is "spam[fs_glue]eggs"
281 :param new_python_glue: how to glue python parts (glue between class and function etc)
282 :param old_python_glue: which symbols need to be replaced by [new_python_glue]
283 :param fs_to_python_glue: between last fs-part and first python part
284 :param python_parts_action: additional action for python parts
285 :return: list of targets with patched separators
286 """
287 if not targets:
288 return []
290 def _patch_target(target):
291 # /path/foo.py::parts.to.python
292 match = re.match("^(:?(.+)[.]py::)?(.+)$", target)
293 assert match, "unexpected string: {0}".format(target)
294 fs_part = match.group(2)
295 python_part = match.group(3).replace(old_python_glue, new_python_glue)
296 if python_parts_action is not None:
297 python_part = python_parts_action(fs_part, python_part)
298 if fs_part:
299 return fs_part.replace("/", fs_glue) + fs_to_python_glue + python_part
300 else:
301 return python_part
303 return map(_patch_target, targets)
306def jb_patch_separator(targets, fs_glue, python_glue, fs_to_python_glue):
307 """
308 Converts python target if format "/path/foo.py::parts.to.python" provided by Java to
309 python-specific format
311 :param targets: list of dot-separated targets
312 :param fs_glue: how to glue fs parts of target. I.e.: module "eggs" in "spam" package is "spam[fs_glue]eggs"
313 :param python_glue: how to glue python parts (glue between class and function etc)
314 :param fs_to_python_glue: between last fs-part and first python part
315 :return: list of targets with patched separators
316 """
317 return jb_patch_targets(targets, fs_glue, '.', python_glue, fs_to_python_glue)
320def jb_start_tests():
321 """
322 Parses arguments, starts protocol and fixes syspath and returns tuple of arguments
323 """
324 path, targets, additional_args = parse_arguments()
325 start_protocol()
326 return path, targets, additional_args
329def jb_finish_tests():
330 # To be called before process exist to close all suites
331 instance = NewTeamcityServiceMessages.INSTANCE
333 # instance may not be set if you run like pytest --version
334 if instance:
335 instance.close_suites()
338def start_protocol():
339 properties = {"durationStrategy": "manual"} if is_parallel_mode() else dict()
340 NewTeamcityServiceMessages().message('enteredTheMatrix', **properties)
343def parse_arguments():
344 """
345 Parses arguments, fixes syspath and returns tuple of arguments
347 :return: (string with path or None, list of targets or None, list of additional arguments)
348 It may return list with only one element (name itself) if name is the same or split names to several parts
349 """
350 # Handle additional args after --
351 additional_args = []
352 try:
353 index = sys.argv.index("--")
354 additional_args = sys.argv[index + 1:]
355 del sys.argv[index:]
356 except ValueError:
357 pass
358 utils = _jb_utils.VersionAgnosticUtils()
359 namespace = utils.get_options(
360 _jb_utils.OptionDescription('--path', 'Path to file or folder to run'),
361 _jb_utils.OptionDescription('--offset', 'Root node offset'),
362 _jb_utils.OptionDescription('--target', 'Python target to run', "append"))
363 del sys.argv[1:] # Remove all args
365 # PyCharm helpers dir is first dir in sys.path because helper is launched.
366 # But sys.path should be same as when launched with test runner directly
367 try:
368 if os.path.abspath(sys.path[0]) == os.path.abspath(
369 os.environ["PYCHARM_HELPERS_DIR"]):
370 path = sys.path.pop(0)
371 if path not in sys.path:
372 sys.path.append(path)
373 except KeyError:
374 pass
375 _TREE_MANAGER_HOLDER.offset = int(namespace.offset if namespace.offset else 0)
376 return namespace.path, namespace.target, additional_args
379def jb_doc_args(framework_name, args):
380 """
381 Runner encouraged to report its arguments to user with aid of this function
383 """
384 print("Launching {0} with arguments {1} in {2}\n".format(framework_name,
385 " ".join(args),
386 PROJECT_DIR))