205 lines
7.1 KiB
Python
205 lines
7.1 KiB
Python
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from http import HTTPStatus
|
|
from typing import Any, Optional, TYPE_CHECKING, Iterable, IO
|
|
|
|
from boxsdk import BoxAPIException
|
|
from boxsdk.util.api_call_decorator import api_call
|
|
from boxsdk.util.chunked_uploader import ChunkedUploader
|
|
from boxsdk.session.session import Session
|
|
from boxsdk.config import API
|
|
from .base_object import BaseObject
|
|
from ..pagination.limit_offset_based_dict_collection import LimitOffsetBasedDictCollection
|
|
|
|
if TYPE_CHECKING:
|
|
from boxsdk.pagination.box_object_collection import BoxObjectCollection
|
|
from boxsdk.object.file import File
|
|
|
|
|
|
class UploadSession(BaseObject):
|
|
_item_type = 'upload_session'
|
|
_parent_item_type = 'file'
|
|
_default_upload_url = API.UPLOAD_URL
|
|
|
|
def __init__(
|
|
self, session: Session, object_id: str, response_object: dict = None, use_upload_session_urls: bool = True
|
|
):
|
|
super().__init__(session, object_id, response_object)
|
|
self._use_upload_session_urls = use_upload_session_urls
|
|
|
|
def get_url(self, *args: Any, url_key: str = None) -> str:
|
|
"""
|
|
Base class override. Endpoint is a little different - it's /files/upload_sessions.
|
|
"""
|
|
session_endpoints = getattr(self, 'session_endpoints', {})
|
|
if self._use_upload_session_urls and url_key in session_endpoints and self.session.api_config.UPLOAD_URL == self._default_upload_url:
|
|
return session_endpoints[url_key]
|
|
|
|
return self._session.get_url(
|
|
f'{self._parent_item_type}s/{self._item_type}s',
|
|
self._object_id,
|
|
*args
|
|
).replace(self.session.api_config.BASE_API_URL, self.session.api_config.UPLOAD_URL)
|
|
|
|
@api_call
|
|
def get_parts(self, limit: Optional[int] = None, offset: Optional[int] = None) -> 'BoxObjectCollection':
|
|
"""
|
|
Get a list of parts uploaded so far.
|
|
|
|
:param limit:
|
|
The maximum number of items to return per page. If not specified, then will use the server-side default.
|
|
:param offset:
|
|
The index at which to start returning items.
|
|
:returns:
|
|
Returns a :class:`BoxObjectCollection` object containing the uploaded parts.
|
|
"""
|
|
return LimitOffsetBasedDictCollection(
|
|
session=self.session,
|
|
url=self.get_url('parts', url_key='list_parts'),
|
|
limit=limit,
|
|
offset=offset,
|
|
fields=None,
|
|
return_full_pages=False,
|
|
)
|
|
|
|
@api_call
|
|
def upload_part_bytes(
|
|
self,
|
|
part_bytes: bytes,
|
|
offset: int,
|
|
total_size: int,
|
|
part_content_sha1: Optional[bytes] = None
|
|
) -> dict:
|
|
"""
|
|
Upload a part of a file.
|
|
|
|
:param part_bytes:
|
|
Part bytes
|
|
:param offset:
|
|
Offset, in number of bytes, of the part compared to the beginning of the file. This number should be a
|
|
multiple of the part size.
|
|
:param total_size:
|
|
The size of the file that this part belongs to.
|
|
:param part_content_sha1:
|
|
SHA-1 hash of the part's content. If not specified, this will be calculated.
|
|
:returns:
|
|
The uploaded part record.
|
|
"""
|
|
|
|
if part_content_sha1 is None:
|
|
sha1 = hashlib.sha1()
|
|
sha1.update(part_bytes)
|
|
part_content_sha1 = sha1.digest()
|
|
|
|
range_end = min(offset + self.part_size - 1, total_size - 1) # pylint:disable=no-member
|
|
headers = {
|
|
'Content-Type': 'application/octet-stream',
|
|
'Digest': f'SHA={base64.b64encode(part_content_sha1).decode("utf-8")}',
|
|
'Content-Range': f'bytes {offset}-{range_end}/{total_size}',
|
|
}
|
|
response = self._session.put(
|
|
self.get_url(url_key='upload_part'),
|
|
headers=headers,
|
|
data=part_bytes,
|
|
)
|
|
return response.json()['part']
|
|
|
|
@api_call
|
|
def commit(
|
|
self,
|
|
content_sha1: bytes,
|
|
parts: Iterable[Optional[dict]] = None,
|
|
file_attributes: dict = None,
|
|
etag: Optional[str] = None
|
|
) -> Optional['File']:
|
|
"""
|
|
Commit a multiput upload.
|
|
|
|
:param content_sha1:
|
|
SHA-1 hash of the file contents that was uploaded.
|
|
:param parts:
|
|
List of parts that were uploaded.
|
|
:param file_attributes:
|
|
A `dict` of attributes to set on the uploaded file.
|
|
:param etag:
|
|
If specified, instruct the Box API to delete the folder only if the current version's etag matches.
|
|
:returns:
|
|
The newly-uploaded file object or None if commit was not processed
|
|
"""
|
|
body = {}
|
|
if file_attributes is not None:
|
|
body['attributes'] = file_attributes
|
|
if parts is not None:
|
|
body['parts'] = parts
|
|
else:
|
|
body['parts'] = list(self.get_parts())
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'Digest': f'SHA={base64.b64encode(content_sha1).decode("utf-8")}',
|
|
}
|
|
if etag is not None:
|
|
headers['If-Match'] = etag
|
|
|
|
try:
|
|
response = self._session.post(
|
|
self.get_url('commit', url_key='commit'),
|
|
headers=headers,
|
|
data=json.dumps(body),
|
|
)
|
|
except BoxAPIException as box_api_exc:
|
|
if box_api_exc.status == HTTPStatus.ACCEPTED:
|
|
return None
|
|
raise box_api_exc
|
|
|
|
entry = response.json()['entries'][0]
|
|
return self.translator.translate(
|
|
session=self._session,
|
|
response_object=entry,
|
|
)
|
|
|
|
@api_call
|
|
def abort(self) -> bool:
|
|
"""
|
|
Abort an upload session, cancelling the upload and removing any parts that have already been uploaded.
|
|
|
|
:returns:
|
|
A boolean indication success of the upload abort.
|
|
"""
|
|
|
|
box_response = self._session.delete(
|
|
self.get_url(url_key='abort'),
|
|
expect_json_response=False
|
|
)
|
|
return box_response.ok
|
|
|
|
def get_chunked_uploader_for_stream(self, content_stream: IO[bytes], file_size: int) -> ChunkedUploader:
|
|
"""
|
|
Instantiate the chunked upload instance and create upload session.
|
|
|
|
:param content_stream:
|
|
File-like object containing the content of the part to be uploaded.
|
|
:param file_size:
|
|
The size of the file that this part belongs to.
|
|
:returns:
|
|
A :class:`ChunkedUploader` object.
|
|
"""
|
|
return ChunkedUploader(self, content_stream, file_size)
|
|
|
|
def get_chunked_uploader(self, file_path: str) -> ChunkedUploader:
|
|
# pylint: disable=consider-using-with
|
|
"""
|
|
Instantiate the chunked upload instance and create upload session with path to file.
|
|
|
|
:param file_path:
|
|
The local path to the file you wish to upload.
|
|
:returns:
|
|
A :class:`ChunkedUploader` object.
|
|
"""
|
|
total_size = os.stat(file_path).st_size
|
|
content_stream = open(file_path, 'rb')
|
|
return self.get_chunked_uploader_for_stream(
|
|
content_stream=content_stream,
|
|
file_size=total_size,
|
|
)
|