Repository: google/mobly

Issue:
Propagate stderr from adb commands
The current mobly adb proxy does not propagate stderr if ret code is zero.
We thought this was ok since Android has fixed return code issues in M.

But turns out many China manufacturers did not fix this in China devices.
In order to better support China devices and potentially other devices of the same ret code problem, we need to surface stderr.


Discussion:

Resolving patch:
diff --git a/mobly/controllers/android_device_lib/adb.py b/mobly/controllers/android_device_lib/adb.py
--- a/mobly/controllers/android_device_lib/adb.py
+++ b/mobly/controllers/android_device_lib/adb.py
@@ -138,7 +138,7 @@ class AdbProxy(object):
     def __init__(self, serial=''):
         self.serial = serial
 
-    def _exec_cmd(self, args, shell, timeout):
+    def _exec_cmd(self, args, shell, timeout, stderr):
         """Executes adb commands.
 
         Args:
@@ -148,6 +148,8 @@ def _exec_cmd(self, args, shell, timeout):
                 False to invoke it directly. See subprocess.Popen() docs.
             timeout: float, the number of seconds to wait before timing out.
                 If not specified, no timeout takes effect.
+            stderr: a Byte stream, like io.BytesIO, stderr of the command will
+                be written to this object if provided.
 
         Returns:
             The output of the adb command run if exit code is 0.
@@ -169,6 +171,8 @@ def _exec_cmd(self, args, shell, timeout):
                 raise AdbTimeoutError(cmd=args, timeout=timeout)
 
         (out, err) = proc.communicate()
+        if stderr:
+            stderr.write(err)
         ret = proc.returncode
         logging.debug('cmd: %s, stdout: %s, stderr: %s, ret: %s',
                       cli_cmd_to_string(args), out, err, ret)
@@ -177,7 +181,7 @@ def _exec_cmd(self, args, shell, timeout):
         else:
             raise AdbError(cmd=args, stdout=out, stderr=err, ret_code=ret)
 
-    def _exec_adb_cmd(self, name, args, shell, timeout):
+    def _exec_adb_cmd(self, name, args, shell, timeout, stderr):
         if shell:
             # Add quotes around "adb" in case the ADB path contains spaces. This
             # is pretty common on Windows (e.g. Program Files).
@@ -195,7 +199,9 @@ def _exec_adb_cmd(self, name, args, shell, timeout):
                     adb_cmd.append(args)
                 else:
                     adb_cmd.extend(args)
-        return self._exec_cmd(adb_cmd, shell=shell, timeout=timeout)
+        out = self._exec_cmd(
+            adb_cmd, shell=shell, timeout=timeout, stderr=stderr)
+        return out
 
     def getprop(self, prop_name):
         """Get a property of the device.
@@ -273,7 +279,7 @@ def instrument(self, package, options=None, runner=None):
         return self.shell(instrumentation_command)
 
     def __getattr__(self, name):
-        def adb_call(args=None, shell=False, timeout=None):
+        def adb_call(args=None, shell=False, timeout=None, stderr=None):
             """Wrapper for an ADB command.
 
             Args:
@@ -283,6 +289,8 @@ def adb_call(args=None, shell=False, timeout=None):
                     False to invoke it directly. See subprocess.Proc() docs.
                 timeout: float, the number of seconds to wait before timing out.
                     If not specified, no timeout takes effect.
+                stderr: a Byte stream, like io.BytesIO, stderr of the command
+                    will be written to this object if provided.
 
             Returns:
                 The output of the adb command run if exit code is 0.
@@ -290,6 +298,6 @@ def adb_call(args=None, shell=False, timeout=None):
             args = args or ''
             clean_name = name.replace('_', '-')
             return self._exec_adb_cmd(
-                clean_name, args, shell=shell, timeout=timeout)
+                clean_name, args, shell=shell, timeout=timeout, stderr=stderr)
 
         return adb_call


Test patch:
diff --git a/tests/mobly/controllers/android_device_lib/adb_test.py b/tests/mobly/controllers/android_device_lib/adb_test.py
--- a/tests/mobly/controllers/android_device_lib/adb_test.py
+++ b/tests/mobly/controllers/android_device_lib/adb_test.py
@@ -12,11 +12,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import io
 import mock
+import subprocess
 
 from collections import OrderedDict
 from future.tests.base import unittest
-
 from mobly.controllers.android_device_lib import adb
 
 # Mock parameters for instrumentation.
@@ -42,6 +43,9 @@
 # Mock Shell Command
 MOCK_SHELL_COMMAND = 'ls'
 MOCK_COMMAND_OUTPUT = '/system/bin/ls'.encode('utf-8')
+MOCK_DEFAULT_STDOUT = 'out'
+MOCK_DEFAULT_STDERR = 'err'
+MOCK_DEFAULT_COMMAND_OUTPUT = MOCK_DEFAULT_STDOUT.encode('utf-8')
 MOCK_ADB_SHELL_COMMAND_CHECK = 'adb shell command -v ls'
 
 
@@ -58,7 +62,8 @@ def _mock_process(self, mock_psutil_process, mock_popen):
         mock_psutil_process.return_value = mock.Mock()
 
         mock_proc.communicate = mock.Mock(
-            return_value=('out'.encode('utf-8'), 'err'.encode('utf-8')))
+            return_value=(MOCK_DEFAULT_STDOUT.encode('utf-8'),
+                          MOCK_DEFAULT_STDERR.encode('utf-8')))
         mock_proc.returncode = 0
         return (mock_psutil_process, mock_popen)
 
@@ -68,9 +73,9 @@ def test_exec_cmd_no_timeout_success(self, mock_psutil_process,
                                          mock_Popen):
         self._mock_process(mock_psutil_process, mock_Popen)
 
-        reply = adb.AdbProxy()._exec_cmd(
-            ['fake_cmd'], shell=False, timeout=None)
-        self.assertEqual('out', reply.decode('utf-8'))
+        out = adb.AdbProxy()._exec_cmd(
+            ['fake_cmd'], shell=False, timeout=None, stderr=None)
+        self.assertEqual(MOCK_DEFAULT_STDOUT, out.decode('utf-8'))
 
     @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
     @mock.patch('mobly.controllers.android_device_lib.adb.psutil.Process')
@@ -81,7 +86,8 @@ def test_exec_cmd_error_no_timeout(self, mock_psutil_process, mock_popen):
 
         with self.assertRaisesRegex(adb.AdbError,
                                     'Error executing adb cmd .*'):
-            adb.AdbProxy()._exec_cmd(['fake_cmd'], shell=False, timeout=None)
+            adb.AdbProxy()._exec_cmd(
+                ['fake_cmd'], shell=False, timeout=None, stderr=None)
 
     @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
     @mock.patch('mobly.controllers.android_device_lib.adb.psutil.Process')
@@ -89,8 +95,9 @@ def test_exec_cmd_with_timeout_success(self, mock_psutil_process,
                                            mock_popen):
         self._mock_process(mock_psutil_process, mock_popen)
 
-        reply = adb.AdbProxy()._exec_cmd(['fake_cmd'], shell=False, timeout=1)
-        self.assertEqual('out', reply.decode('utf-8'))
+        out = adb.AdbProxy()._exec_cmd(
+            ['fake_cmd'], shell=False, timeout=1, stderr=None)
+        self.assertEqual(MOCK_DEFAULT_STDOUT, out.decode('utf-8'))
 
     @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
     @mock.patch('mobly.controllers.android_device_lib.adb.psutil.Process')
@@ -104,7 +111,8 @@ def test_exec_cmd_timed_out(self, mock_psutil_process, mock_popen):
         with self.assertRaisesRegex(adb.AdbTimeoutError,
                                     'Timed out executing command "fake_cmd" '
                                     'after 0.1s.'):
-            adb.AdbProxy()._exec_cmd(['fake_cmd'], shell=False, timeout=0.1)
+            adb.AdbProxy()._exec_cmd(
+                ['fake_cmd'], shell=False, timeout=0.1, stderr=None)
 
     @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
     @mock.patch('mobly.controllers.android_device_lib.adb.psutil.Process')
@@ -113,66 +121,100 @@ def test_exec_cmd_with_negative_timeout_value(self, mock_psutil_process,
         self._mock_process(mock_psutil_process, mock_popen)
         with self.assertRaisesRegex(adb.Error,
                                     'Timeout is not a positive value: -1'):
-            adb.AdbProxy()._exec_cmd(['fake_cmd'], shell=False, timeout=-1)
+            adb.AdbProxy()._exec_cmd(
+                ['fake_cmd'], shell=False, timeout=-1, stderr=None)
 
     def test_exec_adb_cmd(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().shell(['arg1', 'arg2'])
             mock_exec_cmd.assert_called_once_with(
-                ['adb', 'shell', 'arg1', 'arg2'], shell=False, timeout=None)
+                ['adb', 'shell', 'arg1', 'arg2'],
+                shell=False,
+                timeout=None,
+                stderr=None)
+
+    def test_exec_adb_cmd_with_serial(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy('12345').shell(['arg1', 'arg2'])
             mock_exec_cmd.assert_called_once_with(
                 ['adb', '-s', '12345', 'shell', 'arg1', 'arg2'],
                 shell=False,
-                timeout=None)
+                timeout=None,
+                stderr=None)
 
     def test_exec_adb_cmd_with_shell_true(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().shell('arg1 arg2', shell=True)
             mock_exec_cmd.assert_called_once_with(
-                '"adb" shell arg1 arg2', shell=True, timeout=None)
+                '"adb" shell arg1 arg2', shell=True, timeout=None, stderr=None)
+
+    def test_exec_adb_cmd_with_shell_true_with_serial(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy('12345').shell('arg1 arg2', shell=True)
             mock_exec_cmd.assert_called_once_with(
-                '"adb" -s "12345" shell arg1 arg2', shell=True, timeout=None)
+                '"adb" -s "12345" shell arg1 arg2',
+                shell=True,
+                timeout=None,
+                stderr=None)
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.subprocess.Popen')
+    @mock.patch('mobly.controllers.android_device_lib.adb.psutil.Process')
+    def test_exec_adb_cmd_with_stderr_pipe(self, mock_psutil_process,
+                                           mock_popen):
+        self._mock_process(mock_psutil_process, mock_popen)
+        stderr_redirect = io.BytesIO()
+        out = adb.AdbProxy().shell(
+            'arg1 arg2', shell=True, stderr=stderr_redirect)
+        self.assertEqual(MOCK_DEFAULT_STDOUT, out.decode('utf-8'))
+        self.assertEqual(MOCK_DEFAULT_STDERR,
+                         stderr_redirect.getvalue().decode('utf-8'))
 
     def test_instrument_without_parameters(self):
         """Verifies the AndroidDevice object's instrument command is correct in
         the basic case.
         """
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().instrument(MOCK_INSTRUMENTATION_PACKAGE)
             mock_exec_cmd.assert_called_once_with(
                 ['adb', 'shell', MOCK_BASIC_INSTRUMENTATION_COMMAND],
                 shell=False,
-                timeout=None)
+                timeout=None,
+                stderr=None)
 
     def test_instrument_with_runner(self):
         """Verifies the AndroidDevice object's instrument command is correct
         with a runner specified.
         """
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().instrument(
                 MOCK_INSTRUMENTATION_PACKAGE,
                 runner=MOCK_INSTRUMENTATION_RUNNER)
             mock_exec_cmd.assert_called_once_with(
                 ['adb', 'shell', MOCK_RUNNER_INSTRUMENTATION_COMMAND],
                 shell=False,
-                timeout=None)
+                timeout=None,
+                stderr=None)
 
     def test_instrument_with_options(self):
         """Verifies the AndroidDevice object's instrument command is correct
         with options.
         """
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().instrument(
                 MOCK_INSTRUMENTATION_PACKAGE,
                 options=MOCK_INSTRUMENTATION_OPTIONS)
             mock_exec_cmd.assert_called_once_with(
                 ['adb', 'shell', MOCK_OPTIONS_INSTRUMENTATION_COMMAND],
                 shell=False,
-                timeout=None)
+                timeout=None,
+                stderr=None)
 
     def test_cli_cmd_to_string(self):
         cmd = ['"adb"', 'a b', 'c//']
@@ -182,11 +224,13 @@ def test_cli_cmd_to_string(self):
 
     def test_has_shell_command_called_correctly(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             adb.AdbProxy().has_shell_command(MOCK_SHELL_COMMAND)
             mock_exec_cmd.assert_called_once_with(
                 ['adb', 'shell', 'command', '-v', MOCK_SHELL_COMMAND],
                 shell=False,
-                timeout=None)
+                timeout=None,
+                stderr=None)
 
     def test_has_shell_command_with_existing_command(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
@@ -196,6 +240,7 @@ def test_has_shell_command_with_existing_command(self):
 
     def test_has_shell_command_with_missing_command_on_older_devices(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             mock_exec_cmd.side_effect = adb.AdbError(
                 MOCK_ADB_SHELL_COMMAND_CHECK, '', '', 0)
             self.assertFalse(
@@ -203,6 +248,7 @@ def test_has_shell_command_with_missing_command_on_older_devices(self):
 
     def test_has_shell_command_with_missing_command_on_newer_devices(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_DEFAULT_COMMAND_OUTPUT
             mock_exec_cmd.side_effect = adb.AdbError(
                 MOCK_ADB_SHELL_COMMAND_CHECK, '', '', 1)
             self.assertFalse(