diff --git a/gcloud/bigquery/dataset.py b/gcloud/bigquery/dataset.py index db5cfb70f216..47d799e33186 100644 --- a/gcloud/bigquery/dataset.py +++ b/gcloud/bigquery/dataset.py @@ -20,6 +20,29 @@ from gcloud.bigquery.table import Table +class AccessGrant(object): + """Represent grant of an access role to an entity. + + :type role: string (one of 'OWNER', 'WRITER', 'READER'). + :param role: role granted to the entity. + + :type entity_type: string (one of 'specialGroup', 'groupByEmail', or + 'userByEmail') + :param entity_type: type of entity being granted the role. + + :type entity_id: string + :param entity_id: ID of entity being granted the role. + """ + def __init__(self, role, entity_type, entity_id): + self.role = role + self.entity_type = entity_type + self.entity_id = entity_id + + def __repr__(self): + return '' % ( + self.role, self.entity_type, self.entity_id) + + class Dataset(object): """Datasets are containers for tables. @@ -32,12 +55,16 @@ class Dataset(object): :type client: :class:`gcloud.bigquery.client.Client` :param client: A client which holds credentials and project configuration for the dataset (which requires a project). + + :type access_grants: list of :class:`AccessGrant` + :param access_grants: roles granted to entities for this dataset """ - def __init__(self, name, client): + def __init__(self, name, client, access_grants=()): self.name = name self._client = client self._properties = {} + self.access_grants = access_grants @property def project(self): @@ -57,6 +84,29 @@ def path(self): """ return '/projects/%s/datasets/%s' % (self.project, self.name) + @property + def access_grants(self): + """Dataset's access grants. + + :rtype: list of :class:`AccessGrant` + :returns: roles granted to entities for this dataset + """ + return list(self._access_grants) + + @access_grants.setter + def access_grants(self, value): + """Update dataset's access grants + + :type value: list of :class:`AccessGrant` + :param value: roles granted to entities for this dataset + + :raises: TypeError if 'value' is not a sequence, or ValueError if + any item in the sequence is not an AccessGrant + """ + if not all(isinstance(field, AccessGrant) for field in value): + raise ValueError('Values must be AccessGrant instances') + self._access_grants = tuple(value) + @property def created(self): """Datetime at which the dataset was created. @@ -227,6 +277,27 @@ def _require_client(self, client): client = self._client return client + def _parse_access_grants(self, access): + """Parse a resource fragment into a set of access grants. + + :type access: list of mappings + :param access: each mapping represents a single access grant + + :rtype: list of :class:`AccessGrant` + :returns: a list of parsed grants + """ + result = [] + for grant in access: + grant = grant.copy() + role = grant.pop('role') + # Hypothetical case: we don't know that the back-end will ever + # return such structures, but they are logical. See: + # https://github.com/GoogleCloudPlatform/gcloud-python/pull/1046#discussion_r36687769 + for entity_type, entity_id in sorted(grant.items()): + result.append( + AccessGrant(role, entity_type, entity_id)) + return result + def _set_properties(self, api_response): """Update properties from resource in body of ``api_response`` @@ -235,12 +306,22 @@ def _set_properties(self, api_response): """ self._properties.clear() cleaned = api_response.copy() + access = cleaned.pop('access', ()) + self.access_grants = self._parse_access_grants(access) if 'creationTime' in cleaned: cleaned['creationTime'] = float(cleaned['creationTime']) if 'lastModifiedTime' in cleaned: cleaned['lastModifiedTime'] = float(cleaned['lastModifiedTime']) self._properties.update(cleaned) + def _build_access_resource(self): + """Generate a resource fragment for dataset's access grants.""" + result = [] + for grant in self.access_grants: + info = {'role': grant.role, grant.entity_type: grant.entity_id} + result.append(info) + return result + def _build_resource(self): """Generate a resource for ``create`` or ``update``.""" resource = { @@ -260,6 +341,9 @@ def _build_resource(self): if self.location is not None: resource['location'] = self.location + if len(self.access_grants) > 0: + resource['access'] = self._build_access_resource() + return resource def create(self, client=None): diff --git a/gcloud/bigquery/test_dataset.py b/gcloud/bigquery/test_dataset.py index 1d65c0e1318f..3edeb0432f5a 100644 --- a/gcloud/bigquery/test_dataset.py +++ b/gcloud/bigquery/test_dataset.py @@ -15,6 +15,22 @@ import unittest2 +class TestAccessGrant(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.bigquery.dataset import AccessGrant + return AccessGrant + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor_defaults(self): + grant = self._makeOne('OWNER', 'userByEmail', 'phred@example.com') + self.assertEqual(grant.role, 'OWNER') + self.assertEqual(grant.entity_type, 'userByEmail') + self.assertEqual(grant.entity_id, 'phred@example.com') + + class TestDataset(unittest2.TestCase): PROJECT = 'project' DS_NAME = 'dataset-name' @@ -39,6 +55,8 @@ def _setUpConstants(self): def _makeResource(self): self._setUpConstants() + USER_EMAIL = 'phred@example.com' + GROUP_EMAIL = 'group-name@lists.example.com' return { 'creationTime': self.WHEN_TS * 1000, 'datasetReference': @@ -48,10 +66,32 @@ def _makeResource(self): 'lastModifiedTime': self.WHEN_TS * 1000, 'location': 'US', 'selfLink': self.RESOURCE_URL, + 'access': [ + {'role': 'OWNER', 'userByEmail': USER_EMAIL}, + {'role': 'OWNER', 'groupByEmail': GROUP_EMAIL}, + {'role': 'WRITER', 'specialGroup': 'projectWriters'}, + {'role': 'READER', 'specialGroup': 'projectReaders'}], } - def _verifyResourceProperties(self, dataset, resource): + def _verifyAccessGrants(self, access_grants, resource): + r_grants = [] + for r_grant in resource['access']: + role = r_grant.pop('role') + for entity_type, entity_id in sorted(r_grant.items()): + r_grants.append({'role': role, + 'entity_type': entity_type, + 'entity_id': entity_id}) + + self.assertEqual(len(access_grants), len(r_grants)) + for a_grant, r_grant in zip(access_grants, r_grants): + self.assertEqual(a_grant.role, r_grant['role']) + self.assertEqual(a_grant.entity_type, r_grant['entity_type']) + self.assertEqual(a_grant.entity_id, r_grant['entity_id']) + + def _verifyReadonlyResourceProperties(self, dataset, resource): + self.assertEqual(dataset.dataset_id, self.DS_ID) + if 'creationTime' in resource: self.assertEqual(dataset.created, self.WHEN) else: @@ -69,12 +109,21 @@ def _verifyResourceProperties(self, dataset, resource): else: self.assertEqual(dataset.self_link, None) + def _verifyResourceProperties(self, dataset, resource): + + self._verifyReadonlyResourceProperties(dataset, resource) + self.assertEqual(dataset.default_table_expiration_ms, resource.get('defaultTableExpirationMs')) self.assertEqual(dataset.description, resource.get('description')) self.assertEqual(dataset.friendly_name, resource.get('friendlyName')) self.assertEqual(dataset.location, resource.get('location')) + if 'access' in resource: + self._verifyAccessGrants(dataset.access_grants, resource) + else: + self.assertEqual(dataset.access_grants, []) + def test_ctor(self): client = _Client(self.PROJECT) dataset = self._makeOne(self.DS_NAME, client) @@ -84,6 +133,7 @@ def test_ctor(self): self.assertEqual( dataset.path, '/projects/%s/datasets/%s' % (self.PROJECT, self.DS_NAME)) + self.assertEqual(dataset.access_grants, []) self.assertEqual(dataset.created, None) self.assertEqual(dataset.dataset_id, None) @@ -96,6 +146,29 @@ def test_ctor(self): self.assertEqual(dataset.friendly_name, None) self.assertEqual(dataset.location, None) + def test_access_roles_setter_non_list(self): + client = _Client(self.PROJECT) + dataset = self._makeOne(self.DS_NAME, client) + with self.assertRaises(TypeError): + dataset.access_grants = object() + + def test_access_roles_setter_invalid_field(self): + from gcloud.bigquery.dataset import AccessGrant + client = _Client(self.PROJECT) + dataset = self._makeOne(self.DS_NAME, client) + phred = AccessGrant('OWNER', 'userByEmail', 'phred@example.com') + with self.assertRaises(ValueError): + dataset.access_grants = [phred, object()] + + def test_access_roles_setter(self): + from gcloud.bigquery.dataset import AccessGrant + client = _Client(self.PROJECT) + dataset = self._makeOne(self.DS_NAME, client) + phred = AccessGrant('OWNER', 'userByEmail', 'phred@example.com') + bharney = AccessGrant('OWNER', 'userByEmail', 'bharney@example.com') + dataset.access_grants = [phred, bharney] + self.assertEqual(dataset.access_grants, [phred, bharney]) + def test_default_table_expiration_ms_setter_bad_value(self): client = _Client(self.PROJECT) dataset = self._makeOne(self.DS_NAME, client) @@ -175,6 +248,41 @@ def test_from_api_repr_w_properties(self): self.assertTrue(dataset._client is client) self._verifyResourceProperties(dataset, RESOURCE) + def test__parse_access_grants_w_unknown_entity_type(self): + USER_EMAIL = 'phred@example.com' + GROUP_EMAIL = 'group-name@lists.example.com' + RESOURCE = { + 'access': [ + {'role': 'OWNER', 'userByEmail': USER_EMAIL}, + {'role': 'WRITER', 'groupByEmail': GROUP_EMAIL}, + {'role': 'READER', 'specialGroup': 'projectReaders'}, + {'role': 'READER', 'unknown': 'UNKNOWN'}] + } + client = _Client(self.PROJECT) + dataset = self._makeOne(self.DS_NAME, client=client) + grants = dataset._parse_access_grants(RESOURCE['access']) + self._verifyAccessGrants(grants, RESOURCE) + + def test__parse_access_grants_w_multiple_entity_types(self): + # Hypothetical case: we don't know that the back-end will ever + # return such structures, but they are logical. See: + # https://github.com/GoogleCloudPlatform/gcloud-python/pull/1046#discussion_r36687769 + USER_EMAIL = 'phred@example.com' + OTHER_EMAIL = 'bharney@example.com' + GROUP_EMAIL = 'group-name@lists.example.com' + RESOURCE = { + 'access': [ + {'role': 'OWNER', 'userByEmail': USER_EMAIL}, + {'role': 'WRITER', 'groupByEmail': GROUP_EMAIL}, + {'role': 'READER', + 'specialGroup': 'projectReaders', + 'userByEmail': OTHER_EMAIL}] + } + client = _Client(self.PROJECT) + dataset = self._makeOne(self.DS_NAME, client=client) + grants = dataset._parse_access_grants(RESOURCE['access']) + self._verifyAccessGrants(grants, RESOURCE) + def test_create_w_bound_client(self): PATH = 'projects/%s/datasets' % self.PROJECT RESOURCE = self._makeResource() @@ -196,7 +304,10 @@ def test_create_w_bound_client(self): self._verifyResourceProperties(dataset, RESOURCE) def test_create_w_alternate_client(self): + from gcloud.bigquery.dataset import AccessGrant PATH = 'projects/%s/datasets' % self.PROJECT + USER_EMAIL = 'phred@example.com' + GROUP_EMAIL = 'group-name@lists.example.com' DESCRIPTION = 'DESCRIPTION' TITLE = 'TITLE' RESOURCE = self._makeResource() @@ -209,6 +320,11 @@ def test_create_w_alternate_client(self): dataset = self._makeOne(self.DS_NAME, client=CLIENT1) dataset.friendly_name = TITLE dataset.description = DESCRIPTION + dataset.access_grants = [ + AccessGrant('OWNER', 'userByEmail', USER_EMAIL), + AccessGrant('OWNER', 'groupByEmail', GROUP_EMAIL), + AccessGrant('READER', 'specialGroup', 'projectReaders'), + AccessGrant('WRITER', 'specialGroup', 'projectWriters')] dataset.create(client=CLIENT2) @@ -222,6 +338,11 @@ def test_create_w_alternate_client(self): {'projectId': self.PROJECT, 'datasetId': self.DS_NAME}, 'description': DESCRIPTION, 'friendlyName': TITLE, + 'access': [ + {'role': 'OWNER', 'userByEmail': USER_EMAIL}, + {'role': 'OWNER', 'groupByEmail': GROUP_EMAIL}, + {'role': 'READER', 'specialGroup': 'projectReaders'}, + {'role': 'WRITER', 'specialGroup': 'projectWriters'}], } self.assertEqual(req['data'], SENT) self._verifyResourceProperties(dataset, RESOURCE)