Skip to content

Commit 741d3fd

Browse files
feat: add support for Etag headers on reads (#489)
Support conditional requests based on ETag for read operations (`reload`, `exists`, `download_*`). My own testing seems to indicate that the JSON API does not support ETag If-Match/If-None-Match headers on modify requests (`patch`, `delete`, etc.), please correct me if I am mistaken. This part two of #451. Part one in #488. Fixes #451 🦕
1 parent 49ba14c commit 741d3fd

File tree

11 files changed

+701
-16
lines changed

11 files changed

+701
-16
lines changed

docs/generation_metageneration.rst

+44-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
1-
Conditional Requests Via Generation / Metageneration Preconditions
2-
==================================================================
1+
Conditional Requests Via ETag / Generation / Metageneration Preconditions
2+
=========================================================================
33

44
Preconditions tell Cloud Storage to only perform a request if the
5-
:ref:`generation <concept-generation>` or
5+
:ref:`ETag <concept-etag>`, :ref:`generation <concept-generation>`, or
66
:ref:`metageneration <concept-metageneration>` number of the affected object
7-
meets your precondition criteria. These checks of the generation and
7+
meets your precondition criteria. These checks of the ETag, generation, and
88
metageneration numbers ensure that the object is in the expected state,
99
allowing you to perform safe read-modify-write updates and conditional
1010
operations on objects
1111

1212
Concepts
1313
--------
1414

15+
.. _concept-etag:
16+
17+
ETag
18+
::::::::::::::
19+
20+
An ETag is returned as part of the response header whenever a resource is
21+
returned, as well as included in the resource itself. Users should make no
22+
assumptions about the value used in an ETag except that it changes whenever the
23+
underlying data changes, per the
24+
`specification <https://tools.ietf.org/html/rfc7232#section-2.3>`_
25+
26+
The ``ETag`` attribute is set by the GCS back-end, and is read-only in the
27+
client library.
28+
1529
.. _concept-metageneration:
1630

1731
Metageneration
@@ -59,6 +73,32 @@ See also
5973
Conditional Parameters
6074
----------------------
6175

76+
.. _using-if-etag-match:
77+
78+
Using ``if_etag_match``
79+
:::::::::::::::::::::::::::::
80+
81+
Passing the ``if_etag_match`` parameter to a method which retrieves a
82+
blob resource (e.g.,
83+
:meth:`Blob.reload <google.cloud.storage.blob.Blob.reload>`)
84+
makes the operation conditional on whether the blob's current ``ETag`` matches
85+
the given value. This parameter is not supported for modification (e.g.,
86+
:meth:`Blob.update <google.cloud.storage.blob.Blob.update>`).
87+
88+
89+
.. _using-if-etag-not-match:
90+
91+
Using ``if_etag_not_match``
92+
:::::::::::::::::::::::::::::
93+
94+
Passing the ``if_etag_not_match`` parameter to a method which retrieves a
95+
blob resource (e.g.,
96+
:meth:`Blob.reload <google.cloud.storage.blob.Blob.reload>`)
97+
makes the operation conditional on whether the blob's current ``ETag`` matches
98+
the given value. This parameter is not supported for modification (e.g.,
99+
:meth:`Blob.update <google.cloud.storage.blob.Blob.update>`).
100+
101+
62102
.. _using-if-generation-match:
63103

64104
Using ``if_generation_match``

google/cloud/storage/_helpers.py

+39-3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from datetime import datetime
2323
import os
2424

25+
from six import string_types
2526
from six.moves.urllib.parse import urlsplit
2627
from google import resumable_media
2728
from google.cloud.storage.constants import _DEFAULT_TIMEOUT
@@ -34,6 +35,12 @@
3435

3536
_DEFAULT_STORAGE_HOST = u"https://storage.googleapis.com"
3637

38+
# etag match parameters in snake case and equivalent header
39+
_ETAG_MATCH_PARAMETERS = (
40+
("if_etag_match", "If-Match"),
41+
("if_etag_not_match", "If-None-Match"),
42+
)
43+
3744
# generation match parameters in camel and snake cases
3845
_GENERATION_MATCH_PARAMETERS = (
3946
("if_generation_match", "ifGenerationMatch"),
@@ -147,6 +154,8 @@ def reload(
147154
self,
148155
client=None,
149156
projection="noAcl",
157+
if_etag_match=None,
158+
if_etag_not_match=None,
150159
if_generation_match=None,
151160
if_generation_not_match=None,
152161
if_metageneration_match=None,
@@ -168,6 +177,12 @@ def reload(
168177
Defaults to ``'noAcl'``. Specifies the set of
169178
properties to return.
170179
180+
:type if_etag_match: Union[str, Set[str]]
181+
:param if_etag_match: (Optional) See :ref:`using-if-etag-match`
182+
183+
:type if_etag_not_match: Union[str, Set[str]])
184+
:param if_etag_not_match: (Optional) See :ref:`using-if-etag-not-match`
185+
171186
:type if_generation_match: long
172187
:param if_generation_match:
173188
(Optional) See :ref:`using-if-generation-match`
@@ -205,10 +220,14 @@ def reload(
205220
if_metageneration_match=if_metageneration_match,
206221
if_metageneration_not_match=if_metageneration_not_match,
207222
)
223+
headers = self._encryption_headers()
224+
_add_etag_match_headers(
225+
headers, if_etag_match=if_etag_match, if_etag_not_match=if_etag_not_match
226+
)
208227
api_response = client._get_resource(
209228
self.path,
210229
query_params=query_params,
211-
headers=self._encryption_headers(),
230+
headers=headers,
212231
timeout=timeout,
213232
retry=retry,
214233
_target_object=self,
@@ -384,8 +403,7 @@ def update(
384403

385404

386405
def _scalar_property(fieldname):
387-
"""Create a property descriptor around the :class:`_PropertyMixin` helpers.
388-
"""
406+
"""Create a property descriptor around the :class:`_PropertyMixin` helpers."""
389407

390408
def _getter(self):
391409
"""Scalar property getter."""
@@ -449,6 +467,24 @@ def _convert_to_timestamp(value):
449467
return mtime
450468

451469

470+
def _add_etag_match_headers(headers, **match_parameters):
471+
"""Add generation match parameters into the given parameters list.
472+
473+
:type headers: dict
474+
:param headers: Headers dict.
475+
476+
:type match_parameters: dict
477+
:param match_parameters: if*etag*match parameters to add.
478+
"""
479+
for snakecase_name, header_name in _ETAG_MATCH_PARAMETERS:
480+
value = match_parameters.get(snakecase_name)
481+
482+
if value is not None:
483+
if isinstance(value, string_types):
484+
value = [value]
485+
headers[header_name] = ", ".join(value)
486+
487+
452488
def _add_generation_match_parameters(parameters, **match_parameters):
453489
"""Add generation match parameters into the given parameters list.
454490

google/cloud/storage/blob.py

+78
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
from google.cloud._helpers import _rfc3339_nanos_to_datetime
6060
from google.cloud._helpers import _to_bytes
6161
from google.cloud.exceptions import NotFound
62+
from google.cloud.storage._helpers import _add_etag_match_headers
6263
from google.cloud.storage._helpers import _add_generation_match_parameters
6364
from google.cloud.storage._helpers import _PropertyMixin
6465
from google.cloud.storage._helpers import _scalar_property
@@ -634,6 +635,8 @@ def generate_signed_url(
634635
def exists(
635636
self,
636637
client=None,
638+
if_etag_match=None,
639+
if_etag_not_match=None,
637640
if_generation_match=None,
638641
if_generation_not_match=None,
639642
if_metageneration_match=None,
@@ -651,6 +654,14 @@ def exists(
651654
(Optional) The client to use. If not passed, falls back to the
652655
``client`` stored on the blob's bucket.
653656
657+
:type if_etag_match: Union[str, Set[str]]
658+
:param if_etag_match:
659+
(Optional) See :ref:`using-if-etag-match`
660+
661+
:type if_etag_not_match: Union[str, Set[str]]
662+
:param if_etag_not_match:
663+
(Optional) See :ref:`using-if-etag-not-match`
664+
654665
:type if_generation_match: long
655666
:param if_generation_match:
656667
(Optional) See :ref:`using-if-generation-match`
@@ -692,12 +703,19 @@ def exists(
692703
if_metageneration_match=if_metageneration_match,
693704
if_metageneration_not_match=if_metageneration_not_match,
694705
)
706+
707+
headers = {}
708+
_add_etag_match_headers(
709+
headers, if_etag_match=if_etag_match, if_etag_not_match=if_etag_not_match
710+
)
711+
695712
try:
696713
# We intentionally pass `_target_object=None` since fields=name
697714
# would limit the local properties.
698715
client._get_resource(
699716
self.path,
700717
query_params=query_params,
718+
headers=headers,
701719
timeout=timeout,
702720
retry=retry,
703721
_target_object=None,
@@ -1002,6 +1020,8 @@ def download_to_file(
10021020
start=None,
10031021
end=None,
10041022
raw_download=False,
1023+
if_etag_match=None,
1024+
if_etag_not_match=None,
10051025
if_generation_match=None,
10061026
if_generation_not_match=None,
10071027
if_metageneration_match=None,
@@ -1057,6 +1077,14 @@ def download_to_file(
10571077
:param raw_download:
10581078
(Optional) If true, download the object without any expansion.
10591079
1080+
:type if_etag_match: Union[str, Set[str]]
1081+
:param if_etag_match:
1082+
(Optional) See :ref:`using-if-etag-match`
1083+
1084+
:type if_etag_not_match: Union[str, Set[str]]
1085+
:param if_etag_not_match:
1086+
(Optional) See :ref:`using-if-etag-not-match`
1087+
10601088
:type if_generation_match: long
10611089
:param if_generation_match:
10621090
(Optional) See :ref:`using-if-generation-match`
@@ -1121,6 +1149,8 @@ def download_to_file(
11211149
start=start,
11221150
end=end,
11231151
raw_download=raw_download,
1152+
if_etag_match=if_etag_match,
1153+
if_etag_not_match=if_etag_not_match,
11241154
if_generation_match=if_generation_match,
11251155
if_generation_not_match=if_generation_not_match,
11261156
if_metageneration_match=if_metageneration_match,
@@ -1137,6 +1167,8 @@ def download_to_filename(
11371167
start=None,
11381168
end=None,
11391169
raw_download=False,
1170+
if_etag_match=None,
1171+
if_etag_not_match=None,
11401172
if_generation_match=None,
11411173
if_generation_not_match=None,
11421174
if_metageneration_match=None,
@@ -1168,6 +1200,14 @@ def download_to_filename(
11681200
:param raw_download:
11691201
(Optional) If true, download the object without any expansion.
11701202
1203+
:type if_etag_match: Union[str, Set[str]]
1204+
:param if_etag_match:
1205+
(Optional) See :ref:`using-if-etag-match`
1206+
1207+
:type if_etag_not_match: Union[str, Set[str]]
1208+
:param if_etag_not_match:
1209+
(Optional) See :ref:`using-if-etag-not-match`
1210+
11711211
:type if_generation_match: long
11721212
:param if_generation_match:
11731213
(Optional) See :ref:`using-if-generation-match`
@@ -1233,6 +1273,8 @@ def download_to_filename(
12331273
start=start,
12341274
end=end,
12351275
raw_download=raw_download,
1276+
if_etag_match=if_etag_match,
1277+
if_etag_not_match=if_etag_not_match,
12361278
if_generation_match=if_generation_match,
12371279
if_generation_not_match=if_generation_not_match,
12381280
if_metageneration_match=if_metageneration_match,
@@ -1260,6 +1302,8 @@ def download_as_bytes(
12601302
start=None,
12611303
end=None,
12621304
raw_download=False,
1305+
if_etag_match=None,
1306+
if_etag_not_match=None,
12631307
if_generation_match=None,
12641308
if_generation_not_match=None,
12651309
if_metageneration_match=None,
@@ -1288,6 +1332,14 @@ def download_as_bytes(
12881332
:param raw_download:
12891333
(Optional) If true, download the object without any expansion.
12901334
1335+
:type if_etag_match: Union[str, Set[str]]
1336+
:param if_etag_match:
1337+
(Optional) See :ref:`using-if-etag-match`
1338+
1339+
:type if_etag_not_match: Union[str, Set[str]]
1340+
:param if_etag_not_match:
1341+
(Optional) See :ref:`using-if-etag-not-match`
1342+
12911343
:type if_generation_match: long
12921344
:param if_generation_match:
12931345
(Optional) See :ref:`using-if-generation-match`
@@ -1355,6 +1407,8 @@ def download_as_bytes(
13551407
start=start,
13561408
end=end,
13571409
raw_download=raw_download,
1410+
if_etag_match=if_etag_match,
1411+
if_etag_not_match=if_etag_not_match,
13581412
if_generation_match=if_generation_match,
13591413
if_generation_not_match=if_generation_not_match,
13601414
if_metageneration_match=if_metageneration_match,
@@ -1371,6 +1425,8 @@ def download_as_string(
13711425
start=None,
13721426
end=None,
13731427
raw_download=False,
1428+
if_etag_match=None,
1429+
if_etag_not_match=None,
13741430
if_generation_match=None,
13751431
if_generation_not_match=None,
13761432
if_metageneration_match=None,
@@ -1401,6 +1457,14 @@ def download_as_string(
14011457
:param raw_download:
14021458
(Optional) If true, download the object without any expansion.
14031459
1460+
:type if_etag_match: Union[str, Set[str]]
1461+
:param if_etag_match:
1462+
(Optional) See :ref:`using-if-etag-match`
1463+
1464+
:type if_etag_not_match: Union[str, Set[str]]
1465+
:param if_etag_not_match:
1466+
(Optional) See :ref:`using-if-etag-not-match`
1467+
14041468
:type if_generation_match: long
14051469
:param if_generation_match:
14061470
(Optional) See :ref:`using-if-generation-match`
@@ -1460,6 +1524,8 @@ def download_as_string(
14601524
start=start,
14611525
end=end,
14621526
raw_download=raw_download,
1527+
if_etag_match=if_etag_match,
1528+
if_etag_not_match=if_etag_not_match,
14631529
if_generation_match=if_generation_match,
14641530
if_generation_not_match=if_generation_not_match,
14651531
if_metageneration_match=if_metageneration_match,
@@ -1475,6 +1541,8 @@ def download_as_text(
14751541
end=None,
14761542
raw_download=False,
14771543
encoding=None,
1544+
if_etag_match=None,
1545+
if_etag_not_match=None,
14781546
if_generation_match=None,
14791547
if_generation_not_match=None,
14801548
if_metageneration_match=None,
@@ -1507,6 +1575,14 @@ def download_as_text(
15071575
downloaded bytes. Defaults to the ``charset`` param of
15081576
attr:`content_type`, or else to "utf-8".
15091577
1578+
:type if_etag_match: Union[str, Set[str]]
1579+
:param if_etag_match:
1580+
(Optional) See :ref:`using-if-etag-match`
1581+
1582+
:type if_etag_not_match: Union[str, Set[str]]
1583+
:param if_etag_not_match:
1584+
(Optional) See :ref:`using-if-etag-not-match`
1585+
15101586
:type if_generation_match: long
15111587
:param if_generation_match:
15121588
(Optional) See :ref:`using-if-generation-match`
@@ -1558,6 +1634,8 @@ def download_as_text(
15581634
start=start,
15591635
end=end,
15601636
raw_download=raw_download,
1637+
if_etag_match=if_etag_match,
1638+
if_etag_not_match=if_etag_not_match,
15611639
if_generation_match=if_generation_match,
15621640
if_generation_not_match=if_generation_not_match,
15631641
if_metageneration_match=if_metageneration_match,

0 commit comments

Comments
 (0)