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.
@@ -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