Skip to content

Commit da2b023

Browse files
authored
Merge pull request #2506 from tseaver/1982-storage-add_compose
Add 'Blob.compose' API wrapper method.
2 parents 8bd0177 + ae5beb8 commit da2b023

File tree

2 files changed

+118
-0
lines changed

2 files changed

+118
-0
lines changed

packages/google-cloud-storage/google/cloud/storage/blob.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,32 @@ def make_public(self, client=None):
655655
self.acl.all().grant_read()
656656
self.acl.save(client=client)
657657

658+
def compose(self, sources, client=None):
659+
"""Concatenate source blobs into this one.
660+
661+
:type sources: list of :class:`Blob`
662+
:param sources: blobs whose contents will be composed into this blob.
663+
664+
:type client: :class:`~google.cloud.storage.client.Client` or
665+
``NoneType``
666+
:param client: Optional. The client to use. If not passed, falls back
667+
to the ``client`` stored on the blob's bucket.
668+
669+
:raises: :exc:`ValueError` if this blob does not have its
670+
:attr:`content_type` set.
671+
"""
672+
if self.content_type is None:
673+
raise ValueError("Destination 'content_type' not set.")
674+
client = self._require_client(client)
675+
request = {
676+
'sourceObjects': [{'name': source.name} for source in sources],
677+
'destination': self._properties.copy(),
678+
}
679+
api_response = client.connection.api_request(
680+
method='POST', path=self.path + '/compose', data=request,
681+
_target_object=self)
682+
self._set_properties(api_response)
683+
658684
cache_control = _scalar_property('cacheControl')
659685
"""HTTP 'Cache-Control' header for this object.
660686

packages/google-cloud-storage/unit_tests/test_blob.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,98 @@ def test_make_public(self):
11021102
self.assertEqual(kw[0]['data'], {'acl': permissive})
11031103
self.assertEqual(kw[0]['query_params'], {'projection': 'full'})
11041104

1105+
def test_compose_wo_content_type_set(self):
1106+
SOURCE_1 = 'source-1'
1107+
SOURCE_2 = 'source-2'
1108+
DESTINATION = 'destinaton'
1109+
connection = _Connection()
1110+
client = _Client(connection)
1111+
bucket = _Bucket(client=client)
1112+
source_1 = self._makeOne(SOURCE_1, bucket=bucket)
1113+
source_2 = self._makeOne(SOURCE_2, bucket=bucket)
1114+
destination = self._makeOne(DESTINATION, bucket=bucket)
1115+
1116+
with self.assertRaises(ValueError):
1117+
destination.compose(sources=[source_1, source_2])
1118+
1119+
def test_compose_minimal(self):
1120+
from six.moves.http_client import OK
1121+
SOURCE_1 = 'source-1'
1122+
SOURCE_2 = 'source-2'
1123+
DESTINATION = 'destinaton'
1124+
RESOURCE = {
1125+
'etag': 'DEADBEEF'
1126+
}
1127+
after = ({'status': OK}, RESOURCE)
1128+
connection = _Connection(after)
1129+
client = _Client(connection)
1130+
bucket = _Bucket(client=client)
1131+
source_1 = self._makeOne(SOURCE_1, bucket=bucket)
1132+
source_2 = self._makeOne(SOURCE_2, bucket=bucket)
1133+
destination = self._makeOne(DESTINATION, bucket=bucket)
1134+
destination.content_type = 'text/plain'
1135+
1136+
destination.compose(sources=[source_1, source_2])
1137+
1138+
self.assertEqual(destination.etag, 'DEADBEEF')
1139+
1140+
SENT = {
1141+
'sourceObjects': [
1142+
{'name': source_1.name},
1143+
{'name': source_2.name},
1144+
],
1145+
'destination': {
1146+
'contentType': 'text/plain',
1147+
},
1148+
}
1149+
kw = connection._requested
1150+
self.assertEqual(len(kw), 1)
1151+
self.assertEqual(kw[0]['method'], 'POST')
1152+
self.assertEqual(kw[0]['path'], '/b/name/o/%s/compose' % DESTINATION)
1153+
self.assertEqual(kw[0]['data'], SENT)
1154+
1155+
def test_compose_w_additional_property_changes(self):
1156+
from six.moves.http_client import OK
1157+
SOURCE_1 = 'source-1'
1158+
SOURCE_2 = 'source-2'
1159+
DESTINATION = 'destinaton'
1160+
RESOURCE = {
1161+
'etag': 'DEADBEEF'
1162+
}
1163+
after = ({'status': OK}, RESOURCE)
1164+
connection = _Connection(after)
1165+
client = _Client(connection)
1166+
bucket = _Bucket(client=client)
1167+
source_1 = self._makeOne(SOURCE_1, bucket=bucket)
1168+
source_2 = self._makeOne(SOURCE_2, bucket=bucket)
1169+
destination = self._makeOne(DESTINATION, bucket=bucket)
1170+
destination.content_type = 'text/plain'
1171+
destination.content_language = 'en-US'
1172+
destination.metadata = {'my-key': 'my-value'}
1173+
1174+
destination.compose(sources=[source_1, source_2])
1175+
1176+
self.assertEqual(destination.etag, 'DEADBEEF')
1177+
1178+
SENT = {
1179+
'sourceObjects': [
1180+
{'name': source_1.name},
1181+
{'name': source_2.name},
1182+
],
1183+
'destination': {
1184+
'contentType': 'text/plain',
1185+
'contentLanguage': 'en-US',
1186+
'metadata': {
1187+
'my-key': 'my-value',
1188+
}
1189+
},
1190+
}
1191+
kw = connection._requested
1192+
self.assertEqual(len(kw), 1)
1193+
self.assertEqual(kw[0]['method'], 'POST')
1194+
self.assertEqual(kw[0]['path'], '/b/name/o/%s/compose' % DESTINATION)
1195+
self.assertEqual(kw[0]['data'], SENT)
1196+
11051197
def test_cache_control_getter(self):
11061198
BLOB_NAME = 'blob-name'
11071199
bucket = _Bucket()

0 commit comments

Comments
 (0)