Allow reusing shell libs in Python 3 code

This simplifies code reuse in the MultiNode Tradefed python runner and
in upcoming changes.

Issue: INFRA-135
Issue: INFRA-137
Change-Id: I6bbf2ca96cc74c2eabf7308c57be07064d81a8e4
Signed-off-by: Karsten Tausche <karsten@fairphone.com>
diff --git a/automated/android/multinode/tradefed/tradefed-runner-multinode.py b/automated/android/multinode/tradefed/tradefed-runner-multinode.py
index e2da6ec..f862057 100755
--- a/automated/android/multinode/tradefed/tradefed-runner-multinode.py
+++ b/automated/android/multinode/tradefed/tradefed-runner-multinode.py
@@ -14,6 +14,7 @@
 sys.path.insert(0, '../../../lib/')
 sys.path.insert(1, '../../')
 import py_test_lib                                  # nopep8
+from py_util_lib import call_shell_lib              # nopep8
 import tradefed.result_parser as result_parser      # nopep8
 from multinode.tradefed.utils import *              # nopep8
 from multinode.tradefed.sts_util import StsUtil     # nopep8
@@ -226,8 +227,8 @@
     if num_available_devices < len(devices):
         logger.debug('Some devices are lost. Dumping state of adb/USB devices.')
         child.sendline('dump logs')
-        subprocess.run(['sh', '-c', '. ../../../lib/sh-test-lib && . ../../../lib/android-test-lib '
-                        '&& adb_debug_info'])
+
+        call_shell_lib("adb_debug_info")
         logger.debug('"adb devices" output')
         subprocess.run(['adb', 'devices'])
 
diff --git a/automated/android/multinode/tradefed/utils.py b/automated/android/multinode/tradefed/utils.py
index c3b748f..14ecac6 100644
--- a/automated/android/multinode/tradefed/utils.py
+++ b/automated/android/multinode/tradefed/utils.py
@@ -4,6 +4,9 @@
 import subprocess
 import time
 
+sys.path.insert(0, "../../../lib/")
+from py_util_lib import call_shell_lib  # nopep8
+
 
 class Device:
     tcpip_device_re = re.compile(
@@ -119,16 +122,8 @@
             bootTimeoutSecs = max(
                 10, int(reconnectTimeoutSecs) - fastbootRebootTimeoutSecs
             )
-            return (
-                subprocess.run(
-                    [
-                        "sh",
-                        "-c",
-                        ". ../../../lib/sh-test-lib && . ../../../lib/android-test-lib && "
-                        'export ANDROID_SERIAL="%s" && wait_boot_completed %s'
-                        % (self.serial_or_address, bootTimeoutSecs),
-                    ]
-                ).returncode == 0
+            return self._call_shell_lib(
+                "wait_boot_completed {}".format(bootTimeoutSecs)
             )
 
         # adb may not yet have realized that the connection is broken
@@ -202,6 +197,18 @@
         self.worker_handshake_iteration += 1
         return True
 
+    def _call_shell_lib(self, command: str) -> bool:
+        """Call a function implemented in the (Android) shell library.
+        Ensure that device-specific commands are executed on `self`.
+
+        Arguments:
+            command: Function defined in sh-test-lib or android-test-lib to
+                call, including its parameters.
+        Return:
+            True if the executed shell exists with 0, False otherwise.
+        """
+        return call_shell_lib(command, device=self.serial_or_address) == 0
+
 
 class RetryCheck:
     def __init__(self, total_max_retries, retries_if_unchanged):
diff --git a/automated/lib/py_util_lib.py b/automated/lib/py_util_lib.py
new file mode 100644
index 0000000..a3da5eb
--- /dev/null
+++ b/automated/lib/py_util_lib.py
@@ -0,0 +1,45 @@
+"""Shared Python 3 utility code.
+"""
+
+from pathlib import Path
+import subprocess
+from typing import Dict, Optional
+
+AUTOMATED_LIB_DIR = Path(__file__).resolve().parent
+
+
+def call_shell_lib(
+    command: str,
+    environment: Optional[Dict[str, str]] = None,
+    device: Optional[str] = None,
+) -> int:
+    """Python-to-shell adaptor, facilitating code reuse.
+
+    This executes a given command line on a shell with sourced sh-test-lib and
+    android-test-lib.
+
+    Arguments:
+        command: Function or command line including parameters to execute in a
+            shell.
+        environment: Environment to execute the shell command in. This is a
+            mapping of environment variable names to their values.
+        device: ADB identifier (serial or IP and port) of a device. If set, this
+            will be appended as ANDROID_SERIAL to the environment.
+    Return:
+        The exit code of the invoked shell command.
+    """
+    if device:
+        if not environment:
+            environment = {}
+        environment["ANDROID_SERIAL"] = device
+    return subprocess.run(
+        [
+            "sh",
+            "-c",
+            ". {}/sh-test-lib && "
+            ". {}/android-test-lib && {}".format(
+                AUTOMATED_LIB_DIR, AUTOMATED_LIB_DIR, command
+            ),
+        ],
+        env=environment,
+    ).returncode