diff --git a/storage/google/cloud/storage/_helpers.py b/storage/google/cloud/storage/_helpers.py index 88f9b8dc0ca7..9e47c10269fc 100644 --- a/storage/google/cloud/storage/_helpers.py +++ b/storage/google/cloud/storage/_helpers.py @@ -67,6 +67,11 @@ def client(self): """Abstract getter for the object client.""" raise NotImplementedError + @property + def user_project(self): + """Abstract getter for the object user_project.""" + raise NotImplementedError + def _require_client(self, client): """Check client or verify over-ride. @@ -94,6 +99,8 @@ def reload(self, client=None): # Pass only '?projection=noAcl' here because 'acl' and related # are handled via custom endpoints. query_params = {'projection': 'noAcl'} + if self.user_project is not None: + query_params['userProject'] = self.user_project api_response = client._connection.api_request( method='GET', path=self.path, query_params=query_params, _target_object=self) @@ -140,11 +147,14 @@ def patch(self, client=None): client = self._require_client(client) # Pass '?projection=full' here because 'PATCH' documented not # to work properly w/ 'noAcl'. + query_params = {'projection': 'full'} + if self.user_project is not None: + query_params['userProject'] = self.user_project update_properties = {key: self._properties[key] for key in self._changes} api_response = client._connection.api_request( method='PATCH', path=self.path, data=update_properties, - query_params={'projection': 'full'}, _target_object=self) + query_params=query_params, _target_object=self) self._set_properties(api_response) diff --git a/storage/google/cloud/storage/blob.py b/storage/google/cloud/storage/blob.py index de59fdf1f2bd..778166df9249 100644 --- a/storage/google/cloud/storage/blob.py +++ b/storage/google/cloud/storage/blob.py @@ -222,6 +222,16 @@ def client(self): """The client bound to this blob.""" return self.bucket.client + @property + def user_project(self): + """Project ID used for API requests made via this blob. + + Derived from bucket's value. + + :rtype: str + """ + return self.bucket.user_project + @property def public_url(self): """The public URL for this blob's object. diff --git a/storage/google/cloud/storage/bucket.py b/storage/google/cloud/storage/bucket.py index 32e97306f289..8877c679aa90 100644 --- a/storage/google/cloud/storage/bucket.py +++ b/storage/google/cloud/storage/bucket.py @@ -85,6 +85,10 @@ class Bucket(_PropertyMixin): :type name: str :param name: The name of the bucket. Bucket names must start and end with a number or letter. + + :type user_project: str + :param user_project: (Optional) the project ID to be billed for API + requests made via this instance. """ _MAX_OBJECTS_FOR_ITERATION = 256 @@ -108,12 +112,13 @@ class Bucket(_PropertyMixin): https://cloud.google.com/storage/docs/storage-classes """ - def __init__(self, client, name=None): + def __init__(self, client, name=None, user_project=None): name = _validate_name(name) super(Bucket, self).__init__(name=name) self._client = client self._acl = BucketACL(self) self._default_object_acl = DefaultObjectACL(self) + self._user_project = user_project def __repr__(self): return '' % (self.name,) @@ -123,6 +128,16 @@ def client(self): """The client bound to this bucket.""" return self._client + @property + def user_project(self): + """Project ID to be billed for API requests made via this bucket. + + If unset, API requests are billed to the bucket owner. + + :rtype: str + """ + return self._user_project + def blob(self, blob_name, chunk_size=None, encryption_key=None): """Factory constructor for blob object. diff --git a/storage/tests/unit/test__helpers.py b/storage/tests/unit/test__helpers.py index 89967f3a0db0..21883e2c4ac9 100644 --- a/storage/tests/unit/test__helpers.py +++ b/storage/tests/unit/test__helpers.py @@ -26,7 +26,7 @@ def _get_target_class(): def _make_one(self, *args, **kw): return self._get_target_class()(*args, **kw) - def _derivedClass(self, path=None): + def _derivedClass(self, path=None, user_project=None): class Derived(self._get_target_class()): @@ -36,30 +36,67 @@ class Derived(self._get_target_class()): def path(self): return path + @property + def user_project(self): + return user_project + return Derived def test_path_is_abstract(self): mixin = self._make_one() - self.assertRaises(NotImplementedError, lambda: mixin.path) + with self.assertRaises(NotImplementedError): + mixin.path def test_client_is_abstract(self): mixin = self._make_one() - self.assertRaises(NotImplementedError, lambda: mixin.client) + with self.assertRaises(NotImplementedError): + mixin.client + + def test_user_project_is_abstract(self): + mixin = self._make_one() + with self.assertRaises(NotImplementedError): + mixin.user_project def test_reload(self): connection = _Connection({'foo': 'Foo'}) client = _Client(connection) derived = self._derivedClass('/path')() - # Make sure changes is not a set, so we can observe a change. + # Make sure changes is not a set instance before calling reload + # (which will clear / replace it with an empty set), checked below. + derived._changes = object() + derived.reload(client=client) + self.assertEqual(derived._properties, {'foo': 'Foo'}) + kw = connection._requested + self.assertEqual(len(kw), 1) + self.assertEqual(kw[0], { + 'method': 'GET', + 'path': '/path', + 'query_params': {'projection': 'noAcl'}, + '_target_object': derived, + }) + self.assertEqual(derived._changes, set()) + + def test_reload_w_user_project(self): + user_project = 'user-project-123' + connection = _Connection({'foo': 'Foo'}) + client = _Client(connection) + derived = self._derivedClass('/path', user_project)() + # Make sure changes is not a set instance before calling reload + # (which will clear / replace it with an empty set), checked below. derived._changes = object() derived.reload(client=client) self.assertEqual(derived._properties, {'foo': 'Foo'}) kw = connection._requested self.assertEqual(len(kw), 1) - self.assertEqual(kw[0]['method'], 'GET') - self.assertEqual(kw[0]['path'], '/path') - self.assertEqual(kw[0]['query_params'], {'projection': 'noAcl'}) - # Make sure changes get reset by reload. + self.assertEqual(kw[0], { + 'method': 'GET', + 'path': '/path', + 'query_params': { + 'projection': 'noAcl', + 'userProject': user_project, + }, + '_target_object': derived, + }) self.assertEqual(derived._changes, set()) def test__set_properties(self): @@ -87,11 +124,42 @@ def test_patch(self): self.assertEqual(derived._properties, {'foo': 'Foo'}) kw = connection._requested self.assertEqual(len(kw), 1) - self.assertEqual(kw[0]['method'], 'PATCH') - self.assertEqual(kw[0]['path'], '/path') - self.assertEqual(kw[0]['query_params'], {'projection': 'full'}) - # Since changes does not include `baz`, we don't see it sent. - self.assertEqual(kw[0]['data'], {'bar': BAR}) + self.assertEqual(kw[0], { + 'method': 'PATCH', + 'path': '/path', + 'query_params': {'projection': 'full'}, + # Since changes does not include `baz`, we don't see it sent. + 'data': {'bar': BAR}, + '_target_object': derived, + }) + # Make sure changes get reset by patch(). + self.assertEqual(derived._changes, set()) + + def test_patch_w_user_project(self): + user_project = 'user-project-123' + connection = _Connection({'foo': 'Foo'}) + client = _Client(connection) + derived = self._derivedClass('/path', user_project)() + # Make sure changes is non-empty, so we can observe a change. + BAR = object() + BAZ = object() + derived._properties = {'bar': BAR, 'baz': BAZ} + derived._changes = set(['bar']) # Ignore baz. + derived.patch(client=client) + self.assertEqual(derived._properties, {'foo': 'Foo'}) + kw = connection._requested + self.assertEqual(len(kw), 1) + self.assertEqual(kw[0], { + 'method': 'PATCH', + 'path': '/path', + 'query_params': { + 'projection': 'full', + 'userProject': user_project, + }, + # Since changes does not include `baz`, we don't see it sent. + 'data': {'bar': BAR}, + '_target_object': derived, + }) # Make sure changes get reset by patch(). self.assertEqual(derived._changes, set()) diff --git a/storage/tests/unit/test_blob.py b/storage/tests/unit/test_blob.py index a5d49bc4bacb..084745ebb54d 100644 --- a/storage/tests/unit/test_blob.py +++ b/storage/tests/unit/test_blob.py @@ -141,6 +141,19 @@ def test_path_with_non_ascii(self): blob = self._make_one(blob_name, bucket=bucket) self.assertEqual(blob.path, '/b/name/o/Caf%C3%A9') + def test_client(self): + blob_name = 'BLOB' + bucket = _Bucket() + blob = self._make_one(blob_name, bucket=bucket) + self.assertIs(blob.client, bucket.client) + + def test_user_project(self): + user_project = 'user-project-123' + blob_name = 'BLOB' + bucket = _Bucket(user_project=user_project) + blob = self._make_one(blob_name, bucket=bucket) + self.assertEqual(blob.user_project, user_project) + def test_public_url(self): BLOB_NAME = 'blob-name' bucket = _Bucket() @@ -2280,7 +2293,7 @@ def api_request(self, **kw): class _Bucket(object): - def __init__(self, client=None, name='name'): + def __init__(self, client=None, name='name', user_project=None): if client is None: connection = _Connection() client = _Client(connection) @@ -2290,6 +2303,7 @@ def __init__(self, client=None, name='name'): self._deleted = [] self.name = name self.path = '/b/' + name + self.user_project = user_project def delete_blob(self, blob_name, client=None): del self._blobs[blob_name] diff --git a/storage/tests/unit/test_bucket.py b/storage/tests/unit/test_bucket.py index 34835110bd67..d68cd4ca980a 100644 --- a/storage/tests/unit/test_bucket.py +++ b/storage/tests/unit/test_bucket.py @@ -33,13 +33,16 @@ class _SigningCredentials( class Test_Bucket(unittest.TestCase): - def _make_one(self, client=None, name=None, properties=None): + @staticmethod + def _get_target_class(): from google.cloud.storage.bucket import Bucket + return Bucket + def _make_one(self, client=None, name=None, properties=None): if client is None: connection = _Connection() client = _Client(connection) - bucket = Bucket(client, name=name) + bucket = self._get_target_class()(client, name=name) bucket._properties = properties or {} return bucket @@ -53,6 +56,22 @@ def test_ctor(self): self.assertIs(bucket._acl.bucket, bucket) self.assertFalse(bucket._default_object_acl.loaded) self.assertIs(bucket._default_object_acl.bucket, bucket) + self.assertIsNone(bucket.user_project) + + def test_ctor_w_user_project(self): + NAME = 'name' + USER_PROJECT = 'user-project-123' + connection = _Connection() + client = _Client(connection) + klass = self._get_target_class() + bucket = klass(client, name=NAME, user_project=USER_PROJECT) + self.assertEqual(bucket.name, NAME) + self.assertEqual(bucket._properties, {}) + self.assertEqual(bucket.user_project, USER_PROJECT) + self.assertFalse(bucket._acl.loaded) + self.assertIs(bucket._acl.bucket, bucket) + self.assertFalse(bucket._default_object_acl.loaded) + self.assertIs(bucket._default_object_acl.bucket, bucket) def test_blob(self): from google.cloud.storage.blob import Blob @@ -73,9 +92,8 @@ def test_blob(self): self.assertEqual(blob._encryption_key, KEY) def test_bucket_name_value(self): - bucket_name = 'testing123' - mixin = self._make_one(name=bucket_name) - self.assertEqual(mixin.name, bucket_name) + BUCKET_NAME = 'bucket-name' + bucket = self._make_one(name=BUCKET_NAME) bad_start_bucket_name = '/testing123' with self.assertRaises(ValueError): @@ -85,6 +103,13 @@ def test_bucket_name_value(self): with self.assertRaises(ValueError): self._make_one(name=bad_end_bucket_name) + def test_user_project(self): + BUCKET_NAME = 'name' + USER_PROJECT = 'user-project-123' + bucket = self._make_one(name=BUCKET_NAME) + bucket._user_project = USER_PROJECT + self.assertEqual(bucket.user_project, USER_PROJECT) + def test_exists_miss(self): from google.cloud.exceptions import NotFound