-
-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
networkutil: Add new download_file utility function (#423)
* networkutil: Add new download_file utility function * DXVK: Use download_file util function * Luxtorpeda: Use download_file util function * networkutil: Fix type hint * DXVK: Typing and formatting * networkutil: Catch more errors when making request * networkutil: Make `download_file` lambda optional * networkutil: Warn if filesize is 0 instead of returning `False` in `download_file` * networkutil: Add safety checks to prevent crashing if buffer_size is 0 * raise exceptions in download_file and handle them in ctmods * networkutil: Note exceptions for download_file * ctmod: Add message_box_message connection for reporting errors * ctmod: Update download_file error dialog title * ctmod: Translate error dialog strings * minor stylistic changes * Use double quotes to allow for single quote in string * ctmod_luxtorpeda: Clean imports * ctmod_z0dxvk: Clean imports * ctmod_z0dxvk: Apply stylistic change --------- Co-authored-by: DavidoTek <54072917+DavidoTek@users.noreply.github.com>
- Loading branch information
Showing
4 changed files
with
160 additions
and
60 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import os | ||
from PySide6.QtCore import Property | ||
import requests | ||
|
||
from typing import Callable | ||
|
||
|
||
def download_file(url: str, destination: str, progress_callback: Callable[[int], None] | Callable[..., None] = lambda *args, **kwargs: None, download_cancelled: Property | None = None, buffer_size: int = 65536, stream: bool = True, known_size: int = 0): | ||
""" | ||
Download a file from a given URL using `requests` to a destination directory with download progress, with some optional parameters: | ||
* `progress_callback`: Function or Lambda that gets called with the download progress each time it changes | ||
* `download_cancelled`: Qt Property that can stop the download | ||
* `buffer_size`: Size of chunks to download the file in | ||
* `stream`: Lazily parse response - If response headers won't contain `'Content-Length'` and the file size is not known ahead of time, set this to `False` to get file size from response content length | ||
* `known_size`: If size is known ahead of time, this can be given to calculate download progress in place of Content-Length header (e.g. where it may be missing) | ||
Returns `True` if download succeeds, `False` otherwise. | ||
Raises: `OSError`, `requests.ConnectionError`, `requests.Timeout` | ||
Return Type: bool | ||
""" | ||
|
||
# Try to get the data for the file we want | ||
try: | ||
response: requests.Response = requests.get(url, stream=stream) | ||
except (OSError, requests.ConnectionError, requests.Timeout) as e: | ||
print(f"Error: Failed to make request to URL '{url}', cannot complete download! Reason: {e}") | ||
raise e | ||
|
||
progress_callback(1) # 1 = download started | ||
|
||
# Figure out file size for reporting download progress | ||
if stream and response.headers.get('Transfer-Encoding', '').lower() == 'chunked': | ||
print("Warning: Using 'stream=True' in request but 'Transfer-Encoding' in Response is 'Chunked', so we may not get 'Content-Length' to parse file size!") | ||
|
||
# Sometimes ctmods can have access to the asset size, so they can give it to us | ||
# If it is not specified, or if it is zero/Falsey, try to get it from the response | ||
file_size = known_size | ||
if not known_size: | ||
file_size = int(response.headers.get('Content-Length', 0)) | ||
|
||
# Sometimes Content-Length is not sent (such as for DXVK Async), so use response length in that case | ||
# See: https://stackoverflow.com/questions/53797628/request-has-no-content-length#53797919 | ||
# | ||
# Only get response.content if we aren't streaming so that we don't hold up the entire function, | ||
# and defeating the point of streaming to begin with | ||
if not stream: | ||
file_size = len(response.content) | ||
|
||
if file_size <= 0: | ||
print('Warning: Failed to get file size, the progress bar may not display accurately!') | ||
|
||
if buffer_size <= 0: | ||
print(f"Warning: Buffer Size was '{buffer_size}', defaulting to '65536'") | ||
buffer_size = 65536 | ||
|
||
# NOTE: If we don't get a known_size or if we can't get the size from Cotent-Length or the response size, | ||
# we cannot report download progress! | ||
# | ||
# Right now, only GitLab doesn't give us Content-Length because it uses Chunked Transfer-Encoding, | ||
# but ctmods should be able to get the size and pass it as known_size. | ||
# | ||
# If we ever make it this far without a file_size (e.g. we are stream=True and we don't get a | ||
# Content-Length, or len(response.content) is 0), then then the progress bar will stall at 1% until | ||
# the download finishes where it will jump to 99%, until extraction completes. | ||
try: | ||
chunk_count = int(file_size / buffer_size) | ||
except ZeroDivisionError as e: | ||
print(f'Error: Could not calculate chunk_count, {e}') | ||
print('Defaulting to chunk count of 1') | ||
chunk_count = 1 | ||
|
||
current_chunk = 1 | ||
|
||
# Get download filepath and download directory path without filename | ||
destination_file_path: str = os.path.expanduser(destination) | ||
destination_dir_path: str = os.path.dirname(destination_file_path) | ||
|
||
# Create download path if it doesn't exist (and make sure we have permission to do so) | ||
try: | ||
os.makedirs(destination_dir_path, exist_ok=True) | ||
except OSError as e: | ||
print(f'Error: Failed to create path to destination directory, cannot complete download! Reason: {e}') | ||
raise e | ||
|
||
# Download file and return progress to any given callback | ||
with open(destination, 'wb') as destination_file: | ||
for chunk in response.iter_content(chunk_size=buffer_size): | ||
chunk: bytes | ||
|
||
if download_cancelled: | ||
progress_callback(-2) # -2 = Download cancelled | ||
return False | ||
|
||
if not chunk: | ||
continue | ||
|
||
_ = destination_file.write(chunk) | ||
destination_file.flush() | ||
|
||
download_progress = int(min(current_chunk / chunk_count * 98.0, 98.0)) # 1...98 = Download in progress | ||
progress_callback(download_progress) | ||
|
||
current_chunk += 1 | ||
|
||
progress_callback(99) # 99 = Download completed successfully | ||
return True | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters