resumable-upload 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- resumable_upload/__init__.py +29 -0
- resumable_upload/client/__init__.py +7 -0
- resumable_upload/client/base.py +379 -0
- resumable_upload/client/retry.py +203 -0
- resumable_upload/client/stats.py +66 -0
- resumable_upload/exceptions.py +30 -0
- resumable_upload/fingerprint.py +60 -0
- resumable_upload/server.py +347 -0
- resumable_upload/storage.py +170 -0
- resumable_upload/url_storage.py +104 -0
- resumable_upload-0.0.1.dist-info/METADATA +424 -0
- resumable_upload-0.0.1.dist-info/RECORD +15 -0
- resumable_upload-0.0.1.dist-info/WHEEL +5 -0
- resumable_upload-0.0.1.dist-info/licenses/LICENSE +21 -0
- resumable_upload-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Resumable Upload Library
|
|
2
|
+
|
|
3
|
+
A Python implementation of the TUS resumable upload protocol.
|
|
4
|
+
Provides both server and client components with minimal dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.0.1"
|
|
8
|
+
|
|
9
|
+
from resumable_upload.client import TusClient, TusClientWithRetry, UploadStats
|
|
10
|
+
from resumable_upload.exceptions import TusCommunicationError, TusUploadFailed
|
|
11
|
+
from resumable_upload.fingerprint import Fingerprint
|
|
12
|
+
from resumable_upload.server import TusHTTPRequestHandler, TusServer
|
|
13
|
+
from resumable_upload.storage import SQLiteStorage, Storage
|
|
14
|
+
from resumable_upload.url_storage import FileURLStorage, URLStorage
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"TusServer",
|
|
18
|
+
"TusHTTPRequestHandler",
|
|
19
|
+
"TusClient",
|
|
20
|
+
"TusClientWithRetry",
|
|
21
|
+
"UploadStats",
|
|
22
|
+
"Storage",
|
|
23
|
+
"SQLiteStorage",
|
|
24
|
+
"TusCommunicationError",
|
|
25
|
+
"TusUploadFailed",
|
|
26
|
+
"Fingerprint",
|
|
27
|
+
"URLStorage",
|
|
28
|
+
"FileURLStorage",
|
|
29
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""TUS protocol client implementations."""
|
|
2
|
+
|
|
3
|
+
from resumable_upload.client.base import TusClient
|
|
4
|
+
from resumable_upload.client.retry import TusClientWithRetry
|
|
5
|
+
from resumable_upload.client.stats import UploadStats
|
|
6
|
+
|
|
7
|
+
__all__ = ["TusClient", "TusClientWithRetry", "UploadStats"]
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""TUS protocol client implementation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import IO, Callable, Optional, Union
|
|
8
|
+
from urllib.error import HTTPError
|
|
9
|
+
from urllib.parse import urljoin
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
from resumable_upload.exceptions import TusCommunicationError, TusUploadFailed
|
|
13
|
+
from resumable_upload.fingerprint import Fingerprint
|
|
14
|
+
from resumable_upload.url_storage import URLStorage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TusClient:
|
|
18
|
+
"""TUS protocol client for uploading files.
|
|
19
|
+
|
|
20
|
+
This client implements TUS protocol version 1.0.0 as specified at:
|
|
21
|
+
https://tus.io/protocols/resumable-upload.html
|
|
22
|
+
|
|
23
|
+
Version Handling:
|
|
24
|
+
- Uses TUS version 1.0.0
|
|
25
|
+
- Sends "Tus-Resumable: 1.0.0" header with all requests
|
|
26
|
+
- Compatible with TUS 1.0.0 compliant servers
|
|
27
|
+
- Server must support version 1.0.0 to accept uploads
|
|
28
|
+
|
|
29
|
+
Features:
|
|
30
|
+
- File upload with configurable chunk size
|
|
31
|
+
- Automatic resume of interrupted uploads
|
|
32
|
+
- Progress tracking via callbacks
|
|
33
|
+
- Optional SHA1 checksum verification
|
|
34
|
+
- Metadata support for file information
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> client = TusClient("http://localhost:8080/files")
|
|
38
|
+
>>> url = client.upload_file(
|
|
39
|
+
... "large_file.bin",
|
|
40
|
+
... metadata={"filename": "large_file.bin"},
|
|
41
|
+
... progress_callback=lambda up, total: print(f"{up}/{total}"),
|
|
42
|
+
... )
|
|
43
|
+
>>> # Resume interrupted upload
|
|
44
|
+
>>> client.resume_upload("large_file.bin", url)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
TUS_VERSION = "1.0.0"
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
url: str,
|
|
52
|
+
chunk_size: Union[int, float] = 1024 * 1024,
|
|
53
|
+
checksum: bool = True,
|
|
54
|
+
verify_tls_cert: bool = True,
|
|
55
|
+
metadata_encoding: str = "utf-8",
|
|
56
|
+
store_url: bool = False,
|
|
57
|
+
url_storage: Optional[URLStorage] = None,
|
|
58
|
+
fingerprinter: Optional[Fingerprint] = None,
|
|
59
|
+
):
|
|
60
|
+
"""Initialize TUS client.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
url: Base URL of TUS server
|
|
64
|
+
chunk_size: Size of upload chunks in bytes (default: 1MB). Can be int or float.
|
|
65
|
+
checksum: Enable checksum verification (default: True)
|
|
66
|
+
verify_tls_cert: Verify TLS certificates (default: True)
|
|
67
|
+
metadata_encoding: Encoding for metadata values (default: utf-8)
|
|
68
|
+
store_url: Store upload URLs for resumability (default: False)
|
|
69
|
+
url_storage: Custom URL storage implementation
|
|
70
|
+
fingerprinter: Custom fingerprint implementation
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: If chunk_size is less than 1
|
|
74
|
+
"""
|
|
75
|
+
if chunk_size < 1:
|
|
76
|
+
raise ValueError(f"chunk_size must be at least 1 byte, got {chunk_size}")
|
|
77
|
+
self.url = url.rstrip("/")
|
|
78
|
+
self.chunk_size = int(chunk_size)
|
|
79
|
+
self.checksum = checksum
|
|
80
|
+
self.verify_tls_cert = verify_tls_cert
|
|
81
|
+
self.metadata_encoding = metadata_encoding
|
|
82
|
+
self.store_url = store_url
|
|
83
|
+
self.url_storage = url_storage
|
|
84
|
+
self.fingerprinter = fingerprinter or Fingerprint()
|
|
85
|
+
|
|
86
|
+
def upload_file(
|
|
87
|
+
self,
|
|
88
|
+
file_path: Optional[str] = None,
|
|
89
|
+
file_stream: Optional[IO] = None,
|
|
90
|
+
metadata: Optional[dict[str, str]] = None,
|
|
91
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
|
92
|
+
stop_at: Optional[int] = None,
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Upload a file to the server.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
file_path: Path to file to upload (required if file_stream not provided)
|
|
98
|
+
file_stream: File stream to upload (alternative to file_path)
|
|
99
|
+
metadata: Optional metadata dictionary
|
|
100
|
+
progress_callback: Optional callback function(uploaded_bytes, total_bytes)
|
|
101
|
+
stop_at: Stop upload at this byte offset (for partial uploads)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
URL of the uploaded file
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: If neither file_path nor file_stream provided
|
|
108
|
+
FileNotFoundError: If file doesn't exist
|
|
109
|
+
TusCommunicationError: If upload fails
|
|
110
|
+
"""
|
|
111
|
+
if not file_path and not file_stream:
|
|
112
|
+
raise ValueError("Either file_path or file_stream must be provided")
|
|
113
|
+
|
|
114
|
+
if file_path and not os.path.exists(file_path):
|
|
115
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
116
|
+
|
|
117
|
+
# Get file size
|
|
118
|
+
if file_stream:
|
|
119
|
+
file_stream.seek(0, os.SEEK_END)
|
|
120
|
+
file_size = file_stream.tell()
|
|
121
|
+
file_stream.seek(0)
|
|
122
|
+
else:
|
|
123
|
+
file_size = os.path.getsize(file_path)
|
|
124
|
+
|
|
125
|
+
metadata = metadata or {}
|
|
126
|
+
|
|
127
|
+
# Add filename to metadata if not present and we have a file_path
|
|
128
|
+
if "filename" not in metadata and file_path:
|
|
129
|
+
metadata["filename"] = os.path.basename(file_path)
|
|
130
|
+
|
|
131
|
+
# Check for stored URL if enabled
|
|
132
|
+
upload_url = None
|
|
133
|
+
if self.store_url and self.url_storage:
|
|
134
|
+
fingerprint = self.fingerprinter.get_fingerprint(file_path or file_stream)
|
|
135
|
+
upload_url = self.url_storage.get_url(fingerprint)
|
|
136
|
+
|
|
137
|
+
# Create upload if no stored URL
|
|
138
|
+
if not upload_url:
|
|
139
|
+
upload_url = self._create_upload(file_size, metadata)
|
|
140
|
+
if self.store_url and self.url_storage:
|
|
141
|
+
fingerprint = self.fingerprinter.get_fingerprint(file_path or file_stream)
|
|
142
|
+
self.url_storage.set_url(fingerprint, upload_url)
|
|
143
|
+
|
|
144
|
+
# Upload file in chunks
|
|
145
|
+
if file_stream:
|
|
146
|
+
fs = file_stream
|
|
147
|
+
fs.seek(0)
|
|
148
|
+
else:
|
|
149
|
+
fs = open(file_path, "rb") # noqa: SIM115
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
offset = self._get_offset(upload_url)
|
|
153
|
+
fs.seek(offset)
|
|
154
|
+
|
|
155
|
+
max_offset = stop_at if stop_at is not None else file_size
|
|
156
|
+
|
|
157
|
+
while offset < max_offset:
|
|
158
|
+
chunk_size = min(self.chunk_size, max_offset - offset)
|
|
159
|
+
chunk = fs.read(chunk_size)
|
|
160
|
+
if not chunk:
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
self._upload_chunk(upload_url, offset, chunk)
|
|
164
|
+
offset += len(chunk)
|
|
165
|
+
|
|
166
|
+
if progress_callback:
|
|
167
|
+
progress_callback(offset, file_size)
|
|
168
|
+
finally:
|
|
169
|
+
if not file_stream and file_path:
|
|
170
|
+
fs.close()
|
|
171
|
+
|
|
172
|
+
return upload_url
|
|
173
|
+
|
|
174
|
+
def resume_upload(
|
|
175
|
+
self,
|
|
176
|
+
file_path: str,
|
|
177
|
+
upload_url: str,
|
|
178
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Resume an interrupted upload.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
file_path: Path to file to upload
|
|
184
|
+
upload_url: URL of the existing upload
|
|
185
|
+
progress_callback: Optional callback function(uploaded_bytes, total_bytes)
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
URL of the uploaded file
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
FileNotFoundError: If file doesn't exist
|
|
192
|
+
HTTPError: If upload fails
|
|
193
|
+
"""
|
|
194
|
+
if not os.path.exists(file_path):
|
|
195
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
196
|
+
|
|
197
|
+
file_size = os.path.getsize(file_path)
|
|
198
|
+
|
|
199
|
+
# Get current offset
|
|
200
|
+
offset = self._get_offset(upload_url)
|
|
201
|
+
|
|
202
|
+
# Resume upload from current offset
|
|
203
|
+
with open(file_path, "rb") as f:
|
|
204
|
+
f.seek(offset)
|
|
205
|
+
while offset < file_size:
|
|
206
|
+
chunk = f.read(self.chunk_size)
|
|
207
|
+
if not chunk:
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
self._upload_chunk(upload_url, offset, chunk)
|
|
211
|
+
offset += len(chunk)
|
|
212
|
+
|
|
213
|
+
if progress_callback:
|
|
214
|
+
progress_callback(offset, file_size)
|
|
215
|
+
|
|
216
|
+
return upload_url
|
|
217
|
+
|
|
218
|
+
def delete_upload(self, upload_url: str) -> None:
|
|
219
|
+
"""Delete an upload from the server.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
upload_url: URL of the upload to delete
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
HTTPError: If deletion fails
|
|
226
|
+
"""
|
|
227
|
+
headers = {
|
|
228
|
+
"Tus-Resumable": self.TUS_VERSION,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
req = Request(upload_url, headers=headers, method="DELETE")
|
|
232
|
+
try:
|
|
233
|
+
with urlopen(req):
|
|
234
|
+
pass
|
|
235
|
+
except HTTPError as e:
|
|
236
|
+
if e.code != 404:
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
def _create_upload(self, file_size: int, metadata: dict[str, str]) -> str:
|
|
240
|
+
"""Create a new upload on the server."""
|
|
241
|
+
# Encode metadata
|
|
242
|
+
encoded_metadata = self.encode_metadata(metadata)
|
|
243
|
+
|
|
244
|
+
headers = {
|
|
245
|
+
"Tus-Resumable": self.TUS_VERSION,
|
|
246
|
+
"Upload-Length": str(file_size),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if encoded_metadata:
|
|
250
|
+
headers["Upload-Metadata"] = ",".join(encoded_metadata)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
req = Request(self.url, headers=headers, method="POST")
|
|
254
|
+
with urlopen(req) as response:
|
|
255
|
+
location = response.headers.get("Location")
|
|
256
|
+
if not location:
|
|
257
|
+
raise TusCommunicationError("Server did not return Location header")
|
|
258
|
+
|
|
259
|
+
# Handle relative URLs
|
|
260
|
+
if not location.startswith("http"):
|
|
261
|
+
location = urljoin(self.url, location)
|
|
262
|
+
|
|
263
|
+
return location
|
|
264
|
+
except HTTPError as e:
|
|
265
|
+
raise TusCommunicationError(
|
|
266
|
+
f"Failed to create upload: {e.reason}",
|
|
267
|
+
status_code=e.code,
|
|
268
|
+
response_content=e.read(),
|
|
269
|
+
) from e
|
|
270
|
+
|
|
271
|
+
def encode_metadata(self, metadata: dict[str, str]) -> list:
|
|
272
|
+
"""
|
|
273
|
+
Encode metadata according to TUS protocol specification.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
metadata: Dictionary of metadata key-value pairs
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
List of encoded metadata strings
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: If metadata keys contain invalid characters
|
|
283
|
+
"""
|
|
284
|
+
encoded_list = []
|
|
285
|
+
for key, value in metadata.items():
|
|
286
|
+
key_str = str(key)
|
|
287
|
+
|
|
288
|
+
# Validate key does not contain spaces or commas
|
|
289
|
+
if re.search(r"^$|[\s,]+", key_str):
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f'Upload-metadata key "{key_str}" cannot be empty nor contain spaces or commas.'
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
value_bytes = value.encode(self.metadata_encoding)
|
|
295
|
+
encoded_value = base64.b64encode(value_bytes).decode("ascii")
|
|
296
|
+
encoded_list.append(f"{key_str} {encoded_value}")
|
|
297
|
+
|
|
298
|
+
return encoded_list
|
|
299
|
+
|
|
300
|
+
def _get_offset(self, upload_url: str) -> int:
|
|
301
|
+
"""Get the current upload offset."""
|
|
302
|
+
headers = {
|
|
303
|
+
"Tus-Resumable": self.TUS_VERSION,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
req = Request(upload_url, headers=headers, method="HEAD")
|
|
308
|
+
with urlopen(req) as response:
|
|
309
|
+
offset = response.headers.get("Upload-Offset")
|
|
310
|
+
if offset is None:
|
|
311
|
+
raise TusCommunicationError("Server did not return Upload-Offset header")
|
|
312
|
+
return int(offset)
|
|
313
|
+
except HTTPError as e:
|
|
314
|
+
raise TusCommunicationError(
|
|
315
|
+
f"Failed to get offset: {e.reason}",
|
|
316
|
+
status_code=e.code,
|
|
317
|
+
response_content=e.read(),
|
|
318
|
+
) from e
|
|
319
|
+
|
|
320
|
+
def _upload_chunk(self, upload_url: str, offset: int, data: bytes) -> None:
|
|
321
|
+
"""Upload a chunk of data."""
|
|
322
|
+
headers = {
|
|
323
|
+
"Tus-Resumable": self.TUS_VERSION,
|
|
324
|
+
"Upload-Offset": str(offset),
|
|
325
|
+
"Content-Type": "application/offset+octet-stream",
|
|
326
|
+
"Content-Length": str(len(data)),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# Add checksum if enabled
|
|
330
|
+
if self.checksum:
|
|
331
|
+
checksum_bytes = hashlib.sha1(data).digest()
|
|
332
|
+
checksum_b64 = base64.b64encode(checksum_bytes).decode("ascii")
|
|
333
|
+
headers["Upload-Checksum"] = f"sha1 {checksum_b64}"
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
req = Request(upload_url, data=data, headers=headers, method="PATCH")
|
|
337
|
+
with urlopen(req):
|
|
338
|
+
pass
|
|
339
|
+
except HTTPError as e:
|
|
340
|
+
raise TusUploadFailed(
|
|
341
|
+
f"Failed to upload chunk at offset {offset}: {e.reason}",
|
|
342
|
+
status_code=e.code,
|
|
343
|
+
response_content=e.read(),
|
|
344
|
+
) from e
|
|
345
|
+
|
|
346
|
+
def get_file_size(self, file_source: Union[str, IO]) -> int:
|
|
347
|
+
"""
|
|
348
|
+
Get the size of a file.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
file_source: Either a file path (str) or file stream (IO)
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
File size in bytes
|
|
355
|
+
"""
|
|
356
|
+
if isinstance(file_source, str):
|
|
357
|
+
return os.path.getsize(file_source)
|
|
358
|
+
else:
|
|
359
|
+
current_pos = file_source.tell()
|
|
360
|
+
file_source.seek(0, os.SEEK_END)
|
|
361
|
+
size = file_source.tell()
|
|
362
|
+
file_source.seek(current_pos)
|
|
363
|
+
return size
|
|
364
|
+
|
|
365
|
+
def get_file_stream(self, file_source: Union[str, IO]) -> IO:
|
|
366
|
+
"""
|
|
367
|
+
Get a file stream from a file path or stream.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
file_source: Either a file path (str) or file stream (IO)
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
File stream object
|
|
374
|
+
"""
|
|
375
|
+
if isinstance(file_source, str):
|
|
376
|
+
return open(file_source, "rb")
|
|
377
|
+
else:
|
|
378
|
+
file_source.seek(0)
|
|
379
|
+
return file_source
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Advanced TUS client with retry logic and robust error handling."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from threading import Lock
|
|
6
|
+
from typing import Callable, Optional, Union
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
|
|
9
|
+
from resumable_upload.client.base import TusClient
|
|
10
|
+
from resumable_upload.client.stats import UploadStats
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TusClientWithRetry(TusClient):
|
|
14
|
+
"""TUS client with automatic retry logic and detailed progress tracking.
|
|
15
|
+
|
|
16
|
+
Extends the base TusClient with:
|
|
17
|
+
- Automatic retry with exponential backoff
|
|
18
|
+
- Detailed progress statistics via UploadStats
|
|
19
|
+
- Comprehensive error handling and logging
|
|
20
|
+
- Configurable retry parameters
|
|
21
|
+
|
|
22
|
+
The TUS protocol requires sequential chunk uploads (not parallel) because
|
|
23
|
+
each chunk must be uploaded at the correct offset. The server validates that
|
|
24
|
+
the Upload-Offset header matches the current file position.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> client = TusClientWithRetry(
|
|
28
|
+
... "http://localhost:8080/files", max_retries=3, retry_delay=1.0
|
|
29
|
+
... )
|
|
30
|
+
>>> def progress(stats):
|
|
31
|
+
... print(f"Progress: {stats.progress_percent:.1f}%")
|
|
32
|
+
>>> url = client.upload_file("file.bin", progress_callback=progress)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
url: str,
|
|
38
|
+
chunk_size: Union[int, float] = 1024 * 1024, # 1MB chunks
|
|
39
|
+
checksum: bool = True, # Enable checksum verification
|
|
40
|
+
max_retries: int = 3, # Retry attempts per chunk
|
|
41
|
+
retry_delay: float = 1.0, # Initial delay between retries (seconds)
|
|
42
|
+
):
|
|
43
|
+
"""Initialize TUS client with retry capability.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
url: Base URL of TUS server
|
|
47
|
+
chunk_size: Size of each chunk in bytes (default: 1MB). Can be int or float.
|
|
48
|
+
checksum: Enable SHA1 checksum verification (default: True)
|
|
49
|
+
max_retries: Maximum retry attempts for failed chunks (default: 3)
|
|
50
|
+
retry_delay: Base delay between retry attempts in seconds (default: 1.0)
|
|
51
|
+
"""
|
|
52
|
+
super().__init__(url=url, chunk_size=chunk_size, checksum=checksum)
|
|
53
|
+
self.max_retries = max_retries
|
|
54
|
+
self.retry_delay = retry_delay
|
|
55
|
+
self.stats_lock = Lock()
|
|
56
|
+
self.logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
def upload_file(
|
|
59
|
+
self,
|
|
60
|
+
file_path: str,
|
|
61
|
+
metadata: Optional[dict[str, str]] = None,
|
|
62
|
+
chunk_size: Optional[Union[int, float]] = None,
|
|
63
|
+
progress_callback: Optional[Callable[[UploadStats], None]] = None,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Upload file with retry logic and progress tracking.
|
|
66
|
+
|
|
67
|
+
Uploads chunks sequentially as required by the TUS protocol. Each chunk
|
|
68
|
+
must be uploaded at the correct offset for data integrity.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
file_path: Path to file to upload
|
|
72
|
+
metadata: Optional metadata dictionary
|
|
73
|
+
chunk_size: Optional override for chunk size. Can be int or float.
|
|
74
|
+
progress_callback: Optional callback for progress updates with UploadStats
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
URL of uploaded file
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
FileNotFoundError: If file doesn't exist
|
|
81
|
+
ValueError: If chunk_size is less than 1
|
|
82
|
+
Exception: On upload failure after all retries
|
|
83
|
+
"""
|
|
84
|
+
import os
|
|
85
|
+
|
|
86
|
+
if not os.path.exists(file_path):
|
|
87
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
88
|
+
|
|
89
|
+
file_size = os.path.getsize(file_path)
|
|
90
|
+
stats = UploadStats(total_bytes=file_size)
|
|
91
|
+
|
|
92
|
+
# Use provided chunk_size or instance default, convert to int
|
|
93
|
+
actual_chunk_size_raw = chunk_size if chunk_size is not None else self.chunk_size
|
|
94
|
+
if actual_chunk_size_raw < 1:
|
|
95
|
+
raise ValueError(f"chunk_size must be at least 1 byte, got {actual_chunk_size_raw}")
|
|
96
|
+
actual_chunk_size = int(actual_chunk_size_raw)
|
|
97
|
+
|
|
98
|
+
self.logger.info(f"Starting upload of {file_path} ({file_size} bytes)")
|
|
99
|
+
self.logger.info(f"Chunk size: {actual_chunk_size}, Max retries: {self.max_retries}")
|
|
100
|
+
|
|
101
|
+
# Create upload
|
|
102
|
+
upload_url = self._create_upload(file_size, metadata or {})
|
|
103
|
+
self.logger.info(f"Upload created: {upload_url}")
|
|
104
|
+
|
|
105
|
+
# Calculate chunks
|
|
106
|
+
num_chunks = (file_size + actual_chunk_size - 1) // actual_chunk_size
|
|
107
|
+
self.logger.info(f"Split into {num_chunks} chunks")
|
|
108
|
+
|
|
109
|
+
# Upload chunks sequentially (required by TUS protocol)
|
|
110
|
+
for i in range(num_chunks):
|
|
111
|
+
offset = i * actual_chunk_size
|
|
112
|
+
size = min(actual_chunk_size, file_size - offset)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
self._upload_chunk_with_retry(file_path, upload_url, offset, size, stats)
|
|
116
|
+
|
|
117
|
+
with self.stats_lock:
|
|
118
|
+
stats.uploaded_bytes += size
|
|
119
|
+
stats.chunks_completed += 1
|
|
120
|
+
|
|
121
|
+
if progress_callback:
|
|
122
|
+
progress_callback(stats)
|
|
123
|
+
|
|
124
|
+
self.logger.debug(f"Chunk {i + 1}/{num_chunks} completed (offset {offset})")
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
self.logger.error(f"Chunk at offset {offset} failed after all retries: {e}")
|
|
128
|
+
with self.stats_lock:
|
|
129
|
+
stats.chunks_failed += 1
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
self.logger.info(
|
|
133
|
+
f"Upload completed in {stats.elapsed_time:.2f}s ({stats.upload_speed_mbps:.2f} MB/s)"
|
|
134
|
+
)
|
|
135
|
+
return upload_url
|
|
136
|
+
|
|
137
|
+
def _upload_chunk_with_retry(
|
|
138
|
+
self,
|
|
139
|
+
file_path: str,
|
|
140
|
+
upload_url: str,
|
|
141
|
+
offset: int,
|
|
142
|
+
size: int,
|
|
143
|
+
stats: UploadStats,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Upload a single chunk with retry logic.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
file_path: Path to source file
|
|
149
|
+
upload_url: Upload URL
|
|
150
|
+
offset: Byte offset in file
|
|
151
|
+
size: Chunk size in bytes
|
|
152
|
+
stats: Upload statistics object
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
Exception: If all retry attempts fail
|
|
156
|
+
"""
|
|
157
|
+
last_error = None
|
|
158
|
+
|
|
159
|
+
for attempt in range(self.max_retries):
|
|
160
|
+
try:
|
|
161
|
+
# Read chunk from file
|
|
162
|
+
with open(file_path, "rb") as f:
|
|
163
|
+
f.seek(offset)
|
|
164
|
+
data = f.read(size)
|
|
165
|
+
|
|
166
|
+
# Verify chunk was read correctly
|
|
167
|
+
if len(data) != size:
|
|
168
|
+
raise ValueError(f"Read {len(data)} bytes, expected {size} at offset {offset}")
|
|
169
|
+
|
|
170
|
+
# Upload chunk using base class method
|
|
171
|
+
self._upload_chunk(upload_url, offset, data)
|
|
172
|
+
|
|
173
|
+
# Success!
|
|
174
|
+
if attempt > 0:
|
|
175
|
+
with self.stats_lock:
|
|
176
|
+
stats.chunks_retried += 1
|
|
177
|
+
self.logger.info(
|
|
178
|
+
f"Chunk at offset {offset} succeeded after {attempt + 1} attempts"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
except (HTTPError, URLError, OSError) as e:
|
|
184
|
+
last_error = e
|
|
185
|
+
if attempt < self.max_retries - 1:
|
|
186
|
+
# Exponential backoff
|
|
187
|
+
delay = self.retry_delay * (2**attempt)
|
|
188
|
+
self.logger.warning(
|
|
189
|
+
f"Chunk at offset {offset} failed "
|
|
190
|
+
f"(attempt {attempt + 1}/{self.max_retries}): {e}. "
|
|
191
|
+
f"Retrying in {delay:.1f}s..."
|
|
192
|
+
)
|
|
193
|
+
time.sleep(delay)
|
|
194
|
+
else:
|
|
195
|
+
self.logger.error(
|
|
196
|
+
f"Chunk at offset {offset} failed after {self.max_retries} attempts"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# All retries failed
|
|
200
|
+
raise Exception(
|
|
201
|
+
f"Failed to upload chunk at offset {offset} after {self.max_retries} "
|
|
202
|
+
f"attempts: {last_error}"
|
|
203
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Upload statistics tracking for TUS client."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class UploadStats:
|
|
9
|
+
"""Statistics for upload progress.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
total_bytes: Total number of bytes to upload
|
|
13
|
+
uploaded_bytes: Number of bytes uploaded so far
|
|
14
|
+
chunks_completed: Number of chunks successfully uploaded
|
|
15
|
+
chunks_failed: Number of chunks that failed
|
|
16
|
+
chunks_retried: Number of chunks that required retries
|
|
17
|
+
start_time: Timestamp when upload started
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
total_bytes: int
|
|
21
|
+
uploaded_bytes: int = 0
|
|
22
|
+
chunks_completed: int = 0
|
|
23
|
+
chunks_failed: int = 0
|
|
24
|
+
chunks_retried: int = 0
|
|
25
|
+
start_time: float = 0.0
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if self.start_time == 0.0:
|
|
29
|
+
self.start_time = time.time()
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def elapsed_time(self) -> float:
|
|
33
|
+
"""Get elapsed time in seconds."""
|
|
34
|
+
return time.time() - self.start_time
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def upload_speed(self) -> float:
|
|
38
|
+
"""Get upload speed in bytes/second."""
|
|
39
|
+
if self.elapsed_time > 0:
|
|
40
|
+
return self.uploaded_bytes / self.elapsed_time
|
|
41
|
+
return 0.0
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def upload_speed_mbps(self) -> float:
|
|
45
|
+
"""Get upload speed in MB/second."""
|
|
46
|
+
return self.upload_speed / (1024 * 1024)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def progress_percent(self) -> float:
|
|
50
|
+
"""Get progress as percentage (0-100)."""
|
|
51
|
+
if self.total_bytes > 0:
|
|
52
|
+
return (self.uploaded_bytes / self.total_bytes) * 100
|
|
53
|
+
return 0.0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def eta_seconds(self) -> float:
|
|
57
|
+
"""Get estimated time to completion in seconds."""
|
|
58
|
+
if self.upload_speed > 0:
|
|
59
|
+
remaining_bytes = self.total_bytes - self.uploaded_bytes
|
|
60
|
+
return remaining_bytes / self.upload_speed
|
|
61
|
+
return 0.0
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def total_chunks(self) -> int:
|
|
65
|
+
"""Get total number of chunks (completed + failed)."""
|
|
66
|
+
return self.chunks_completed + self.chunks_failed
|