Skip to content

Commit b433692

Browse files
authored
feat: Fix closing file after chunked upload (#761)
Closes: SDK-2611
1 parent 5d26431 commit b433692

File tree

3 files changed

+65
-29
lines changed

3 files changed

+65
-29
lines changed

boxsdk/object/folder.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class _CollaborationType(TextEnum):
4545

4646
class _Collaborator:
4747
"""This helper class represents a collaborator on Box. A Collaborator can be a User, Group, or an email address"""
48+
4849
def __init__(self, collaborator: Any):
4950
if isinstance(collaborator, User):
5051
self._setup(user=collaborator)
@@ -138,21 +139,29 @@ def create_upload_session(self, file_size: int, file_name: str) -> 'UploadSessio
138139
)
139140

140141
@api_call
141-
def get_chunked_uploader(self, file_path: str) -> 'ChunkedUploader':
142+
def get_chunked_uploader(self, file_path: str, file_name: Optional[str] = None) -> 'ChunkedUploader':
142143
# pylint: disable=consider-using-with
143144
"""
144145
Instantiate the chunked upload instance and create upload session with path to file.
145146
146147
:param file_path:
147148
The local path to the file you wish to upload.
149+
:param file_name:
150+
The name with extention of the file that will be uploaded, e.g. new_file_name.zip.
151+
If not specified, the name from the local system is used.
148152
:returns:
149153
A :class:`ChunkedUploader` object.
150154
"""
151155
total_size = os.stat(file_path).st_size
156+
upload_file_name = file_name if file_name else os.path.basename(file_path)
152157
content_stream = open(file_path, 'rb')
153-
file_name = os.path.basename(file_path)
154-
upload_session = self.create_upload_session(total_size, file_name)
155-
return upload_session.get_chunked_uploader_for_stream(content_stream, total_size)
158+
159+
try:
160+
upload_session = self.create_upload_session(total_size, upload_file_name)
161+
return upload_session.get_chunked_uploader_for_stream(content_stream, total_size)
162+
except Exception:
163+
content_stream.close()
164+
raise
156165

157166
def _get_accelerator_upload_url_fow_new_uploads(self) -> Optional[str]:
158167
"""
@@ -310,7 +319,9 @@ def upload_stream(
310319
headers['Content-MD5'] = sha1
311320
if not headers:
312321
headers = None
313-
file_response = self._session.post(url, data=data, files=files, expect_json_response=False, headers=headers).json()
322+
file_response = self._session.post(
323+
url, data=data, files=files, expect_json_response=False, headers=headers
324+
).json()
314325
if 'entries' in file_response:
315326
file_response = file_response['entries'][0]
316327
return self.translator.translate(

boxsdk/util/chunked_uploader.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
class ChunkedUploader:
1111

12-
def __init__(self, upload_session: 'UploadSession', content_stream: IO, file_size: int):
12+
def __init__(self, upload_session: 'UploadSession', content_stream: IO[bytes], file_size: int):
1313
"""
1414
The initializer for the :class:`ChunkedUploader`
1515
@@ -42,8 +42,7 @@ def start(self) -> Optional['File']:
4242
if self._is_aborted:
4343
raise BoxException('The upload has been previously aborted. Please retry upload with a new upload session.')
4444
self._upload()
45-
content_sha1 = self._sha1.digest()
46-
return self._upload_session.commit(content_sha1=content_sha1, parts=self._part_array)
45+
return self._commit_and_erase_stream_reference_when_succeed()
4746

4847
def resume(self) -> Optional['File']:
4948
"""
@@ -68,8 +67,7 @@ def resume(self) -> Optional['File']:
6867
self._inflight_part = None
6968
self._part_definitions[part['offset']] = part
7069
self._upload()
71-
content_sha1 = self._sha1.digest()
72-
return self._upload_session.commit(content_sha1=content_sha1, parts=self._part_array)
70+
return self._commit_and_erase_stream_reference_when_succeed()
7371

7472
def abort(self) -> bool:
7573
"""
@@ -86,7 +84,7 @@ def abort(self) -> bool:
8684

8785
def _upload(self) -> None:
8886
"""
89-
Utility function for looping through all parts of of the upload session and uploading them.
87+
Utility function for looping through all parts of the upload session and uploading them.
9088
"""
9189
while len(self._part_array) < self._upload_session.total_parts:
9290
# Retrieve the part inflight if it exists, if it does not exist then get the next part from the stream.
@@ -124,6 +122,14 @@ def _get_next_part(self) -> 'InflightPart':
124122
copied_length += len(bytes_read)
125123
return InflightPart(offset, chunk, self._upload_session, self._file_size)
126124

125+
def _commit_and_erase_stream_reference_when_succeed(self):
126+
content_sha1 = self._sha1.digest()
127+
commit_result = self._upload_session.commit(content_sha1=content_sha1, parts=self._part_array)
128+
# Remove file stream reference when uploading file succeeded
129+
if commit_result is not None:
130+
self._content_stream = None
131+
return commit_result
132+
127133

128134
class InflightPart:
129135

docs/usage/files.md

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ file's contents, upload new versions, and perform other common file operations
1717
- [Automatic Uploader](#automatic-uploader)
1818
- [Upload new file](#upload-new-file)
1919
- [Upload new file version](#upload-new-file-version)
20+
- [Preflight check before upload](#preflight-check-before-upload)
2021
- [Resume Upload](#resume-upload)
2122
- [Abort Chunked Upload](#abort-chunked-upload)
2223
- [Manual Process](#manual-process)
@@ -200,39 +201,39 @@ without aborting the entire upload, and failed parts can then be retried.
200201

201202
#### Upload new file
202203

203-
The SDK provides a method of automatically handling a chunked upload. First get a folder you want to upload the file to. Then call [`folder.get_chunked_uploader(file_path, rename_file=False)`][get_chunked_uploader_for_file] to retrieve a [`ChunkedUploader`][chunked_uploader_class] object. Calling the method [`chunked_upload.start()`][start] will kick off the chunked upload process and return the [File][file_class]
204+
The SDK provides a method of automatically handling a chunked upload. First get a folder you want to upload the file to.
205+
Then call [`folder.get_chunked_uploader(file_path, rename_file=False)`][get_chunked_uploader_for_file] to retrieve
206+
a [`ChunkedUploader`][chunked_uploader_class] object. Calling the method [`chunked_upload.start()`][start] will
207+
kick off the chunked upload process and return the [File][file_class]
204208
object that was uploaded.
205209

206210
<!-- samples x_chunked_uploads automatic -->
207211
```python
208212
# uploads large file to a root folder
209-
chunked_uploader = client.folder('0').get_chunked_uploader('/path/to/file')
213+
chunked_uploader = client.folder('0').get_chunked_uploader(file_path='/path/to/file.txt', file_name='new_name.txt')
210214
uploaded_file = chunked_uploader.start()
211215
print(f'File "{uploaded_file.name}" uploaded to Box with file ID {uploaded_file.id}')
212216
```
213217

214-
You can also return a [`ChunkedUploader`][chunked_uploader_class] object by creating a [`UploadSession`][upload_session_class] object first
215-
and calling the method [`upload_session.get_chunked_upload(file_path)`][get_chunked_uploader] or
216-
[`upload_session.get_chunked_uploader_for_stream(content_stream, file_size)`][get_chunked_uploader_for_stream].
217-
218-
```python
219-
chunked_uploader = client.upload_session('56781').get_chunked_uploader('/path/to/file')
220-
uploaded_file = chunked_uploader.start()
221-
print(f'File "{uploaded_file.name}" uploaded to Box with file ID {uploaded_file.id}')
222-
```
218+
You can also upload file stream by creating a [`UploadSession`][upload_session_class] first and then calling the
219+
method [`upload_session.get_chunked_uploader_for_stream(content_stream, file_size)`][get_chunked_uploader_for_stream].
223220

224221
```python
225222
test_file_path = '/path/to/large_file.mp4'
226-
content_stream = open(test_file_path, 'rb')
227-
total_size = os.stat(test_file_path).st_size
228-
chunked_uploader = client.upload_session('56781').get_chunked_uploader_for_stream(content_stream, total_size)
229-
uploaded_file = chunked_uploader.start()
230-
print(f'File "{uploaded_file.name}" uploaded to Box with file ID {uploaded_file.id}')
223+
with open(test_file_path, 'rb') as content_stream:
224+
total_size = os.stat(test_file_path).st_size
225+
upload_session = client.folder('0').create_upload_session(file_size=total_size, file_name='large_file.mp4')
226+
chunked_uploader = upload_session.get_chunked_uploader_for_stream(content_stream=content_stream, file_size=total_size)
227+
uploaded_file = chunked_uploader.start()
228+
print(f'File "{uploaded_file.name}" uploaded to Box with file ID {uploaded_file.id}')
231229
```
232230

233231
#### Upload new file version
234232

235-
To upload a new file version for a large file, first get a file you want to replace. Then call [`file.get_chunked_uploader(file_path)`][get_chunked_uploader_for_version] to retrieve a [`ChunkedUploader`][chunked_uploader_class] object. Calling the method [`chunked_upload.start()`][start] will kick off the chunked upload process and return the updated [File][file_class].
233+
To upload a new file version for a large file, first get a file you want to replace.
234+
Then call [`file.get_chunked_uploader(file_path)`][get_chunked_uploader_for_version]
235+
to retrieve a [`ChunkedUploader`][chunked_uploader_class] object. Calling the method [`chunked_upload.start()`][start]
236+
will kick off the chunked upload process and return the updated [File][file_class].
236237

237238
<!-- samples x_chunked_uploads automatic_new_version -->
238239
```python
@@ -243,13 +244,31 @@ print(f'File "{uploaded_file.name}" uploaded to Box with file ID {uploaded_file.
243244
# the uploaded_file.id will be the same as 'existing_big_file_id'
244245
```
245246

247+
#### Preflight check before upload
248+
249+
To check if a file can be uploaded with given name to a specific folder call
250+
[`folder.preflight_check(size, name)`][preflight_check]. If the check did not pass, this method will raise an exception
251+
including some details on why it did not pass.
252+
253+
<!-- samples x_chunked_uploads automatic_new_version -->
254+
```python
255+
file_name = 'large_file.mp4'
256+
test_file_path = '/path/to/large_file.mp4'
257+
total_size = os.stat(test_file_path).st_size
258+
destination_folder_id = '0'
259+
try:
260+
client.folder(destination_folder_id).preflight_check(size=total_size, name=file_name)
261+
except BoxAPIException as e:
262+
print(f'File {file_name} cannot be uploaded to folder with id: {destination_folder_id}. Reason: {e.message}')
263+
```
264+
246265
[start]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.chunked_uploader.ChunkedUploader.start
247266
[chunked_uploader_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.chunked_uploader.ChunkedUploader
248267
[get_chunked_uploader_for_version]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.file.File.get_chunked_uploader
249268
[get_chunked_uploader_for_file]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.folder.Folder.get_chunked_uploader
250269
[upload_session_class]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.upload_session.UploadSession
251-
[get_chunked_uploader]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.upload_session.UploadSession.get_chunked_uploader
252270
[get_chunked_uploader_for_stream]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.upload_session.UploadSession.get_chunked_uploader_for_stream
271+
[preflight_check]: https://box-python-sdk.readthedocs.io/en/latest/boxsdk.object.html#boxsdk.object.folder.Folder.preflight_check
253272

254273
#### Resume Upload
255274

0 commit comments

Comments
 (0)