Skip to content
12 changes: 11 additions & 1 deletion storage/google/cloud/storage/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:

This comment was marked as spam.

This comment was marked as spam.

query_params['userProject'] = self.user_project
api_response = client._connection.api_request(
method='GET', path=self.path, query_params=query_params,
_target_object=self)
Expand Down Expand Up @@ -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)


Expand Down
10 changes: 10 additions & 0 deletions storage/google/cloud/storage/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.


:rtype: str
"""
return self.bucket.user_project

@property
def public_url(self):
"""The public URL for this blob's object.
Expand Down
17 changes: 16 additions & 1 deletion storage/google/cloud/storage/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 '<Bucket: %s>' % (self.name,)
Expand All @@ -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.

This comment was marked as spam.

This comment was marked as spam.


:rtype: str
"""
return self._user_project

def blob(self, blob_name, chunk_size=None, encryption_key=None):
"""Factory constructor for blob object.

Expand Down
94 changes: 81 additions & 13 deletions storage/tests/unit/test__helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()):

Expand All @@ -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

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.


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):
Expand Down Expand Up @@ -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())

Expand Down
16 changes: 15 additions & 1 deletion storage/tests/unit/test_blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand Down
35 changes: 30 additions & 5 deletions storage/tests/unit/test_bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

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
Expand All @@ -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):
Expand All @@ -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

Expand Down