import argparse import io import os import requests import sys import time import urllib import zlib from voussoirkit import bytestring from voussoirkit import dotdict from voussoirkit import httperrors from voussoirkit import pathclass from voussoirkit import pipeable from voussoirkit import progressbars from voussoirkit import ratelimiter from voussoirkit import sentinel from voussoirkit import vlogging log = vlogging.getLogger(__name__, 'downloady') USERAGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36' HEADERS = { 'User-Agent': USERAGENT, } FILENAME_BADCHARS = '*?"<>|\r\n' # When using dynamic chunk sizing, this is the ideal time to process a # single chunk, in seconds. IDEAL_CHUNK_TIME = 0.2 TIMEOUT = 60 TEMP_EXTENSION = '.downloadytemp' if os.name == 'nt': SPECIAL_FILENAMES = pathclass.WINDOWS_RESERVED_NAMES else: SPECIAL_FILENAMES = [os.devnull] SPECIAL_FILENAMES = [os.path.normcase(x) for x in SPECIAL_FILENAMES] FILE_EXISTS = sentinel.Sentinel('file exists no overwrite', truthyness=False) class DownloadyException(Exception): pass class NotEnoughBytes(DownloadyException): pass class ServerNoRange(DownloadyException): pass class SpecialPath: ''' This class is to be used for special paths like /dev/null and Windows's nul. Unlike regular download paths, these special paths will not be renamed with a temporary file extension, and the containing paths will not be checked for existence or mkdir'ed. ''' def __init__(self, path): self.absolute_path = path def open(self, *args, **kwargs): return open(self.absolute_path, *args, **kwargs) def download_file( url, localname=None, auth=None, bytespersecond=None, custom_validator=None, progressbar=None, do_head=True, headers=None, overwrite=False, raise_for_undersized=True, ratemeter=None, timeout=None, verbose=False, verify_ssl=True, ): plan = prepare_plan( url, localname, auth=auth, bytespersecond=bytespersecond, custom_validator=custom_validator, progressbar=progressbar, do_head=do_head, headers=headers, overwrite=overwrite, raise_for_undersized=raise_for_undersized, ratemeter=ratemeter, timeout=timeout, verify_ssl=verify_ssl, ) if isinstance(plan, sentinel.Sentinel): return plan return download_plan(plan) def download_plan(plan): if isinstance(plan.download_into, pathclass.Path): plan.download_into.parent.makedirs(exist_ok=True) plan.download_into.touch() if plan.plan_type in ['resume', 'partial']: if isinstance(plan.download_into, io.IOBase): file_handle = plan.download_into if plan.seek_to > 0: file_handle.seek(plan.seek_to) else: file_handle = plan.download_into.open('r+b') file_handle.seek(plan.seek_to) bytes_downloaded = plan.seek_to elif plan.plan_type == 'fulldownload': if isinstance(plan.download_into, io.IOBase): file_handle = plan.download_into else: file_handle = plan.download_into.open('wb') bytes_downloaded = 0 if plan.header_range_min is not None: plan.headers['range'] = 'bytes={min}-{max}'.format( min=plan.header_range_min, max=plan.header_range_max, ) log.info('Downloading %s into "%s"', plan.url, plan.real_localname) download_stream = request( 'get', plan.url, stream=True, auth=plan.auth, headers=plan.headers, timeout=plan.timeout, verify_ssl=plan.verify_ssl, ) if plan.remote_total_bytes is None: # Since we didn't do a head, let's fill this in now. plan.remote_total_bytes = download_stream.headers.get('Content-Length', None) plan.remote_total_bytes = None if plan.remote_total_bytes is None else int(plan.remote_total_bytes) progressbar = progressbars.normalize_progressbar(plan.progressbar, total=plan.remote_total_bytes) if plan.limiter: chunk_size = int(plan.limiter.allowance * IDEAL_CHUNK_TIME) else: chunk_size = 128 * bytestring.KIBIBYTE chunks = stream_to_chunks(download_stream, chunk_size, limiter=plan.limiter) if download_stream.headers.get('Content-Encoding') == 'gzip': log.debug('Content is gzipped. Decompressor active.') chunks = decompress_gzip(chunks) for chunk in chunks: httperrors.raise_for_status(download_stream) chunk_bytes = len(chunk) # log.loud(f'Writing {chunk_bytes} to disk.') file_handle.write(chunk) bytes_downloaded += chunk_bytes if progressbar is not None: progressbar.step(bytes_downloaded) if plan.ratemeter is not None: plan.ratemeter.digest(chunk_bytes) if progressbar is not None: progressbar.done() # Don't close the user's file handle if isinstance(plan.real_localname, io.IOBase): return plan.real_localname file_handle.close() # Don't try to rename /dev/null or other special names if isinstance(plan.real_localname, SpecialPath): return plan.real_localname temp_localsize = plan.download_into.size undersized = ( plan.plan_type != 'partial' and plan.remote_total_bytes is not None and temp_localsize < plan.remote_total_bytes ) if undersized and plan.raise_for_undersized: message = 'File does not contain expected number of bytes. Received {size} / {total}.' message = message.format(size=temp_localsize, total=plan.remote_total_bytes) raise NotEnoughBytes(message) if plan.custom_validator: plan.custom_validator(plan.download_into) if plan.download_into != plan.real_localname: os.rename(plan.download_into, plan.real_localname) return plan.real_localname def decompress_gzip(stream): decompressor = zlib.decompressobj(16 | zlib.MAX_WBITS) for chunk in stream: chunk = decompressor.decompress(chunk) yield chunk yield decompressor.flush() def dynamic_chunk_sizer(chunk_size, chunk_time, ideal_chunk_time): ''' Calculates a new chunk size based on the time it took to do the previous chunk versus the ideal chunk time. ''' # If chunk_time = scale * ideal_chunk_time, # Then ideal_chunk_size = chunk_size / scale scale = chunk_time / ideal_chunk_time scale = min(scale, 2) scale = max(scale, 0.5) suggestion = chunk_size / scale # Give the current size double weight so small fluctuations don't send # the needle bouncing all over. new_size = int((chunk_size + chunk_size + suggestion) / 3) # I doubt any real-world scenario will dynamically suggest a chunk_size of # zero, but let's enforce a one-byte minimum anyway. new_size = max(new_size, 1) return new_size def prepare_plan( url, localname, auth=None, bytespersecond=None, custom_validator=None, progressbar=None, do_head=True, headers=None, overwrite=False, raise_for_undersized=True, ratemeter=None, timeout=TIMEOUT, verify_ssl=True, ): # Chapter 1: File existence headers = headers or {} user_provided_range = 'range' in headers url = sanitize_url(url) if localname in [None, '']: localname = basename_from_url(url) if isinstance(localname, io.IOBase): real_localname = localname temp_localname = localname real_exists = False temp_exists = False elif is_special_file(localname): real_localname = SpecialPath(localname) temp_localname = SpecialPath(localname) real_exists = False temp_exists = False else: localname = pathclass.Path(localname) if localname.is_dir: localname = localname.with_child(basename_from_url(url)) localname = sanitize_filename(localname.absolute_path) real_localname = pathclass.Path(localname) temp_localname = real_localname.add_extension(TEMP_EXTENSION) real_exists = real_localname.exists temp_exists = temp_localname.exists if real_exists and overwrite is False and not user_provided_range: log.debug('File exists and overwrite is off. Nothing to do.') return FILE_EXISTS if isinstance(real_localname, SpecialPath): temp_localsize = 0 elif isinstance(real_localname, io.IOBase): try: temp_localsize = real_localname.tell() except io.UnsupportedOperation: temp_localsize = 0 else: temp_localsize = int(temp_exists and temp_localname.size) # Chapter 2: Ratelimiting if bytespersecond is None: limiter = None elif isinstance(bytespersecond, ratelimiter.Ratelimiter): limiter = bytespersecond else: limiter = ratelimiter.Ratelimiter(allowance=bytespersecond) # Chapter 3: Extracting range if user_provided_range: user_range_min = int(headers['range'].split('bytes=')[1].split('-')[0]) user_range_max = headers['range'].split('-')[1] if user_range_max != '': user_range_max = int(user_range_max) else: user_range_min = None user_range_max = None # Chapter 4: Server range support # Always include a range on the first request to figure out whether the # server supports it. Use 0- to get correct remote_total_bytes if user_provided_range and not do_head: raise DownloadyException('Cannot determine range support without the head request') temp_headers = headers.copy() temp_headers.update({'range': 'bytes=0-'}) if do_head: # I'm using a GET instead of an actual HEAD here because some servers respond # differently, even though they're not supposed to. head = request('get', url, stream=True, headers=temp_headers, auth=auth, verify_ssl=verify_ssl) remote_total_bytes = head.headers.get('content-length', None) remote_total_bytes = None if remote_total_bytes is None else int(remote_total_bytes) server_respects_range = (head.status_code == 206 and 'content-range' in head.headers) log.debug(f'Server respects range: {server_respects_range}') head.connection.close() del head else: remote_total_bytes = None server_respects_range = False if user_provided_range and not server_respects_range: raise ServerNoRange('Server did not respect your range header') # Chapter 5: Plan definitions plan_base = { 'url': url, 'auth': auth, 'custom_validator': custom_validator, 'progressbar': progressbar, 'limiter': limiter, 'headers': headers, 'real_localname': real_localname, 'raise_for_undersized': raise_for_undersized, 'ratemeter': ratemeter, 'remote_total_bytes': remote_total_bytes, 'timeout': timeout, 'verify_ssl': verify_ssl, } plan_fulldownload = dotdict.DotDict( plan_base, download_into=temp_localname, header_range_min=None, header_range_max=None, plan_type='fulldownload', seek_to=0, ) plan_resume = dotdict.DotDict( plan_base, download_into=temp_localname, header_range_min=temp_localsize, header_range_max='', plan_type='resume', seek_to=temp_localsize, ) plan_partial = dotdict.DotDict( plan_base, download_into=real_localname, header_range_min=user_range_min, header_range_max=user_range_max, plan_type='partial', seek_to=user_range_min, ) # Chapter 6: Redeem your meal vouchers here if real_exists: if overwrite: os.remove(real_localname) if user_provided_range: return plan_partial return plan_fulldownload elif temp_exists and temp_localsize > 0: if overwrite: return plan_fulldownload if user_provided_range: return plan_partial if server_respects_range: log.info('Resume from byte %d' % plan_resume.seek_to) return plan_resume return plan_fulldownload else: if user_provided_range: return plan_partial return plan_fulldownload raise DownloadyException('No plan was chosen?') def basename_from_url(url): ''' Determine the local filename appropriate for a URL. ''' localname = urllib.parse.unquote(url) localname = localname.rstrip('/') localname = localname.split('?')[0] localname = localname.rsplit('/', 1)[-1] return localname def is_special_file(file): if isinstance(file, pathclass.Path): return False file = os.path.normpath(file) file = file.rsplit(os.sep)[-1] file = os.path.normcase(file) return file in SPECIAL_FILENAMES def request(method, url, headers=None, timeout=TIMEOUT, verify_ssl=True, **kwargs): if headers is None: headers = {} else: headers = headers.copy() for (key, value) in HEADERS.items(): headers.setdefault(key, value) session = requests.Session() a = requests.adapters.HTTPAdapter(max_retries=30) b = requests.adapters.HTTPAdapter(max_retries=30) session.mount('http://', a) session.mount('https://', b) session.max_redirects = 40 method = { 'get': session.get, 'head': session.head, 'post': session.post, }[method] log.debug(f'Request headers: {headers}') response = method(url, headers=headers, timeout=timeout, verify=verify_ssl, **kwargs) log.debug(f'Response headers: {response.status_code} {response.headers}') httperrors.raise_for_status(response) return response def sanitize_filename(text, exclusions=''): to_remove = FILENAME_BADCHARS for exclude in exclusions: to_remove = to_remove.replace(exclude, '') for char in to_remove: text = text.replace(char, '') (drive, path) = os.path.splitdrive(text) path = path.replace(':', '') text = drive + path return text def sanitize_url(url): url = url.replace('%3A//', '://') return url def stream_to_chunks(download_stream, chunk_size, limiter): while True: chunk_start = time.perf_counter() # log.loud(f'Reading {chunk_size} from stream.') chunk = download_stream.raw.read(chunk_size) chunk_bytes = len(chunk) # log.loud(f'Got {chunk_bytes} from stream.') if chunk_bytes == 0: break if limiter is not None: limiter.limit(chunk_bytes) chunk_time = time.perf_counter() - chunk_start chunk_size = dynamic_chunk_sizer(chunk_size, chunk_time, IDEAL_CHUNK_TIME) yield chunk def download_argparse(args): url = pipeable.input(args.url, split_lines=False) if args.progressbar.lower() in {'none', 'off'}: progressbar = None if args.progressbar.lower() == 'bar': progressbar = progressbars.bar1_bytestring bytespersecond = args.bytespersecond if bytespersecond is not None: bytespersecond = bytestring.parsebytes(bytespersecond) headers = {} if args.range is not None: headers['range'] = 'bytes=%s' % args.range retry = args.retry if not retry: retry = 1 while retry != 0: # Negative numbers permit infinite retries. try: download_file( url=url, localname=args.localname, bytespersecond=bytespersecond, progressbar=progressbar, do_head=args.no_head is False, headers=headers, overwrite=args.overwrite, timeout=args.timeout, verbose=True, verify_ssl=args.no_ssl is False, ) except (NotEnoughBytes, requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError): retry -= 1 if retry == 0: raise else: break @vlogging.main_decorator def main(argv): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('url') parser.add_argument('localname', nargs='?', default=None) parser.add_argument('--progressbar', default='bar') parser.add_argument('-bps', '--bytespersecond', dest='bytespersecond', default=None) parser.add_argument('--overwrite', action='store_true') parser.add_argument('--range', default=None) parser.add_argument('--timeout', type=int, default=TIMEOUT) parser.add_argument('--retry', nargs='?', type=int, default=1) parser.add_argument('--no_head', '--no-head', dest='no_head', action='store_true') parser.add_argument('--no_ssl', '--no-ssl', dest='no_ssl', action='store_true') parser.set_defaults(func=download_argparse) args = parser.parse_args(argv) return args.func(args) if __name__ == '__main__': raise SystemExit(main(sys.argv[1:]))