@@ -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
716732def 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 ('\r Done\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