Repository: google/mobly

Issue:
[Android Device] Identify the device in exceptions thrown from a device instance.
Right now we have to manually add the device serial every time we throw an exception from inside AndroidDevice, which is tedious and annoying.
We should have a better way to handle this, perhaps an inner exception class.


Discussion:

Resolving patch:
diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py
--- a/mobly/controllers/android_device.py
+++ b/mobly/controllers/android_device.py
@@ -35,6 +35,7 @@
 MOBLY_CONTROLLER_CONFIG_NAME = 'AndroidDevice'
 
 ANDROID_DEVICE_PICK_ALL_TOKEN = '*'
+_DEBUG_PREFIX_TEMPLATE = '[AndroidDevice|%s] %s'
 
 # Key name for adb logcat extra params in config file.
 ANDROID_DEVICE_ADB_LOGCAT_PARAM_KEY = 'adb_logcat_param'
@@ -49,12 +50,16 @@ class Error(signals.ControllerError):
     pass
 
 
-class SnippetError(signals.ControllerError):
-    """Raised when somethig wrong with snippet happens."""
+class DeviceError(Error):
+    """Raised for errors from within an AndroidDevice object."""
 
+    def __init__(self, ad, msg):
+        new_msg = '%s %s' % (repr(ad), msg)
+        super(DeviceError, self).__init__(new_msg)
 
-class DoesNotExistError(Error):
-    """Raised when something that does not exist is referenced."""
+
+class SnippetError(DeviceError):
+    """Raised for errors related to Mobly snippet."""
 
 
 def create(configs):
@@ -83,8 +88,8 @@ def create(configs):
 
     for ad in ads:
         if ad.serial not in connected_ads:
-            raise DoesNotExistError('Android device %s is specified in config'
-                                    ' but is not attached.' % ad.serial)
+            raise DeviceError(ad, 'Android device is specified in config but '
+                              ' is not attached.')
     _start_services_on_ads(ads)
     return ads
 
@@ -133,15 +138,15 @@ def _start_services_on_ads(ads):
         running_ads.append(ad)
         try:
             ad.start_services()
-        except Exception as e:
+        except Exception:
             is_required = getattr(ad, KEY_DEVICE_REQUIRED, True)
             if is_required:
                 ad.log.exception('Failed to start some services, abort!')
                 destroy(running_ads)
                 raise
             else:
-                logging.warning('Skipping device %s because some service '
-                                'failed to start: %s', ad.serial, e)
+                ad.log.exception('Skipping this optional device because some '
+                                 'services failed to start.')
 
 
 def _parse_device_list(device_list_str, key):
@@ -225,10 +230,10 @@ def get_instances_with_configs(configs):
         try:
             ad = AndroidDevice(serial)
             ad.load_config(c)
-        except Exception as e:
+        except Exception:
             if is_required:
                 raise
-            logging.warning('Skipping device %s due to error: %s', serial, e)
+            ad.log.exception('Skipping this optional device due to error.')
             continue
         results.append(ad)
     return results
@@ -344,8 +349,8 @@ class AndroidDevice(object):
                   android device should be stored.
         log: A logger adapted from root logger with an added prefix specific
              to an AndroidDevice instance. The default prefix is
-             [AndroidDevice|<serial>]. Use self.set_logger_prefix_tag to use
-             a different tag in the prefix.
+             [AndroidDevice|<serial>]. Use self.set_debug_tag to use a
+             different tag in the prefix.
         adb_logcat_file_path: A string that's the full path to the adb logcat
                               file collected, if any.
         adb: An AdbProxy object used for interacting with the device via adb.
@@ -358,8 +363,9 @@ def __init__(self, serial=''):
         # logging.log_path only exists when this is used in an Mobly test run.
         log_path_base = getattr(logging, 'log_path', '/tmp/logs')
         self.log_path = os.path.join(log_path_base, 'AndroidDevice%s' % serial)
+        self._debug_tag = self.serial
         self.log = AndroidDeviceLoggerAdapter(logging.getLogger(),
-                                              {'tag': self.serial})
+                                              {'tag': self.debug_tag})
         self.sl4a = None
         self.ed = None
         self._adb_logcat_process = None
@@ -372,22 +378,36 @@ def __init__(self, serial=''):
         # names, values are the clients: {<attr name string>: <client object>}.
         self._snippet_clients = {}
 
-    def set_logger_prefix_tag(self, tag):
-        """Set a tag for the log line prefix of this instance.
+    def __repr__(self):
+        return "<AndroidDevice|%s>" % self.debug_tag
 
-        By default, the tag is the serial of the device, but sometimes having
-        the serial number in the log line doesn't help much with debugging. It
-        could be more helpful if users can mark the role of the device instead.
+    @property
+    def debug_tag(self):
+        """A string that represents a device object in debug info. Default value
+        is the device serial.
 
-        For example, instead of marking the serial number:
-            'INFO [AndroidDevice|abcdefg12345] One pending call ringing.'
+        This will be used as part of the prefix of debugging messages emitted by
+        this device object, like log lines and the message of DeviceError.
+        """
+        return self._debug_tag
 
-        marking the role of the device here is  more useful here:
-            'INFO [AndroidDevice|Caller] One pending call ringing.'
+    @debug_tag.setter
+    def debug_tag(self, tag):
+        """Setter for the debug tag.
 
-        Args:
-            tag: A string that is the tag to use.
+        By default, the tag is the serial of the device, but sometimes it may
+        be more descriptive to use a different tag of the user's choice.
+
+        Changing debug tag changes part of the prefix of debug info emitted by
+        this object, like log lines and the message of DeviceError.
+
+        Example:
+            By default, the device's serial number is used:
+                'INFO [AndroidDevice|abcdefg12345] One pending call ringing.'
+            The tag can be customized with `ad.debug_tag = 'Caller'`:
+                'INFO [AndroidDevice|Caller] One pending call ringing.'
         """
+        self._debug_tag = tag
         self.log.extra['tag'] = tag
 
     def start_services(self):
@@ -542,8 +562,9 @@ def load_config(self, config):
         """
         for k, v in config.items():
             if hasattr(self, k):
-                raise Error('Attempting to set existing attribute %s on %s' %
-                            (k, self.serial))
+                raise DeviceError(self, (
+                    'Attribute %s already exists with value %s, cannot set '
+                    'again.') % (k, getattr(self, k)))
             setattr(self, k, v)
 
     def root_adb(self):
@@ -576,17 +597,21 @@ def load_snippet(self, name, package):
         # Should not load snippet with the same attribute more than once.
         if name in self._snippet_clients:
             raise SnippetError(
+                self,
                 'Attribute "%s" is already registered with package "%s", it '
                 'cannot be used again.' %
                 (name, self._snippet_clients[name].package))
         # Should not load snippet with an existing attribute.
         if hasattr(self, name):
-            raise SnippetError('Attribute "%s" already exists, please use a '
-                               'different name.' % name)
+            raise SnippetError(
+                self,
+                'Attribute "%s" already exists, please use a different name.' %
+                name)
         # Should not load the same snippet package more than once.
         for client_name, client in self._snippet_clients.items():
             if package == client.package:
                 raise SnippetError(
+                    self,
                     'Snippet package "%s" has already been loaded under name'
                     ' "%s".' % (package, client_name))
         host_port = utils.get_available_host_port()
@@ -660,9 +685,9 @@ def cat_adb_log(self, tag, begin_time):
                 period.
         """
         if not self.adb_logcat_file_path:
-            raise Error(
-                'Attempting to cat adb log when none has been collected on '
-                'Android device %s.' % self.serial)
+            raise DeviceError(
+                self,
+                'Attempting to cat adb log when none has been collected.')
         end_time = mobly_logger.get_log_line_timestamp()
         self.log.debug('Extracting adb log from logcat.')
         adb_excerpt_path = os.path.join(self.log_path, 'AdbLogExcerpts')
@@ -704,8 +729,9 @@ def start_adb_logcat(self):
         save the logcat in a file.
         """
         if self._adb_logcat_process:
-            raise Error('Android device %s already has an adb logcat thread '
-                        'going on. Cannot start another one.' % self.serial)
+            raise DeviceError(
+                self,
+                'Logcat thread is already running, cannot start another one.')
         # Disable adb log spam filter for rootable. Have to stop and clear
         # settings first because 'start' doesn't support --clear option before
         # Android N.
@@ -728,9 +754,7 @@ def stop_adb_logcat(self):
         """Stops the adb logcat collection subprocess.
         """
         if not self._adb_logcat_process:
-            raise Error(
-                'Android device %s does not have an ongoing adb logcat '
-                'collection.' % self.serial)
+            raise DeviceError(self, 'No ongoing adb logcat collection found.')
         utils.stop_standing_subprocess(self._adb_logcat_process)
         self._adb_logcat_process = None
 
@@ -764,8 +788,7 @@ def take_bug_report(self, test_name, begin_time):
         if new_br:
             out = self.adb.shell('bugreportz').decode('utf-8')
             if not out.startswith('OK'):
-                raise Error('Failed to take bugreport on %s: %s' %
-                            (self.serial, out))
+                raise DeviceError(self, 'Failed to take bugreport: %s' % out)
             br_out_path = out.split(':')[1].strip()
             self.adb.pull('%s %s' % (br_out_path, full_out_path))
         else:
@@ -826,7 +849,7 @@ def wait_for_boot_completion(self):
                 # process, which is normal. Ignoring these errors.
                 pass
             time.sleep(5)
-        raise Error('Device %s booting process timed out.' % self.serial)
+        raise DeviceError(self, 'Booting process timed out.')
 
     def _get_active_snippet_info(self):
         """Collects information on currently active snippet clients.
@@ -878,5 +901,5 @@ class AndroidDeviceLoggerAdapter(logging.LoggerAdapter):
     """
 
     def process(self, msg, kwargs):
-        msg = '[AndroidDevice|%s] %s' % (self.extra['tag'], msg)
+        msg = _DEBUG_PREFIX_TEMPLATE % (self.extra['tag'], msg)
         return (msg, kwargs)
diff --git a/mobly/controllers/android_device_lib/jsonrpc_client_base.py b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
--- a/mobly/controllers/android_device_lib/jsonrpc_client_base.py
+++ b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
@@ -39,6 +39,7 @@
 from builtins import str
 
 import json
+import logging
 import socket
 import threading
 import time
@@ -102,7 +103,12 @@ class JsonRpcClientBase(object):
         uid: (int) The uid of this session.
     """
 
-    def __init__(self, host_port, device_port, app_name, adb_proxy):
+    def __init__(self,
+                 host_port,
+                 device_port,
+                 app_name,
+                 adb_proxy,
+                 log=logging.getLogger()):
         """
         Args:
             host_port: (int) The host port of this RPC client.
@@ -121,6 +127,7 @@ def __init__(self, host_port, device_port, app_name, adb_proxy):
         self._counter = None
         self._lock = threading.Lock()
         self._event_client = None
+        self._log = log
 
     def __del__(self):
         self.close()
@@ -178,6 +185,7 @@ def start_app(self, wait_time=APP_START_WAIT_TIME):
         for _ in range(wait_time):
             time.sleep(1)
             if self._is_app_running():
+                self.log.debug('Successfully started %s', self.app_name)
                 return
         raise AppStartError('%s failed to start on %s.' %
                             (self.app_name, self._adb.serial))
diff --git a/mobly/controllers/android_device_lib/snippet_client.py b/mobly/controllers/android_device_lib/snippet_client.py
--- a/mobly/controllers/android_device_lib/snippet_client.py
+++ b/mobly/controllers/android_device_lib/snippet_client.py
@@ -54,7 +54,8 @@ def __init__(self, package, host_port, adb_proxy, log=logging.getLogger()):
             host_port=host_port,
             device_port=host_port,
             app_name=package,
-            adb_proxy=adb_proxy)
+            adb_proxy=adb_proxy,
+            log=log)
         self.package = package
         self._serial = self._adb.getprop('ro.boot.serialno')
         self.log = log


Test patch:
diff --git a/tests/mobly/controllers/android_device_test.py b/tests/mobly/controllers/android_device_test.py
--- a/tests/mobly/controllers/android_device_test.py
+++ b/tests/mobly/controllers/android_device_test.py
@@ -206,7 +206,7 @@ def test_AndroidDevice_take_bug_report_fail(self, exe_mock, create_dir_mock,
         """
         mock_serial = 1
         ad = android_device.AndroidDevice(serial=mock_serial)
-        expected_msg = "Failed to take bugreport on 1: OMG I died!"
+        expected_msg = ".* Failed to take bugreport."
         with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
             ad.take_bug_report("test_something", "sometime")
@@ -244,8 +244,7 @@ def test_AndroidDevice_take_logcat(self, stop_proc_mock, start_proc_mock,
         """
         mock_serial = 1
         ad = android_device.AndroidDevice(serial=mock_serial)
-        expected_msg = ("Android device .* does not have an ongoing adb logcat"
-                        " collection.")
+        expected_msg = ".* No ongoing adb logcat collection found."
         # Expect error if stop is called before start.
         with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
@@ -261,8 +260,8 @@ def test_AndroidDevice_take_logcat(self, stop_proc_mock, start_proc_mock,
         start_proc_mock.assert_called_with(adb_cmd % (ad.serial,
                                                       expected_log_path))
         self.assertEqual(ad.adb_logcat_file_path, expected_log_path)
-        expected_msg = ("Android device .* already has an adb logcat thread "
-                        "going on. Cannot start another one.")
+        expected_msg = ('Logcat thread is already running, cannot start another'
+                        ' one.')
         # Expect error if start is called back to back.
         with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
@@ -289,8 +288,7 @@ def test_AndroidDevice_take_logcat_with_user_param(
         mock_serial = 1
         ad = android_device.AndroidDevice(serial=mock_serial)
         ad.adb_logcat_param = "-b radio"
-        expected_msg = ("Android device .* does not have an ongoing adb logcat"
-                        " collection.")
+        expected_msg = '.* No ongoing adb logcat collection found.'
         # Expect error if stop is called before start.
         with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
@@ -324,8 +322,8 @@ def test_AndroidDevice_cat_adb_log(self, mock_timestamp_getter,
         mock_serial = 1
         ad = android_device.AndroidDevice(serial=mock_serial)
         # Expect error if attempted to cat adb log before starting adb logcat.
-        expected_msg = ("Attempting to cat adb log when none has been "
-                        "collected on Android device .*")
+        expected_msg = (".* Attempting to cat adb log when none"
+                        " has been collected.")
         with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
             ad.cat_adb_log("some_test", MOCK_ADB_LOGCAT_BEGIN_TIME)
@@ -370,7 +368,7 @@ def test_AndroidDevice_load_snippet_dup_package(
         ad.load_snippet('snippet', MOCK_SNIPPET_PACKAGE_NAME)
         expected_msg = ('Snippet package "%s" has already been loaded under '
                         'name "snippet".') % MOCK_SNIPPET_PACKAGE_NAME
-        with self.assertRaisesRegexp(android_device.SnippetError,
+        with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
             ad.load_snippet('snippet2', MOCK_SNIPPET_PACKAGE_NAME)
 
@@ -388,7 +386,7 @@ def test_AndroidDevice_load_snippet_dup_snippet_name(
         expected_msg = ('Attribute "%s" is already registered with package '
                         '"%s", it cannot be used again.') % (
                         'snippet', MOCK_SNIPPET_PACKAGE_NAME)
-        with self.assertRaisesRegexp(android_device.SnippetError,
+        with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
             ad.load_snippet('snippet', MOCK_SNIPPET_PACKAGE_NAME + 'haha')
 
@@ -403,7 +401,7 @@ def test_AndroidDevice_load_snippet_dup_attribute_name(
         ad = android_device.AndroidDevice(serial=1)
         expected_msg = ('Attribute "%s" already exists, please use a different'
                         ' name') % 'adb'
-        with self.assertRaisesRegexp(android_device.SnippetError,
+        with self.assertRaisesRegexp(android_device.Error,
                                      expected_msg):
             ad.load_snippet('adb', MOCK_SNIPPET_PACKAGE_NAME)
 
@@ -420,6 +418,30 @@ def test_AndroidDevice_snippet_cleanup(self, MockGetPort, MockSnippetClient,
         ad.stop_services()
         self.assertFalse(hasattr(ad, 'snippet'))
 
+    @mock.patch('mobly.controllers.android_device_lib.adb.AdbProxy',
+                return_value=mock_android_device.MockAdbProxy(1))
+    @mock.patch('mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+                return_value=mock_android_device.MockFastbootProxy(1))
+    def test_AndroidDevice_debug_tag(self, MockFastboot, MockAdbProxy):
+        mock_serial = 1
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        self.assertEqual(ad.debug_tag, 1)
+        try:
+            raise android_device.DeviceError(ad, 'Something')
+        except android_device.DeviceError as e:
+            self.assertEqual('<AndroidDevice|1> Something', str(e))
+        # Verify that debug tag's setter updates the debug prefix correctly.
+        ad.debug_tag = 'Mememe'
+        try:
+            raise android_device.DeviceError(ad, 'Something')
+        except android_device.DeviceError as e:
+            self.assertEqual('<AndroidDevice|Mememe> Something', str(e))
+        # Verify that repr is changed correctly.
+        try:
+            raise Exception(ad, 'Something')
+        except Exception as e:
+            self.assertEqual("(<AndroidDevice|Mememe>, 'Something')", str(e))
+
 
 if __name__ == "__main__":
     unittest.main()