Coverage for src/robotide/contrib/testrunner/TestRunnerAgent.py: 39%

275 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-06 10:40 +0100

1#!/usr/bin/env python 

2# ---------------------------------------------------------------------------- 

3# Copyright 2010 Orbitz WorldWide 

4# 

5# Licensed under the Apache License, Version 2.0 (the "License"); 

6# you may not use this file except in compliance with the License. 

7# You may obtain a copy of the License at 

8# 

9# http://www.apache.org/licenses/LICENSE-2.0 

10# 

11# Unless required by applicable law or agreed to in writing, software 

12# distributed under the License is distributed on an "AS IS" BASIS, 

13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

14# See the License for the specific language governing permissions and 

15# limitations under the License. 

16 

17# Modified by Mikko Korpela under NSN copyrights 

18# Copyright 2008-2015 Nokia Solutions and Networks 

19# 

20# Licensed under the Apache License, Version 2.0 (the "License"); 

21# you may not use this file except in compliance with the License. 

22# You may obtain a copy of the License at 

23# 

24# http://www.apache.org/licenses/LICENSE-2.0 

25# 

26# Unless required by applicable law or agreed to in writing, software 

27# distributed under the License is distributed on an "AS IS" BASIS, 

28# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

29# See the License for the specific language governing permissions and 

30# limitations under the License. 

31 

32# Ammended by Timothy Alexander <dragonfyre13@gmail.com> 

33# (StreamHandler class added) 

34# Copyright 2013 Timothy Alexander 

35# Licensed under the Apache License, Version 2.0 

36# http://www.apache.org/licenses/LICENSE-2.0 

37 

38# 

39# Modified by Mateusz Marzec under NSN copyrights 

40# Copyright 2015 Nokia Solutions and Networks 

41# * Licensed under the Apache License, Version 2.0, 

42# * see license.txt file for details. 

43# 

44 

45# Ammended by Helio Guilherme <helioxentric@gmail.com> 

46# Copyright 2008-2015 Nokia Networks 

47# Copyright 2016- Robot Framework Foundation 

48# 

49# Licensed under the Apache License, Version 2.0 (the "License"); 

50# you may not use this file except in compliance with the License. 

51# You may obtain a copy of the License at 

52# 

53# http://www.apache.org/licenses/LICENSE-2.0 

54# 

55# Unless required by applicable law or agreed to in writing, software 

56# distributed under the License is distributed on an "AS IS" BASIS, 

57# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

58# See the License for the specific language governing permissions and 

59# limitations under the License. 

60 

61"""A Robot Framework listener that sends information to a socket 

62 

63This uses a custom streamhandler module, preferring json but sending either 

64json or pickle to send objects to the listening server. It should probably be 

65refactored to call an XMLRPC server. 

66""" 

67 

68import copy 

69import os 

70import pickle 

71import platform 

72import sys 

73import socket 

74import threading 

75 

76PLATFORM = platform.python_implementation() 

77 

78try: 

79 import socketserver 

80except ImportError as e: 

81 print(f"Exception at TestRunnerAgent import SocketServer: {e}") 

82 raise e 

83 

84try: 

85 # to find robot (we use provided lib) 

86 sys.path.append(os.path.join(os.path.dirname(__file__), '../../lib')) 

87 from robot.errors import ExecutionFailed 

88 from robot.running import EXECUTION_CONTEXTS 

89 from robot.running.signalhandler import STOP_SIGNAL_MONITOR 

90 from robot.utils import encoding 

91 from robot.utils.encoding import SYSTEM_ENCODING 

92except ImportError: 

93 encoding = None 

94 raise 

95 

96try: 

97 import json 

98 _JSONAVAIL = True 

99except ImportError: 

100 json = None 

101 _JSONAVAIL = False 

102 

103try: 

104 from StringIO import StringIO 

105except ImportError: # py3 <=3.6 

106 from io import StringIO 

107 

108HOST = "localhost" 

109 

110# Setting Output encoding to UTF-8 and ignoring the platform specs 

111# RIDE will expect UTF-8 

112# Set output encoding to UTF-8 for piped output streams 

113# DEBUG This was working in Linux always! 

114# if encoding: 

115# encoding.OUTPUT_ENCODING = 'UTF-8' 

116# print("DEBUG: TestRunnerAgent encoding %s\n" % SYSTEM_ENCODING ) 

117 

118 

119class TestRunnerAgent: 

120 """Pass all listener events to a remote listener 

121 

122 If called with one argument, that argument is a port 

123 If called with two, the first is a hostname, the second is a port 

124 """ 

125 ROBOT_LISTENER_API_VERSION = 2 

126 

127 def __init__(self, *args): 

128 self.port = int(args[0]) 

129 self.host = HOST 

130 self.sock = None 

131 self.filehandler = None 

132 self.streamhandler = None 

133 self._connect() 

134 self._send_pid() 

135 self._create_debugger((len(args) >= 2) and (args[1] == 'True')) 

136 self._create_kill_server() 

137 print("TestRunnerAgent: Running under %s %s\n" % 

138 (PLATFORM, sys.version.split()[0])) 

139 

140 def _create_debugger(self, pause_on_failure): 

141 self._debugger = RobotDebugger(pause_on_failure) 

142 

143 def _create_kill_server(self): 

144 self._killer = RobotKillerServer(self._debugger) 

145 self._server_thread = threading.Thread( 

146 target=self._killer.serve_forever) 

147 # DEPRECATED: self._server_thread.setDaemon(True) 

148 self._server_thread.daemon = True 

149 self._server_thread.start() 

150 self._send_server_port(self._killer.server_address[1]) 

151 

152 def _send_pid(self): 

153 self._send_socket("pid", os.getpid()) 

154 

155 def _send_server_port(self, port): 

156 self._send_socket("port", port) 

157 

158 def start_test(self, name, attrs): 

159 self._send_socket("start_test", name, attrs) 

160 

161 def end_test(self, name, attrs): 

162 self._send_socket("end_test", name, attrs) 

163 

164 def start_suite(self, name, attrs): 

165 attrs_copy = copy.copy(attrs) 

166 del attrs_copy['doc'] 

167 attrs_copy['is_dir'] = os.path.isdir(attrs['source']) 

168 self._send_socket("start_suite", name, attrs_copy) 

169 

170 def end_suite(self, name, attrs): 

171 attrs_copy = copy.copy(attrs) 

172 del attrs_copy['doc'] 

173 attrs_copy['is_dir'] = os.path.isdir(attrs['source']) 

174 self._send_socket("end_suite", name, attrs_copy) 

175 

176 def start_keyword(self, name, attrs): 

177 # pass empty args, see https://github.com/nokia/RED/issues/32 

178 

179 # we're cutting args from original attrs dict, because it may contain 

180 # objects which are not json-serializable, and we don't need them anyway 

181 attrs_copy = copy.copy(attrs) 

182 del attrs_copy['args'] 

183 del attrs_copy['doc'] 

184 del attrs_copy['assign'] 

185 

186 self._send_socket("start_keyword", name, attrs_copy) 

187 if self._debugger.is_breakpoint(name, attrs): # must check original 

188 self._debugger.pause() 

189 paused = self._debugger.is_paused() 

190 if paused: 

191 self._send_socket('paused') 

192 self._debugger.start_keyword() 

193 if paused: 

194 self._send_socket('continue') 

195 

196 def end_keyword(self, name, attrs): 

197 # pass empty args, see https://github.com/nokia/RED/issues/32 

198 attrs_copy = copy.copy(attrs) 

199 del attrs_copy['args'] 

200 del attrs_copy['doc'] 

201 del attrs_copy['assign'] 

202 

203 self._send_socket("end_keyword", name, attrs_copy) 

204 self._debugger.end_keyword(attrs['status'] == 'PASS') 

205 

206 def message(self, message): 

207 """ Just ignore it """ 

208 pass 

209 

210 def log_message(self, message): 

211 self._send_socket("log_message", message) 

212 

213 def log_file(self, path): 

214 self._send_socket("log_file", path) 

215 

216 def output_file(self, path): 

217 """ Just ignore it """ 

218 pass 

219 

220 def report_file(self, path): 

221 self._send_socket("report_file", path) 

222 

223 def summary_file(self, path): 

224 """ Just ignore it """ 

225 pass 

226 

227 def debug_file(self, path): 

228 """ Just ignore it """ 

229 pass 

230 

231 def close(self): 

232 self._send_socket("close") 

233 if self.sock: 

234 self.filehandler.close() 

235 self.sock.close() 

236 

237 def _connect(self): 

238 """Establish a connection for sending data""" 

239 try: 

240 self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 

241 self.sock.connect((self.host, self.port)) 

242 # Iron python does not return right object type if not binary mode 

243 self.filehandler = self.sock.makefile('wb') 

244 self.streamhandler = StreamHandler(self.filehandler) 

245 except socket.error as ex: 

246 print('unable to open socket to "%s:%s" error: %s' 

247 % (self.host, self.port, str(ex))) 

248 self.sock = None 

249 self.filehandler = None 

250 

251 def _send_socket(self, name, *args): 

252 try: 

253 if self.filehandler: 

254 packet = (name, args) 

255 self.streamhandler.dump(packet) 

256 self.filehandler.flush() 

257 except Exception: 

258 import traceback 

259 traceback.print_exc(file=sys.stdout) 

260 sys.stdout.flush() 

261 raise 

262 

263 

264class RobotDebugger(object): 

265 

266 def __init__(self, pause_on_failure=False): 

267 self._state = 'running' 

268 self._keyword_level = 0 

269 self._pause_when_on_level = -1 

270 self._pause_on_failure = pause_on_failure 

271 self._resume = threading.Event() 

272 

273 @staticmethod 

274 def is_breakpoint(name, attrs): 

275 if len(attrs['args']) > 0: 1f

276 return name == 'BuiltIn.Comment' and \ 1f

277 str(attrs['args'][0]).upper().startswith(u"PAUSE") 

278 

279 def pause(self): 

280 self._resume.clear() 1edb

281 self._state = 'pause' 1edb

282 

283 def pause_on_failure(self, pause): 

284 self._pause_on_failure = pause 1c

285 

286 def resume(self): 

287 self._state = 'running' 1ce

288 self._pause_when_on_level = -1 1ce

289 self._resume.set() 1ce

290 

291 def step_next(self): 

292 self._state = 'step_next' 1db

293 self._resume.set() 1db

294 

295 def step_over(self): 

296 self._state = 'step_over' 1b

297 self._resume.set() 1b

298 

299 def start_keyword(self): 

300 while self._state == 'pause': 1cdb

301 self._resume.wait() 1cdb

302 self._resume.clear() 1cdb

303 if self._state == 'step_next': 1cdb

304 self._state = 'pause' 1db

305 elif self._state == 'step_over': 1cb

306 self._pause_when_on_level = self._keyword_level 1b

307 self._state = 'resume' 1b

308 self._keyword_level += 1 1cdb

309 

310 def end_keyword(self, passed=True): 

311 self._keyword_level -= 1 1cdb

312 if self._keyword_level == self._pause_when_on_level or\ 1cdb

313 (self._pause_on_failure and not passed): 

314 self._state = 'pause' 1cb

315 

316 def is_paused(self): 

317 return self._state == 'pause' 1e

318 

319 

320class RobotKillerServer(socketserver.TCPServer): 

321 allow_reuse_address = True 

322 

323 def __init__(self, debugger): 

324 socketserver.TCPServer.__init__(self, ("", 0), RobotKillerHandler) 

325 self.debugger = debugger 

326 

327 

328class RobotKillerHandler(socketserver.StreamRequestHandler): 

329 def handle(self): 

330 data = self.request.makefile('r').read().strip() 

331 if data == 'kill': 

332 self._signal_kill() 

333 elif data == 'pause': 

334 self.server.debugger.pause() 

335 elif data == 'resume': 

336 self.server.debugger.resume() 

337 elif data == 'step_next': 

338 self.server.debugger.step_next() 

339 elif data == 'step_over': 

340 self.server.debugger.step_over() 

341 elif data == 'pause_on_failure': 

342 self.server.debugger.pause_on_failure(True) 

343 elif data == 'do_not_pause_on_failure': 

344 self.server.debugger.pause_on_failure(False) 

345 

346 @staticmethod 

347 def _signal_kill(): 

348 try: 

349 STOP_SIGNAL_MONITOR(1, '') 

350 except ExecutionFailed: 

351 pass 

352 

353 

354# NOTE: Moved to bottom of TestRunnerAgent per feedback in pull request, 

355# so jybot doesn't encounter issues. Special imports at top of file. 

356class StreamError(Exception): 

357 """ 

358 Base class for EncodeError and DecodeError 

359 """ 

360 pass 

361 

362 

363class EncodeError(StreamError): 

364 """ 

365 This exception is raised when an unencodable object is passed to the 

366 dump() method or function. 

367 """ 

368 wrapped_exceptions = (pickle.PicklingError, ) 

369 

370 

371class DecodeError(StreamError): 

372 """ 

373 This exception is raised when there is a problem decoding an object, 

374 such as a security violation. 

375 

376 Note that other exceptions may also be raised during decoding, including 

377 AttributeError, EOFError, ImportError, and IndexError. 

378 """ 

379 # NOTE: No JSONDecodeError in json in stdlib for python >= 2.6 

380 wrapped_exceptions = (pickle.UnpicklingError,) 

381 if _JSONAVAIL and hasattr(json, 'JSONDecodeError'): 381 ↛ exitline 381 didn't exit class 'DecodeError' because the condition on line 381 was always true

382 wrapped_exceptions = (pickle.UnpicklingError, json.JSONDecodeError) 

383 

384 

385def dump(obj, fp): 

386 StreamHandler(fp).dump(obj) 

387 

388 

389def load(fp): 

390 return StreamHandler(fp).load() 

391 

392 

393def dumps(obj): 

394 """ 

395 Similar method to json dumps, prepending data with message length 

396 header. Replaces 'pickle.dumps', so can be used in place without 

397 the memory leaks on receiving side in 'pickle.loads' (related to 

398 memoization of data) 

399 

400 NOTE: Protocol is ignored when json representation is used 

401 """ 

402 fp = StringIO() 

403 StreamHandler(fp).dump(obj) 

404 return fp.getvalue() 

405 

406 

407def loads(s): 

408 """ 

409 Reads in json message or pickle message prepended with message length 

410 header from a string. Message is expected to be encoded by this class as 

411 well, to have same message length header type. 

412 

413 Specifically replaces 'pickle.loads' as that function/method has serious 

414 memory leak issues with long term use of same Unpickler object for 

415 encoding data to send, specifically related to memoization of data to 

416 encode. 

417 """ 

418 fp = StringIO(s) 

419 return StreamHandler(fp).load() 

420 

421 

422class StreamHandler(object): 

423 """ 

424 This class provides a common streaming approach for the purpose 

425 of reliably sending data over a socket interface. Replaces usage of 

426 'Unpickler.load' where possible with JSON format prepended by message length 

427 header. Uses json in python stdlib (in python >= 2.6) or simplejson (in 

428 python < 2.6). If neither are available, falls back to pickle.Pickler and 

429 pickle.Unpickler, attempting to eliminate memory leakage where possible at 

430 the expense of CPU usage (by not re-using Pickler or Unpickler objects). 

431 

432 NOTE: StreamHandler currently assumes that same python version is installed 

433 on both sides of reading/writing (or simplejson is loaded in case of one 

434 side or other using python < 2.6). This could be resolved by requiring an 

435 initial header with json vs pickle determination from the writing side, but 

436 would considerably complicate the protocol(s) further (handshake would need 

437 to occur at least, and assumes encoding is used over a socket, etc.) 

438 

439 json.raw_decode could be used rather than prepending with a message header 

440 in theory (assuming json is available), but performance of repeatedly 

441 failing to parse written data would make this an unworkable solution in 

442 many cases. 

443 """ 

444 loads = staticmethod(loads) 

445 dumps = staticmethod(dumps) 

446 

447 def __init__(self, fp): 

448 """ 

449 Stream handler that encodes objects as either JSON (if available) with 

450 message length header prepended for sending over a socket, or as a 

451 pickled object if using python < 2.6 and simplejson is not installed. 

452 

453 Since 'pickle.load' has memory leak issues with memoization (remembers 

454 absolutely everything decoded since instantiation), json is a preferred 

455 method to encode/decode for long-running processes which pass large 

456 amounts of data back and forth. 

457 """ 

458 if _JSONAVAIL: 

459 self._json_encoder = json.JSONEncoder(separators=(',', ':'), 

460 sort_keys=True).encode 

461 self._json_decoder = json.JSONDecoder(strict=False).decode 

462 else: 

463 def json_not_impl(dummy): 

464 _ = dummy 

465 raise NotImplementedError('Python should include json. Please check your Python installation.') 

466 self._json_decoder = staticmethod(json_not_impl) 

467 self._json_encoder = staticmethod(json_not_impl) 

468 self.fp = fp 

469 

470 def dump(self, obj): 

471 """ 

472 Similar method to json dump, prepending data with message length 

473 header. Replaces 'pickle.dump', so can be used in place without 

474 the memory leaks on receiving side in 'pickle.load' (related to 

475 memoization of data) 

476 

477 NOTE: Protocol is ignored when json representation is used 

478 """ 

479 # NOTE: Slightly less efficient than doing iterencode directly into the 

480 # fp, however difference is negligable and reduces complexity of 

481 # of the StreamHandler class (treating pickle and json the same) 

482 write_list = [] 

483 if _JSONAVAIL: 

484 try: 

485 s = self._json_encoder(obj) 

486 write_list.append('J') 

487 write_list.extend([str(len(s)), '|', s]) 

488 except Exception as ex: 

489 # Probably just failed to JSON-encode an object; try pickle. 

490 print(f"Exception at StreamHandler.dump(): {ex}") 

491 if not write_list: 

492 s = pickle.dumps(obj, pickle.HIGHEST_PROTOCOL) 

493 write_list.append('P') 

494 write_list.extend([str(len(s)), '|', s]) 

495 self.fp.write(bytes(''.join(write_list), "UTF-8")) 

496 

497 def load(self): 

498 """ 

499 Reads in json message prepended with message length header from a file 

500 (or socket, or other .read() enabled object). Message is expected to be 

501 encoded by this class as well, to have same message length header type. 

502 

503 Specifically replaces 'pickle.load' as that function/method has serious 

504 memory leak issues with long term use of same Unpickler object for 

505 encoding data to send, specifically related to memoization of data to 

506 encode. 

507 """ 

508 header = self._load_header() 

509 msgtype = header[0] 

510 msglen = header[1:] 

511 if not msglen.isdigit(): 

512 raise DecodeError('Message header not valid: %r' % header) 

513 msglen = int(msglen) 

514 buff = StringIO() 

515 # Don't use StringIO.len for sizing, reports string len not bytes 

516 buff.write(self.fp.read(msglen)) 

517 try: 

518 if msgtype == 'J': 

519 return self._json_decoder(buff.getvalue()) 

520 elif msgtype == 'P': 

521 return pickle.loads(buff.getvalue()) 

522 else: 

523 raise DecodeError("Message type %r not supported" % msgtype) 

524 except DecodeError.wrapped_exceptions as ex: 

525 raise DecodeError(str(ex)) 

526 

527 def _load_header(self): 

528 """ 

529 Load in just the header bit from a socket/file pointer 

530 """ 

531 buff = StringIO() 

532 while len(buff.getvalue()) == 0 or buff.getvalue()[-1] != '|': 

533 recv_char = self.fp.read(1) 

534 if not recv_char: 

535 raise EOFError('File/Socket closed while reading load header') 

536 buff.write(recv_char) 

537 return buff.getvalue()[:-1]