Standardize output excerpt APIs in services. (#633)

diff --git a/mobly/controllers/android_device_lib/service_manager.py b/mobly/controllers/android_device_lib/service_manager.py
index ef6ac4e..11b0ab8 100644
--- a/mobly/controllers/android_device_lib/service_manager.py
+++ b/mobly/controllers/android_device_lib/service_manager.py
@@ -82,6 +82,7 @@
                 self._device,
                 'A service is already registered with alias "%s".' % alias)
         service_obj = service_class(self._device, configs)
+        service_obj.alias = alias
         if start_service:
             service_obj.start()
         self._service_objects[alias] = service_obj
@@ -103,6 +104,42 @@
                     'Failed to stop service instance "%s".' % alias):
                 service_obj.stop()
 
+    def for_each(self, func):
+        """Executes a function with all registered services.
+
+        Args:
+            func: function, the function to execute. This function should take
+                a service object as args.
+        """
+        aliases = list(self._service_objects.keys())
+        for alias in aliases:
+            with expects.expect_no_raises(
+                    'Failed to execute "%s" for service "%s".' %
+                (func.__name__, alias)):
+                func(self._service_objects[alias])
+
+    def create_output_excerpts_all(self, test_info):
+        """Creates output excerpts from all services.
+
+        This calls `create_output_excerpts` on all registered services.
+
+        Args:
+            test_info: RuntimeTestInfo, the test info associated with the scope
+                of the excerpts.
+
+        Returns:
+            Dict, keys are the names of the services, values are the paths to
+                the excerpt files created by the corresponding services.
+        """
+        excerpt_paths = {}
+
+        def create_output_excerpts_for_one(service):
+            paths = service.create_output_excerpts(test_info)
+            excerpt_paths[service.alias] = paths
+
+        self.for_each(create_output_excerpts_for_one)
+        return excerpt_paths
+
     def unregister_all(self):
         """Safely unregisters all active instances.
 
diff --git a/mobly/controllers/android_device_lib/services/base_service.py b/mobly/controllers/android_device_lib/services/base_service.py
index 14380df..c785db2 100644
--- a/mobly/controllers/android_device_lib/services/base_service.py
+++ b/mobly/controllers/android_device_lib/services/base_service.py
@@ -14,11 +14,13 @@
 """Module for the BaseService."""
 
 
+#TODO(xpconanfan): use `abc` after py2 deprecation.
 class BaseService(object):
     """Base class of a Mobly AndroidDevice service.
 
     This class defines the interface for Mobly's AndroidDevice service.
     """
+    _alias = None
 
     def __init__(self, device, configs=None):
         """Constructor of the class.
@@ -36,6 +38,18 @@
         self._configs = configs
 
     @property
+    def alias(self):
+        """String, alias used to register this service with service manager.
+
+        This can be None if the service is never registered.
+        """
+        return self._alias
+
+    @alias.setter
+    def alias(self, alias):
+        self._alias = alias
+
+    @property
     def is_alive(self):
         """True if the service is active; False otherwise."""
         raise NotImplementedError('"is_alive" is a required service property.')
@@ -85,3 +99,29 @@
         disconnect, and `start` will be called by default.
         """
         self.start()
+
+    def create_output_excerpts(self, test_info):
+        """Creates excerpts of the service's output files.
+
+        [Optional] This method only applies to services with output files.
+
+        For services that generates output files, calling this method would
+        create excerpts of the output files. An excerpt should contain info
+        between two calls of `create_output_excerpts` or from the start of the
+        service to the call to `create_output_excerpts`.
+
+        Use `AndroidDevice#generate_filename` to get the proper filenames for
+        excerpts.
+
+        This is usually called at the end of: `setup_class`, `teardown_test`,
+        or `teardown_class`.
+
+        Args:
+            test_info: RuntimeTestInfo, the test info associated with the scope
+                of the excerpts.
+
+        Returns:
+            List of strings, the absolute paths to the excerpt files created.
+                Empty list if no excerpt files are created.
+        """
+        return []
diff --git a/mobly/controllers/android_device_lib/services/logcat.py b/mobly/controllers/android_device_lib/services/logcat.py
index 6671fbb..a27fc40 100644
--- a/mobly/controllers/android_device_lib/services/logcat.py
+++ b/mobly/controllers/android_device_lib/services/logcat.py
@@ -100,6 +100,9 @@
     def create_per_test_excerpt(self, current_test_info):
         """Convenient method for creating excerpts of adb logcat.
 
+        .. deprecated:: 1.9.2
+           Use :func:`create_output_excerpts` instead.
+
         To use this feature, call this method at the end of: `setup_class`,
         `teardown_test`, and `teardown_class`.
 
@@ -107,14 +110,34 @@
         log directory specific to the current test.
 
         Args:
-          current_test_info: `self.current_test_info` in a Mobly test.
+            current_test_info: `self.current_test_info` in a Mobly test.
+        """
+        self.create_output_excerpts(current_test_info)
+
+    def create_output_excerpts(self, test_info):
+        """Convenient method for creating excerpts of adb logcat.
+
+        This moves the current content of `self.adb_logcat_file_path` to the
+        log directory specific to the current test.
+
+        Call this method at the end of: `setup_class`, `teardown_test`, and
+        `teardown_class`.
+
+        Args:
+            test_info: `self.current_test_info` in a Mobly test.
+
+        Returns:
+            List of strings, the absolute paths to excerpt files.
         """
         self.pause()
-        dest_path = current_test_info.output_path
+        dest_path = test_info.output_path
         utils.create_dir(dest_path)
-        self._ad.log.debug('AdbLog excerpt location: %s', dest_path)
+        filename = os.path.basename(self.adb_logcat_file_path)
         shutil.move(self.adb_logcat_file_path, dest_path)
         self.resume()
+        excerpt_file_path = os.path.join(dest_path, filename)
+        self._ad.log.debug('AdbLog excerpt created at: %s', excerpt_file_path)
+        return [excerpt_file_path]
 
     @property
     def is_alive(self):
diff --git a/tests/mobly/controllers/android_device_lib/service_manager_test.py b/tests/mobly/controllers/android_device_lib/service_manager_test.py
index e7a3932..6e24505 100755
--- a/tests/mobly/controllers/android_device_lib/service_manager_test.py
+++ b/tests/mobly/controllers/android_device_lib/service_manager_test.py
@@ -76,6 +76,7 @@
         self.assertTrue(service)
         self.assertTrue(service.is_alive)
         self.assertTrue(manager.is_any_alive)
+        self.assertEqual(service.alias, 'mock_service')
         self.assertEqual(service.start_func.call_count, 1)
 
     def test_register_with_configs(self):
@@ -118,6 +119,67 @@
         with self.assertRaisesRegex(service_manager.Error, msg):
             manager.register('mock_service', MockService)
 
+    def test_for_each(self):
+        manager = service_manager.ServiceManager(mock.MagicMock())
+        manager.register('mock_service1', MockService)
+        manager.register('mock_service2', MockService)
+        service1 = manager.mock_service1
+        service2 = manager.mock_service2
+        service1.ha = mock.MagicMock()
+        service2.ha = mock.MagicMock()
+        manager.for_each(lambda service: service.ha())
+        service1.ha.assert_called_with()
+        service2.ha.assert_called_with()
+
+    def test_for_each_modify_during_iteration(self):
+        manager = service_manager.ServiceManager(mock.MagicMock())
+        manager.register('mock_service1', MockService)
+        manager.register('mock_service2', MockService)
+        service1 = manager.mock_service1
+        service2 = manager.mock_service2
+        service1.ha = mock.MagicMock()
+        service2.ha = mock.MagicMock()
+        manager.for_each(lambda service: manager._service_objects.pop(service.
+                                                                      alias))
+        self.assertFalse(manager._service_objects)
+
+    def test_for_each_one_fail(self):
+        manager = service_manager.ServiceManager(mock.MagicMock())
+        manager.register('mock_service1', MockService)
+        manager.register('mock_service2', MockService)
+        service1 = manager.mock_service1
+        service2 = manager.mock_service2
+        service1.ha = mock.MagicMock()
+        service1.ha.side_effect = Exception('Failure in service1.')
+        service2.ha = mock.MagicMock()
+        manager.for_each(lambda service: service.ha())
+        service1.ha.assert_called_with()
+        service2.ha.assert_called_with()
+        self.assert_recorded_one_error('Failure in service1.')
+
+    def test_create_output_excerpts_all(self):
+        manager = service_manager.ServiceManager(mock.MagicMock())
+        manager.register('mock_service1', MockService)
+        manager.register('mock_service2', MockService)
+        manager.register('mock_service3', MockService)
+        service1 = manager.mock_service1
+        service2 = manager.mock_service2
+        service3 = manager.mock_service3
+        service1.create_output_excerpts = mock.MagicMock()
+        service2.create_output_excerpts = mock.MagicMock()
+        service3.create_output_excerpts = mock.MagicMock()
+        service1.create_output_excerpts.return_value = ['path/to/1.txt']
+        service2.create_output_excerpts.return_value = [
+            'path/to/2-1.txt', 'path/to/2-2.txt'
+        ]
+        service3.create_output_excerpts.return_value = []
+        mock_test_info = mock.MagicMock(output_path='path/to')
+        result = manager.create_output_excerpts_all(mock_test_info)
+        self.assertEqual(result['mock_service1'], ['path/to/1.txt'])
+        self.assertEqual(result['mock_service2'],
+                         ['path/to/2-1.txt', 'path/to/2-2.txt'])
+        self.assertEqual(result['mock_service3'], [])
+
     def test_unregister(self):
         manager = service_manager.ServiceManager(mock.MagicMock())
         manager.register('mock_service', MockService)
diff --git a/tests/mobly/controllers/android_device_lib/services/base_service_test.py b/tests/mobly/controllers/android_device_lib/services/base_service_test.py
new file mode 100755
index 0000000..e97848d
--- /dev/null
+++ b/tests/mobly/controllers/android_device_lib/services/base_service_test.py
@@ -0,0 +1,31 @@
+# Copyright 2019 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.
+
+import mock
+from future.tests.base import unittest
+from mobly.controllers.android_device_lib.services import base_service
+
+
+class BaseServiceTest(unittest.TestCase):
+    def setUp(self):
+        self.mock_device = mock.MagicMock()
+        self.service = base_service.BaseService(self.mock_device)
+
+    def test_alias(self):
+        self.service.alias = 'SomeService'
+        self.assertEqual(self.service.alias, 'SomeService')
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/mobly/controllers/android_device_lib/services/logcat_test.py b/tests/mobly/controllers/android_device_lib/services/logcat_test.py
index 3106a02..e4d2ca6 100755
--- a/tests/mobly/controllers/android_device_lib/services/logcat_test.py
+++ b/tests/mobly/controllers/android_device_lib/services/logcat_test.py
@@ -66,6 +66,7 @@
 
 class LogcatTest(unittest.TestCase):
     """Tests for Logcat service and its integration with AndroidDevice."""
+
     def setUp(self):
         # Set log_path to logging since mobly logger setup is not called.
         if not hasattr(logging, 'log_path'):
@@ -263,6 +264,58 @@
                 return_value=mock_android_device.MockAdbProxy('1'))
     @mock.patch('mobly.controllers.android_device_lib.fastboot.FastbootProxy',
                 return_value=mock_android_device.MockFastbootProxy('1'))
+    @mock.patch('mobly.utils.start_standing_subprocess',
+                return_value='process')
+    @mock.patch('mobly.utils.stop_standing_subprocess')
+    @mock.patch(
+        'mobly.controllers.android_device_lib.services.logcat.Logcat.clear_adb_log',
+        return_value=mock_android_device.MockAdbProxy('1'))
+    def test_logcat_service_create_output_excerpts(self, clear_adb_mock,
+                                                   stop_proc_mock,
+                                                   start_proc_mock,
+                                                   FastbootProxy,
+                                                   MockAdbProxy):
+        mock_serial = '1'
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        logcat_service = logcat.Logcat(ad)
+        logcat_service.start()
+        FILE_CONTENT = 'Some log.\n'
+        with open(logcat_service.adb_logcat_file_path, 'w') as f:
+            f.write(FILE_CONTENT)
+        test_output_dir = os.path.join(self.tmp_dir, 'test_foo')
+        mock_record = mock.MagicMock()
+        mock_record.begin_time = 123
+        test_run_info = runtime_test_info.RuntimeTestInfo(
+            'test_foo', test_output_dir, mock_record)
+        actual_path1 = logcat_service.create_output_excerpts(test_run_info)[0]
+        expected_path1 = os.path.join(test_output_dir, 'test_foo-123',
+                                      'adblog,fakemodel,1.txt')
+        self.assertTrue(os.path.exists(expected_path1))
+        self.assertEqual(actual_path1, expected_path1)
+        self.AssertFileContains(FILE_CONTENT, expected_path1)
+        self.assertFalse(os.path.exists(logcat_service.adb_logcat_file_path))
+        # Generate some new logs and do another excerpt.
+        FILE_CONTENT = 'Some more logs!!!\n'
+        with open(logcat_service.adb_logcat_file_path, 'w') as f:
+            f.write(FILE_CONTENT)
+        test_output_dir = os.path.join(self.tmp_dir, 'test_bar')
+        mock_record = mock.MagicMock()
+        mock_record.begin_time = 456
+        test_run_info = runtime_test_info.RuntimeTestInfo(
+            'test_bar', test_output_dir, mock_record)
+        actual_path2 = logcat_service.create_output_excerpts(test_run_info)[0]
+        expected_path2 = os.path.join(test_output_dir, 'test_bar-456',
+                                      'adblog,fakemodel,1.txt')
+        self.assertTrue(os.path.exists(expected_path2))
+        self.assertEqual(actual_path2, expected_path2)
+        self.AssertFileContains(FILE_CONTENT, expected_path2)
+        self.AssertFileDoesNotContain(FILE_CONTENT, expected_path1)
+        self.assertFalse(os.path.exists(logcat_service.adb_logcat_file_path))
+
+    @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'))
     @mock.patch('mobly.utils.create_dir')
     @mock.patch('mobly.utils.start_standing_subprocess',
                 return_value='process')