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
« 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.
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.
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
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#
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.
61"""A Robot Framework listener that sends information to a socket
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"""
68import copy
69import os
70import pickle
71import platform
72import sys
73import socket
74import threading
76PLATFORM = platform.python_implementation()
78try:
79 import socketserver
80except ImportError as e:
81 print(f"Exception at TestRunnerAgent import SocketServer: {e}")
82 raise e
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
96try:
97 import json
98 _JSONAVAIL = True
99except ImportError:
100 json = None
101 _JSONAVAIL = False
103try:
104 from StringIO import StringIO
105except ImportError: # py3 <=3.6
106 from io import StringIO
108HOST = "localhost"
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 )
119class TestRunnerAgent:
120 """Pass all listener events to a remote listener
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
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]))
140 def _create_debugger(self, pause_on_failure):
141 self._debugger = RobotDebugger(pause_on_failure)
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])
152 def _send_pid(self):
153 self._send_socket("pid", os.getpid())
155 def _send_server_port(self, port):
156 self._send_socket("port", port)
158 def start_test(self, name, attrs):
159 self._send_socket("start_test", name, attrs)
161 def end_test(self, name, attrs):
162 self._send_socket("end_test", name, attrs)
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)
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)
176 def start_keyword(self, name, attrs):
177 # pass empty args, see https://github.com/nokia/RED/issues/32
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']
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')
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']
203 self._send_socket("end_keyword", name, attrs_copy)
204 self._debugger.end_keyword(attrs['status'] == 'PASS')
206 def message(self, message):
207 """ Just ignore it """
208 pass
210 def log_message(self, message):
211 self._send_socket("log_message", message)
213 def log_file(self, path):
214 self._send_socket("log_file", path)
216 def output_file(self, path):
217 """ Just ignore it """
218 pass
220 def report_file(self, path):
221 self._send_socket("report_file", path)
223 def summary_file(self, path):
224 """ Just ignore it """
225 pass
227 def debug_file(self, path):
228 """ Just ignore it """
229 pass
231 def close(self):
232 self._send_socket("close")
233 if self.sock:
234 self.filehandler.close()
235 self.sock.close()
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
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
264class RobotDebugger(object):
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()
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")
279 def pause(self):
280 self._resume.clear() 1edb
281 self._state = 'pause' 1edb
283 def pause_on_failure(self, pause):
284 self._pause_on_failure = pause 1c
286 def resume(self):
287 self._state = 'running' 1ce
288 self._pause_when_on_level = -1 1ce
289 self._resume.set() 1ce
291 def step_next(self):
292 self._state = 'step_next' 1db
293 self._resume.set() 1db
295 def step_over(self):
296 self._state = 'step_over' 1b
297 self._resume.set() 1b
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
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
316 def is_paused(self):
317 return self._state == 'pause' 1e
320class RobotKillerServer(socketserver.TCPServer):
321 allow_reuse_address = True
323 def __init__(self, debugger):
324 socketserver.TCPServer.__init__(self, ("", 0), RobotKillerHandler)
325 self.debugger = debugger
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)
346 @staticmethod
347 def _signal_kill():
348 try:
349 STOP_SIGNAL_MONITOR(1, '')
350 except ExecutionFailed:
351 pass
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
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, )
371class DecodeError(StreamError):
372 """
373 This exception is raised when there is a problem decoding an object,
374 such as a security violation.
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)
385def dump(obj, fp):
386 StreamHandler(fp).dump(obj)
389def load(fp):
390 return StreamHandler(fp).load()
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)
400 NOTE: Protocol is ignored when json representation is used
401 """
402 fp = StringIO()
403 StreamHandler(fp).dump(obj)
404 return fp.getvalue()
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.
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()
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).
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.)
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)
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.
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
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)
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"))
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.
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))
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]