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

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 

9 

10import _jb_utils 

11from teamcity import teamcity_presence_env_var, messages 

12 

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" 

16 

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()) 

23 

24 

25def _parse_parametrized(part): 

26 """ 

27 

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. 

31  

32 Tests with docstring are reported in similar way but they have space before parenthesis and should be ignored 

33 by this function 

34  

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)] 

41 

42 

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 

48 

49 @property 

50 def manager(self): 

51 if not self._manager_imp: 

52 self._fill_manager() 

53 return self._manager_imp 

54 

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) 

62 

63 

64_TREE_MANAGER_HOLDER = _TreeManagerHolder() 

65 

66 

67def set_parallel_mode(): 

68 _TREE_MANAGER_HOLDER.parallel = True 

69 

70 

71def is_parallel_mode(): 

72 return _TREE_MANAGER_HOLDER.parallel 

73 

74 

75# Monkeypatching TC 

76_old_service_messages = messages.TeamcityServiceMessages 

77 

78PARSE_FUNC = None 

79 

80 

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 

86 

87 def __init__(self, *args, **kwargs): 

88 super(NewTeamcityServiceMessages, self).__init__(*args, **kwargs) 

89 NewTeamcityServiceMessages.INSTANCE = self 

90 

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 

96 

97 full_name = properties["name"] 

98 try: 

99 # Report directory so Java site knows which folder to resolve names against 

100 

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 

114 

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 

123 

124 properties["nodeId"] = str(current) 

125 properties["parentNodeId"] = str(parent) 

126 

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) 

131 

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 

142 

143 def _fix_setup_teardown_name(self, test_name): 

144 """ 

145 

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 

152 

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)])) 

158 

159 def blockClosed(self, name, flowId=None): 

160 

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 

167 

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) 

174 

175 self.testFinished(test_name) 

176 self._latest_subtest_result = None 

177 

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 

181 

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) 

185 

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) 

193 

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) 

198 

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) 

202 

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) 

206 

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} 

213 

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) 

220 

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] 

229 

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) 

235 

236 def do_commands(self, commands): 

237 """ 

238 

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) 

248 

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) 

255 

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() 

268 

269 

270messages.TeamcityServiceMessages = NewTeamcityServiceMessages 

271 

272 

273# Monkeypatched 

274 

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 

278 

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 [] 

289 

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 

302 

303 return map(_patch_target, targets) 

304 

305 

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 

310 

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) 

318 

319 

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 

327 

328 

329def jb_finish_tests(): 

330 # To be called before process exist to close all suites 

331 instance = NewTeamcityServiceMessages.INSTANCE 

332 

333 # instance may not be set if you run like pytest --version 

334 if instance: 

335 instance.close_suites() 

336 

337 

338def start_protocol(): 

339 properties = {"durationStrategy": "manual"} if is_parallel_mode() else dict() 

340 NewTeamcityServiceMessages().message('enteredTheMatrix', **properties) 

341 

342 

343def parse_arguments(): 

344 """ 

345 Parses arguments, fixes syspath and returns tuple of arguments 

346 

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 

364 

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 

377 

378 

379def jb_doc_args(framework_name, args): 

380 """ 

381 Runner encouraged to report its arguments to user with aid of this function 

382 

383 """ 

384 print("Launching {0} with arguments {1} in {2}\n".format(framework_name, 

385 " ".join(args), 

386 PROJECT_DIR))