In this post, I will discuss my implementation of a Python client and server chat application. The server is written in Python and uses the socket library and the client, also written in Python, uses Tkinter for the GUI.
Bonus web socket implementation with a JavaScript client.
Server
The chat server has to handle, essentially, three operations: connecting clients, receiving messages, and broadcasting messages to the connected clients. This is very straightforward in Python using the select and socket libraries and can be implemented in fewer than 100 LOC.
To start we need two helper functions: one function to broadcast messages to all connected clients and another to print the usage for the user operating the server.
print_usage()
This function just displays a message describing how to start the server. The function is called if the script is started incorrectly.
def print_usage(): print("Usage:\n\tpython server.py <hostname> <port> <recv_amount>\n") print("\thostname - name of the host to listen on (typically localhost or '', use local host for '')") print("\tport - port to listen to") print("\trecv_amt - max bytes received by the server 4096 is plenty for simple chat")
broadcast_message()
The second function iterates a global connection list and broadcasts the message to every connection except for the connection that sent the message to the server.
def broadcast_message(sock, message): global CONNECTION_LIST for loop_sock in CONNECTION_LIST: if loop_sock != server_socket and loop_sock != sock: try: loop_sock.send(bytes(message, 'UTF-8')) except: loop_sock.close() CONNECTION_LIST.remove(socket)
Shown here for the first time is that the server socket is stored in this list as well. This is because data must be read from this socket when a client is connected which will be shown below.
Server Setup and Main Loop
When starting the server there is a small amount of setup required before we can start accepting connections.
if __name__ == "__main__": global CONNECTION_LIST CONNECTION_LIST = [] if len(sys.argv) != 4: print_usage() exit() RECV_BUFFER = int(sys.argv[3]) HOST = '' if sys.argv[1] != 'localhost': HOST = sys.argv[1] PORT = int(sys.argv[2]) print(HOST, PORT) server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.bind((HOST, PORT)) server_socket.listen(15) CONNECTION_LIST.append(server_socket) print("Chat server started on port " + str(PORT))
Above, the global connection list is initialized, the RECV_BUFFER size is set (this is the maximum number of bytes the server can receive at once), and the server is started on the specified HOST and PORT.
Next, we start the server loop which accepts connections and messages and broadcast the received messages. There is also a command that can be sent that stops the server immediately, but this was added more as a joke than anything. However, feel free to keep it in your implementation if you think it adds utility.
while True: read_sockets, write_sockets, error_sockets = select.select(CONNECTION_LIST, [], []) for sock in read_sockets: if sock == server_socket: sockfd, addr = server_socket.accept() CONNECTION_LIST.append(sockfd) print("Client (%s, %s) connected." % (addr[0], addr[1])) new_connect = "[%s:%s] entered the room" % (addr[0], addr[1]) broadcast_message(sockfd, new_connect) else: try: data = sock.recv(RECV_BUFFER) msg = str(data) msg = msg.replace('b\'', '') msg = msg.replace('\'', '') if 'immediate termination passphrase: plsstop' in msg: broadcast_message(sock, 'Connection terminated, server shutting down..') server_socket.close() exit() elif data: broadcast_message(sock, msg) print('Data received: ' + str(data)) except: print("Client (%s, %s) is offline" % (addr[0], addr[1])) broadcast_message(sock, "Client (%s, %s) is offline" % (addr[0], addr[1])) sock.close() if sock in CONNECTION_LIST: CONNECTION_LIST.remove(sock) continue
Above, the server continuously loops and uses the Python select library to get a list of sockets that are trying to send data to our server. For each connection trying to communicate with the server one of two things will happen: i) if the server socket has sent and received a message a new connection is created with a new client, ii) otherwise a message is received from a client and broadcast to the other clients. Note that there is a special check for the server termination command mentioned above.
Python Client
The client is set up to use Tkinter as a GUI. Because of this, the code for it is substantially longer than that for the server. Therefore, I will be giving a brief overview of the functions that might be most interesting to those trying to implement this type of application but save the details of the implementation for the full code printouts below.
Notable Client Functions
In the Python logic, there are a few notable functions that should be implemented for interacting with the server. The functions need not be hooked up to a GUI but could just send and receive data that is printed to the console. Below is a list with a short description, again details are in the full code section.
- connect – adds informational messages to the client and sends a connection request to a server running on the specified host and port combination
- send_msg – sends a message to the server
- disconnect – disconnects from the server
- recv_msg – receives a message from the server and displays it to the user
Although I wrote this code a long time ago, as far as I can recall most of the other functionality in the Python client code is used to set up the GUI and hookup the GUI components to the correct functionality.
Here’s what it might look like to implement the client without a GUI, i.e. only using the console.
import socket, select, string, sys def prompt(): sys.stdout.write('<You> ') sys.stdout.flush() # main function if __name__ == "__main__": if (len(sys.argv) < 3): print('Usage : python cmd_client.py hostname port') sys.exit() host = sys.argv[1] port = int(sys.argv[2]) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(2) # connect to remote host try: s.connect((host, port)) except: print('Unable to connect') sys.exit() print('Connected to remote host. Start sending messages') prompt() while 1: socket_list = [sys.stdin, s] # Get the list sockets which are readable read_sockets, write_sockets, error_sockets = select.select(socket_list, [], []) for sock in read_sockets: #incoming message from remote server if sock == s: data = sock.recv(4096) if not data: print('\nDisconnected from chat server') sys.exit() else: #print data sys.stdout.write(data) prompt() #user entered a message else: msg = sys.stdin.readline() s.send(msg) prompt()
Full Code
Full Server Code
import socket import select import sys def broadcast_message(sock, message): global CONNECTION_LIST for loop_sock in CONNECTION_LIST: if loop_sock != server_socket and loop_sock != sock: try: loop_sock.send(bytes(message, 'UTF-8')) except: loop_sock.close() CONNECTION_LIST.remove(socket) def print_usage(): print("Usage:\n\tpython server.py <hostname> <port> <recv_amount>\n") print("\thostname - name of the host to listen on (typically localhost or '', use local host for '')") print("\tport - port to listen to") print("\trecv_amt - max bytes received by the server 4096 is plenty for simple chat") if __name__ == "__main__": global CONNECTION_LIST CONNECTION_LIST = [] if len(sys.argv) != 4: print_usage() exit() RECV_BUFFER = int(sys.argv[3]) HOST = '' if sys.argv[1] != 'localhost': HOST = sys.argv[1] PORT = int(sys.argv[2]) print(HOST, PORT) server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.bind((HOST, PORT)) server_socket.listen(15) CONNECTION_LIST.append(server_socket) print("Chat server started on port " + str(PORT)) while True: read_sockets, write_sockets, error_sockets = select.select(CONNECTION_LIST, [], []) for sock in read_sockets: if sock == server_socket: sockfd, addr = server_socket.accept() CONNECTION_LIST.append(sockfd) print("Client (%s, %s) connected." % (addr[0], addr[1])) new_connect = "[%s:%s] entered the room" % (addr[0], addr[1]) broadcast_message(sockfd, new_connect) else: try: data = sock.recv(RECV_BUFFER) msg = str(data) msg = msg.replace('b\'', '') msg = msg.replace('\'', '') if 'immediate termination passphrase: plsstop' in msg: broadcast_message(sock, 'Connection terminated, server shutting down..') server_socket.close() exit() elif data: broadcast_message(sock, msg) print('Data received: ' + str(data)) except: print("Client (%s, %s) is offline" % (addr[0], addr[1])) broadcast_message(sock, "Client (%s, %s) is offline" % (addr[0], addr[1])) sock.close() if sock in CONNECTION_LIST: CONNECTION_LIST.remove(sock) continue server_socket.close()
Full Client Code
import socket import threading from tkinter import * import select from tkinter.scrolledtext import ScrolledText global writeLock message = '' send = threading.Event() send_lock = threading.Lock() class App: def __init__(self, master): connect_frame = Frame(master, relief=RAISED, borderwidth=1) connect_frame.pack(fill=X, expand=True) chat_frame = Frame(master, relief=RAISED, borderwidth=1) chat_frame.pack(fill=BOTH, expand=True) # self.connect_frame(connect_frame) self.chat_frame_setup(chat_frame) self.connect('vpn.aasoftwaresolutions.com', 9009) def connect_frame(self, frame): connect = Label(frame, text="Connect").grid(row=0, column=0, pady=(10, 5), padx=(5, 0)) host = Label(frame, text="Host: ").grid(row=1, column=0, padx=(10, 3), pady=(0, 10)) host_box = Entry(frame, background='white', width=45) host_box.grid(row=1, column=1, padx=(0, 10), pady=(0, 10)) port = Label(frame, text="Port: ", width=10).grid(row=1, column=2, padx=(0, 3), pady=(0, 10)) port_box = Entry(frame, background='white') port_box.grid(row=1, column=3, padx=(0, 15), pady=(0, 10)) global connect_label connect_label = Label(frame, text="") connect_label.grid(row=2, column=0, padx=(5, 0), pady=(5, 10), columnspan=3) global connect_button connect_button = Button( frame, text="Connect", width=15, command=(lambda: self.connect(host_box.get(), port_box.get())) ) connect_button.grid(row=2, column=3, padx=(5, 15), pady=(5, 10)) def chat_frame_setup(self, frame): chat_label = Label(frame, text="Chat").grid(row=0, column=0, pady=(10, 5), padx=(5, 0)) global chat_box global disconnect_button global send_button global type_box chat_box = ScrolledText(frame, font=('consolas', 10), undo=False, wrap='word', state=DISABLED) chat_box.grid(row=0, column=0, columnspan=1, rowspan=3, padx=(15, 15), pady=(10, 10)) type_box = Text(frame, width=80, height=2) type_box.grid(row=4, column=0) button_frame = Frame(frame) disconnect_button = Button( button_frame, text="Disconnect", width=15, command=(lambda: self.disconnect()) ) disconnect_button.grid(row=0, column=1, padx=(0, 15), pady=(0, 15)) send_button = Button( button_frame, text="Send", width=15, command=(lambda: self.send_message()) ) send_button.grid(row=0, column=0, padx=(0, 5), pady=(0, 15)) button_frame.grid(row=6, column=0) @staticmethod def send_message(): global message global send text = type_box.get(1.0, END) text = text.strip() if text != '' and text != '\n' and text != ' ': message = text with writeLock: chat_box['state'] = NORMAL chat_box.insert(END, "<YOU>: " + text + '\n') chat_box['state'] = DISABLED type_box.delete(0.0, END) chat_box.see(END) global send_lock # send.set() send_lock.release() @staticmethod def connect(host, port): # connect_label.configure(text=("Attempting connection to " + host + " on port " + port + ".")) chat_box['state'] = NORMAL chat_box.insert(END, 'Attempting connection to ' + str(host) + ' on port ' + str(port) + '...\n') chat_box['state'] = DISABLED chat_box.see(END) # attempt to connect global s global send s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) success = True try: int_port = int(port) s.connect((host, int_port)) except: success = False chat_box['state'] = NORMAL chat_box.insert(END, 'Unable to connect to ' + str(host) + ' on port ' + str(port) + ', you know who to call.\n') chat_box['state'] = DISABLED chat_box.see(END) # connect_label.configure(text="Unable to connect to " + host + " on port " + port + ".") if success: chat_box['state'] = NORMAL chat_box.insert(END, 'Successfully connected to ' + str(host) + ' on port ' + str(port) + '!\n\n') chat_box['state'] = DISABLED chat_box.see(END) # connect_button.configure(state=DISABLED) # connect_label.configure(text="Connected to " + host + ".") send_thread = threading.Thread(target=send_msg, args=(s,)).start() recv_thread = threading.Thread(target=recv_msg, args=(s,)).start() @staticmethod def disconnect(): chat_box['state'] = NORMAL chat_box.insert(END, "Disconnected from the chat, your messages will no longer be seen..." + '\n') chat_box['state'] = DISABLED s.close() # TODO change label in here # TODO send thread stop event signals here connect_button.configure(state=NORMAL) @staticmethod def acquire_lock(): global send_lock send_lock.acquire() def on_click(app): app.send_message() def send_msg(sock_send): global send while True: send_lock.acquire() send.clear() sock_send.send(bytes("<" + socket.gethostname() + ">" + ": " + message, 'UTF-8')) send_lock.release() App.acquire_lock() def recv_msg(sock): global writeLock while True: read_sockets, write_sockets, error_sockets = select.select([s], [], []) for loop_sock in read_sockets: if loop_sock == sock: data = loop_sock.recv(4096) if not data: sys.exit() else: chat_box['state'] = NORMAL msg = str(data) msg = msg.replace('b\'', '') msg = msg.replace('\'', '') chat_box.insert(END, msg + '\n') chat_box['state'] = DISABLED # type_box.delete(0.0, END) chat_box.see(END) if __name__ == "__main__": writeLock = threading.Lock() send_lock.acquire() root = Tk() root.title("ChatClient") app = App(root) root.bind("<Return>", lambda event: on_click(app)) root.mainloop()
Bonus: WebSockets
For those interested, below is some full code for a JavaScript client and Python server using web sockets. This can be seen in action on another site of mine: http://webtalk.anthonymorast.com/chatnow.html. This website also provides links to download the client and server described above and also offers functionality to send a message via SMS (i.e. to cell phones). I won’t go into details about the implementation but the code is pretty brief and easy to follow.
Server Code
import asyncio import json import logging import websockets logging.basicConfig() USERS = dict() ID = 99 LAST_TWENTY = [] async def register(websocket): global ID, USERS, LAST_TWENTY contains = False if not contains: lst_msgs = "" for msg in LAST_TWENTY: lst_msgs += (msg) USERS[websocket] = ID ID += 1 print(lst_msgs) use = [websocket] son = json.dumps({'type': 'connect', 'msgs': lst_msgs}) print(son) await asyncio.wait([user.send(son) for user in use]) print(son) async def unregister(websocket): USERS.pop(websocket, None) async def broadcast(message, name, id): global LAST_TWENTY users = list(USERS.keys()) if len(users) > 0: LAST_TWENTY.append("<" + name + " (" + str(id) + ")>: " + message) if len(LAST_TWENTY) >= 20: LAST_TWENTY.pop(0) son = json.dumps({'type': 'message', 'msg': message, 'name': name, 'id': id}) await asyncio.wait([user.send(son) for user in users]) async def chat(websocket, path): # register(websocket) sends user_event() to websocket try: async for message in websocket: data = json.loads(message) if data['action'] == 'connect': print("Connection from:", websocket.remote_address) await register(websocket) elif data['action'] == 'send': await broadcast(data['message'], data['name'], USERS[websocket]) elif data['action'] == 'bye': await unregister(websocket) else: logging.error( "unsupported event: {}", data) finally: ignore = True asyncio.get_event_loop().run_until_complete(websockets.serve(chat, '', 9009)) asyncio.get_event_loop().run_forever()
Client Code
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags --> <title>WebChat</title> <!-- Bootstrap --> <link href="css/bootstrap.min.css" rel="stylesheet"> <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries --> <!-- WARNING: Respond.js doesn't work if you view the page via file:// --> <!--[if lt IE 9]> <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script> <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script> <![endif]--> <style> #chatarea { resize: none; } #name { width: 12em; margin-bottom: 1em; } #sendmsg { margin-top: 1em; } </style> </head> <body> <script language="javascript" type="text/javascript"> var uri = "ws://YOUR_URI"; var output; var id; function init() { output = document.getElementById("output"); connect(); } function connect() { socket = new WebSocket(uri); socket.onopen = function(evt) { onOpen(evt) }; socket.onmessage = function(evt) { onMessage(evt) }; socket.onerror = function(evt) { onError(evt) }; } function onOpen(evt) { doSend('connect', '', ''); } function onMessage(evt) { // parse JSON and update box var data = JSON.parse(evt.data); if (data.type === 'connect') { var area = document.getElementById("chatarea"); area.value += data.msgs; } else if (data.type === 'message') { var area = document.getElementById("chatarea"); var chatbox = document.getElementById("message"); var msg = "<" + data.name + " (" + data.id + ")>: " + data.msg; chatbox.value = ""; area.value += msg; $('#chatarea').scrollTop($('#chatarea')[0].scrollHeight); } } function doSend(type, msg, name) { var son = JSON.stringify({action: new String(type), message: new String(msg), name: new String(name)}); socket.send(son); } function sendMsg() { var name = document.getElementById("name").value; var msg = document.getElementById("message").value; if (name === "") { name = "anonymous"; } if (msg === "" ) { return; } doSend('send', msg, name); } window.onload=function() { var msgarea = document.getElementById("message"); msgarea.addEventListener("keyup", function(event) { if(event.keyCode === 13) { event.preventDefault(); document.getElementById("sendmsg").click(); } }); } window.onbeforeunload = function() { doSend('bye', '', ''); } window.addEventListener("load", init, false); </script> <nav class="navbar navbar-default"> <div class="container-fluid"> <!-- Brand and toggle get grouped for better mobile display --> <div class="navbar-header"> <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false"> <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <a class="navbar-brand" href="./index.php">WebChat</a> </div> <!-- Collect the nav links, forms, and other content for toggling --> <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1"> <ul class="nav navbar-nav"> <li><a href="./smsmailer.php">SMS Mailer</a></li> <li><a href="./clientchat.php">ClientChat</a></li> <li class="active"><a href="#">Chat</a></li> </ul> </div> </nav> <div class="container"> <h2>Chat Now</h2> <br/> <div id="chatbox"> <textarea class="form-control" id="chatarea" readonly rows="20" cols="100" name="message"></textarea> </div> <br/><br/> <div id="sendcontrols"> <input type="text" class="form-control" id="name" placeholder="anonymous" name="name" maxlength="20"> <textarea class="form-control" id="message" placeholder="Message" rows="2" cols="100" name="message"></textarea> <button type="submit" class="btn btn-default" id="sendmsg" onClick="sendMsg()">Send Message</button> </div> <div class="footer"> </div> </div> <!-- jQuery (necessary for Bootstrap's JavaScript plugins) --> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script> <!-- Include all compiled plugins (below), or include individual files as needed --> <script src="js/bootstrap.min.js"></script> </body> </html>