Repository: googleapis/python-cloud-core

Issue:
Create client from service account stored in string
Hi,

This was raised in python-storage: https://github.com/googleapis/python-storage/issues/93

python-cloud-core library has support for [from_service_account_json](https://github.com/googleapis/python-cloud-core/blob/master/google/cloud/client.py#L50-L84), which accepts a file path to a service account. 

Feature request is to add support to accept a json string of the service account. User stores the service account file contents in an environment variable instead of in a file.

**Open question**: I'm not sure if this is a path that should be supported so I'm leaving that as an open question in this issue. If this feature is not supported then the workaround is to construct a credentials object and explicitly set a project id:

```python
# Storage example but is a general issue AFAIU

import os
import json

from google.cloud import storage
from google.oauth2 import service_account

credentials_json_string = "{...service account....}"

credentials_json = json.loads(credentials_json_string)
credentials = service_account.Credentials.from_service_account_info(credentials_json)

# Credentials has project_id already so project should be optional but it's not.
client = storage.Client(project=credentials['project_id'], credentials=credentials)

print(list(client.list_buckets()))
```

## Proposal:

```python
    @classmethod
    def from_service_account_json_string(cls, json_credentials, *args, **kwargs):
        """Factory to create credentials with provided json_credentials.
        :type json_credentials: str
        :param json_credentials: The contents of a private key file. This string must contain
                                      a JSON object with a private key and
                                      other credentials information (downloaded
                                      from the Google APIs console).
        :type args: tuple
        :param args: Remaining positional arguments to pass to constructor.
        :param kwargs: Remaining keyword arguments to pass to constructor.
        :rtype: :class:`_ClientFactoryMixin`
        :returns: The client created with the provided JSON credentials.
        :raises TypeError: if there is a conflict with the kwargs
                 and the credentials created by the factory.
        """
        if "credentials" in kwargs:
            raise TypeError("credentials must not be in keyword arguments")
        credentials_info = json.load(json_credentials)
        credentials = service_account.Credentials.from_service_account_info(
            credentials_info
        )
        if cls._SET_PROJECT:
            if "project" not in kwargs:
                kwargs["project"] = credentials_info.get("project_id")

        kwargs["credentials"] = credentials
        return cls(*args, **kwargs)
```

Discussion:
tseaver: Note:  some of these changes are spurious, introduced by my use of `pre-commit`, which blackened the source at the time.  I have opened #98 to track the fact that we don't use `black` in this repository.

Patch:
diff --git a/google/cloud/client.py b/google/cloud/client.py
index fdb81a5..0cfffdb 100644
--- a/google/cloud/client.py
+++ b/google/cloud/client.py
@@ -51,6 +51,36 @@ class _ClientFactoryMixin(object):
 
     _SET_PROJECT = False
 
+    @classmethod
+    def from_service_account_info(cls, info, *args, **kwargs):
+        """Factory to retrieve JSON credentials while creating client.
+
+        :type info: str
+        :param info:
+            The JSON object with a private key and other credentials
+            information (downloaded from the Google APIs console).
+
+        :type args: tuple
+        :param args: Remaining positional arguments to pass to constructor.
+
+        :param kwargs: Remaining keyword arguments to pass to constructor.
+
+        :rtype: :class:`_ClientFactoryMixin`
+        :returns: The client created with the retrieved JSON credentials.
+        :raises TypeError: if there is a conflict with the kwargs
+                 and the credentials created by the factory.
+        """
+        if "credentials" in kwargs:
+            raise TypeError("credentials must not be in keyword arguments")
+
+        credentials = service_account.Credentials.from_service_account_info(info)
+        if cls._SET_PROJECT:
+            if "project" not in kwargs:
+                kwargs["project"] = info.get("project_id")
+
+        kwargs["credentials"] = credentials
+        return cls(*args, **kwargs)
+
     @classmethod
     def from_service_account_json(cls, json_credentials_path, *args, **kwargs):
         """Factory to retrieve JSON credentials while creating client.
@@ -73,19 +103,10 @@ class _ClientFactoryMixin(object):
         :raises TypeError: if there is a conflict with the kwargs
                  and the credentials created by the factory.
         """
-        if "credentials" in kwargs:
-            raise TypeError("credentials must not be in keyword arguments")
         with io.open(json_credentials_path, "r", encoding="utf-8") as json_fi:
             credentials_info = json.load(json_fi)
-        credentials = service_account.Credentials.from_service_account_info(
-            credentials_info
-        )
-        if cls._SET_PROJECT:
-            if "project" not in kwargs:
-                kwargs["project"] = credentials_info.get("project_id")
 
-        kwargs["credentials"] = credentials
-        return cls(*args, **kwargs)
+        return cls.from_service_account_info(credentials_info)
 
 
 class Client(_ClientFactoryMixin):


Test patch:
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index 8137826..0e31348 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -143,15 +143,39 @@ class TestClient(unittest.TestCase):
             self.assertIs(client._http, session)
             self.assertEqual(AuthorizedSession.call_count, 1)
 
+    def test_from_service_account_info(self):
+        klass = self._get_target_class()
+
+        info = {"dummy": "value", "valid": "json"}
+        constructor_patch = mock.patch(
+            "google.oauth2.service_account.Credentials.from_service_account_info",
+            return_value=_make_credentials(),
+        )
+
+        with constructor_patch as constructor:
+            client_obj = klass.from_service_account_info(info)
+
+        self.assertIs(client_obj._credentials, constructor.return_value)
+        self.assertIsNone(client_obj._http_internal)
+        constructor.assert_called_once_with(info)
+
+    def test_from_service_account_info_w_explicit_credentials(self):
+        KLASS = self._get_target_class()
+
+        info = {"dummy": "value", "valid": "json"}
+
+        with self.assertRaises(TypeError):
+            KLASS.from_service_account_info(info, credentials=mock.sentinel.credentials)
+
     def test_from_service_account_json(self):
         from google.cloud import _helpers
 
         klass = self._get_target_class()
 
-        # Mock both the file opening and the credentials constructor.
         info = {"dummy": "value", "valid": "json"}
-        json_fi = io.StringIO(_helpers._bytes_to_unicode(json.dumps(info)))
-        file_open_patch = mock.patch("io.open", return_value=json_fi)
+        json_file = io.StringIO(_helpers._bytes_to_unicode(json.dumps(info)))
+
+        file_open_patch = mock.patch("io.open", return_value=json_file)
         constructor_patch = mock.patch(
             "google.oauth2.service_account.Credentials." "from_service_account_info",
             return_value=_make_credentials(),
@@ -167,14 +191,6 @@ class TestClient(unittest.TestCase):
         file_open.assert_called_once_with(mock.sentinel.filename, "r", encoding="utf-8")
         constructor.assert_called_once_with(info)
 
-    def test_from_service_account_json_bad_args(self):
-        KLASS = self._get_target_class()
-
-        with self.assertRaises(TypeError):
-            KLASS.from_service_account_json(
-                mock.sentinel.filename, credentials=mock.sentinel.credentials
-            )
-
 
 class Test_ClientProjectMixin(unittest.TestCase):
     @staticmethod
@@ -377,6 +393,40 @@ class TestClientWithProject(unittest.TestCase):
         PROJECT = u"PROJECT"
         self._explicit_ctor_helper(PROJECT)
 
+    def _from_service_account_info_helper(self, project=None):
+        klass = self._get_target_class()
+
+        info = {"dummy": "value", "valid": "json"}
+        kwargs = {}
+
+        if project is None:
+            expected_project = "eye-d-of-project"
+        else:
+            expected_project = project
+            kwargs["project"] = project
+
+        info["project_id"] = expected_project
+
+        constructor_patch = mock.patch(
+            "google.oauth2.service_account.Credentials.from_service_account_info",
+            return_value=_make_credentials(),
+        )
+
+        with constructor_patch as constructor:
+            client_obj = klass.from_service_account_info(info, **kwargs)
+
+        self.assertIs(client_obj._credentials, constructor.return_value)
+        self.assertIsNone(client_obj._http_internal)
+        self.assertEqual(client_obj.project, expected_project)
+
+        constructor.assert_called_once_with(info)
+
+    def test_from_service_account_info(self):
+        self._from_service_account_info_helper()
+
+    def test_from_service_account_info_with_project(self):
+        self._from_service_account_info_helper(project="prah-jekt")
+
     def _from_service_account_json_helper(self, project=None):
         from google.cloud import _helpers