Skip to content

Commit 637c3f4

Browse files
tseaverlandrito
authored andcommitted
Add '{Bucket,Blob}.user_project' properties. (googleapis#3490)
* Add abstract '_PropertyMixin.user_project' property. * Support 'user_project' in '_PropertyMixin.{reload,patch}'. * Add 'user_project' param to 'Bucket.__init__'. * Save and expose via read-only 'user_project' property. * Implement 'Blob.user_property' via bucket's value.
1 parent e4c5842 commit 637c3f4

File tree

6 files changed

+163
-21
lines changed

6 files changed

+163
-21
lines changed

storage/google/cloud/storage/_helpers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def client(self):
6767
"""Abstract getter for the object client."""
6868
raise NotImplementedError
6969

70+
@property
71+
def user_project(self):
72+
"""Abstract getter for the object user_project."""
73+
raise NotImplementedError
74+
7075
def _require_client(self, client):
7176
"""Check client or verify over-ride.
7277
@@ -94,6 +99,8 @@ def reload(self, client=None):
9499
# Pass only '?projection=noAcl' here because 'acl' and related
95100
# are handled via custom endpoints.
96101
query_params = {'projection': 'noAcl'}
102+
if self.user_project is not None:
103+
query_params['userProject'] = self.user_project
97104
api_response = client._connection.api_request(
98105
method='GET', path=self.path, query_params=query_params,
99106
_target_object=self)
@@ -140,11 +147,14 @@ def patch(self, client=None):
140147
client = self._require_client(client)
141148
# Pass '?projection=full' here because 'PATCH' documented not
142149
# to work properly w/ 'noAcl'.
150+
query_params = {'projection': 'full'}
151+
if self.user_project is not None:
152+
query_params['userProject'] = self.user_project
143153
update_properties = {key: self._properties[key]
144154
for key in self._changes}
145155
api_response = client._connection.api_request(
146156
method='PATCH', path=self.path, data=update_properties,
147-
query_params={'projection': 'full'}, _target_object=self)
157+
query_params=query_params, _target_object=self)
148158
self._set_properties(api_response)
149159

150160

storage/google/cloud/storage/blob.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ def client(self):
222222
"""The client bound to this blob."""
223223
return self.bucket.client
224224

225+
@property
226+
def user_project(self):
227+
"""Project ID used for API requests made via this blob.
228+
229+
Derived from bucket's value.
230+
231+
:rtype: str
232+
"""
233+
return self.bucket.user_project
234+
225235
@property
226236
def public_url(self):
227237
"""The public URL for this blob's object.

storage/google/cloud/storage/bucket.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ class Bucket(_PropertyMixin):
8585
:type name: str
8686
:param name: The name of the bucket. Bucket names must start and end with a
8787
number or letter.
88+
89+
:type user_project: str
90+
:param user_project: (Optional) the project ID to be billed for API
91+
requests made via this instance.
8892
"""
8993

9094
_MAX_OBJECTS_FOR_ITERATION = 256
@@ -108,12 +112,13 @@ class Bucket(_PropertyMixin):
108112
https://cloud.google.com/storage/docs/storage-classes
109113
"""
110114

111-
def __init__(self, client, name=None):
115+
def __init__(self, client, name=None, user_project=None):
112116
name = _validate_name(name)
113117
super(Bucket, self).__init__(name=name)
114118
self._client = client
115119
self._acl = BucketACL(self)
116120
self._default_object_acl = DefaultObjectACL(self)
121+
self._user_project = user_project
117122

118123
def __repr__(self):
119124
return '<Bucket: %s>' % (self.name,)
@@ -123,6 +128,16 @@ def client(self):
123128
"""The client bound to this bucket."""
124129
return self._client
125130

131+
@property
132+
def user_project(self):
133+
"""Project ID to be billed for API requests made via this bucket.
134+
135+
If unset, API requests are billed to the bucket owner.
136+
137+
:rtype: str
138+
"""
139+
return self._user_project
140+
126141
def blob(self, blob_name, chunk_size=None, encryption_key=None):
127142
"""Factory constructor for blob object.
128143

storage/tests/unit/test__helpers.py

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _get_target_class():
2626
def _make_one(self, *args, **kw):
2727
return self._get_target_class()(*args, **kw)
2828

29-
def _derivedClass(self, path=None):
29+
def _derivedClass(self, path=None, user_project=None):
3030

3131
class Derived(self._get_target_class()):
3232

@@ -36,30 +36,67 @@ class Derived(self._get_target_class()):
3636
def path(self):
3737
return path
3838

39+
@property
40+
def user_project(self):
41+
return user_project
42+
3943
return Derived
4044

4145
def test_path_is_abstract(self):
4246
mixin = self._make_one()
43-
self.assertRaises(NotImplementedError, lambda: mixin.path)
47+
with self.assertRaises(NotImplementedError):
48+
mixin.path
4449

4550
def test_client_is_abstract(self):
4651
mixin = self._make_one()
47-
self.assertRaises(NotImplementedError, lambda: mixin.client)
52+
with self.assertRaises(NotImplementedError):
53+
mixin.client
54+
55+
def test_user_project_is_abstract(self):
56+
mixin = self._make_one()
57+
with self.assertRaises(NotImplementedError):
58+
mixin.user_project
4859

4960
def test_reload(self):
5061
connection = _Connection({'foo': 'Foo'})
5162
client = _Client(connection)
5263
derived = self._derivedClass('/path')()
53-
# Make sure changes is not a set, so we can observe a change.
64+
# Make sure changes is not a set instance before calling reload
65+
# (which will clear / replace it with an empty set), checked below.
66+
derived._changes = object()
67+
derived.reload(client=client)
68+
self.assertEqual(derived._properties, {'foo': 'Foo'})
69+
kw = connection._requested
70+
self.assertEqual(len(kw), 1)
71+
self.assertEqual(kw[0], {
72+
'method': 'GET',
73+
'path': '/path',
74+
'query_params': {'projection': 'noAcl'},
75+
'_target_object': derived,
76+
})
77+
self.assertEqual(derived._changes, set())
78+
79+
def test_reload_w_user_project(self):
80+
user_project = 'user-project-123'
81+
connection = _Connection({'foo': 'Foo'})
82+
client = _Client(connection)
83+
derived = self._derivedClass('/path', user_project)()
84+
# Make sure changes is not a set instance before calling reload
85+
# (which will clear / replace it with an empty set), checked below.
5486
derived._changes = object()
5587
derived.reload(client=client)
5688
self.assertEqual(derived._properties, {'foo': 'Foo'})
5789
kw = connection._requested
5890
self.assertEqual(len(kw), 1)
59-
self.assertEqual(kw[0]['method'], 'GET')
60-
self.assertEqual(kw[0]['path'], '/path')
61-
self.assertEqual(kw[0]['query_params'], {'projection': 'noAcl'})
62-
# Make sure changes get reset by reload.
91+
self.assertEqual(kw[0], {
92+
'method': 'GET',
93+
'path': '/path',
94+
'query_params': {
95+
'projection': 'noAcl',
96+
'userProject': user_project,
97+
},
98+
'_target_object': derived,
99+
})
63100
self.assertEqual(derived._changes, set())
64101

65102
def test__set_properties(self):
@@ -87,11 +124,42 @@ def test_patch(self):
87124
self.assertEqual(derived._properties, {'foo': 'Foo'})
88125
kw = connection._requested
89126
self.assertEqual(len(kw), 1)
90-
self.assertEqual(kw[0]['method'], 'PATCH')
91-
self.assertEqual(kw[0]['path'], '/path')
92-
self.assertEqual(kw[0]['query_params'], {'projection': 'full'})
93-
# Since changes does not include `baz`, we don't see it sent.
94-
self.assertEqual(kw[0]['data'], {'bar': BAR})
127+
self.assertEqual(kw[0], {
128+
'method': 'PATCH',
129+
'path': '/path',
130+
'query_params': {'projection': 'full'},
131+
# Since changes does not include `baz`, we don't see it sent.
132+
'data': {'bar': BAR},
133+
'_target_object': derived,
134+
})
135+
# Make sure changes get reset by patch().
136+
self.assertEqual(derived._changes, set())
137+
138+
def test_patch_w_user_project(self):
139+
user_project = 'user-project-123'
140+
connection = _Connection({'foo': 'Foo'})
141+
client = _Client(connection)
142+
derived = self._derivedClass('/path', user_project)()
143+
# Make sure changes is non-empty, so we can observe a change.
144+
BAR = object()
145+
BAZ = object()
146+
derived._properties = {'bar': BAR, 'baz': BAZ}
147+
derived._changes = set(['bar']) # Ignore baz.
148+
derived.patch(client=client)
149+
self.assertEqual(derived._properties, {'foo': 'Foo'})
150+
kw = connection._requested
151+
self.assertEqual(len(kw), 1)
152+
self.assertEqual(kw[0], {
153+
'method': 'PATCH',
154+
'path': '/path',
155+
'query_params': {
156+
'projection': 'full',
157+
'userProject': user_project,
158+
},
159+
# Since changes does not include `baz`, we don't see it sent.
160+
'data': {'bar': BAR},
161+
'_target_object': derived,
162+
})
95163
# Make sure changes get reset by patch().
96164
self.assertEqual(derived._changes, set())
97165

storage/tests/unit/test_blob.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,19 @@ def test_path_with_non_ascii(self):
141141
blob = self._make_one(blob_name, bucket=bucket)
142142
self.assertEqual(blob.path, '/b/name/o/Caf%C3%A9')
143143

144+
def test_client(self):
145+
blob_name = 'BLOB'
146+
bucket = _Bucket()
147+
blob = self._make_one(blob_name, bucket=bucket)
148+
self.assertIs(blob.client, bucket.client)
149+
150+
def test_user_project(self):
151+
user_project = 'user-project-123'
152+
blob_name = 'BLOB'
153+
bucket = _Bucket(user_project=user_project)
154+
blob = self._make_one(blob_name, bucket=bucket)
155+
self.assertEqual(blob.user_project, user_project)
156+
144157
def test_public_url(self):
145158
BLOB_NAME = 'blob-name'
146159
bucket = _Bucket()
@@ -2280,7 +2293,7 @@ def api_request(self, **kw):
22802293

22812294
class _Bucket(object):
22822295

2283-
def __init__(self, client=None, name='name'):
2296+
def __init__(self, client=None, name='name', user_project=None):
22842297
if client is None:
22852298
connection = _Connection()
22862299
client = _Client(connection)
@@ -2290,6 +2303,7 @@ def __init__(self, client=None, name='name'):
22902303
self._deleted = []
22912304
self.name = name
22922305
self.path = '/b/' + name
2306+
self.user_project = user_project
22932307

22942308
def delete_blob(self, blob_name, client=None):
22952309
del self._blobs[blob_name]

storage/tests/unit/test_bucket.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ class _SigningCredentials(
3333

3434
class Test_Bucket(unittest.TestCase):
3535

36-
def _make_one(self, client=None, name=None, properties=None):
36+
@staticmethod
37+
def _get_target_class():
3738
from google.cloud.storage.bucket import Bucket
39+
return Bucket
3840

41+
def _make_one(self, client=None, name=None, properties=None):
3942
if client is None:
4043
connection = _Connection()
4144
client = _Client(connection)
42-
bucket = Bucket(client, name=name)
45+
bucket = self._get_target_class()(client, name=name)
4346
bucket._properties = properties or {}
4447
return bucket
4548

@@ -53,6 +56,22 @@ def test_ctor(self):
5356
self.assertIs(bucket._acl.bucket, bucket)
5457
self.assertFalse(bucket._default_object_acl.loaded)
5558
self.assertIs(bucket._default_object_acl.bucket, bucket)
59+
self.assertIsNone(bucket.user_project)
60+
61+
def test_ctor_w_user_project(self):
62+
NAME = 'name'
63+
USER_PROJECT = 'user-project-123'
64+
connection = _Connection()
65+
client = _Client(connection)
66+
klass = self._get_target_class()
67+
bucket = klass(client, name=NAME, user_project=USER_PROJECT)
68+
self.assertEqual(bucket.name, NAME)
69+
self.assertEqual(bucket._properties, {})
70+
self.assertEqual(bucket.user_project, USER_PROJECT)
71+
self.assertFalse(bucket._acl.loaded)
72+
self.assertIs(bucket._acl.bucket, bucket)
73+
self.assertFalse(bucket._default_object_acl.loaded)
74+
self.assertIs(bucket._default_object_acl.bucket, bucket)
5675

5776
def test_blob(self):
5877
from google.cloud.storage.blob import Blob
@@ -73,9 +92,8 @@ def test_blob(self):
7392
self.assertEqual(blob._encryption_key, KEY)
7493

7594
def test_bucket_name_value(self):
76-
bucket_name = 'testing123'
77-
mixin = self._make_one(name=bucket_name)
78-
self.assertEqual(mixin.name, bucket_name)
95+
BUCKET_NAME = 'bucket-name'
96+
bucket = self._make_one(name=BUCKET_NAME)
7997

8098
bad_start_bucket_name = '/testing123'
8199
with self.assertRaises(ValueError):
@@ -85,6 +103,13 @@ def test_bucket_name_value(self):
85103
with self.assertRaises(ValueError):
86104
self._make_one(name=bad_end_bucket_name)
87105

106+
def test_user_project(self):
107+
BUCKET_NAME = 'name'
108+
USER_PROJECT = 'user-project-123'
109+
bucket = self._make_one(name=BUCKET_NAME)
110+
bucket._user_project = USER_PROJECT
111+
self.assertEqual(bucket.user_project, USER_PROJECT)
112+
88113
def test_exists_miss(self):
89114
from google.cloud.exceptions import NotFound
90115

0 commit comments

Comments
 (0)