Skip to content

Commit c1b99fe

Browse files
authored
Implement https resume (#11)
1 parent 7e1fe97 commit c1b99fe

File tree

1 file changed

+94
-14
lines changed

1 file changed

+94
-14
lines changed

tools/idf_tools.py

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -676,23 +676,32 @@ def report_progress(count: int, block_size: int, total_size: int) -> None:
676676
sys.stdout.write('\r%d%%' % percent)
677677
sys.stdout.flush()
678678

679-
def download_file_with_progress(response, destination: str) -> None:
679+
def download_file_with_progress(response, destination: str, resume_from: int = 0, expected_total: int = 0) -> None:
680680
"""
681681
Downloads file from urllib response object with progress display.
682682
683683
This function replaces the manual implementation that was in the original
684684
urlretrieve_ctx function, providing progress feedback during download.
685+
Supports resuming partial downloads.
685686
686687
Args:
687688
response: urllib response object to read from
688689
destination: Local file path to save the downloaded content
689-
"""
690-
with open(destination, 'wb') as f:
691-
total_size = int(response.getheader('Content-Length', 0))
692-
downloaded = 0
693-
block_size = 8192
694-
blocknum = 0
695-
690+
resume_from: Byte offset to resume from (0 for fresh download)
691+
expected_total: Expected total file size (for progress display when resuming)
692+
"""
693+
# Use append mode if resuming, write mode for fresh download
694+
mode = 'ab' if resume_from > 0 else 'wb'
695+
with open(destination, mode) as f:
696+
content_length = int(response.getheader('Content-Length', 0))
697+
# When resuming, Content-Length is the remaining bytes, not total
698+
total_size = expected_total if expected_total > 0 else content_length
699+
downloaded = resume_from
700+
block_size = 32768 # 32KB - balance between efficiency and reliability
701+
blocknum = downloaded // block_size
702+
703+
if resume_from > 0:
704+
info(f'Resuming download from {resume_from} bytes')
696705
if total_size > 0:
697706
info(f'File size: {total_size} bytes')
698707

@@ -712,6 +721,13 @@ def download_file_with_progress(response, destination: str) -> None:
712721
sys.stdout.write(f'\r{downloaded} bytes downloaded')
713722
sys.stdout.flush()
714723

724+
# Check if download was complete - raise exception if truncated
725+
if total_size > 0 and downloaded < total_size:
726+
raise ContentTooShortError(
727+
f'retrieval incomplete: got only {downloaded} out of {total_size} bytes',
728+
(destination, None)
729+
)
730+
715731

716732
def mkdir_p(path: str) -> None:
717733
"""
@@ -886,7 +902,7 @@ def urlretrieve_ctx(
886902
return result
887903

888904

889-
def download(url: str, destination: str) -> Union[None, Exception]:
905+
def download(url: str, destination: str, is_retry: bool = False) -> Union[None, Exception]:
890906
"""
891907
Download from given url and save into given destination.
892908
@@ -895,16 +911,26 @@ def download(url: str, destination: str) -> Union[None, Exception]:
895911
SSL configuration accordingly. Multiple fallback contexts are tried to maximize
896912
compatibility across different systems, especially macOS.
897913
914+
Supports resuming partial downloads using HTTP Range header.
915+
898916
Args:
899917
url: URL to download from
900918
destination: Local file path to save to
919+
is_retry: Set to True when retrying after deleting a corrupted file
901920
902921
Returns:
903922
None on success, Exception object on failure
904923
"""
905924
info(f'Downloading {url}')
906925
info(f'Destination: {destination}')
907926

927+
# Check for existing partial download to resume (skip on retry to start fresh)
928+
resume_from = 0
929+
if not is_retry and os.path.isfile(destination):
930+
resume_from = os.path.getsize(destination)
931+
if resume_from > 0:
932+
info(f'Found partial download ({resume_from} bytes), will attempt to resume')
933+
908934
# Get SSL fallback contexts for robust SSL handling
909935
ssl_contexts = get_ssl_fallback_contexts(url)
910936
last_exception = None
@@ -915,12 +941,54 @@ def download(url: str, destination: str) -> Union[None, Exception]:
915941

916942
if url.startswith('https'):
917943
# HTTPS with specific SSL context
918-
req = urllib.request.Request(url, headers={
919-
'User-Agent': 'pioarduino'
920-
})
921-
944+
headers = {'User-Agent': 'pioarduino'}
945+
946+
# Add Range header for resume support
947+
if resume_from > 0:
948+
headers['Range'] = f'bytes={resume_from}-'
949+
950+
req = urllib.request.Request(url, headers=headers)
951+
922952
with urllib.request.urlopen(req, context=ctx, timeout=60) as response:
923-
download_file_with_progress(response, destination)
953+
# Check if server supports resume (206 Partial Content)
954+
# or if it's sending the full file (200 OK)
955+
if response.status == 206:
956+
# Server supports resume, get total size from Content-Range
957+
content_range = response.getheader('Content-Range', '')
958+
expected_total = 0
959+
if content_range:
960+
# Format: bytes start-end/total
961+
try:
962+
expected_total = int(content_range.split('/')[-1])
963+
except (ValueError, IndexError):
964+
pass
965+
966+
# Check if file is already complete or needs restart
967+
if expected_total > 0:
968+
if resume_from == expected_total:
969+
info(f'File already complete ({resume_from} bytes), skipping download')
970+
return None
971+
if resume_from > expected_total:
972+
if is_retry:
973+
return Exception(f'File still oversized after retry: {destination}')
974+
warn(f'File is oversized ({resume_from} > {expected_total}), restarting download')
975+
return download(url, destination, is_retry=True)
976+
else:
977+
# Server sent 206 but no valid Content-Range - malformed response
978+
if is_retry:
979+
return Exception(f'Server did not provide file size after retry: {destination}')
980+
warn('Server did not provide file size, restarting download')
981+
return download(url, destination, is_retry=True)
982+
983+
download_file_with_progress(response, destination, resume_from, expected_total)
984+
elif response.status == 200:
985+
# Server doesn't support resume or sent full file
986+
if resume_from > 0:
987+
info('Server does not support resume, downloading from start')
988+
download_file_with_progress(response, destination)
989+
else:
990+
# Unexpected status, try normal download
991+
download_file_with_progress(response, destination)
924992

925993
elif url.startswith('http'):
926994
# HTTP without SSL context
@@ -934,6 +1002,18 @@ def download(url: str, destination: str) -> Union[None, Exception]:
9341002
sys.stdout.write('\rDone\n')
9351003
sys.stdout.flush()
9361004
return None
1005+
1006+
except urllib.error.HTTPError as e:
1007+
# Handle HTTP 416 Range Not Satisfiable - file is corrupt/oversized, restart fresh
1008+
if e.code == 416 and resume_from > 0:
1009+
if is_retry:
1010+
return Exception(f'Range request failed after retry: {url}')
1011+
warn('Range request failed (file may be corrupted), restarting download')
1012+
return download(url, destination, is_retry=True)
1013+
1014+
last_exception = e
1015+
warn(f'Configuration "{config_name}" failed: {str(e)[:100]}...')
1016+
continue
9371017

9381018
except Exception as e:
9391019
last_exception = e

0 commit comments

Comments
 (0)