Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Resumable uploads #294

Open
wants to merge 97 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
0fe4a5b
feat: Resumable uploads
juancarlospaco Aug 28, 2024
ef3f479
feat: Resumable uploads
juancarlospaco Aug 28, 2024
0e064e6
feat: resumable progress wip
juancarlospaco Sep 4, 2024
3d42e60
feat: resumable progress wip
juancarlospaco Sep 4, 2024
a46d2c2
fix: code styles
juancarlospaco Sep 4, 2024
2e488b5
fix: code styles
juancarlospaco Sep 4, 2024
45e3554
fix: code styles
juancarlospaco Sep 4, 2024
d923bb6
fix: code styles
juancarlospaco Sep 4, 2024
87ab99d
fix: code styles
juancarlospaco Sep 4, 2024
313cfb7
fix: code styles
juancarlospaco Sep 4, 2024
f7bfdb0
feat: progress
juancarlospaco Sep 9, 2024
276bada
feat: progress
juancarlospaco Sep 9, 2024
3989e4b
feat: progress
juancarlospaco Sep 9, 2024
51e78c3
feat: More progress
juancarlospaco Sep 9, 2024
1d6b144
feat: More progress
juancarlospaco Sep 9, 2024
594413e
feat: More progress
juancarlospaco Sep 9, 2024
49da68e
fix: style
juancarlospaco Sep 11, 2024
3725cf0
fix: style
juancarlospaco Sep 11, 2024
46af272
fix: style
juancarlospaco Sep 11, 2024
2c5edd4
fix: style
juancarlospaco Sep 11, 2024
b51d38c
feat: progress
juancarlospaco Sep 11, 2024
a041bd4
feat: Allow to terminate upload link
juancarlospaco Sep 11, 2024
60d5f87
feat: Allow to terminate upload link
juancarlospaco Sep 11, 2024
f38cc33
feat: update Fileinfo
juancarlospaco Sep 11, 2024
d934d71
fix: update
juancarlospaco Sep 11, 2024
197b3f1
fix: update
juancarlospaco Sep 11, 2024
bc8d58f
fix: update
juancarlospaco Sep 11, 2024
b659780
fix: update
juancarlospaco Sep 11, 2024
01cfb62
fix: style
juancarlospaco Sep 11, 2024
8da95d2
fix: Deferred upload working wip
juancarlospaco Sep 16, 2024
0568eda
fix: Deferred upload working wip
juancarlospaco Sep 16, 2024
95d577f
fix: Deferred upload working wip
juancarlospaco Sep 16, 2024
aab78da
fix: Progress
juancarlospaco Sep 17, 2024
13513bc
fix: Progress
juancarlospaco Sep 17, 2024
3c8ed17
fix: Progress
juancarlospaco Sep 17, 2024
761811e
fix: Progress
juancarlospaco Sep 17, 2024
dc6f323
fix: arg checking, no empty filenames
juancarlospaco Sep 19, 2024
bd174d7
fix: arg checking, no empty filenames
juancarlospaco Sep 19, 2024
fdbafd7
Merge branch 'main' into resumable
juancarlospaco Sep 25, 2024
5c99620
Merge branch 'main' into resumable
juancarlospaco Oct 1, 2024
87eedcc
Merge branch 'main' into resumable
juancarlospaco Oct 8, 2024
9f8d85b
fix: improve readability
juancarlospaco Oct 15, 2024
e13466b
fix: test resumable WIP
juancarlospaco Oct 15, 2024
9aef96d
fix: test resumable WIP
juancarlospaco Oct 15, 2024
8fb7841
fix: test resumable WIP
juancarlospaco Oct 15, 2024
d2fd324
fix: test resumable WIP
juancarlospaco Oct 15, 2024
c5c37ba
fix: test resumable WIP
juancarlospaco Oct 15, 2024
64f0fee
fix: test resumable WIP
juancarlospaco Oct 15, 2024
ecc020b
fix: test resumable WIP
juancarlospaco Oct 15, 2024
16987df
fix: Use content instead of data
juancarlospaco Oct 15, 2024
4fef9af
fix: Use content instead of data
juancarlospaco Oct 15, 2024
927f1a2
fix: test resumable WIP
juancarlospaco Oct 15, 2024
6d37bb8
fix: test resumable WIP
juancarlospaco Oct 15, 2024
200e399
fix: test resumable WIP
juancarlospaco Oct 15, 2024
92b2464
fix: test resumable async WIP
juancarlospaco Oct 15, 2024
a440336
fix: test resumable async WIP
juancarlospaco Oct 15, 2024
d150278
Merge branch 'main' into resumable
juancarlospaco Oct 15, 2024
992f438
fix: verify the link was generated as expected
juancarlospaco Oct 16, 2024
f16ca11
fix: verify the link was generated as expected
juancarlospaco Oct 16, 2024
0ba7485
fix: style, improve readability
juancarlospaco Oct 16, 2024
cb23962
Merge branch 'main' into resumable
juancarlospaco Oct 20, 2024
7b86859
fix: Add more tests
juancarlospaco Oct 21, 2024
9b9926a
fix: Add more tests
juancarlospaco Oct 21, 2024
f529d51
fix: Add more tests
juancarlospaco Oct 21, 2024
e8682e0
fix: Add more tests
juancarlospaco Oct 21, 2024
f3e50a6
fix: Add more tests
juancarlospaco Oct 21, 2024
03773c7
fix: Style
juancarlospaco Oct 21, 2024
d5a7abe
fix: Style
juancarlospaco Oct 21, 2024
e60539a
fix: Grammar
juancarlospaco Oct 21, 2024
3925e9d
fix: Style
juancarlospaco Oct 21, 2024
92cf2ac
fix: Style
juancarlospaco Oct 21, 2024
ca69c12
fix: Style
juancarlospaco Oct 21, 2024
0eabc1b
fix: Style
juancarlospaco Oct 21, 2024
c831866
fix: Style
juancarlospaco Oct 21, 2024
ddf99f2
fix: Style
juancarlospaco Oct 21, 2024
952aa7b
fix: Add comments
juancarlospaco Oct 21, 2024
5e88936
fix: Add comments
juancarlospaco Oct 21, 2024
c029d28
fix: style
juancarlospaco Oct 22, 2024
41e5915
fix: style
juancarlospaco Oct 22, 2024
005aceb
fix: simplify
juancarlospaco Oct 22, 2024
1e2d293
fix: simplify
juancarlospaco Oct 22, 2024
671b545
fix: simplify
juancarlospaco Oct 22, 2024
438ad8b
fix: style
juancarlospaco Oct 22, 2024
aeb0244
fix: style
juancarlospaco Oct 22, 2024
279882e
fix: add documentation
juancarlospaco Oct 23, 2024
5379eaf
Merge branch 'main' into resumable
juancarlospaco Oct 23, 2024
dfc7ad5
fix: add more documentation
juancarlospaco Oct 23, 2024
0a23b97
fix: add more documentation
juancarlospaco Oct 23, 2024
60b98dd
fix: add more documentation
juancarlospaco Oct 23, 2024
7180e58
fix: add more documentation
juancarlospaco Oct 23, 2024
c2bd3c5
fix: add more documentation
juancarlospaco Oct 23, 2024
c1720d9
fix: merge
juancarlospaco Oct 25, 2024
c5e8caf
fix: Fix conflicts with main, resync
juancarlospaco Oct 28, 2024
20c2376
Merge branch 'main' into resumable
juancarlospaco Oct 28, 2024
1633e38
fix: Resync
juancarlospaco Oct 28, 2024
02fcbcd
Merge branch 'main' into resumable
juancarlospaco Oct 28, 2024
59790f9
fix: Resync
juancarlospaco Oct 28, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,7 @@ venv.bak/
.mypy_cache/
.dmypy.json
dmypy.json


# For testing purposes only.
test_image.svg
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ sphinx-toolbox = "^3.4.0"

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = "--ignore=tests/resumable"

[build-system]
build-backend = "poetry.core.masonry.api"
Expand Down
10 changes: 9 additions & 1 deletion storage3/_async/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,23 @@
from ..types import CreateOrUpdateBucketOptions, RequestMethod
from ..utils import AsyncClient, StorageException
from .file_api import AsyncBucket
from .resumable import AsyncResumableUpload

__all__ = ["AsyncStorageBucketAPI"]
__all__ = ("AsyncStorageBucketAPI",)


class AsyncStorageBucketAPI:
"""This class abstracts access to the endpoint to the Get, List, Empty, and Delete operations on a bucket"""

def __init__(self, session: AsyncClient) -> None:
self._client = session
self._resumable = None

@property
def resumable(self):
if self._resumable is None:
self._resumable = AsyncResumableUpload(self._client)
return self._resumable

async def _request(
self,
Expand Down
205 changes: 205 additions & 0 deletions storage3/_async/resumable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import os
from datetime import datetime

from ..types import FileInfo, UploadMetadata
from ..utils import (
AsyncClient,
FileStore,
StorageException,
base64encode_metadata,
is_valid_arg,
)

__all__ = ("AsyncResumableUpload",)


class AsyncResumableUpload:
def __init__(self, session: AsyncClient) -> None:
self._client = session
self.url = f"{self._client.base_url}upload/resumable"
self.expiration_time_format = "%a, %d %b %Y %X %Z"
self._filestore = FileStore()

def get_link(self, objectname) -> str:
"""Get the link associated with objectname in the bucket

Parameters
----------
objectname
This could be the local filename or objectname in the storage
"""
if not is_valid_arg(objectname):
raise StorageException("Bucketname cannot be empty")
return self._filestore.get_link(objectname)

async def create_unique_link(
self, bucketname=None, objectname=None, filename=None
) -> None:
"""Create unique link according to bucketname and objectname

Parameters
----------
bucketname
Storage bucket
objectname
Filename in the bucket
filename
Local file
"""
if not is_valid_arg(bucketname):
raise StorageException("Bucketname cannot be empty")

if not (is_valid_arg(objectname) or is_valid_arg(filename)):
raise StorageException("Must specify objectname or filename")

file = filename if filename else objectname

if not is_valid_arg(file):
raise StorageException("Must specify objectname or filename")

upload_mode = None

info = FileInfo(
name=file, link="", length="", headers={"Tus-Resumable": "1.0.0"}
)

if not filename:
upload_mode = "Upload-Defer-Length"
info["headers"][upload_mode] = "1"
else:
upload_mode = "Upload-Length"
size = str(os.stat(filename).st_size)

if int(size) == 0:
raise StorageException(
f"Cannot create a link for an empty file: {file}"
)

info["headers"][upload_mode] = size
info["length"] = size

obj_name = os.path.split(file)[1]
metadata = UploadMetadata(bucketName=bucketname, objectName=obj_name)

info["headers"]["Upload-Metadata"] = base64encode_metadata(metadata)
response = await self._client.post(self.url, headers=info["headers"])

if response.status_code != 201:
raise StorageException(response.content)

expiration_time = datetime.strptime(
response.headers["upload-expires"], self.expiration_time_format
)
info["expiration_time"] = expiration_time.timestamp()

info["link"] = response.headers["location"]
del info["headers"][upload_mode]
self._filestore.mark_file(info)

async def resumable_offset(self, link, headers) -> str:
"""Get the current offset to be used

Parameters
----------
link
Target url
headers
Metadata headers sent to the server
"""

if not self._filestore.link_exists(link):
raise StorageException(f"There's no reference to that link: {link}")

response = await self._client.head(link, headers=headers)

if "upload-offset" not in response.headers:
raise StorageException("Error while fetching the next offset.")

return response.headers["upload-offset"]

async def terminate(self, file: str) -> None:
"""Drop the link associated with a file

Parameters
----------
file
file name used to get its metadata info
"""
if not is_valid_arg(file):
raise StorageException("File argument cannot be empty")

info = self._filestore.get_file_info(file)
response = await self._client.delete(info["link"], headers=info["headers"])

if response.status_code != 204:
raise StorageException(response.content)

self._filestore.remove_file(file)

async def upload(
self, filename, upload_defer=False, link=None, objectname=None, mb_size=1
) -> None:
"""Send file's content in chunks to the target url

Parameters
----------
filename
Local file
upload_defer
Requires link and objectname to be True to retrieve file info in the FileStore
link
Target url
objectname
Name of the file in the bucket
mb_size
Amount of megabytes to be sent in each iteration
"""
if upload_defer:
if not (is_valid_arg(link) and is_valid_arg(objectname)):
raise StorageException(
"Upload-Defer mode requires a link and objectname"
)

if not is_valid_arg(filename):
raise StorageException("Must specify a filename")

target_file = objectname if upload_defer else filename
chunk_size = 1048576 * int(max(1, mb_size)) # 1024 * 1024 * mb_size
size = None
self._filestore.update_file_headers(
target_file, "Content-Type", "application/offset+octet-stream"
)
storage_link = link if upload_defer else self.get_link(target_file)

if upload_defer:
size = str(os.stat(filename).st_size)

if int(size) == 0:
raise StorageException(f"Cannot upload an empty file: {filename}")

self._filestore.update_file_headers(target_file, "Upload-Length", size)
self._filestore.update_file_headers(target_file, "Upload-Offset", "0")
headers = self._filestore.get_file_headers(target_file)
response = await self._client.patch(storage_link, headers=headers)
self._filestore.delete_file_headers(target_file, "Upload-Length")

while True:
headers = self._filestore.get_file_headers(target_file)
offset = await self.resumable_offset(storage_link, headers)
file = self._filestore.open_file(filename, offset=int(offset))
self._filestore.update_file_headers(target_file, "Upload-Offset", offset)

chunk = file.read(chunk_size)
headers = self._filestore.get_file_headers(target_file)

response = await self._client.patch(
storage_link, headers=headers, content=chunk
)

if response.status_code not in {201, 204}:
raise StorageException(response.content)

if "tus-complete" in response.headers:
self._filestore.close_file(file)
self._filestore.remove_file(target_file)
break
11 changes: 10 additions & 1 deletion storage3/_sync/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
from ..types import CreateOrUpdateBucketOptions, RequestMethod
from ..utils import StorageException, SyncClient
from .file_api import SyncBucket
from .resumable import ResumableUpload

__all__ = ["SyncStorageBucketAPI"]
__all__ = ("SyncStorageBucketAPI",)


class SyncStorageBucketAPI:
"""This class abstracts access to the endpoint to the Get, List, Empty, and Delete operations on a bucket"""

def __init__(self, session: SyncClient) -> None:
self._client = session
self._resumable = None

def _request(
self,
Expand All @@ -33,6 +35,13 @@ def _request(

return response

@property
def resumable(self):
if self._resumable is None:
self._resumable = ResumableUpload(self._client)

return self._resumable

def list_buckets(self) -> list[SyncBucket]:
"""Retrieves the details of all storage buckets within an existing product."""
# if the request doesn't error, it is assured to return a list
Expand Down
Loading