A Client and Server Chat Application in Python

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.

For the impatient.

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>

Leave a Reply

Your email address will not be published. Required fields are marked *