diff options
Diffstat (limited to 'mobly/snippet/client_base.py')
-rw-r--r-- | mobly/snippet/client_base.py | 438 |
1 files changed, 438 insertions, 0 deletions
diff --git a/mobly/snippet/client_base.py b/mobly/snippet/client_base.py new file mode 100644 index 0000000..0009f20 --- /dev/null +++ b/mobly/snippet/client_base.py @@ -0,0 +1,438 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The JSON RPC client base for communicating with snippet servers. + +The JSON RPC protocol expected by this module is: + +.. code-block:: json + + Request: + { + 'id': <Required. Monotonically increasing integer containing the ID of this + request.>, + 'method': <Required. String containing the name of the method to execute.>, + 'params': <Required. JSON array containing the arguments to the method, + `null` if no positional arguments for the RPC method.>, + 'kwargs': <Optional. JSON dict containing the keyword arguments for the + method, `null` if no positional arguments for the RPC method.>, + } + + Response: + { + 'error': <Required. String containing the error thrown by executing the + method, `null` if no error occurred.>, + 'id': <Required. Int id of request that this response maps to.>, + 'result': <Required. Arbitrary JSON object containing the result of + executing the method, `null` if the method could not be executed + or returned void.>, + 'callback': <Required. String that represents a callback ID used to + identify events associated with a particular CallbackHandler + object, `null` if this is not an asynchronous RPC.>, + } +""" + +import abc +import json +import threading +import time + +from mobly.snippet import errors + +# Maximum logging length of RPC response in DEBUG level when verbose logging is +# off. +_MAX_RPC_RESP_LOGGING_LENGTH = 1024 + +# The required field names of RPC response. +RPC_RESPONSE_REQUIRED_FIELDS = ('id', 'error', 'result', 'callback') + + +class ClientBase(abc.ABC): + """Base class for JSON RPC clients that connect to snippet servers. + + Connects to a remote device running a JSON RPC compatible server. Users call + the function `start_server` to start the server on the remote device before + sending any RPC. After sending all RPCs, users call the function `stop` + to stop the snippet server and release all the requested resources. + + Attributes: + package: str, the user-visible name of the snippet library being + communicated with. + host_port: int, the host port of this RPC client. + device_port: int, the device port of this RPC client. + log: Logger, the logger of the corresponding device controller. + verbose_logging: bool, if True, prints more detailed log + information. Default is True. + """ + + def __init__(self, package, device): + """Initializes the instance of ClientBase. + + Args: + package: str, the user-visible name of the snippet library being + communicated with. + device: DeviceController, the device object associated with a client. + """ + + self.package = package + self.host_port = None + self.device_port = None + self.log = device.log + self.verbose_logging = True + self._device = device + self._counter = None + self._lock = threading.Lock() + self._event_client = None + + def __del__(self): + self.close_connection() + + def initialize(self): + """Initializes the snippet client to interact with the remote device. + + This function contains following stages: + 1. preparing to start the snippet server. + 2. starting the snippet server on the remote device. + 3. making a connection to the snippet server. + + After this, the self.host_port and self.device_port attributes must be + set. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + errors.ServerStartPreCheckError: when prechecks for starting the server + failed. + errors.ServerStartError: when failed to start the snippet server. + """ + + # Use log.info here so people can follow along with the initialization + # process. Initialization can be slow, especially if there are + # multiple snippets, this avoids the perception that the framework + # is hanging for a long time doing nothing. + self.log.info('Initializing the snippet package %s.', self.package) + start_time = time.perf_counter() + + self.log.debug('Preparing to start the snippet server of %s.', self.package) + self.before_starting_server() + + try: + self.log.debug('Starting the snippet server of %s.', self.package) + self.start_server() + + self.log.debug('Making a connection to the snippet server of %s.', + self.package) + self._make_connection() + + except Exception: + self.log.error( + 'Error occurred trying to start and connect to the snippet server ' + 'of %s.', self.package) + try: + self.stop() + except Exception: # pylint: disable=broad-except + # Only prints this exception and re-raises the original exception + self.log.exception( + 'Failed to stop the snippet package %s after failure to start ' + 'and connect.', self.package) + + raise + + self.log.debug( + 'Snippet package %s initialized after %.1fs on host port %d.', + self.package, + time.perf_counter() - start_time, self.host_port) + + @abc.abstractmethod + def before_starting_server(self): + """Performs the preparation steps before starting the remote server. + + For example, subclass can check or modify the device settings at this + stage. + + Raises: + errors.ServerStartPreCheckError: when prechecks for starting the server + failed. + """ + + @abc.abstractmethod + def start_server(self): + """Starts the server on the remote device. + + The client has completed the preparations, so the client calls this + function to start the server. + """ + + def _make_connection(self): + """Proxy function of make_connection. + + This function resets the RPC id counter before calling `make_connection`. + """ + self._counter = self._id_counter() + self.make_connection() + + @abc.abstractmethod + def make_connection(self): + """Makes a connection to the snippet server on the remote device. + + This function makes a connection to the server and sends a handshake + request to ensure the server is available for upcoming RPCs. + + There are two types of connections used by snippet clients: + * The client makes a new connection each time it needs to send an RPC. + * The client makes a connection in this stage and uses it for all the RPCs. + In this case, the client should implement `close_connection` to close + the connection. + + This function uses self.host_port for communicating with the server. If + self.host_port is 0 or None, this function finds an available host port to + make the connection and set self.host_port to the found port. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + """ + + def __getattr__(self, name): + """Wrapper for python magic to turn method calls into RPCs.""" + + def rpc_call(*args, **kwargs): + return self._rpc(name, *args, **kwargs) + + return rpc_call + + def _id_counter(self): + """Returns an id generator.""" + i = 0 + while True: + yield i + i += 1 + + def set_snippet_client_verbose_logging(self, verbose): + """Switches verbose logging. True for logging full RPC responses. + + By default it will write full messages returned from RPCs. Turning off the + verbose logging will result in writing no more than + _MAX_RPC_RESP_LOGGING_LENGTH characters per RPC returned string. + + _MAX_RPC_RESP_LOGGING_LENGTH will be set to 1024 by default. The length + contains the full RPC response in JSON format, not just the RPC result + field. + + Args: + verbose: bool, if True, turns on verbose logging, otherwise turns off. + """ + self.log.info('Sets verbose logging to %s.', verbose) + self.verbose_logging = verbose + + @abc.abstractmethod + def restore_server_connection(self, port=None): + """Reconnects to the server after the device was disconnected. + + Instead of creating a new instance of the client: + - Uses the given port (or finds a new available host_port if 0 or None is + given). + - Tries to connect to the remote server with the selected port. + + Args: + port: int, if given, this is the host port from which to connect to the + remote device port. Otherwise, finds a new available port as host + port. + + Raises: + errors.ServerRestoreConnectionError: when failed to restore the connection + to the snippet server. + """ + + def _rpc(self, rpc_func_name, *args, **kwargs): + """Sends an RPC to the server. + + Args: + rpc_func_name: str, the name of the snippet function to execute on the + server. + *args: any, the positional arguments of the RPC request. + **kwargs: any, the keyword arguments of the RPC request. + + Returns: + The result of the RPC. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + errors.ApiError: the RPC went through, however executed with errors. + """ + try: + self.check_server_proc_running() + except Exception: + self.log.error( + 'Server process running check failed, skip sending RPC method(%s).', + rpc_func_name) + raise + + with self._lock: + rpc_id = next(self._counter) + request = self._gen_rpc_request(rpc_id, rpc_func_name, *args, **kwargs) + + self.log.debug('Sending RPC request %s.', request) + response = self.send_rpc_request(request) + self.log.debug('RPC request sent.') + + if self.verbose_logging or _MAX_RPC_RESP_LOGGING_LENGTH >= len(response): + self.log.debug('Snippet received: %s', response) + else: + self.log.debug('Snippet received: %s... %d chars are truncated', + response[:_MAX_RPC_RESP_LOGGING_LENGTH], + len(response) - _MAX_RPC_RESP_LOGGING_LENGTH) + + response_decoded = self._decode_response_string_and_validate_format( + rpc_id, response) + return self._handle_rpc_response(rpc_func_name, response_decoded) + + @abc.abstractmethod + def check_server_proc_running(self): + """Checks whether the server is still running. + + If the server is not running, it throws an error. As this function is called + each time the client tries to send an RPC, this should be a quick check + without affecting performance. Otherwise it is fine to not check anything. + + Raises: + errors.ServerDiedError: if the server died. + """ + + def _gen_rpc_request(self, rpc_id, rpc_func_name, *args, **kwargs): + """Generates the JSON RPC request. + + In the generated JSON string, the fields are sorted by keys in ascending + order. + + Args: + rpc_id: int, the id of this RPC. + rpc_func_name: str, the name of the snippet function to execute + on the server. + *args: any, the positional arguments of the RPC. + **kwargs: any, the keyword arguments of the RPC. + + Returns: + A string of the JSON RPC request. + """ + data = {'id': rpc_id, 'method': rpc_func_name, 'params': args} + if kwargs: + data['kwargs'] = kwargs + return json.dumps(data, sort_keys=True) + + @abc.abstractmethod + def send_rpc_request(self, request): + """Sends the JSON RPC request to the server and gets a response. + + Note that the request and response are both in string format. So if the + connection with server provides interfaces in bytes format, please + transform them to string in the implementation of this function. + + Args: + request: str, a string of the RPC request. + + Returns: + A string of the RPC response. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + """ + + def _decode_response_string_and_validate_format(self, rpc_id, response): + """Decodes response JSON string to python dict and validates its format. + + Args: + rpc_id: int, the actual id of this RPC. It should be the same with the id + in the response, otherwise throws an error. + response: str, the JSON string of the RPC response. + + Returns: + A dict decoded from the response JSON string. + + Raises: + errors.ProtocolError: if the response format is invalid. + """ + if not response: + raise errors.ProtocolError(self._device, + errors.ProtocolError.NO_RESPONSE_FROM_SERVER) + + result = json.loads(response) + for field_name in RPC_RESPONSE_REQUIRED_FIELDS: + if field_name not in result: + raise errors.ProtocolError( + self._device, + errors.ProtocolError.RESPONSE_MISSING_FIELD % field_name) + + if result['id'] != rpc_id: + raise errors.ProtocolError(self._device, + errors.ProtocolError.MISMATCHED_API_ID) + + return result + + def _handle_rpc_response(self, rpc_func_name, response): + """Handles the content of RPC response. + + If the RPC response contains error information, it throws an error. If the + RPC is asynchronous, it creates and returns a callback handler + object. Otherwise, it returns the result field of the response. + + Args: + rpc_func_name: str, the name of the snippet function that this RPC + triggered on the snippet server. + response: dict, the object decoded from the response JSON string. + + Returns: + The result of the RPC. If synchronous RPC, it is the result field of the + response. If asynchronous RPC, it is the callback handler object. + + Raises: + errors.ApiError: if the snippet function executed with errors. + """ + + if response['error']: + raise errors.ApiError(self._device, response['error']) + if response['callback'] is not None: + return self.handle_callback(response['callback'], response['result'], + rpc_func_name) + return response['result'] + + @abc.abstractmethod + def handle_callback(self, callback_id, ret_value, rpc_func_name): + """Creates a callback handler for the asynchronous RPC. + + Args: + callback_id: str, the callback ID for creating a callback handler object. + ret_value: any, the result field of the RPC response. + rpc_func_name: str, the name of the snippet function executed on the + server. + + Returns: + The callback handler object. + """ + + @abc.abstractmethod + def stop(self): + """Releases all the resources acquired in `initialize`.""" + + @abc.abstractmethod + def close_connection(self): + """Closes the connection to the snippet server on the device. + + This is a unilateral closing from the client side, without tearing down + the snippet server running on the device. + + The connection to the snippet server can be re-established by calling + `restore_server_connection`. + """ |