Repository: cloudant/python-cloudant

Issue:
Cloudant.bluemix does not work on IBM Bluemix Dedicated service
Please include the following information in your ticket.

- Cloudant (python-cloudant) version(s) that are affected by this issue.  *2.6.0*
- Python version  *3.6.2*
- A small code sample that demonstrates the issue.  *See below*

IBM Bluemix Dedicated service uses "cloudantNoSQLDB Dedicated" as the name of the service, rather than "cloudantNoSQLDB" used by Public Bluemix.  Unfortunately, the CloudFoundryService class hardcodes the value:   `cloudant_services = services.get('cloudantNoSQLDB', [])`

Any chance we could make that value a parameter?


Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index eab0557..c683e44 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,7 @@
 Unreleased
 ==========
 - [NEW] Added ``Result.all()`` convenience method.
+- [NEW] Allow ``service_name`` to be specified when instantiating from a Bluemix VCAP_SERVICES environment variable.
 - [IMPROVED] Updated ``posixpath.join`` references to use ``'/'.join`` when concatenating URL parts.
 - [IMPROVED] Updated documentation by replacing deprecated Cloudant links with the latest Bluemix links.
 
diff --git a/src/cloudant/__init__.py b/src/cloudant/__init__.py
index 04131db..7b1ba55 100644
--- a/src/cloudant/__init__.py
+++ b/src/cloudant/__init__.py
@@ -92,7 +92,7 @@ def cloudant_iam(account_name, api_key, **kwargs):
     cloudant_session.disconnect()
 
 @contextlib.contextmanager
-def cloudant_bluemix(vcap_services, instance_name=None, **kwargs):
+def cloudant_bluemix(vcap_services, instance_name=None, service_name=None, **kwargs):
     """
     Provides a context manager to create a Cloudant session and provide access
     to databases, docs etc.
@@ -101,6 +101,7 @@ def cloudant_bluemix(vcap_services, instance_name=None, **kwargs):
     :type vcap_services: dict or str
     :param str instance_name: Optional Bluemix instance name. Only required if
         multiple Cloudant instances are available.
+    :param str service_name: Optional Bluemix service name.
     :param str encoder: Optional json Encoder object used to encode
         documents for storage. Defaults to json.JSONEncoder.
 
@@ -149,11 +150,10 @@ def cloudant_bluemix(vcap_services, instance_name=None, **kwargs):
             print client.all_dbs()
             # ...
     """
-    service = CloudFoundryService(vcap_services, instance_name)
-    cloudant_session = Cloudant(
-        service.username,
-        service.password,
-        url=service.url,
+    cloudant_session = Cloudant.bluemix(
+        vcap_services,
+        instance_name=instance_name,
+        service_name=service_name,
         **kwargs
     )
     cloudant_session.connect()
diff --git a/src/cloudant/_common_util.py b/src/cloudant/_common_util.py
index fe2e068..05e3bd3 100644
--- a/src/cloudant/_common_util.py
+++ b/src/cloudant/_common_util.py
@@ -498,18 +498,18 @@ class IAMSession(ClientSession):
 class CloudFoundryService(object):
     """ Manages Cloud Foundry service configuration. """
 
-    def __init__(self, vcap_services, name=None):
+    def __init__(self, vcap_services, instance_name=None, service_name=None):
         try:
             services = vcap_services
             if not isinstance(vcap_services, dict):
                 services = json.loads(vcap_services)
 
-            cloudant_services = services.get('cloudantNoSQLDB', [])
+            cloudant_services = services.get(service_name, [])
 
             # use first service if no name given and only one service present
-            use_first = name is None and len(cloudant_services) == 1
+            use_first = instance_name is None and len(cloudant_services) == 1
             for service in cloudant_services:
-                if use_first or service.get('name') == name:
+                if use_first or service.get('name') == instance_name:
                     credentials = service['credentials']
                     self._host = credentials['host']
                     self._name = service.get('name')
diff --git a/src/cloudant/client.py b/src/cloudant/client.py
index 3a1360c..ce7d493 100755
--- a/src/cloudant/client.py
+++ b/src/cloudant/client.py
@@ -754,7 +754,7 @@ class Cloudant(CouchDB):
         return resp.json()
 
     @classmethod
-    def bluemix(cls, vcap_services, instance_name=None, **kwargs):
+    def bluemix(cls, vcap_services, instance_name=None, service_name=None, **kwargs):
         """
         Create a Cloudant session using a VCAP_SERVICES environment variable.
 
@@ -762,6 +762,7 @@ class Cloudant(CouchDB):
         :type vcap_services: dict or str
         :param str instance_name: Optional Bluemix instance name. Only required
             if multiple Cloudant instances are available.
+        :param str service_name: Optional Bluemix service name.
 
         Example usage:
 
@@ -775,7 +776,10 @@ class Cloudant(CouchDB):
 
             print client.all_dbs()
         """
-        service = CloudFoundryService(vcap_services, instance_name)
+        service_name = service_name or 'cloudantNoSQLDB'  # default service
+        service = CloudFoundryService(vcap_services,
+                                      instance_name=instance_name,
+                                      service_name=service_name)
         return Cloudant(service.username,
                         service.password,
                         url=service.url,


Test patch:
diff --git a/tests/unit/client_tests.py b/tests/unit/client_tests.py
index 796e5fc..db78861 100644
--- a/tests/unit/client_tests.py
+++ b/tests/unit/client_tests.py
@@ -552,6 +552,34 @@ class CloudantClientTests(UnitTestDbBase):
         except Exception as err:
             self.fail('Exception {0} was raised.'.format(str(err)))
 
+    def test_cloudant_bluemix_dedicated_context_helper(self):
+        """
+        Test that the cloudant_bluemix context helper works as expected when
+        specifying a service name.
+        """
+        instance_name = 'Cloudant NoSQL DB-wq'
+        service_name = 'cloudantNoSQLDB Dedicated'
+        vcap_services = {service_name: [{
+          'credentials': {
+            'username': self.user,
+            'password': self.pwd,
+            'host': '{0}.cloudant.com'.format(self.account),
+            'port': 443,
+            'url': self.url
+          },
+          'name': instance_name,
+        }]}
+
+        try:
+            with cloudant_bluemix(vcap_services,
+                                  instance_name=instance_name,
+                                  service_name=service_name) as c:
+                self.assertIsInstance(c, Cloudant)
+                self.assertIsInstance(c.r_session, requests.Session)
+                self.assertEquals(c.session()['userCtx']['name'], self.user)
+        except Exception as err:
+            self.fail('Exception {0} was raised.'.format(str(err)))
+
     def test_constructor_with_account(self):
         """
         Test instantiating a client object using an account name
diff --git a/tests/unit/cloud_foundry_tests.py b/tests/unit/cloud_foundry_tests.py
index 043949f..43249b7 100644
--- a/tests/unit/cloud_foundry_tests.py
+++ b/tests/unit/cloud_foundry_tests.py
@@ -91,68 +91,104 @@ class CloudFoundryServiceTests(unittest.TestCase):
                 ]
             }
         ]})
+        self._test_vcap_services_dedicated = json.dumps({
+            'cloudantNoSQLDB Dedicated': [  # dedicated service name
+                {
+                    'name': 'Cloudant NoSQL DB 1',  # valid service
+                    'credentials': {
+                        'host': 'example.cloudant.com',
+                        'password': 'pa$$w0rd01',
+                        'port': 1234,
+                        'username': 'example'
+                    }
+                }
+            ]
+        })
 
     def test_get_vcap_service_default_success(self):
-        service = CloudFoundryService(self._test_vcap_services_single)
+        service = CloudFoundryService(
+            self._test_vcap_services_single,
+            service_name='cloudantNoSQLDB'
+        )
         self.assertEqual('Cloudant NoSQL DB 1', service.name)
 
     def test_get_vcap_service_default_success_as_dict(self):
         service = CloudFoundryService(
-            json.loads(self._test_vcap_services_single)
+            json.loads(self._test_vcap_services_single),
+            service_name='cloudantNoSQLDB'
         )
         self.assertEqual('Cloudant NoSQL DB 1', service.name)
 
     def test_get_vcap_service_default_failure_multiple_services(self):
         with self.assertRaises(CloudantException) as cm:
-            CloudFoundryService(self._test_vcap_services_multiple)
+            CloudFoundryService(
+                self._test_vcap_services_multiple,
+                service_name='cloudantNoSQLDB'
+            )
         self.assertEqual('Missing service in VCAP_SERVICES', str(cm.exception))
 
     def test_get_vcap_service_instance_host(self):
         service = CloudFoundryService(
-            self._test_vcap_services_multiple, 'Cloudant NoSQL DB 1'
+            self._test_vcap_services_multiple,
+            instance_name='Cloudant NoSQL DB 1',
+            service_name='cloudantNoSQLDB'
         )
         self.assertEqual('example.cloudant.com', service.host)
 
     def test_get_vcap_service_instance_password(self):
         service = CloudFoundryService(
-            self._test_vcap_services_multiple, 'Cloudant NoSQL DB 1'
+            self._test_vcap_services_multiple,
+            instance_name='Cloudant NoSQL DB 1',
+            service_name='cloudantNoSQLDB'
         )
         self.assertEqual('pa$$w0rd01', service.password)
 
     def test_get_vcap_service_instance_port(self):
         service = CloudFoundryService(
-            self._test_vcap_services_multiple, 'Cloudant NoSQL DB 1'
+            self._test_vcap_services_multiple,
+            instance_name='Cloudant NoSQL DB 1',
+            service_name='cloudantNoSQLDB'
         )
         self.assertEqual('1234', service.port)
 
     def test_get_vcap_service_instance_port_default(self):
         service = CloudFoundryService(
-            self._test_vcap_services_multiple, 'Cloudant NoSQL DB 2'
+            self._test_vcap_services_multiple,
+            instance_name='Cloudant NoSQL DB 2',
+            service_name='cloudantNoSQLDB'
         )
         self.assertEqual('443', service.port)
 
     def test_get_vcap_service_instance_url(self):
         service = CloudFoundryService(
-            self._test_vcap_services_multiple, 'Cloudant NoSQL DB 1'
+            self._test_vcap_services_multiple,
+            instance_name='Cloudant NoSQL DB 1',
+            service_name='cloudantNoSQLDB'
         )
         self.assertEqual('https://example.cloudant.com:1234', service.url)
 
     def test_get_vcap_service_instance_username(self):
         service = CloudFoundryService(
-            self._test_vcap_services_multiple, 'Cloudant NoSQL DB 1'
+            self._test_vcap_services_multiple,
+            instance_name='Cloudant NoSQL DB 1',
+            service_name='cloudantNoSQLDB'
         )
         self.assertEqual('example', service.username)
 
     def test_raise_error_for_missing_host(self):
         with self.assertRaises(CloudantException):
             CloudFoundryService(
-                self._test_vcap_services_multiple, 'Cloudant NoSQL DB 3'
+                self._test_vcap_services_multiple,
+                instance_name='Cloudant NoSQL DB 3',
+                service_name='cloudantNoSQLDB'
             )
 
     def test_raise_error_for_missing_password(self):
         with self.assertRaises(CloudantException) as cm:
             CloudFoundryService(
-                self._test_vcap_services_multiple, 'Cloudant NoSQL DB 4'
+                self._test_vcap_services_multiple,
+                instance_name='Cloudant NoSQL DB 4',
+                service_name='cloudantNoSQLDB'
             )
         self.assertEqual(
             "Invalid service: 'password' missing",
@@ -162,7 +198,9 @@ class CloudFoundryServiceTests(unittest.TestCase):
     def test_raise_error_for_missing_username(self):
         with self.assertRaises(CloudantException) as cm:
             CloudFoundryService(
-                self._test_vcap_services_multiple, 'Cloudant NoSQL DB 5'
+                self._test_vcap_services_multiple,
+                instance_name='Cloudant NoSQL DB 5',
+                service_name='cloudantNoSQLDB'
             )
         self.assertEqual(
             "Invalid service: 'username' missing",
@@ -172,7 +210,9 @@ class CloudFoundryServiceTests(unittest.TestCase):
     def test_raise_error_for_invalid_credentials_type(self):
         with self.assertRaises(CloudantException) as cm:
             CloudFoundryService(
-                self._test_vcap_services_multiple, 'Cloudant NoSQL DB 6'
+                self._test_vcap_services_multiple,
+                instance_name='Cloudant NoSQL DB 6',
+                service_name='cloudantNoSQLDB'
             )
         self.assertEqual(
             'Failed to decode VCAP_SERVICES service credentials',
@@ -182,7 +222,9 @@ class CloudFoundryServiceTests(unittest.TestCase):
     def test_raise_error_for_missing_service(self):
         with self.assertRaises(CloudantException) as cm:
             CloudFoundryService(
-                self._test_vcap_services_multiple, 'Cloudant NoSQL DB 7'
+                self._test_vcap_services_multiple,
+                instance_name='Cloudant NoSQL DB 7',
+                service_name='cloudantNoSQLDB'
             )
         self.assertEqual('Missing service in VCAP_SERVICES', str(cm.exception))
 
@@ -190,3 +232,10 @@ class CloudFoundryServiceTests(unittest.TestCase):
         with self.assertRaises(CloudantException) as cm:
             CloudFoundryService('{', 'Cloudant NoSQL DB 1')  # invalid JSON
         self.assertEqual('Failed to decode VCAP_SERVICES JSON', str(cm.exception))
+
+    def test_get_vcap_service_with_dedicated_service_name_success(self):
+        service = CloudFoundryService(
+            self._test_vcap_services_dedicated,
+            service_name='cloudantNoSQLDB Dedicated'
+        )
+        self.assertEqual('Cloudant NoSQL DB 1', service.name)