Skip to content

Commit 0579580

Browse files
DeimvisDmitriy BruseninAcconut
authored
Add support for creation-defer-length extension (#96)
* add declare length option * implement upload_length_deferred * fix declare_length compatibility with upload_length_deferred * clarify upload_length_deferred description Co-authored-by: Marius Kleidl <[email protected]> * remove declare_length --------- Co-authored-by: Dmitriy Brusenin <[email protected]> Co-authored-by: Marius Kleidl <[email protected]>
1 parent e5bce00 commit 0579580

File tree

4 files changed

+74
-10
lines changed

4 files changed

+74
-10
lines changed

tests/test_uploader.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from unittest import mock
55

66
import responses
7+
from responses import matchers
78
from parametrize import parametrize
89
import pytest
910

@@ -209,3 +210,42 @@ def test_upload_checksum(self, request_mock):
209210
self.uploader.upload_checksum = True
210211
self.uploader.upload()
211212
self.assertEqual(self.uploader.offset, self.uploader.get_file_size())
213+
214+
@parametrize("chunk_size", [1, 2, 3, 4, 5, 6])
215+
@responses.activate
216+
def test_upload_length_deferred(self, chunk_size: int):
217+
upload_url = f"{self.client.url}test_upload_length_deferred"
218+
219+
responses.head(
220+
upload_url,
221+
adding_headers={"upload-offset": "0", "Upload-Defer-Length": "1"},
222+
)
223+
uploader = self.client.uploader(
224+
file_stream=io.BytesIO(b"hello"),
225+
url=upload_url,
226+
chunk_size=chunk_size,
227+
upload_length_deferred=True,
228+
)
229+
self.assertTrue(uploader.upload_length_deferred)
230+
self.assertTrue(uploader.stop_at is None)
231+
232+
offset = 0
233+
while not (offset + chunk_size > 5):
234+
next_offset = min(offset + chunk_size, 5)
235+
responses.patch(
236+
upload_url,
237+
adding_headers={"upload-offset": str(next_offset)},
238+
match=[matchers.header_matcher({"upload-offset": str(offset)})],
239+
)
240+
offset = next_offset
241+
last_req_headers = {"upload-offset": str(offset)}
242+
last_req_headers["upload-length"] = "5"
243+
responses.patch(
244+
upload_url,
245+
adding_headers={"upload-offset": "5"},
246+
match=[matchers.header_matcher(last_req_headers)],
247+
)
248+
249+
uploader.upload()
250+
self.assertEqual(uploader.offset, 5)
251+
self.assertEqual(uploader.stop_at, 5)

tusclient/request.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ class BaseTusRequest:
4141

4242
def __init__(self, uploader):
4343
self._url = uploader.url
44-
self.response_headers = {}
4544
self.status_code = None
45+
self.response_headers = {}
4646
self.response_content = None
47+
self.stream_eof = False
4748
self.verify_tls_cert = bool(uploader.verify_tls_cert)
4849
self.file = uploader.get_file_stream()
4950
self.file.seek(uploader.offset)
@@ -53,6 +54,8 @@ def __init__(self, uploader):
5354
"upload-offset": str(uploader.offset),
5455
"Content-Type": "application/offset+octet-stream",
5556
}
57+
self._offset = uploader.offset
58+
self._upload_length_deferred = uploader.upload_length_deferred
5659
self._request_headers.update(uploader.get_headers())
5760
self._content_length = uploader.get_request_length()
5861
self._upload_checksum = uploader.upload_checksum
@@ -80,18 +83,23 @@ def perform(self):
8083
"""
8184
try:
8285
chunk = self.file.read(self._content_length)
86+
stream_eof = len(chunk) < self._content_length
8387
self.add_checksum(chunk)
88+
headers = self._request_headers
89+
if stream_eof and self._upload_length_deferred:
90+
headers["upload-length"] = str(self._offset + len(chunk))
8491
resp = requests.patch(
8592
self._url,
8693
data=chunk,
87-
headers=self._request_headers,
94+
headers=headers,
8895
verify=self.verify_tls_cert,
8996
stream=True,
9097
cert=self.client_cert
9198
)
9299
self.status_code = resp.status_code
93100
self.response_content = resp.content
94101
self.response_headers = {k.lower(): v for k, v in resp.headers.items()}
102+
self.stream_eof = stream_eof
95103
except requests.exceptions.RequestException as error:
96104
raise TusUploadFailed(error)
97105

tusclient/uploader/baseuploader.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ class BaseUploader:
7373
- upload_checksum (bool):
7474
Whether or not to supply the Upload-Checksum header along with each
7575
chunk. Defaults to False.
76+
- upload_length_deferred (bool):
77+
Whether or not to declare the upload length when finished reading the file stream instead of when the upload is started. This is useful
78+
when uploading from a streaming resource, where the total file size isn't available when the upload is created
79+
but only becomes known when the stream finishes. The server must support the `creation-defer-length` extension.
7680
7781
:Constructor Args:
7882
- file_path (str)
@@ -89,6 +93,7 @@ class BaseUploader:
8993
- url_storage (Optinal [<tusclient.storage.interface.Storage>])
9094
- fingerprinter (Optional [<tusclient.fingerprint.interface.Fingerprint>])
9195
- upload_checksum (Optional[bool])
96+
- upload_length_deferred (Optional[bool])
9297
"""
9398

9499
DEFAULT_HEADERS = {"Tus-Resumable": "1.0.0"}
@@ -114,6 +119,7 @@ def __init__(
114119
url_storage: Optional[Storage] = None,
115120
fingerprinter: Optional[interface.Fingerprint] = None,
116121
upload_checksum=False,
122+
upload_length_deferred=False,
117123
):
118124
if file_path is None and file_stream is None:
119125
raise ValueError("Either 'file_path' or 'file_stream' cannot be None.")
@@ -129,7 +135,8 @@ def __init__(
129135
self.verify_tls_cert = verify_tls_cert
130136
self.file_path = file_path
131137
self.file_stream = file_stream
132-
self.stop_at = self.get_file_size()
138+
self.file_size = self.get_file_size() if not upload_length_deferred else None
139+
self.stop_at = self.file_size
133140
self.client = client
134141
self.metadata = metadata or {}
135142
self.metadata_encoding = metadata_encoding
@@ -145,6 +152,7 @@ def __init__(
145152
self._retried = 0
146153
self.retry_delay = retry_delay
147154
self.upload_checksum = upload_checksum
155+
self.upload_length_deferred = upload_length_deferred
148156
(
149157
self.__checksum_algorithm_name,
150158
self.__checksum_algorithm,
@@ -161,7 +169,10 @@ def get_headers(self):
161169
def get_url_creation_headers(self):
162170
"""Return headers required to create upload url"""
163171
headers = self.get_headers()
164-
headers["upload-length"] = str(self.get_file_size())
172+
if self.upload_length_deferred:
173+
headers['upload-defer-length'] = '1'
174+
else:
175+
headers["upload-length"] = str(self.file_size)
165176
headers["upload-metadata"] = ",".join(self.encode_metadata())
166177
return headers
167178

@@ -252,8 +263,9 @@ def get_request_length(self):
252263
"""
253264
Return length of next chunk upload.
254265
"""
255-
remainder = self.stop_at - self.offset
256-
return self.chunk_size if remainder > self.chunk_size else remainder
266+
if self.stop_at is None:
267+
return self.chunk_size
268+
return min(self.chunk_size, self.stop_at - self.offset)
257269

258270
def get_file_stream(self):
259271
"""

tusclient/uploader/uploader.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def upload(self, stop_at: Optional[int] = None):
3333
Determines at what offset value the upload should stop. If not specified this
3434
defaults to the file size.
3535
"""
36-
self.stop_at = stop_at or self.get_file_size()
36+
self.stop_at = stop_at or self.file_size
3737

3838
if not self.url:
3939
# Ensure the POST request is performed even for empty files.
@@ -42,7 +42,7 @@ def upload(self, stop_at: Optional[int] = None):
4242
self.set_url(self.create_url())
4343
self.offset = 0
4444

45-
while self.offset < self.stop_at:
45+
while self.stop_at is None or (self.offset < self.stop_at):
4646
self.upload_chunk()
4747

4848
def upload_chunk(self):
@@ -59,6 +59,8 @@ def upload_chunk(self):
5959

6060
self._do_request()
6161
self.offset = int(self.request.response_headers.get("upload-offset"))
62+
if self.upload_length_deferred and self.request.stream_eof:
63+
self.stop_at = self.offset
6264

6365
@catch_requests_error
6466
def create_url(self):
@@ -120,13 +122,13 @@ async def upload(self, stop_at: Optional[int] = None):
120122
Determines at what offset value the upload should stop. If not specified this
121123
defaults to the file size.
122124
"""
123-
self.stop_at = stop_at or self.get_file_size()
125+
self.stop_at = stop_at or self.file_size
124126

125127
if not self.url:
126128
self.set_url(await self.create_url())
127129
self.offset = 0
128130

129-
while self.offset < self.stop_at:
131+
while self.stop_at is None or (self.offset < self.stop_at):
130132
await self.upload_chunk()
131133

132134
async def upload_chunk(self):
@@ -143,6 +145,8 @@ async def upload_chunk(self):
143145

144146
await self._do_request()
145147
self.offset = int(self.request.response_headers.get("upload-offset"))
148+
if self.upload_length_deferred and self.request.stream_eof:
149+
self.stop_at = self.offset
146150

147151
async def create_url(self):
148152
"""

0 commit comments

Comments
 (0)