I have written a fully functional simple command line multi-connection downloader, written in Python 3, using just threading
, requests
and pathlib
for its core functionality, plus 19 other libraries for some extra features.
I have added resume support and it automatically resumes interrupted downloads when downloading the same URL to the same file path where a partially downloaded file is, and I fixed all the bugs contained in the code.
The core code is extremely simple, it uses requests.get
with stream
enabled to download the file, it uses the range
HTTP header to download the file in 32 segments, using one Thread
to download each.
However the script is extremely complex, with many additional features and poorly formatted code, making it a headache trying to extend it.
I have added many features to the code, for example, it will validate the Windows file path, the URL format, the ping latency of the URL, and accessibility of target resource, and whether or not the server supports the range
header, and it shows download speed, progress bar, time elapsed and ETA...
All the features are necessary, but making the code convoluted and hard to extend.
I plan to add GUI functionalities to the script, and I mainly intend to use it as a library inside other scripts, and add argsparse
to it, and I also intend to make it behave differently depending on whether it is launched in a command shell or not (it should have a GUI if it is launched via explorer.exe
while it shouldn't if it is launched via pwsh.exe
), I am able to do these but I don't know exactly where to start...
Here is the code:
import json
import keyboard
import os
import psutil
import random
import re
import requests
import sys
import time
import validators
from collections import deque
from datetime import datetime
from math import inf
from pathlib import Path
from ping3 import ping
from reprint import output
from requests.sessions import Session
from requests.adapters import HTTPAdapter
from threading import Thread
from urllib3.poolmanager import PoolManager
from win32gui import GetForegroundWindow
from win32process import GetWindowThreadProcessId
def is_active():
active = GetWindowThreadProcessId(GetForegroundWindow())[1]
parents = psutil.Process().parents()
for p in parents:
if p.pid == active:
return True
return False
def timestring(sec):
sec = int(sec)
m, s = divmod(sec, 60)
h, m = divmod(m, 60)
return f'{h:02d}:{m:02d}:{s:02d}'
class Port_Getter:
@staticmethod
def busyports():
return set(i.laddr.port for i in psutil.net_connections())
def __init__(self):
self.assigned = set()
def randomport(self):
port = random.randint(1, 65535)
while port in Port_Getter.busyports() or port in self.assigned:
port = random.randint(1, 65535)
self.assigned.add(port)
return port
class Adapter(HTTPAdapter):
def __init__(self, port, *args, **kwargs):
self._source_port = port
super(Adapter, self).__init__(*args, **kwargs)
def init_poolmanager(self, connections, maxsize, block=False):
self.poolmanager = PoolManager(
num_pools=connections, maxsize=maxsize,
block=block, source_address=('', self._source_port))
class USession(Session):
portassigner = Port_Getter()
def __init__(self, *args, **kwargs):
super(USession, self).__init__(*args, **kwargs)
self.headers.update(
{'connection': 'close', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0'})
self.setport()
def setport(self):
port = USession.portassigner.randomport()
self.mount('http://', Adapter(port))
self.mount('https://', Adapter(port))
class Multidown:
def __init__(self, dic, id):
self.count = 0
self.completed = False
self.id = id
self.dic = dic
self.position = self.getval('position')
def getval(self, key):
return self.dic[self.id][key]
def setval(self, key, val):
self.dic[self.id][key] = val
def worker(self):
interrupted = True
filepath = self.getval('filepath')
path = Path(filepath)
end = self.getval('end')
if not path.exists():
start = self.getval('start')
else:
self.count = path.stat().st_size
start = self.getval('start') + self.count
url = self.getval('url')
self.position = start
f = path.open(mode='ab+')
if self.count != self.getval('length'):
interrupted = False
s = USession()
r = s.get(
url, headers={'range': 'bytes={0}-{1}'.format(start, end)}, stream=True)
while True:
if self.dic['paused']:
r.connection.close()
r.close()
s.close()
interrupted = True
break
if (chunk := next(r.iter_content(131072), None)):
f.write(chunk)
self.count += len(chunk)
self.position += len(chunk)
self.setval('count', self.count)
self.setval('position', self.position)
else:
break
f.close()
if not interrupted:
r.close()
s.close()
if self.count == self.getval('length'):
self.completed = 1
self.setval('completed', 1)
class Singledown:
def __init__(self):
self.count = 0
def worker(self, url, path):
with requests.get(url, stream=True) as r:
with path.open('wb') as file:
for chunk in r.iter_content(1048576):
if chunk:
self.count += len(chunk)
file.write(chunk)
class Verifier:
@staticmethod
def validate_filepath(path):
path = path.replace('\\', '/')
if (not re.match('^[a-zA-Z]:/(((?![<>:"/|?*]).)+((?<![ .])/)?)*$', path) or
not Path(path[:3]).exists()):
print('Invalid windows file path has been inputted, process will now stop.')
return False
return True
@staticmethod
def validate_url(url):
if not validators.url(url):
print('Invalid url been inputted, process will now stop.')
return False
if url.lower().startswith('ftp://'):
print(
"`requests` module doesn't suport File Transfer Protocol, process will now stop")
return False
return True
@staticmethod
def confirm_overwrite(path, overwrite):
filepath = str(path)
if not path.exists():
return True
if path.is_file():
if overwrite:
return True
while True:
answer = input(
f'`{filepath}` already exists, do you want to overwrite it? \n(Yes, No):').lower()
if answer in ['y', 'yes', 'n', 'no']:
if answer.startswith('y'):
os.remove(filepath)
return True
print('Invalid input detected, retaking input.')
print(f'Overwritting {filepath} has been aborted, process will now stop.')
return False
@staticmethod
def test_connection(url):
server = url.split('/')[2]
ok = ping(server, timeout=2)
if ok == False:
print(
'The server of the inputted url is non-existent, process will now stop.')
return False
if ok:
return True
if not ok:
print('Connection has timed out, will reattempt to ping server 5 times.')
for i in range(5):
print(
f'Reattempting to ping server, retrying {i + 1} out of 5')
ok = ping(server, timeout=2)
if ok:
print(
f'Connection successful on retry {i + 1}, process will now continue.')
return True
print(f'Retry {i + 1} out of 5 timed out' + (i != 4)
* ', reattempting in 1 second.' + (i == 4) * '.')
time.sleep(1)
print('Failed to connect server, connection timed out, process will now stop')
return False
@staticmethod
def validate_accessible(url):
head = requests.head(url)
if head.status_code == 200:
return True
for i in range(5):
print(f'Server responce is invalid, retrying {i + 1} out of 5')
head = requests.head(url)
if head.status_code == 200:
print(
f'Connection successful on retry {i + 1}, process will now continue.')
return True
print(f'Retry {i + 1} out of 5 failed to access data' +
(i != 4) * ', reattempting in 1 second.' + (i == 4) * '.')
time.sleep(1)
print("Can't establish a connection with access to data, can't download target file, process will now stop.")
return False
class Downloader:
def __init__(self):
self.recent = deque([0] * 12, maxlen=12)
self.recentspeeds = deque([0] * 200, maxlen=200)
self.dic = dict()
self.workers = []
def download(self, url, filepath, num_connections=32, overwrite=False):
bcontinue = Path(filepath + '.progress.json').exists()
singlethread = False
threads = []
path = Path(filepath)
if not Verifier.validate_filepath(filepath):
raise ValueError()
if not Verifier.validate_url(url):
raise ValueError()
if not bcontinue:
if not Verifier.confirm_overwrite(path, overwrite):
raise InterruptedError()
if not Verifier.test_connection(url):
raise TimeoutError()
if not Verifier.validate_accessible(url):
raise PermissionError()
head = requests.head(url)
folder = '/'.join(filepath.split('/')[:-1])
Path(folder).mkdir(parents=True, exist_ok=True)
headers = head.headers
total = headers.get('content-length')
if not total:
print(
f'Cannot find the total length of the content of {url}, the file will be downloaded using a single thread.')
started = datetime.now()
print('Task started on %s.' %
started.strftime('%Y-%m-%d %H:%M:%S'))
sd = Singledown()
th = Thread(target=sd.worker, args=(url, path))
self.workers.append(sd)
th.start()
total = inf
singlethread = True
else:
total = int(total)
if not headers.get('accept-ranges'):
print(
'Server does not support the `range` parameter, the file will be downloaded using a single thread.')
started = datetime.now()
print('Task started on %s.' %
started.strftime('%Y-%m-%d %H:%M:%S'))
sd = self.Singledown()
th = Thread(target=sd.singledown, args=(url, path))
self.workers.append(sd)
th.start()
singlethread = True
else:
if bcontinue:
progress = json.loads(Path(filepath + '.progress.json').read_text(),
object_hook=lambda d: {int(k) if k.isdigit() else k: v for k, v in d.items()})
segment = total / num_connections
started = datetime.now()
lastpressed = started
print('Task started on %s.' %
started.strftime('%Y-%m-%d %H:%M:%S'))
self.dic['total'] = total
self.dic['connections'] = num_connections
self.dic['paused'] = False
for i in range(num_connections):
if not bcontinue:
start = int(segment * i)
end = int(segment * (i + 1)) - (i != num_connections - 1)
position = start
length = end - start + (i != num_connections - 1)
else:
start = progress[i]['start']
end = progress[i]['end']
position = progress[i]['position']
length = progress[i]['length']
self.dic[i] = {
'start': start,
'position': position,
'end': end,
'filepath': filepath + '.' + str(i).zfill(2) + '.part',
'count': 0,
'length': length,
'url': url,
'completed': False
}
for i in range(num_connections):
md = Multidown(self.dic, i)
th = Thread(target=md.worker)
threads.append(th)
th.start()
self.workers.append(md)
Path(filepath + '.progress.json').write_text(json.dumps(self.dic, indent=4))
downloaded = 0
totalMiB = total / 1048576
speeds = []
interval = 0.04
with output(initial_len=5, interval=0) as dynamic_print:
while True:
Path(filepath + '.progress.json').write_text(json.dumps(self.dic, indent=4))
status = sum([i.completed for i in self.workers])
downloaded = sum(i.count for i in self.workers)
self.recent.append(downloaded)
done = int(100 * downloaded / total)
doneMiB = downloaded / 1048576
gt0 = len([i for i in self.recent if i])
if not gt0:
speed = 0
else:
recent = list(self.recent)[12 - gt0:]
if len(recent) == 1:
speed = recent[0] / 1048576 / interval
else:
diff = [b - a for a, b in zip(recent, recent[1:])]
speed = sum(diff) / len(diff) / 1048576 / interval
speeds.append(speed)
self.recentspeeds.append(speed)
nzspeeds = [i for i in speeds if i]
if nzspeeds:
minspeed = min(nzspeeds)
else:
minspeed = 0
maxspeed = max(speeds)
now = datetime.now()
elapsed = (now - started).total_seconds()
meanspeed = downloaded / elapsed / 1048576
remaining = totalMiB - doneMiB
dynamic_print[0] = '[{0}{1}] {2}'.format(
'\u2588' * done, '\u00b7' * (100-done), str(done)) + '% completed' + (not singlethread) * ', paused: {0}'.format(self.dic['paused'])
dynamic_print[1] = 'Download mode: ' + singlethread * \
'Single-thread' + (not singlethread) * 'Multi-thread (press Space to pause/resume, press Escape to stop)'
dynamic_print[2] = '{0:.2f} MiB downloaded, {1:.2f} MiB total, {2:.2f} MiB remaining, download speed: {3:.2f} MiB/s'.format(
doneMiB, totalMiB, remaining, speed)
if speed and total != inf:
eta = timestring(remaining / speed)
else:
eta = '99:59:59'
dynamic_print[3] = 'Minimum speed: {0:.2f} MiB/s, average speed: {1:.2f} MiB/s, maximum speed: {2:.2f} MiB/s'.format(
minspeed, meanspeed, maxspeed)
dynamic_print[4] = 'Task started on {0}, {1} elapsed, ETA: {2}'.format(
started.strftime('%Y-%m-%d %H:%M:%S'), timestring(elapsed), eta)
if keyboard.is_pressed('space') and is_active():
if not singlethread:
pressed = datetime.now()
if (pressed - lastpressed).total_seconds() > 0.5:
lastpressed = pressed
if self.dic['paused']:
for md in self.workers:
if not md.completed:
th = Thread(target=md.worker)
th.start()
threads.append(th)
self.dic['paused'] = not self.dic['paused']
if self.dic['paused']:
time.sleep(0.1)
while threads:
th = threads.pop(0)
th.join()
if keyboard.is_pressed('esc'):
if not singlethread:
ended = datetime.now()
self.dic['paused'] = True
break
if status == len(self.workers):
if not singlethread:
BLOCKSIZE = 4096
BLOCKS = 1024
CHUNKSIZE = BLOCKSIZE * BLOCKS
with path.open('wb') as dest:
for i in range(32):
file = filepath + '.' + str(i).zfill(2) + '.part'
with Path(file).open('rb') as f:
while (chunk := f.read(CHUNKSIZE)):
dest.write(chunk)
Path(file).unlink()
ended = datetime.now()
break
time.sleep(interval)
time_spent = (ended - started).total_seconds()
meanspeed = total / time_spent / 1048576
status = sum([i.completed for i in self.workers])
if status == len(self.workers):
print('Task completed on {0}, total time elapsed: {1}, average speed: {2:.2f} MiB/s'.format(
ended.strftime('%Y-%m-%d %H:%M:%S'), timestring(time_spent), meanspeed))
Path(filepath + '.progress.json').unlink()
else:
print('Task interrupted on {0}, total time elapsed: {1}, average speed: {2:.2f} MiB/s'.format(
ended.strftime('%Y-%m-%d %H:%M:%S'), timestring(time_spent), meanspeed))
if __name__ == '__main__':
d = Downloader()
d.download(*sys.argv[1:])
Example:
downloader.py 'http://ipv4.download.thinkbroadband.com/1GB.zip' 'D:/Downloads/1GB.zip'
The downloaded file's SHA-256 hash should be: 5674e59283d95efe8c88770515a9bbc80cbb77cb67602389fd91def26d26aed2
.
How should I format my code?
In particular, what to do with the text prompts? What to do with the if conditions? And how should the "main loop" be correctly realized? Should I split my code into multiple files? If I should, then what should they be like?