invocation: support loading pytest module for pytest_name='x.y.z'

To make the naming of pytest more flexible, now LoadPytestModule
supports pytest_name containing dots. If pytest_name does not contain
any dot, will falls back to old implementation.
For example, we currently have a lot of ec_xxx tests, they can now become
  ec/battery.py    (pytest_name='ec.battery')
  ec/lightbar.py   (pytest_name='ec.lightbar')
  etc...

BUG=None
TEST=test on DUT w/ old test list, run_pytest w/ and w/o goofy, make test

Change-Id: I3bc14cd8ea244d9febeaca8c4e8c6d7612984b8b
Reviewed-on: https://chromium-review.googlesource.com/320528
Commit-Ready: Wei-Han Chen <stimim@chromium.org>
Tested-by: Wei-Han Chen <stimim@chromium.org>
Reviewed-by: Hung-Te Lin <hungte@chromium.org>
diff --git a/doc/test_api.rst b/doc/test_api.rst
index 286b188..6c7dff1 100644
--- a/doc/test_api.rst
+++ b/doc/test_api.rst
@@ -36,10 +36,26 @@
 JavaScript files), you may create a directory for it and put the code
 in
 
-  :samp:`py/test/pytests/{name}/{name}.py`.
+  :samp:`py/test/pytests/{name}/{name}.py`. [*]_
 
 Within this file, there should be a single subclass of `unittest.TestCase`.
-There can be only one test class per module.
+There can be only one test class per module, so if you need to have different
+test cases, you need to separate them into different modules. But you can create
+a directory for them, for example:
+
+  - :samp:`py/test/pytests/{dir_name}/{test1}.py`
+  - :samp:`py/test/pytests/{dir_name}/{test2}.py`
+  - :samp:`py/test/pytests/{dir_name}/{test3}.py`.
+
+In this case, the name of your tests are :samp:`{dir_name}.{test1}`,
+:samp:`{dir_name}.{test2}` and :samp:`{dir_name}.{test3}`.
+
+To know more about how we load a pytest from name, please refer to
+:py:func:`cros.factory.test.utils.pytest_utils.LoadPytestModule`.
+
+.. [*] Since the ``__init__.py`` of a package will be loaded whenever its
+       submodule is imported. We recommand keeping ``__init__.py`` empty to
+       prevent longer loading time if ``__init__.py`` is too large.
 
 Test implementation
 -------------------
@@ -144,3 +160,7 @@
     def runTest(self):
       logging.info('path=%s, max_bytes=%d',
                    self.args.path, self.args.max_bytes)
+
+.. py:module:: cros.factory.test.utils.pytest_utils
+
+.. autofunction:: LoadPytestModule
diff --git a/py/goofy/invocation.py b/py/goofy/invocation.py
index b10b186..9ebd785 100755
--- a/py/goofy/invocation.py
+++ b/py/goofy/invocation.py
@@ -43,6 +43,7 @@
 from cros.factory.test.test_lists.test_lists import BuildAllTestLists
 from cros.factory.test.test_lists.test_lists import OldStyleTestList
 from cros.factory.test.utils.service_manager import ServiceManager
+from cros.factory.test.utils.pytest_utils import LoadPytestModule
 from cros.factory.utils import file_utils
 from cros.factory.utils import process_utils
 from cros.factory.utils.string_utils import DecodeUTF8
@@ -816,43 +817,6 @@
   return results[0]
 
 
-def LoadPytestModule(pytest_name):
-  """Loads the given pytest module.
-
-  This function tries to load the module with
-
-      cros.factory.test.pytests.<pytest_base_name>.<pytest_name>
-
-  first and falls back to
-
-      cros.factory.test.pytests.<pytest_name>
-
-  for backward compatibility.
-
-  Args:
-    pytest_name: The name of the pytest module.
-
-  Returns:
-    The loaded pytest module object.
-  """
-  from cros.factory.test import pytests
-  base_pytest_name = pytest_name
-  for suffix in ('_e2etest', '_automator', '_automator_private'):
-    base_pytest_name = re.sub(suffix, '', base_pytest_name)
-
-  try:
-    __import__('cros.factory.test.pytests.%s.%s' %
-               (base_pytest_name, pytest_name))
-    return getattr(getattr(pytests, base_pytest_name), pytest_name)
-  except ImportError:
-    logging.info(
-        ('Cannot import cros.factory.test.pytests.%s.%s. '
-         'Fall back to cros.factory.test.pytests.%s'),
-        base_pytest_name, pytest_name, pytest_name)
-    __import__('cros.factory.test.pytests.%s' % pytest_name)
-    return getattr(pytests, pytest_name)
-
-
 def RunPytest(test_info):
   """Runs a pytest, saving a pickled (status, error_msg) tuple to the
   appropriate results file.
diff --git a/py/test/run_pytest.py b/py/test/run_pytest.py
index 3aebc3b..0949b81 100755
--- a/py/test/run_pytest.py
+++ b/py/test/run_pytest.py
@@ -16,58 +16,20 @@
 import ast
 import inspect
 import logging
+import os
 import pickle
-import re
 import sys
 import unittest
 
 import factory_common  # pylint: disable=W0611
-from cros.factory.test import dut
 from cros.factory.test.args import Args
 from cros.factory.test.dut import utils
-
-
-# Copied from goofy/invocation.py to minimize dependencies.
-def _LoadPytestModule(pytest_name):
-  """Loads the given pytest module.
-
-  This function tries to load the module with
-
-      cros.factory.test.pytests.<pytest_base_name>.<pytest_name>
-
-  first and falls back to
-
-      cros.factory.test.pytests.<pytest_name>
-
-  for backward compatibility.
-
-  Args:
-    pytest_name: The name of the pytest module.
-
-  Returns:
-    The loaded pytest module object.
-  """
-  from cros.factory.test import pytests
-  base_pytest_name = pytest_name
-  for suffix in ('_e2etest', '_automator', '_automator_private'):
-    base_pytest_name = re.sub(suffix, '', base_pytest_name)
-
-  try:
-    __import__('cros.factory.test.pytests.%s.%s' %
-               (base_pytest_name, pytest_name))
-    return getattr(getattr(pytests, base_pytest_name), pytest_name)
-  except ImportError:
-    logging.info(
-        ('Cannot import cros.factory.test.pytests.%s.%s. '
-         'Fall back to cros.factory.test.pytests.%s'),
-        base_pytest_name, pytest_name, pytest_name)
-    __import__('cros.factory.test.pytests.%s' % pytest_name)
-    return getattr(pytests, pytest_name)
+from cros.factory.test.utils.pytest_utils import LoadPytestModule
 
 
 def _GetTestCase(pytest):
   """Returns the first test case class found in a given pytest."""
-  module = _LoadPytestModule(pytest)
+  module = LoadPytestModule(pytest)
   _, test_case = inspect.getmembers(module, lambda obj: (
       inspect.isclass(obj) and issubclass(obj, unittest.TestCase)))[0]
   return test_case
diff --git a/py/test/utils/pytest_utils.py b/py/test/utils/pytest_utils.py
new file mode 100644
index 0000000..4a82aca
--- /dev/null
+++ b/py/test/utils/pytest_utils.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python
+# Copyright 2016 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import logging
+import os
+
+import factory_common  # pylint: disable=W0611
+
+
+def LoadPytestModule(pytest_name):
+  """Loads the given pytest module.
+
+  This function tries to load the module
+
+      :samp:`cros.factory.test.pytests.{pytest_name}`.
+
+  For backward compatibility, this function also tries to load
+
+      :samp:`cros.factory.test.pytests.{pytest_base_name}.{pytest_name}`
+
+  if either
+
+      - :samp:`cros.factory.test.pytests.{pytest_name}` doesn't exist
+      - :samp:`cros.factory.test.pytests.{pytest_name}` is a package.
+
+  If both :samp:`{pytest_name}` and :samp:`{pytest_base_name}.{pytest_name}`
+  exist, :samp:`{pytest_base_name}.{pytest_name}` is returned.
+
+  Examples:
+
+  =============  ==================  ==================
+   pytest_name   what will be searched (in order)
+  =============  ======================================
+   x             x                   x.x [1]_
+   x_automator   x_automator         x.x_automator [1]_
+   x_e2etest     x_e2etest           x.x_e2etest [1]_
+   x.y.z         x.y.z
+  =============  ==================  ==================
+
+  .. [1] this is for backward compatibility, will be deprecated, use ``x.y.z``
+     instead.
+
+  Args:
+    pytest_name: The name of the pytest module.
+
+  Returns:
+    The loaded pytest module object.
+  """
+
+  from cros.factory.test import pytests
+
+  if '.' in pytest_name:
+    __import__('cros.factory.test.pytests.%s' % pytest_name)
+    return reduce(getattr, pytest_name.split('.'), pytests)
+  else:
+
+    try:
+      __import__('cros.factory.test.pytests.%s' % pytest_name)
+      module = getattr(pytests, pytest_name)
+
+      if not os.path.basename(module.__file__).startswith('__init__.py'):
+        # <pytest_name> is not a package.
+        return module
+    except ImportError:
+      pass
+
+    # Cannot find <pytest_name> or <pytest_name> is a package,
+    # fallback to <pytest_base_name>.<pytest_name>.
+    pytest_base_name = pytest_name
+    for suffix in ('_e2etest', '_automator', '_automator_private'):
+      if pytest_base_name.endswith(suffix):
+        pytest_base_name = pytest_base_name[:-len(suffix)]
+
+    try:
+      __import__('cros.factory.test.pytests.%s.%s' %
+                 (pytest_base_name, pytest_name))
+      logging.warn('recommend to use pytest_name=%r instead of pytest_name=%r',
+                   ('%s.%s' % (pytest_base_name, pytest_name)), pytest_name)
+      return getattr(getattr(pytests, pytest_base_name), pytest_name)
+    except ImportError:
+      logging.error('cannot find any pytest module named %s or %s.%s',
+                    pytest_name, pytest_base_name, pytest_name)
+      raise
diff --git a/py/test/utils/pytest_utils_unittest.py b/py/test/utils/pytest_utils_unittest.py
new file mode 100755
index 0000000..df96b22
--- /dev/null
+++ b/py/test/utils/pytest_utils_unittest.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+# Copyright 2016 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import os
+import shutil
+import tempfile
+import unittest
+
+import factory_common  # pylint: disable=W0611
+
+from cros.factory.test import pytests
+from cros.factory.test.utils.pytest_utils import LoadPytestModule
+from cros.factory.utils import file_utils
+
+
+class LoadPytestModuleTest(unittest.TestCase):
+
+  @staticmethod
+  def CreateScript(tmpdir, script_path):
+    """Create an python script under `tmpdir` with path=`script_path`.
+
+    The created script file will be empty, the `__init__.py`s will also be
+    created to make this script file importable.
+
+    Args:
+      tmpdir: the root directory.
+      script_path: path of script file relative to `tmpdir`.
+    """
+
+    # make sure there is no slash in the beginning.
+    if script_path.startswith('/'):
+      script_path = script_path.lstrip('/')
+
+    # create directories
+    if os.path.dirname(script_path):
+      os.makedirs(os.path.dirname(os.path.join(tmpdir, script_path)))
+
+    dirs = os.path.dirname(script_path).split('/')
+    for idx in xrange(len(dirs) + 1):
+      file_utils.TouchFile(
+          os.path.join(os.path.join(tmpdir, *dirs[:idx]), '__init__.py'))
+
+    # create python script
+    file_utils.TouchFile(os.path.join(tmpdir, script_path))
+
+
+  def setUp(self):
+    self.pytests_root = os.path.dirname(pytests.__file__)
+    self.tmpdir = tempfile.mkdtemp(dir=self.pytests_root)
+
+  def tearDown(self):
+    shutil.rmtree(self.tmpdir)
+
+  def testLoadX(self):
+    with file_utils.UnopenedTemporaryFile(
+        suffix='.py', dir=self.pytests_root) as script_file:
+      (pytest_name, _) = os.path.splitext(os.path.basename(script_file))
+      module = LoadPytestModule(pytest_name)
+      self.assertEquals(module.__file__, script_file)
+      # remove tmpXXXXXX.pyc
+      os.unlink(script_file + 'c')
+
+  def testLoadXYZ(self):
+    LoadPytestModuleTest.CreateScript(self.tmpdir, 'x/y/z.py')
+
+    basename = os.path.basename(self.tmpdir)
+    pytest_name = basename + '.x.y.z'
+    module = LoadPytestModule(pytest_name)
+    self.assertEquals(module.__file__, os.path.join(self.tmpdir, 'x/y/z.py'))
+
+  def testBackwardCompatibility(self):
+    basename = os.path.basename(self.tmpdir)
+
+    for suffix in ['', '_automator', '_e2etest', '_automator_private']:
+      LoadPytestModuleTest.CreateScript(
+          self.tmpdir, basename + suffix + '.py')
+
+    for suffix in ['', '_automator', '_e2etest', '_automator_private']:
+      module = LoadPytestModule(basename + suffix)
+      self.assertEquals(module.__file__,
+                        os.path.join(self.tmpdir, basename + suffix + '.py'))
+
+
+if __name__ == '__main__':
+  unittest.main()