Merge "Introduce the ClientsFactory"
diff --git a/tempest/service_clients.py b/tempest/service_clients.py
index 386e621..252ebf4 100644
--- a/tempest/service_clients.py
+++ b/tempest/service_clients.py
@@ -14,6 +14,10 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+import copy
+import importlib
+import inspect
+
 from tempest.lib import auth
 from tempest.lib import exceptions
 
@@ -34,6 +38,88 @@
     return tempest_modules()
 
 
+class ClientsFactory(object):
+    """Builds service clients for a service client module
+
+    This class implements the logic of feeding service client parameters
+    to service clients from a specific module. It allows setting the
+    parameters once and obtaining new instances of the clients without the
+    need of passing any parameter.
+
+    ClientsFactory can be used directly, or consumed via the `ServiceClients`
+    class, which manages the authorization part.
+    """
+    # TODO(andreaf) This version includes ClientsFactory but it does not
+    # use it yet in ServiceClients
+
+    def __init__(self, module_path, client_names, auth_provider, **kwargs):
+        """Initialises the client factory
+
+        :param module_path Path to module that includes all service clients.
+            All service client classes must be exposed by a single module.
+            If they are separated in different modules, defining __all__
+            in the root module can help, similar to what is done by service
+            clients in tempest.
+        :param client_names List or set of names of the service client classes.
+        :param auth_provider The auth provider used to initialise client.
+        :param kwargs Parameters to be passed to all clients. Parameters values
+            can be overwritten when clients are initialised, but parameters
+            cannot be deleted.
+        :raise ImportError if the specified module_path cannot be imported
+
+        Example:
+
+            >>> # Get credentials and an auth_provider
+            >>> clients = ClientsFactory(
+            >>>     module_path='my_service.my_service_clients',
+            >>>     client_names=['ServiceClient1', 'ServiceClient2'],
+            >>>     auth_provider=auth_provider,
+            >>>     service='my_service',
+            >>>     region='region1')
+            >>> my_api_client = clients.MyApiClient()
+            >>> my_api_client_region2 = clients.MyApiClient(region='region2')
+
+        """
+        # Import the module. If it's not importable, the raised exception
+        # provides good enough information about what happened
+        _module = importlib.import_module(module_path)
+        # If any of the classes is not in the module we fail
+        for class_name in client_names:
+            # TODO(andreaf) This always passes all parameters to all clients.
+            # In future to allow clients to specify the list of parameters
+            # that they accept based out of a list of standard ones.
+
+            # Obtain the class
+            klass = self._get_class(_module, class_name)
+            final_kwargs = copy.copy(kwargs)
+
+            # Set the function as an attribute of the factory
+            setattr(self, class_name, self._get_partial_class(
+                klass, auth_provider, final_kwargs))
+
+    @classmethod
+    def _get_partial_class(cls, klass, auth_provider, kwargs):
+
+        # Define a function that returns a new class instance by
+        # combining default kwargs with extra ones
+        def partial_class(**later_kwargs):
+            kwargs.update(later_kwargs)
+            return klass(auth_provider=auth_provider, **kwargs)
+
+        return partial_class
+
+    @classmethod
+    def _get_class(cls, module, class_name):
+        klass = getattr(module, class_name, None)
+        if not klass:
+            msg = 'Invalid class name, %s is not found in %s'
+            raise AttributeError(msg % (class_name, module))
+        if not inspect.isclass(klass):
+            msg = 'Expected a class, got %s of type %s instead'
+            raise TypeError(msg % (klass, type(klass)))
+        return klass
+
+
 class ServiceClients(object):
     """Service client provider class
 
diff --git a/tempest/tests/test_service_clients.py b/tempest/tests/test_service_clients.py
index a559086..26cc93f 100644
--- a/tempest/tests/test_service_clients.py
+++ b/tempest/tests/test_service_clients.py
@@ -13,15 +13,154 @@
 # the License.
 
 import fixtures
+import mock
 import testtools
+import types
 
 from tempest.lib import auth
 from tempest.lib import exceptions
 from tempest import service_clients
 from tempest.tests import base
+from tempest.tests.lib import fake_auth_provider
 from tempest.tests.lib import fake_credentials
 
 
+has_attribute = testtools.matchers.MatchesPredicateWithParams(
+    lambda x, y: hasattr(x, y), '{0} does not have an attribute {1}')
+
+
+class TestClientsFactory(base.TestCase):
+
+    def setUp(self):
+        super(TestClientsFactory, self).setUp()
+        self.classes = []
+
+    def _setup_fake_module(self, class_names=None, extra_dict=None):
+        class_names = class_names or []
+        fake_module = types.ModuleType('fake_service_client')
+        _dict = {}
+        # Add fake classes to the fake module
+        for name in class_names:
+            _dict[name] = type(name, (object,), {})
+            # Store it for assertions
+            self.classes.append(_dict[name])
+        if extra_dict:
+            _dict[extra_dict] = extra_dict
+        fake_module.__dict__.update(_dict)
+        fixture_importlib = self.useFixture(fixtures.MockPatch(
+            'importlib.import_module', return_value=fake_module))
+        return fixture_importlib.mock
+
+    def test___init___one_class(self):
+        fake_partial = 'fake_partial'
+        partial_mock = self.useFixture(fixtures.MockPatch(
+            'tempest.service_clients.ClientsFactory._get_partial_class',
+            return_value=fake_partial)).mock
+        class_names = ['FakeServiceClient1']
+        mock_importlib = self._setup_fake_module(class_names=class_names)
+        auth_provider = fake_auth_provider.FakeAuthProvider()
+        params = {'k1': 'v1', 'k2': 'v2'}
+        factory = service_clients.ClientsFactory('fake_path', class_names,
+                                                 auth_provider, **params)
+        # Assert module has been imported
+        mock_importlib.assert_called_once_with('fake_path')
+        # All attributes have been created
+        for client in class_names:
+            self.assertThat(factory, has_attribute(client))
+        # Partial have been invoked correctly
+        partial_mock.assert_called_once_with(
+            self.classes[0], auth_provider, params)
+        # Get the clients
+        for name in class_names:
+            self.assertEqual(fake_partial, getattr(factory, name))
+
+    def test___init___two_classes(self):
+        fake_partial = 'fake_partial'
+        partial_mock = self.useFixture(fixtures.MockPatch(
+            'tempest.service_clients.ClientsFactory._get_partial_class',
+            return_value=fake_partial)).mock
+        class_names = ['FakeServiceClient1', 'FakeServiceClient2']
+        mock_importlib = self._setup_fake_module(class_names=class_names)
+        auth_provider = fake_auth_provider.FakeAuthProvider()
+        params = {'k1': 'v1', 'k2': 'v2'}
+        factory = service_clients.ClientsFactory('fake_path', class_names,
+                                                 auth_provider, **params)
+        # Assert module has been imported
+        mock_importlib.assert_called_once_with('fake_path')
+        # All attributes have been created
+        for client in class_names:
+            self.assertThat(factory, has_attribute(client))
+        # Partial have been invoked the right number of times
+        partial_mock.call_count = len(class_names)
+        # Get the clients
+        for name in class_names:
+            self.assertEqual(fake_partial, getattr(factory, name))
+
+    def test___init___no_module(self):
+        auth_provider = fake_auth_provider.FakeAuthProvider()
+        class_names = ['FakeServiceClient1', 'FakeServiceClient2']
+        with testtools.ExpectedException(ImportError, '.*fake_module.*'):
+            service_clients.ClientsFactory('fake_module', class_names,
+                                           auth_provider)
+
+    def test___init___not_a_class(self):
+        class_names = ['FakeServiceClient1', 'FakeServiceClient2']
+        extended_class_names = class_names + ['not_really_a_class']
+        self._setup_fake_module(
+            class_names=class_names, extra_dict='not_really_a_class')
+        auth_provider = fake_auth_provider.FakeAuthProvider()
+        expected_msg = '.*not_really_a_class.*str.*'
+        with testtools.ExpectedException(TypeError, expected_msg):
+            service_clients.ClientsFactory('fake_module', extended_class_names,
+                                           auth_provider)
+
+    def test___init___class_not_found(self):
+        class_names = ['FakeServiceClient1', 'FakeServiceClient2']
+        extended_class_names = class_names + ['not_really_a_class']
+        self._setup_fake_module(class_names=class_names)
+        auth_provider = fake_auth_provider.FakeAuthProvider()
+        expected_msg = '.*not_really_a_class.*fake_service_client.*'
+        with testtools.ExpectedException(AttributeError, expected_msg):
+            service_clients.ClientsFactory('fake_module', extended_class_names,
+                                           auth_provider)
+
+    def test__get_partial_class_no_later_kwargs(self):
+        expected_fake_client = 'not_really_a_client'
+        self._setup_fake_module(class_names=[])
+        auth_provider = fake_auth_provider.FakeAuthProvider()
+        params = {'k1': 'v1', 'k2': 'v2'}
+        factory = service_clients.ClientsFactory(
+            'fake_path', [], auth_provider, **params)
+        klass_mock = mock.Mock(return_value=expected_fake_client)
+        partial = factory._get_partial_class(klass_mock, auth_provider, params)
+        # Class has not be initialised yet
+        klass_mock.assert_not_called()
+        # Use partial and assert on parameters
+        client = partial()
+        self.assertEqual(expected_fake_client, client)
+        klass_mock.assert_called_once_with(auth_provider=auth_provider,
+                                           **params)
+
+    def test__get_partial_class_later_kwargs(self):
+        expected_fake_client = 'not_really_a_client'
+        self._setup_fake_module(class_names=[])
+        auth_provider = fake_auth_provider.FakeAuthProvider()
+        params = {'k1': 'v1', 'k2': 'v2'}
+        later_params = {'k2': 'v4', 'k3': 'v3'}
+        factory = service_clients.ClientsFactory(
+            'fake_path', [], auth_provider, **params)
+        klass_mock = mock.Mock(return_value=expected_fake_client)
+        partial = factory._get_partial_class(klass_mock, auth_provider, params)
+        # Class has not be initialised yet
+        klass_mock.assert_not_called()
+        # Use partial and assert on parameters
+        client = partial(**later_params)
+        params.update(later_params)
+        self.assertEqual(expected_fake_client, client)
+        klass_mock.assert_called_once_with(auth_provider=auth_provider,
+                                           **params)
+
+
 class TestServiceClients(base.TestCase):
 
     def setUp(self):