Skip to content

Commit c50e8ae

Browse files
lcianclaude
andauthored
feat: Implement S3-compatible XML multipart uploads (#1)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b5c8fec commit c50e8ae

5 files changed

Lines changed: 1108 additions & 6 deletions

File tree

gcs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from gcs import bucket, object, project, rewrite, upload
15+
from gcs import bucket, multipart_upload, object, project, rewrite, upload

gcs/multipart_upload.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helpers for GCS S3-compatible XML multipart uploads."""
16+
17+
import datetime
18+
import hashlib
19+
import uuid
20+
import xml.etree.ElementTree as ET
21+
22+
import gcs.object
23+
import testbench
24+
import testbench.common
25+
from gcs.upload import Upload
26+
27+
MIN_PART_SIZE = 5 * 1024 * 1024 # 5 MiB
28+
29+
30+
def _create_upload_id(bucket_name, object_name):
31+
return hashlib.sha256(
32+
("%s/%s/o/%s" % (uuid.uuid4().hex, bucket_name, object_name)).encode("utf-8")
33+
).hexdigest()
34+
35+
36+
def init_xml_multipart(request, bucket, object_name):
37+
"""Create an Upload representing an S3-style multipart upload.
38+
39+
Captures metadata from request headers at initiate time; parts are
40+
accumulated later via xml_upload_part.
41+
"""
42+
upload_id = _create_upload_id(bucket.name, object_name)
43+
metadata = {
44+
"bucket": bucket.name,
45+
"name": object_name,
46+
"metadata": {"x_emulator_upload": "xml_multipart"},
47+
}
48+
headers = request.headers
49+
if "content-type" in headers:
50+
metadata["contentType"] = headers["content-type"]
51+
if headers.get("content-encoding"):
52+
metadata["contentEncoding"] = headers["content-encoding"]
53+
if headers.get("content-disposition"):
54+
metadata["contentDisposition"] = headers["content-disposition"]
55+
if headers.get("content-language"):
56+
metadata["contentLanguage"] = headers["content-language"]
57+
elif headers.get("x-goog-content-language"):
58+
metadata["contentLanguage"] = headers["x-goog-content-language"]
59+
if headers.get("cache-control"):
60+
metadata["cacheControl"] = headers["cache-control"]
61+
if headers.get("x-goog-storage-class"):
62+
metadata["storageClass"] = headers["x-goog-storage-class"]
63+
64+
# Collect x-goog-meta-* custom metadata
65+
custom = metadata["metadata"]
66+
for key, value in headers.items():
67+
lower = key.lower()
68+
if lower.startswith("x-goog-meta-"):
69+
custom[lower[len("x-goog-meta-") :]] = value
70+
71+
fake_request = testbench.common.FakeRequest.init_xml(request)
72+
predefined_acl = headers.get("x-goog-acl")
73+
if predefined_acl:
74+
fake_request.args["predefinedAcl"] = predefined_acl
75+
preconditions = testbench.common.make_xml_preconditions(fake_request)
76+
77+
upload = Upload(
78+
request=fake_request,
79+
metadata=metadata,
80+
bucket=bucket,
81+
location="",
82+
upload_id=upload_id,
83+
media=b"",
84+
complete=False,
85+
transfer=set(),
86+
parts={},
87+
kind="xml_multipart",
88+
)
89+
upload.preconditions = preconditions
90+
return upload
91+
92+
93+
# --- ETag helpers ---
94+
95+
96+
def compute_part_etag(data):
97+
return '"%s"' % hashlib.md5(data).hexdigest()
98+
99+
100+
def compute_multipart_etag(part_etags_in_order):
101+
"""Compute the S3-style composite ETag from per-part ETags.
102+
103+
Each element of *part_etags_in_order* is a quoted hex-md5 string
104+
(e.g. '"abc123..."'). The composite ETag is the MD5 of the
105+
concatenated *binary* MD5 digests, suffixed with ``-N``.
106+
"""
107+
binary = b""
108+
for etag in part_etags_in_order:
109+
hex_str = etag.strip('"')
110+
binary += bytes.fromhex(hex_str)
111+
composite = hashlib.md5(binary).hexdigest()
112+
return '"%s-%d"' % (composite, len(part_etags_in_order))
113+
114+
115+
# --- XML response builders ---
116+
117+
_XML_NS = "http://s3.amazonaws.com/doc/2006-03-01/"
118+
119+
120+
def _xml_preamble():
121+
return '<?xml version="1.0" encoding="UTF-8"?>\n'
122+
123+
124+
def _el(tag, text):
125+
e = ET.Element(tag)
126+
e.text = str(text)
127+
return e
128+
129+
130+
def build_initiate_response_xml(bucket, key, upload_id):
131+
root = ET.Element("InitiateMultipartUploadResult", xmlns=_XML_NS)
132+
root.append(_el("Bucket", bucket))
133+
root.append(_el("Key", key))
134+
root.append(_el("UploadId", upload_id))
135+
return (_xml_preamble() + ET.tostring(root, encoding="unicode")).encode("utf-8")
136+
137+
138+
def build_complete_response_xml(location, bucket, key, etag):
139+
root = ET.Element("CompleteMultipartUploadResult", xmlns=_XML_NS)
140+
root.append(_el("Location", location))
141+
root.append(_el("Bucket", bucket))
142+
root.append(_el("Key", key))
143+
root.append(_el("ETag", etag))
144+
return (_xml_preamble() + ET.tostring(root, encoding="unicode")).encode("utf-8")
145+
146+
147+
def build_list_parts_response_xml(
148+
bucket,
149+
key,
150+
upload_id,
151+
parts_page,
152+
part_number_marker,
153+
next_marker,
154+
max_parts,
155+
is_truncated,
156+
):
157+
"""Build ListPartsResult XML.
158+
159+
*parts_page* is a list of (part_number, last_modified_datetime, etag, size).
160+
"""
161+
root = ET.Element("ListPartsResult", xmlns=_XML_NS)
162+
root.append(_el("Bucket", bucket))
163+
root.append(_el("Key", key))
164+
root.append(_el("UploadId", upload_id))
165+
root.append(_el("PartNumberMarker", part_number_marker))
166+
if next_marker is not None:
167+
root.append(_el("NextPartNumberMarker", next_marker))
168+
root.append(_el("MaxParts", max_parts))
169+
root.append(_el("IsTruncated", "true" if is_truncated else "false"))
170+
for part_number, last_modified, etag, size in parts_page:
171+
part_el = ET.SubElement(root, "Part")
172+
part_el.append(_el("PartNumber", part_number))
173+
part_el.append(
174+
_el("LastModified", last_modified.strftime("%Y-%m-%dT%H:%M:%S.000Z"))
175+
)
176+
part_el.append(_el("ETag", etag))
177+
part_el.append(_el("Size", size))
178+
return (_xml_preamble() + ET.tostring(root, encoding="unicode")).encode("utf-8")
179+
180+
181+
# --- XML request parsing ---
182+
183+
184+
def parse_complete_request_xml(body):
185+
"""Parse a CompleteMultipartUpload XML body.
186+
187+
Returns [(part_number: int, etag: str), ...] in document order.
188+
Tolerates the optional S3 namespace.
189+
"""
190+
try:
191+
root = ET.fromstring(body)
192+
except ET.ParseError:
193+
testbench.error.invalid("CompleteMultipartUpload XML", context=None)
194+
# Handle optional namespace
195+
ns = ""
196+
if root.tag.startswith("{"):
197+
ns = root.tag.split("}")[0] + "}"
198+
parts = []
199+
for part in root.findall(ns + "Part"):
200+
part_number_text = part.findtext(ns + "PartNumber")
201+
try:
202+
pn = int(part_number_text)
203+
except (TypeError, ValueError):
204+
testbench.error.invalid("CompleteMultipartUpload XML", context=None)
205+
etag = part.findtext(ns + "ETag")
206+
parts.append((pn, etag))
207+
return parts
208+
209+
210+
# --- Validation ---
211+
212+
213+
def validate_min_part_size(requested_parts, upload):
214+
"""Raise 400 if any non-final part is smaller than MIN_PART_SIZE."""
215+
for i, (part_number, _etag) in enumerate(requested_parts):
216+
is_last = i == len(requested_parts) - 1
217+
if is_last:
218+
break
219+
part = upload.parts.get(part_number)
220+
if part is None:
221+
testbench.error.invalid("Part %d not uploaded" % part_number, context=None)
222+
if len(part["data"]) < MIN_PART_SIZE:
223+
testbench.error.generic(
224+
"EntityTooSmall: Part %d is %d bytes, minimum is %d"
225+
% (part_number, len(part["data"]), MIN_PART_SIZE),
226+
400,
227+
None,
228+
None,
229+
)
230+
231+
232+
def validate_requested_parts(requested_parts, upload):
233+
"""Raise 400 if the completion manifest does not match uploaded parts."""
234+
for part_number, requested_etag in requested_parts:
235+
part = upload.parts.get(part_number)
236+
if part is None:
237+
testbench.error.invalid("Part %d not uploaded" % part_number, context=None)
238+
actual_etag = part["etag"]
239+
if requested_etag != actual_etag:
240+
testbench.error.generic(
241+
"InvalidPart: Part %d ETag %s does not match uploaded part %s"
242+
% (part_number, requested_etag, actual_etag),
243+
400,
244+
None,
245+
None,
246+
)
247+
248+
249+
def validate_requested_part_order(requested_parts):
250+
"""Raise 400 if the completion manifest is not strictly ascending."""
251+
last_part_number = None
252+
for part_number, _requested_etag in requested_parts:
253+
if last_part_number is not None and part_number <= last_part_number:
254+
testbench.error.generic("InvalidPartOrder", 400, None, None)
255+
last_part_number = part_number
256+
257+
258+
def validate_requested_parts_not_empty(requested_parts):
259+
"""Raise 400 if the completion manifest does not include any parts."""
260+
if not requested_parts:
261+
testbench.error.generic(
262+
"InvalidRequest: CompleteMultipartUpload requires at least one part",
263+
400,
264+
None,
265+
None,
266+
)
267+
268+
269+
# --- Finalization ---
270+
271+
272+
def finalize_multipart(upload, requested_parts):
273+
"""Assemble parts into a finalized Object.
274+
275+
*requested_parts* comes from parse_complete_request_xml.
276+
Returns (blob, multipart_etag_str).
277+
"""
278+
validate_requested_parts_not_empty(requested_parts)
279+
validate_requested_part_order(requested_parts)
280+
validate_min_part_size(requested_parts, upload)
281+
validate_requested_parts(requested_parts, upload)
282+
283+
media = b""
284+
etags_in_order = []
285+
for part_number, _etag in requested_parts:
286+
part = upload.parts.get(part_number)
287+
media += part["data"]
288+
etags_in_order.append(part["etag"])
289+
290+
multipart_etag = compute_multipart_etag(etags_in_order)
291+
292+
metadata = dict(upload.metadata)
293+
metadata["metadata"] = dict(metadata.get("metadata", {}))
294+
metadata["metadata"]["x_emulator_upload"] = "xml_multipart"
295+
296+
blob, _ = gcs.object.Object.init_dict(
297+
upload.request, metadata, media, upload.bucket, False
298+
)
299+
return blob, multipart_etag

0 commit comments

Comments
 (0)