Repository: googleapis/google-cloud-python

Issue:
Bigquery client doesn't implement list_projects
Seems weird, but the bigquery client should also be able to list projects:

https://cloud.google.com/bigquery/docs/managing_jobs_datasets_projects#projects



Discussion:
@jonparrott I left the API out because I couldn't imagine a use-case for it.  It seems like the purview of [ResourceManager](https://github.com/GoogleCloudPlatform/gcloud-python/blob/fd0e6bf7aac1f4373c64d8e76552d395a1dbf279/gcloud/resource_manager/client.py#L96), not BigQuery.

There are many users of bigquery that don't use any other APIs. I think
this is important, and its needed for our documentation.

On Thu, Aug 25, 2016, 8:48 AM Tres Seaver notifications@github.com wrote:

> @jonparrott https://github.com/jonparrott I left the API out because I
> couldn't imagine a use-case for it. It seems like the purview of
> ResourceManager
> https://github.com/GoogleCloudPlatform/gcloud-python/blob/fd0e6bf7aac1f4373c64d8e76552d395a1dbf279/gcloud/resource_manager/client.py#L96,
> not BigQuery.
> 
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> https://github.com/GoogleCloudPlatform/gcloud-python/issues/2143#issuecomment-242436765,
> or mute the thread
> https://github.com/notifications/unsubscribe-auth/AAPUczZH09OL1nHcRCQM7NIGDxZ_4rJyks5qjbk5gaJpZM4JorPO
> .

@jonparrott I can add this, but wonder why we don't have it in all the other APIs, too?

@tseaver it's just an artifact of how bigquery is setup. There are multiple "public" projects containing public bigquery datasets. BigQuery kind of exists on its own for historical reasons, so it makes sense for this to be here. (BigQuery was the 3rd cloud platform service - after GAE and GCS).


Resolving patch:
diff --git a/google/cloud/bigquery/client.py b/google/cloud/bigquery/client.py
--- a/google/cloud/bigquery/client.py
+++ b/google/cloud/bigquery/client.py
@@ -25,6 +25,30 @@
 from google.cloud.bigquery.query import QueryResults
 
 
+class Project(object):
+    """Wrapper for resource describing a BigQuery project.
+
+    :type project_id: str
+    :param project_id: Opaque ID of the project
+
+    :type numeric_id: int
+    :param numeric_id: Numeric ID of the project
+
+    :type friendly_name: str
+    :param friendly_name: Display name of the project
+    """
+    def __init__(self, project_id, numeric_id, friendly_name):
+        self.project_id = project_id
+        self.numeric_id = numeric_id
+        self.friendly_name = friendly_name
+
+    @classmethod
+    def from_api_repr(cls, resource):
+        """Factory: construct an instance from a resource dict."""
+        return cls(
+            resource['id'], resource['numericId'], resource['friendlyName'])
+
+
 class Client(JSONClient):
     """Client to bundle configuration needed for API requests.
 
@@ -48,6 +72,42 @@ class Client(JSONClient):
 
     _connection_class = Connection
 
+    def list_projects(self, max_results=None, page_token=None):
+        """List projects for the project associated with this client.
+
+        See:
+        https://cloud.google.com/bigquery/docs/reference/v2/projects/list
+
+        :type max_results: int
+        :param max_results: maximum number of projects to return, If not
+                            passed, defaults to a value set by the API.
+
+        :type page_token: str
+        :param page_token: opaque marker for the next "page" of projects. If
+                           not passed, the API will return the first page of
+                           projects.
+
+        :rtype: tuple, (list, str)
+        :returns: list of :class:`gcloud.bigquery.client.Project`, plus a
+                  "next page token" string:  if the token is not None,
+                  indicates that more projects can be retrieved with another
+                  call (pass that value as ``page_token``).
+        """
+        params = {}
+
+        if max_results is not None:
+            params['maxResults'] = max_results
+
+        if page_token is not None:
+            params['pageToken'] = page_token
+
+        path = '/projects'
+        resp = self.connection.api_request(method='GET', path=path,
+                                           query_params=params)
+        projects = [Project.from_api_repr(resource)
+                    for resource in resp.get('projects', ())]
+        return projects, resp.get('nextPageToken')
+
     def list_datasets(self, include_all=False, max_results=None,
                       page_token=None):
         """List datasets for the project associated with this client.
diff --git a/google/cloud/resource_manager/client.py b/google/cloud/resource_manager/client.py
--- a/google/cloud/resource_manager/client.py
+++ b/google/cloud/resource_manager/client.py
@@ -49,11 +49,12 @@ class Client(BaseClient):
     _connection_class = Connection
 
     def new_project(self, project_id, name=None, labels=None):
-        """Create a :class:`.Project` bound to the current client.
+        """Create a project bound to the current client.
 
         Use :meth:`Project.reload() \
         <google.cloud.resource_manager.project.Project.reload>` to retrieve
-        project metadata after creating a :class:`.Project` instance.
+        project metadata after creating a
+        :class:`~gcloud.resource_manager.project.Project` instance.
 
         .. note:
 
@@ -68,9 +69,10 @@ def new_project(self, project_id, name=None, labels=None):
         :type labels: dict
         :param labels: A list of labels associated with the project.
 
-        :rtype: :class:`.Project`
-        :returns: A new instance of a :class:`.Project` **without**
-                  any metadata loaded.
+        :rtype: :class:`~gcloud.resource_manager.project.Project`
+        :returns: A new instance of a
+                  :class:`~gcloud.resource_manager.project.Project`
+                  **without** any metadata loaded.
         """
         return Project(project_id=project_id,
                        client=self, name=name, labels=labels)
@@ -86,8 +88,9 @@ def fetch_project(self, project_id):
         :type project_id: str
         :param project_id: The ID for this project.
 
-        :rtype: :class:`.Project`
-        :returns: A :class:`.Project` with metadata fetched from the API.
+        :rtype: :class:`~gcloud.resource_manager.project.Project`
+        :returns: A :class:`~gcloud.resource_manager.project.Project` with
+                  metadata fetched from the API.
         """
         project = self.new_project(project_id)
         project.reload()
@@ -142,7 +145,7 @@ def list_projects(self, filter_params=None, page_size=None):
         :returns: A project iterator. The iterator will make multiple API
                   requests if you continue iterating and there are more
                   pages of results. Each item returned will be a.
-                  :class:`.Project`.
+                  :class:`~gcloud.resource_manager.project.Project`.
         """
         extra_params = {}
 
@@ -175,7 +178,7 @@ def __init__(self, client, extra_params=None):
                                                extra_params=extra_params)
 
     def get_items_from_response(self, response):
-        """Yield :class:`.Project` items from response.
+        """Yield projects from response.
 
         :type response: dict
         :param response: The JSON API response for a page of projects.


Test patch:
diff --git a/unit_tests/bigquery/test_client.py b/unit_tests/bigquery/test_client.py
--- a/unit_tests/bigquery/test_client.py
+++ b/unit_tests/bigquery/test_client.py
@@ -34,6 +34,67 @@ def test_ctor(self):
         self.assertTrue(client.connection.credentials is creds)
         self.assertTrue(client.connection.http is http)
 
+    def test_list_projects_defaults(self):
+        from google.cloud.bigquery.client import Project
+        PROJECT_1 = 'PROJECT_ONE'
+        PROJECT_2 = 'PROJECT_TWO'
+        PATH = 'projects'
+        TOKEN = 'TOKEN'
+        DATA = {
+            'nextPageToken': TOKEN,
+            'projects': [
+                {'kind': 'bigquery#project',
+                 'id': PROJECT_1,
+                 'numericId': 1,
+                 'projectReference': {'projectId': PROJECT_1},
+                 'friendlyName': 'One'},
+                {'kind': 'bigquery#project',
+                 'id': PROJECT_2,
+                 'numericId': 2,
+                 'projectReference': {'projectId': PROJECT_2},
+                 'friendlyName': 'Two'},
+            ]
+        }
+        creds = _Credentials()
+        client = self._makeOne(PROJECT_1, creds)
+        conn = client.connection = _Connection(DATA)
+
+        projects, token = client.list_projects()
+
+        self.assertEqual(len(projects), len(DATA['projects']))
+        for found, expected in zip(projects, DATA['projects']):
+            self.assertTrue(isinstance(found, Project))
+            self.assertEqual(found.project_id, expected['id'])
+            self.assertEqual(found.numeric_id, expected['numericId'])
+            self.assertEqual(found.friendly_name, expected['friendlyName'])
+        self.assertEqual(token, TOKEN)
+
+        self.assertEqual(len(conn._requested), 1)
+        req = conn._requested[0]
+        self.assertEqual(req['method'], 'GET')
+        self.assertEqual(req['path'], '/%s' % PATH)
+
+    def test_list_projects_explicit_response_missing_projects_key(self):
+        PROJECT = 'PROJECT'
+        PATH = 'projects'
+        TOKEN = 'TOKEN'
+        DATA = {}
+        creds = _Credentials()
+        client = self._makeOne(PROJECT, creds)
+        conn = client.connection = _Connection(DATA)
+
+        projects, token = client.list_projects(max_results=3, page_token=TOKEN)
+
+        self.assertEqual(len(projects), 0)
+        self.assertEqual(token, None)
+
+        self.assertEqual(len(conn._requested), 1)
+        req = conn._requested[0]
+        self.assertEqual(req['method'], 'GET')
+        self.assertEqual(req['path'], '/%s' % PATH)
+        self.assertEqual(req['query_params'],
+                         {'maxResults': 3, 'pageToken': TOKEN})
+
     def test_list_datasets_defaults(self):
         from google.cloud.bigquery.dataset import Dataset
         PROJECT = 'PROJECT'