Skip to content

Commit 6f6ffbf

Browse files
authored
feat(storage): report resumable upload progress (#64)
1 parent 0b9cce1 commit 6f6ffbf

3 files changed

Lines changed: 62 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ uploaded = bucket.upload_resumable(
5050
"videos/automatic.mp4",
5151
video,
5252
content_type="video/mp4",
53+
on_progress=lambda uploaded, total: print(f"{uploaded}/{total}"),
5354
)
5455
print(uploaded.name)
5556
upload_session = bucket.create_upload_session(
@@ -110,7 +111,9 @@ part count, and expiration time for a resumable upload.
110111
`upload_resumable()` accepts bytes or a binary file-like object, creates a
111112
session, and uploads server-sized chunks. It streams seekable files directly;
112113
non-seekable inputs are spooled to a temporary file with bounded reads. If a
113-
part fails, it makes a best-effort abort and raises the original error.
114+
part or progress callback fails, it makes a best-effort abort and raises the
115+
original error. `on_progress` runs after each successful part with cumulative
116+
uploaded bytes and the total size.
114117
`upload_part()` returns immutable part metadata and can safely retry the same
115118
part number to replace that part.
116119
`locks.get()` returns immutable lock availability, expiry, and fencing-token

‎src/volcano_sdk/storage.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import base64
66
import binascii
77
import json
8-
from collections.abc import Generator, Mapping, Sequence
8+
from collections.abc import Callable, Generator, Mapping, Sequence
99
from contextlib import contextmanager, suppress
1010
from dataclasses import dataclass
1111
from datetime import datetime
@@ -564,6 +564,7 @@ def upload_resumable(
564564
*,
565565
content_type: str = "application/octet-stream",
566566
part_size: int | None = None,
567+
on_progress: Callable[[int, int], None] | None = None,
567568
) -> StorageObject:
568569
"""Upload bytes or a binary stream through a resumable session."""
569570
path = _storage_path(path)
@@ -577,7 +578,13 @@ def upload_resumable(
577578
)
578579
upload_succeeded = False
579580
try:
580-
self._upload_session_parts(path, source, session)
581+
self._upload_session_parts(
582+
path,
583+
source,
584+
session,
585+
total_size,
586+
on_progress,
587+
)
581588
upload_succeeded = True
582589
finally:
583590
if not upload_succeeded:
@@ -589,14 +596,21 @@ def _upload_session_parts(
589596
path: str,
590597
source: BinaryIO,
591598
session: UploadSession,
599+
total_size: int,
600+
on_progress: Callable[[int, int], None] | None,
592601
) -> None:
602+
uploaded = 0
593603
for part_index in range(session.total_parts):
604+
part = _read_upload_part(source, session.part_size)
594605
self.upload_part(
595606
path,
596607
session_id=session.session_id,
597608
part_number=part_index + 1,
598-
data=_read_upload_part(source, session.part_size),
609+
data=part,
599610
)
611+
uploaded += len(part)
612+
if on_progress is not None:
613+
on_progress(uploaded, total_size)
600614

601615
def _abort_failed_upload(self, path: str, session_id: str) -> None:
602616
with suppress(Exception):

‎tests/unit/test_facade.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,47 @@ def test_storage_uploads_bytes_with_server_selected_chunks() -> None:
816816
assert object_.name == "videos/demo.mp4"
817817

818818

819+
def test_storage_reports_progress_after_each_uploaded_part() -> None:
820+
transport = FakeTransport()
821+
transport.upload_session_part_size = 4
822+
transport.upload_session_total_parts = 3
823+
client = VolcanoClient(anon_key="anon-key", _transport=transport)
824+
client.auth.sign_in(email="user@example.com", password="secret")
825+
progress: list[tuple[int, int]] = []
826+
827+
client.storage.from_("assets").upload_resumable(
828+
"file.bin",
829+
b"abcdefghij",
830+
on_progress=lambda uploaded, total: progress.append((uploaded, total)),
831+
)
832+
833+
assert progress == [(4, 10), (8, 10), (10, 10)]
834+
835+
836+
def test_storage_aborts_when_a_progress_callback_fails() -> None:
837+
transport = FakeTransport()
838+
transport.upload_session_part_size = 4
839+
transport.upload_session_total_parts = 2
840+
client = VolcanoClient(anon_key="anon-key", _transport=transport)
841+
client.auth.sign_in(email="user@example.com", password="secret")
842+
843+
def fail_progress(_uploaded: int, _total: int) -> None:
844+
message = "progress failed"
845+
raise RuntimeError(message)
846+
847+
with pytest.raises(RuntimeError, match="progress failed"):
848+
client.storage.from_("assets").upload_resumable(
849+
"file.bin",
850+
b"abcdefgh",
851+
on_progress=fail_progress,
852+
)
853+
854+
assert [operation for operation, _ in transport.calls[-2:]] == [
855+
"uploadPart",
856+
"abortUploadSession",
857+
]
858+
859+
819860
def test_storage_aborts_after_part_failure_without_masking_the_error() -> None:
820861
transport = FakeTransport()
821862
transport.upload_session_part_size = 4

0 commit comments

Comments
 (0)