Skip to content

Commit 8536991

Browse files
committed
Merge pull request #352 from tseaver/351-batch_updates_via_context_manager
Fix #351: batch updates via context manager
2 parents ac0760b + 245b0e9 commit 8536991

File tree

5 files changed

+155
-18
lines changed

5 files changed

+155
-18
lines changed

gcloud/storage/_helpers.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,30 @@ def properties(self):
5555
self._reload_properties()
5656
return self._properties.copy()
5757

58+
@property
59+
def batch(self):
60+
"""Return a context manager which defers/batches updates.
61+
62+
E.g., to batch multiple updates to a bucket::
63+
64+
>>> with bucket.batch:
65+
... bucket.enable_versioning()
66+
... bucket.disable_website()
67+
68+
or for a key::
69+
70+
>>> with key.batch:
71+
... key.content_type = 'image/jpeg'
72+
... key.content_encoding = 'gzip'
73+
74+
Updates will be aggregated and sent as a single call to
75+
:meth:`_patch_properties` IFF the ``with`` block exits without
76+
an exception.
77+
78+
:rtype: :class:`_PropertyBatch`
79+
"""
80+
return _PropertyBatch(self)
81+
5882
def _reload_properties(self):
5983
"""Reload properties from Cloud Storage.
6084
@@ -122,3 +146,25 @@ def get_acl(self):
122146
if not self.acl.loaded:
123147
self.acl.reload()
124148
return self.acl
149+
150+
151+
class _PropertyBatch(object):
152+
"""Context manager: Batch updates to object's ``_patch_properties``
153+
154+
:type wrapped: class derived from :class:`_PropertyMixin`.
155+
:param wrapped: the instance whose property updates to defer/batch.
156+
"""
157+
def __init__(self, wrapped):
158+
self._wrapped = wrapped
159+
self._deferred = {}
160+
161+
def __enter__(self):
162+
"""Intercept / defer property updates."""
163+
self._wrapped._patch_properties = self._deferred.update
164+
165+
def __exit__(self, type, value, traceback):
166+
"""Patch deferred property updates if no error."""
167+
del self._wrapped._patch_properties
168+
if type is None:
169+
if self._deferred:
170+
self._wrapped._patch_properties(self._deferred)

gcloud/storage/bucket.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ def storage_class(self):
544544
"""Retrieve the storage class for the bucket.
545545
546546
See: https://cloud.google.com/storage/docs/json_api/v1/buckets and
547-
https://cloud.google.com/storage/docs/durable-reduced-availability#_DRA_Bucket
547+
https://cloud.google.com/storage/docs/durable-reduced-availability
548548
549549
:rtype: string
550550
:returns: the storage class for the bucket (currently one of

gcloud/storage/key.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ def __init__(self, bucket, extra_params=None):
376376
extra_params=extra_params)
377377

378378
def get_items_from_response(self, response):
379-
"""Factory method, yields :class:`.storage.key.Key` items from response.
379+
"""Yield :class:`.storage.key.Key` items from response.
380380
381381
:type response: dict
382382
:param response: The JSON API response for a page of keys.

gcloud/storage/test__helpers.py

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,34 @@ def test_path_is_abstract(self):
3333
mixin = self._makeOne()
3434
self.assertRaises(NotImplementedError, lambda: mixin.path)
3535

36+
def test_properties_eager(self):
37+
derived = self._derivedClass()(properties={'extant': False})
38+
self.assertEqual(derived.properties, {'extant': False})
39+
40+
def test_batch(self):
41+
connection = _Connection({'foo': 'Qux', 'bar': 'Baz'})
42+
derived = self._derivedClass(connection, '/path')()
43+
with derived.batch:
44+
derived._patch_properties({'foo': 'Foo'})
45+
derived._patch_properties({'bar': 'Baz'})
46+
derived._patch_properties({'foo': 'Qux'})
47+
kw = connection._requested
48+
self.assertEqual(len(kw), 1)
49+
self.assertEqual(kw[0]['method'], 'PATCH')
50+
self.assertEqual(kw[0]['path'], '/path')
51+
self.assertEqual(kw[0]['data'], {'foo': 'Qux', 'bar': 'Baz'})
52+
self.assertEqual(kw[0]['query_params'], {'projection': 'full'})
53+
54+
def test_properties_lazy(self):
55+
connection = _Connection({'foo': 'Foo'})
56+
derived = self._derivedClass(connection, '/path')()
57+
self.assertEqual(derived.properties, {'foo': 'Foo'})
58+
kw = connection._requested
59+
self.assertEqual(len(kw), 1)
60+
self.assertEqual(kw[0]['method'], 'GET')
61+
self.assertEqual(kw[0]['path'], '/path')
62+
self.assertEqual(kw[0]['query_params'], {'projection': 'noAcl'})
63+
3664
def test__reload_properties(self):
3765
connection = _Connection({'foo': 'Foo'})
3866
derived = self._derivedClass(connection, '/path')()
@@ -89,20 +117,6 @@ def test__patch_properties(self):
89117
self.assertEqual(kw[0]['data'], {'foo': 'Foo'})
90118
self.assertEqual(kw[0]['query_params'], {'projection': 'full'})
91119

92-
def test_properties_eager(self):
93-
derived = self._derivedClass()(properties={'extant': False})
94-
self.assertEqual(derived.properties, {'extant': False})
95-
96-
def test_properties_lazy(self):
97-
connection = _Connection({'foo': 'Foo'})
98-
derived = self._derivedClass(connection, '/path')()
99-
self.assertEqual(derived.properties, {'foo': 'Foo'})
100-
kw = connection._requested
101-
self.assertEqual(len(kw), 1)
102-
self.assertEqual(kw[0]['method'], 'GET')
103-
self.assertEqual(kw[0]['path'], '/path')
104-
self.assertEqual(kw[0]['query_params'], {'projection': 'noAcl'})
105-
106120
def test_get_acl_not_yet_loaded(self):
107121
class ACL(object):
108122
loaded = False
@@ -123,6 +137,83 @@ class ACL(object):
123137
self.assertTrue(mixin.get_acl() is acl) # no 'reload'
124138

125139

140+
class TestPropertyBatch(unittest2.TestCase):
141+
142+
def _getTargetClass(self):
143+
from gcloud.storage._helpers import _PropertyBatch
144+
return _PropertyBatch
145+
146+
def _makeOne(self, wrapped):
147+
return self._getTargetClass()(wrapped)
148+
149+
def _makeWrapped(self, connection=None, path=None, **custom_fields):
150+
from gcloud.storage._helpers import _PropertyMixin
151+
152+
class Wrapped(_PropertyMixin):
153+
CUSTOM_PROPERTY_ACCESSORS = custom_fields
154+
155+
@property
156+
def connection(self):
157+
return connection
158+
159+
@property
160+
def path(self):
161+
return path
162+
163+
return Wrapped()
164+
165+
def test_ctor_does_not_intercept__patch_properties(self):
166+
wrapped = self._makeWrapped()
167+
before = wrapped._patch_properties
168+
batch = self._makeOne(wrapped)
169+
after = wrapped._patch_properties
170+
self.assertEqual(before, after)
171+
self.assertTrue(batch._wrapped is wrapped)
172+
173+
def test_cm_intercepts_restores__patch_properties(self):
174+
wrapped = self._makeWrapped()
175+
before = wrapped._patch_properties
176+
batch = self._makeOne(wrapped)
177+
with batch:
178+
# No deferred patching -> no call to the real '_patch_properties'
179+
during = wrapped._patch_properties
180+
after = wrapped._patch_properties
181+
self.assertNotEqual(before, during)
182+
self.assertEqual(before, after)
183+
184+
def test___exit___w_error_skips__patch_properties(self):
185+
class Testing(Exception):
186+
pass
187+
wrapped = self._makeWrapped()
188+
batch = self._makeOne(wrapped)
189+
try:
190+
with batch:
191+
# deferred patching
192+
wrapped._patch_properties({'foo': 'Foo'})
193+
# but error -> no call to the real '_patch_properties'
194+
raise Testing('testing')
195+
except Testing:
196+
pass
197+
198+
def test___exit___no_error_aggregates__patch_properties(self):
199+
connection = _Connection({'foo': 'Foo'})
200+
wrapped = self._makeWrapped(connection, '/path')
201+
batch = self._makeOne(wrapped)
202+
kw = connection._requested
203+
with batch:
204+
# deferred patching
205+
wrapped._patch_properties({'foo': 'Foo'})
206+
wrapped._patch_properties({'bar': 'Baz'})
207+
wrapped._patch_properties({'foo': 'Qux'})
208+
self.assertEqual(len(kw), 0)
209+
# exited w/o error -> call to the real '_patch_properties'
210+
self.assertEqual(len(kw), 1)
211+
self.assertEqual(kw[0]['method'], 'PATCH')
212+
self.assertEqual(kw[0]['path'], '/path')
213+
self.assertEqual(kw[0]['data'], {'foo': 'Qux', 'bar': 'Baz'})
214+
self.assertEqual(kw[0]['query_params'], {'projection': 'full'})
215+
216+
126217
class _Connection(object):
127218

128219
def __init__(self, *responses):

pylintrc_default

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ good-names = i, j, k, ex, Run, _, pb, id,
77

88
[DESIGN]
99
max-args = 10
10-
max-public-methods = 40
1110

1211
[FORMAT]
1312
# NOTE: By default pylint ignores whitespace checks around the
@@ -24,7 +23,8 @@ ignore = datastore_v1_pb2.py
2423
[MESSAGES CONTROL]
2524
disable = I, protected-access, maybe-no-member, no-member,
2625
redefined-builtin, star-args, missing-format-attribute,
27-
similarities, arguments-differ
26+
similarities, arguments-differ,
27+
too-many-public-methods, too-few-public-methods
2828

2929
[REPORTS]
3030
reports = no

0 commit comments

Comments
 (0)