One year ago I learned some Python 3 in Highschool, but I haven't really programmed anything since then. I decided to start a new project to try and practice a bit before entering the first year of an IT degree. I tried to create my own cloud storage system in a console application using the Socket library, i.e. a program which can send file to a server and retreive them later. I wrote both the client and the server script. It was intended to be only an exercise at first, but I may end up actually using it if I can make it good enough.
Since I wasn't at all familiar with the Socket library, I based my code off of this tutorial, but I now think I've made the code different enough to call it my own.
I've put quite some time into this project and now that I got everything working, I'd really like some feedback. In particular, and in order of importance :
- Did I pick up any bad habbits ?
- Could I make this look cleaner, for exemple by modifing synthax, or adding or fusing fuctions ?
- Is my commenting any good ? Should I comment more or less ? Is it clear enough ?
- Is there any way to optimize the transfer ?
Of course, any criticism is good to take so any remark regarding anything is welcome.
Notes:
- This program is meant to transfer file only and trying to download a directory from the server will cause an error. It is however a non-issue as you cannot select a directory to upload from the the client and the server's storage directory should not contain anything that wasn't sent by the client under normal circumstances.
- If you decide to manually add files in the server's storage directory, keep in mind that directories are not supported.
- Modifying the content of the server's storage directory after selecting the option to download files on the client and before you canceled the selection or all downloads are complete may result in an error
- I will be the only person using this app so it isn't useful to make it able to handle multiple requests at the same time.
- Both the computers I use are connected to the same network.
- The app has been tested and works on both Windows and Linux.
- The client needs to run in an environment capable of showing a GUI as I use tkinter's filedialog to select the files to send to the server. This not the case for the server.
- I use python 3.8.2
Here is server script:
import socket
import tqdm
import os
import pickle
import sys
##Setup connection with client
#Set storage directory
workDir = "Insert path to server's storage directory"
os.chdir(workDir)
#Set global variable
SERVER_HOST = "0.0.0.0"
SERVER_PORT = 5001
BUFFER_SIZE = 1024
SEPARATOR = "<SEPARATOR>"
#Start listening for connection attempt and perform the action chosen by the client
def start_listening():
#Accept the connexion of the client
s = socket.socket()
s.bind((SERVER_HOST, SERVER_PORT))
s.listen(1)
print(f"-------------------------\nListening to {SERVER_HOST}:{SERVER_PORT}")
client_socket, CLIENT_HOST = s.accept()
print(f"{CLIENT_HOST} is connected.")
#Perform the action chosen by the client
actionID = client_socket.recv(BUFFER_SIZE).decode()
client_socket.send('0'.encode())
if actionID == '1':
receive_file_choice(client_socket)
if actionID == '2':
send_file_choice(client_socket)
if actionID == '3':
client_socket.close()
s.close()
sys.exit()
#Restart the script to perform another action
client_socket.send('0'.encode())
client_socket.close()
s.close()
start_listening()
##Download
#Receive the number, names and sizes of the files to download
def receive_file_choice(client_socket):
fileNumber = int(client_socket.recv(BUFFER_SIZE).decode())
client_socket.send('0'.encode())
#Control the receive_file() function
for i in range(fileNumber):
received = client_socket.recv(BUFFER_SIZE).decode()
fileName, fileSize = received.split(SEPARATOR)
fileName = os.path.basename(fileName)
fileSize = int(fileSize)
client_socket.send('0'.encode())
print(f"\nDownloading {fileName} ({fileSize} bytes)")
receive_file(client_socket, fileName, fileSize)
#Download the file sent by the client and store them in workDir
def receive_file(client_socket, fileName, fileSize):
client_socket.send('0'.encode())
with open(fileName, "wb") as f:
downloadSize = 0
timeOutFlag = 0
#Setting a time out here ensure the the script can continue even if a packet was lost
client_socket.settimeout(10)
#Receive packets of size BUFFER_SIZE bytes
for i in tqdm.tqdm(range(fileSize//BUFFER_SIZE)):
try:
downloadSize += f.write(client_socket.recv(BUFFER_SIZE))
except (socket.timeout):
print('Error : Download timed out')
timeOutFlag = 1
break
#Tell the client it can send the next packet
client_socket.send('0'.encode())
#Receive remainig bytes
if timeOutFlag == 0:
try:
downloadSize += f.write(client_socket.recv(fileSize%BUFFER_SIZE))
except socket.timeout:
pass
client_socket.settimeout(None)
#Warn the user if data has been lost during the transfer
if downloadSize == fileSize:
print("Done")
else:
print(f"Warning: '{fileName}' does not match expected size\n(Received {downloadSize} bytes instead of {fileSize} - {fileSize - downloadSize} bytes difference)")
#Acknowledge that the download as ended. Send a '1' instead of '0' so that the client don't confuse it with the 'packet received' acknowledgement
client_socket.send('1'.encode())
##Upload
#Send a list of available files and retreive client's choice
def send_file_choice(client_socket):
print('Sending a list of available files to client')
tempFileList = os.listdir(workDir)
#If files are available to download for the client
if len(tempFileList) >= 1:
fileList = [(i, tempFileList[i], os.path.getsize(tempFileList[i])) for i in range(len(tempFileList))]
sentFileList = pickle.dumps(fileList)
client_socket.send(str(len(sentFileList)).encode())
client_socket.recv(1)
client_socket.send(sentFileList)
print('Waiting for client response')
choiceList = client_socket.recv(BUFFER_SIZE).decode()
#Return if the client has chosen to cancel the download
if choiceList.lower() == "-c":
print('Request canceled')
return
choiceList = choiceList.split(',')
client_socket.send('0'.encode())
for i in range(len(choiceList)):
choiceList[i] = int(choiceList[i])
fileNumber = len(choiceList)
client_socket.send(str(fileNumber).encode())
client_socket.recv(1)
control_upload(client_socket, fileList, choiceList)
#Return if no file was found
else:
client_socket.send(pickle.dumps('0'))
print('No file was found')
return
#Operates the send_file() function
def control_upload(client_socket, fileList, choiceList):
for i in choiceList:
fileName, fileSize = fileList[i][1], fileList[i][2]
client_socket.send(f"{fileName}{SEPARATOR}{fileSize}".encode())
client_socket.recv(1)
print(f"\nUploading {fileName} ({fileSize} bytes)")
send_file(client_socket, fileName, fileSize)
#Send requested file to the client
def send_file(client_socket, fileName, fileSize):
client_socket.recv(1)
with open(fileName, "rb") as f :
#Upload the file divided in packet of size BUFFER_SIZE bytes
for i in tqdm.tqdm(range(fileSize//BUFFER_SIZE)):
bytes_read = f.read(BUFFER_SIZE)
client_socket.sendall(bytes_read)
client_socket.recv(1)
#Send the remaining bytes
bytes_read = f.read(fileSize%BUFFER_SIZE)
client_socket.sendall(bytes_read)
#Wait for the server to acknowledge it has ended downloading the file
while True:
if client_socket.recv(1).decode() == '1':
break
print("Done")
if __name__ == '__main__':
start_listening()
Here is client script:
import tkinter as tk
from tkinter import filedialog
import os
import tqdm
import socket
import pickle
from time import sleep
##Set up connection with server
#Set storage directory
workDir = "Insert path to your download directory here"
os.chdir(workDir)
#Set global variables
SERVER_HOST = "Insert server's IP adress here"
SERVER_PORT = 5001
BUFFER_SIZE = 1024
SEPARATOR = "<SEPARATOR>"
#Start the connection with the server, choose the action to perfmorm
def start_connection():
#Connect to the server
s = socket.socket()
print(f"-------------------------\nConnecting to {SERVER_HOST}:{SERVER_PORT}")
#Setting a timeout here prevent the client from trying to connect forever
s.settimeout(30)
try:
s.connect((SERVER_HOST, SERVER_PORT))
except socket.timeout:
print("Error : Connection timed out")
return
#Maintaining the timeout past this point could cause an error if the user took a long time to decide what he want to do. timeout is therefore set to None.
s.settimeout(None)
print("Connected")
#Ask for the user to choose the action to perform until he enter a valid action
while True:
actionID = input("\nChoose action to perform:\n1. Upload\n2. Download\n3. Close server\n4. Exit\n-> ")
if actionID in ['1','2','3','4']:
break
else:
print('Invalid action')
#Perform the requested action
if actionID == '1':
get_file_list(s)
if actionID == '2':
receive_file_choice(s)
if actionID == '3':
confirm = input("Do you want to procede? You won't be able to restart the server without physical access. (y/n)\n-> ")
if confirm == 'y' or confirm == 'Y':
s.send('3'.encode())
print("Server closed")
return
else:
print("Server was not closed")
return
if actionID == '4':
print('Exited')
return
#Restart the script to perform another action
s.recv(1)
sleep(0.2)
s.close()
start_connection()
##Upload
#Make the user choose the files to upload
def get_file_list(s):
s.send('1'.encode())
s.recv(1)
#Open the file selection window
selectionWindow = tk.Tk()
selectionWindow.withdraw()
fileNames = filedialog.askopenfilenames(parent=selectionWindow, initialdir=os.getcwd(), title='Please select file(s)')
fileNumber = len(fileNames)
fileSizes = tuple([os.path.getsize(fileNames[i]) for i in range(fileNumber)])
selectionWindow.destroy()
#Control the send_file() function
s.send(str(fileNumber).encode())
s.recv(1)
for i in range(fileNumber):
fileName, fileSize = fileNames[i], fileSizes[i]
s.send(f"{fileName}{SEPARATOR}{fileSize}".encode())
s.recv(1)
print(f"\nUploading {fileName} ({fileSize} bytes)")
send_file(s, fileName, fileSize)
#Send a file to the server
def send_file(s, fileName, fileSize):
s.recv(1)
with open(fileName, "rb") as f :
#Upload the file divided in packet of size BUFFER_SIZE bytes
for i in tqdm.tqdm(range(fileSize//BUFFER_SIZE)):
bytes_read = f.read(BUFFER_SIZE)
s.sendall(bytes_read)
s.recv(1)
#Send the remaining bytes
bytes_read = f.read(fileSize%BUFFER_SIZE)
s.sendall(bytes_read)
#Wait for the server to acknowledge it has ended downloading the file
while True:
if s.recv(1).decode() == '1':
break
print("Done")
##Download
#Ask for the user to choose the files to download
def receive_file_choice(s):
s.send('2'.encode())
s.recv(1)
#Receive the list of all available files on the server
print('Waiting for a list of available files')
fileListSize = int(s.recv(BUFFER_SIZE).decode())
s.send('0'.encode())
sentFileList = s.recv(fileListSize)
sentFileList = pickle.loads(sentFileList)
#Return if no file is available
if sentFileList == '0':
print('No file is available')
return
else:
print('\nID ; Name ; Size\n----------------')
possibleChoiceList = []
for i in range(len(sentFileList)):
print(f"{sentFileList[i][0]} ; {sentFileList[i][1]} ; {sentFileList[i][2]}")
possibleChoiceList.append(str(sentFileList[i][0]))
#Ask for the user to chose file until a valid entry is given
while True:
choiceListStr = input('\nEnter the IDs of the files to request separated with commas\nEnter "-c" to cancel\n-> ')
#Return if the user wants to cancel the download
if choiceListStr.lower() == "-c":
print('Request canceled')
s.send(choiceListStr.encode())
return
choiceList = choiceListStr.split(',')
#Check if the entry contains something that is not associated with a file (e.g., a negative number, a random letter...)
if not set(choiceList).issubset(set(possibleChoiceList)):
print('You cannot choose a file that is not listed')
#Check if a file is requested twice
elif len(choiceList) != len(set(choiceList)):
print('You cannot choose a file more than once')
#Break the loop if the entry is valid
else:
break
s.send(choiceListStr.encode())
s.recv(1)
control_download(s)
#Operate the receive_file() function
def control_download(s):
print('Waiting for the server to send files')
fileNumber = s.recv(BUFFER_SIZE).decode()
fileNumber = int(fileNumber)
s.send('0'.encode())
print(f"Downloading {fileNumber} files")
for i in range(fileNumber):
receive = s.recv(BUFFER_SIZE).decode()
fileName, fileSize = receive.split(SEPARATOR)
fileName = os.path.basename(fileName)
fileSize = int(fileSize)
s.send('0'.encode())
print(f"\nDownloading {fileName} ({fileSize} bytes)")
receive_file(s, fileName, fileSize)
#Receive a file from the server and store it in workDir
def receive_file(s, fileName, fileSize):
s.send('0'.encode())
with open(fileName, "wb") as f:
downloadSize = 0
timeOutFlag = 0
#Setting a timeout here ensure the the script can continue even if a packet was lost
s.settimeout(10)
#Receive packets of size BUFFER_SIZE bytes
for i in tqdm.tqdm(range(fileSize//BUFFER_SIZE)):
try:
downloadSize += f.write(s.recv(BUFFER_SIZE))
except socket.timeout :
print("\nError : Download timed out")
timeOutFlag = 1
break
#Tell the server it can send the next packet
s.send('0'.encode())
#Receive the remainig bytes
if timeOutFlag == 0:
try:
downloadSize += f.write(s.recv(fileSize%BUFFER_SIZE))
except socket.timeout:
pass
s.settimeout(None)
#Warn the user if data has been lost during the transfer
if downloadSize == fileSize:
print("Done")
else:
print(f"Warning: '{fileName}' does not match expected size\n(Received {downloadSize} bytes instead of {fileSize} - {fileSize - downloadSize} bytes difference)")
#Acknowledge that the download as ended. Send a '1' instead of '0' so that the server don't confuse it with the 'packet received' acknowledgement
s.send('1'.encode())
if __name__ == "__main__":
start_connection()
When choosing the files to download on the client, you should see something like this
ID ; Name ; Size
----------------
0 ; 3tseT.txt ; 7
1 ; Script1.py ; 5102
2 ; Script2.py ; 4835
3 ; NicePDF.pdf ; 193721
4 ; Test1.txt ; 22
5 ; Test2.txt ; 16
6 ; Test3bis.txt ; 1
Enter the IDs of the files to request separated with commas
Enter "-c" to cancel
->
If you want to select the files 0, 1, 4 and 5, enter 0,1,4,5
in the console.
I tried transfering files using
with open(filename, "rb") as f:
while True:
bytes_read = f.read(BUFFER_SIZE)
if not bytes_read:
break
s.sendall(bytes_read)
and
with open(filename, "wb") as f:
while True:
bytes_read = client_socket.recv(BUFFER_SIZE)
if not bytes_read:
break
f.write(bytes_read)
which would have been simpler, but it ended up fusing files together when downloading multiple at once, so I opted for what you can see above.
Also, even though the send('0'.encode())
and recv(1)
significantly slow the transfer down, they are necessary to ensure that the client and the server work synchonously, and getting rid of them causes data loss. Increasing the buffer size can help speed up the process but setting it too high causes data loss as well.
Finally, as english isn't my native language, I apology for any mistake I could have made. Feel free to correct me or ask for clarification.
Thanks for your time and have a nice day.
Edit: Created a github repository to update the code as I receive suggestions.
start_connection()
is not only starting the connection, it also chooses an action by callingchose_action()
. And you are still using magic value sin places likeclient_socket.send("1".encode())
... Create a constant for that value, e.g. OK, or RECEPCTION_OK, or ACK_OK. \$\endgroup\$