Asynchronous Server/Client with Python [0x04]: Starting our Client

Over the last couple posts we have been putting in work on our server and testing it using netcat. This week, we will be starting work on our client which we can expand on to give us more control over the user experience.

In case you haven’t read any of my previous posts for this series, you can find them here!
Part 0x01: Starting our Server
Part 0x02: Logging
Part 0x03: Client Data & Server Broadcast
Part 0x04: Starting our Client <— You are here
Part 0x05 will be linked here when it is complete.

As always, all additional links this project’s github, references, and other sources can be found at the bottom of this post in the “Sauce, References, & Documentation” section.

Advertisements

Starting our Client

Our client should be capable of two very important functions:

  • receiving messages from server
  • sending messages to server

We will be planning to perform the above two tasks asynchronously similar to how our Server is setup using asyncio.

Advertisements

Code: ClientModel.py

import asyncio
class Client:
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
'''
Parameters
———-
reader : asyncio.StreamReader
StreamReader used for receiving incoming messages from client
writer : asyncio.StreamWriter
StreamWriter used for sending outgoing messages to client
'''
self.__reader: asyncio.StreamReader = reader
self.__writer: asyncio.StreamWriter = writer
self.__ip: str = writer.get_extra_info('peername')[0]
self.__port: int = writer.get_extra_info('peername')[1]
self.nickname: str = str(writer.get_extra_info('peername'))
def __str__(self):
'''
Outputs client information as '<nickname> <ip>:<port>'
'''
return f"{self.nickname} {self.ip}:{self.port}"
@property
def reader(self):
'''
Gets the StreamReader associated with a client.
'''
return self.__reader
@property
def writer(self):
'''
Gets the StreamWriter associated with a client.
'''
return self.__writer
@property
def ip(self):
'''
Gets the ip associated with a client
'''
return self.__ip
@property
def port(self):
'''
Gets the port associated with a client
'''
return self.__port
async def get_message(self):
'''
Retrieves the incoming message from a client and returns it in string format.
'''
return str((await self.reader.read(255)).decode('utf8'))

There are no changes to this block of code, but I wanted to take a second to highlight that in this post I have changed the name of what we previously referred to as Client.py to ClientModel.py.

Advertisements

Code: Server.py

import asyncio
import sys
import logging
import pathlib
import os
from ClientModel import Client
from datetime import datetime
class Server:
def __init__(self, ip: str, port: int, loop: asyncio.AbstractEventLoop):
'''
Parameters
———-
ip : str
IP that the server will be using
port : int
Port that the server will be using
loop : asyncio.AbstractEventLoop
Asyncio's running event loop from asyncio.get_event_loop()
———-
'''
self.__ip: str = ip
self.__port: int = port
self.__loop: asyncio.AbstractEventLoop = loop
self.__logger: logging.Logger = self.initialize_logger()
self.__clients: dict[asyncio.Task, Client] = {}
self.logger.info(f"Server Initialized with {self.ip}:{self.port}")
@property
def ip(self):
return self.__ip
@property
def port(self):
return self.__port
@property
def loop(self):
return self.__loop
@property
def logger(self):
return self.__logger
@property
def clients(self):
return self.__clients
def initialize_logger(self):
'''
Initializes a logger and generates a log file in ./logs.
Returns
——-
logging.Logger
Used for writing logs of varying levels to the console and log file.
——-
'''
path = pathlib.Path(os.path.join(os.getcwd(), "logs"))
path.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger('Server')
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
fh = logging.FileHandler(
filename=f'logs/{datetime.now().strftime("%Y-%m-%d-%H-%M-%S")}_server.log'
)
ch.setLevel(logging.INFO)
fh.setLevel(logging.DEBUG)
formatter = logging.Formatter(
'[%(asctime)s] – %(levelname)s – %(message)s'
)
ch.setFormatter(formatter)
fh.setFormatter(formatter)
logger.addHandler(ch)
logger.addHandler(fh)
return logger
def start_server(self):
'''
Starts the server on IP and PORT.
'''
try:
self.server = asyncio.start_server(
self.accept_client, self.ip, self.port
)
self.loop.run_until_complete(self.server)
self.loop.run_forever()
except Exception as e:
self.logger.error(e)
except KeyboardInterrupt:
self.logger.warning("Keyboard Interrupt Detected. Shutting down!")
self.shutdown_server()
def accept_client(self, client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
'''
Callback that is used when server accepts clients
Parameters
———-
client_reader : asyncio.StreamReader
StreamReader generated by asyncio.start_server.
client_writer : asyncio.StreamWriter
StreamWriter generated by asyncio.start_server.
———-
'''
client = Client(client_reader, client_writer)
task = asyncio.Task(self.incoming_client_message_cb(client))
self.clients[task] = client
client_ip = client_writer.get_extra_info('peername')[0]
client_port = client_writer.get_extra_info('peername')[1]
self.logger.info(f"New Connection: {client_ip}:{client_port}")
task.add_done_callback(self.disconnect_client)
async def incoming_client_message_cb(self, client: Client):
'''
Callback for handling incoming messages from client
Parameters
———-
client : Client
———-
'''
while True:
client_message = await client.get_message()
if client_message.startswith("quit"):
break
elif client_message.startswith("/"):
self.handle_client_command(client, client_message)
else:
self.broadcast_message(
f"{client.nickname}: {client_message}".encode('utf8'))
self.logger.info(f"{client_message}")
await client.writer.drain()
self.logger.info("Client Disconnected!")
def handle_client_command(self, client: Client, client_message: str):
'''
Parameters
———-
client : Client
Client associated with incoming message
client_message : str
Incoming message from client that will be parsed for any valid commands
'''
client_message = client_message.replace("\n", "").replace("\r", "")
if client_message.startswith("/nick"):
split_client_message = client_message.split(" ")
if len(split_client_message) >= 2:
client.nickname = split_client_message[1]
client.writer.write(
f"Nickname changed to {client.nickname}\n".encode('utf8'))
return
client.writer.write("Invalid Command\n".encode('utf8'))
def broadcast_message(self, message: bytes, exclusion_list: list = []):
'''
Parameters
———-
message : bytes
A message consisting of utf8 bytes to broadcast to all clients.
OPTIONAL exclusion_list : list[Client]
A list of clients to exclude from receiving the provided message.
———-
'''
for client in self.clients.values():
if client not in exclusion_list:
client.writer.write(message)
def disconnect_client(self, task: asyncio.Task):
'''
Disconnects and broadcasts to the other clients that a client has been disconnected.
Parameters
———-
task : asyncio.Task
The task object associated with the client generated during self.accept_client()
———-
'''
client = self.clients[task]
self.broadcast_message(
f"{client.nickname} has left!".encode('utf8'), [client])
del self.clients[task]
client.writer.write('quit'.encode('utf8'))
client.writer.close()
self.logger.info("End Connection")
def shutdown_server(self):
'''
Shuts down server.
'''
self.logger.info("Shutting down server!")
for client in self.clients.values():
client.writer.write('quit'.encode('utf8'))
self.loop.stop()
if __name__ == '__main__':
if len(sys.argv) < 3:
sys.exit(f"Usage: {sys.argv[0]} HOST_IP PORT")
loop = asyncio.get_event_loop()
server = Server(sys.argv[1], sys.argv[2], loop)
server.start_server()
view raw 0x04_Server.py hosted with ❤ by GitHub
  • Line 6: Changed line to import from the new file name “ClientModel”
  • Line 123: Renamed function to be more descriptive
Advertisements

Code: Client.py

import asyncio
import sys
from aioconsole import ainput
class Client:
def __init__(self, server_ip: str, server_port: int, loop: asyncio.AbstractEventLoop):
self.__server_ip: str = server_ip
self.__server_port: int = server_port
self.__loop: asyncio.AbstractEventLoop = loop
self.__reader: asyncio.StreamReader = None
self.__writer: asyncio.StreamWriter = None
@property
def server_ip(self):
return self.__server_ip
@property
def server_port(self):
return self.__server_port
@property
def loop(self):
return self.__loop
@property
def reader(self):
return self.__reader
@property
def writer(self):
return self.__writer
async def connect_to_server(self):
'''
Connects to the chat server using the server_ip and server_port
provided during initialization
This function will also set the reader/writer properties
upon successful connection to server
'''
try:
self.__reader, self.__writer = await asyncio.open_connection(
self.server_ip, self.server_port)
await asyncio.gather(
self.receive_messages(),
self.start_client_cli()
)
except Exception as ex:
print("An error has occurred: " + ex)
print("Shutting down")
async def receive_messages(self):
'''
Asynchronously receives incoming messages from the
server.
'''
server_message: str = None
while server_message != 'quit':
server_message = await self.get_server_message()
print(f"{server_message}")
if self.loop.is_running():
self.loop.stop()
async def get_server_message(self):
'''
Awaits for messages to be received from self.
If message is received, returns result as utf8 decoded string
'''
return str((await self.reader.read(255)).decode('utf8'))
async def start_client_cli(self):
'''
Starts the client's command line interface for the user.
Accepts and forwards user input to the connected server.
'''
client_message: str = None
while client_message != 'quit':
client_message = await ainput("")
self.writer.write(client_message.encode('utf8'))
await self.writer.drain()
if self.loop.is_running():
self.loop.stop()
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit(f"Usage: {sys.argv[0]} SERVER_IP PORT")
loop = asyncio.get_event_loop()
client = Client(sys.argv[1], sys.argv[2], loop)
asyncio.run(client.connect_to_server())
view raw 0x04_Client.py hosted with ❤ by GitHub
  • Line 7 __init__(): Our client will be initialized with the Server’s IP and Port. Similar to our Server.py code, we will also pass in an event loop to help us manage all of our tasks.
  • Lines 14-32: Defining property getters for all of our private variables.
  • Line 34 connect_to_server(): Using asyncio, we are going to open a connection to our server which will return to our client a StreamReader and StreamWriter for communication. After we have successfully established a connection to the server, we will then use asyncio to run our two main asynchronous functions: receive_messages() and start_client_cli().
  • Lines 54-72 receive_messages(): This asynchronous function will be in charge of monitoring our StreamReader. If any messages occurs, it will call our helper function get_server_message() to get the message in utf8 string format. Any received messages will then simply be printed to the user’s terminal.
  • Lines 74-86 start_client_cli(): We will likely plan to expand on this later, but for now this command line interface will enable our users to asynchronously write input to the command line. All messages received by the user will be sent to our server using our StreamWriter.
Advertisements

Testing Our Code

To test our code locally, we will execute the following:

Server
python3 Server.py 127.0.0.1 44444

Client 1
python3 Client.py 127.0.0.1 44444

Client 2
python3 Client.py 127.0.0.1 44444

Notice how, for now, we are using a “quit” command to handle server/client disconnects.

Advertisements

Conclusion

In this post, we spent a little bit of time putting together our own Client so that we won’t have to rely on using netcat to connect to our server. There’s a lot more room for improvements, but we at least got the basics of our project setup in a way that we can now perform basic communication.

To be honest, I am not 100% sure where I’d like to take this project from here. I think we now have a pretty solid foundation for anyone to take this work and run with it. If I were to continue this project, I’d probably start looking into developing a more user friendly interface so that anyone could pick up this code and start chatting. Or maybe I’ll look into turning it into a mobile app. Only time can really tell what I’ll end up doing with this project, but I hope you’ve enjoyed what’s here so far!

As always, if you liked what you read and you’d like to support me, please consider buying me some coffee! Every cup of coffee sent my way helps me stay awake after my full-time job so that I can produce more high quality blog posts! Any and all support would be greatly appreciated!

Ballad Serial — Trans Arsonist Art Network

Sauce, References, & Documentation

Github: https://github.com/D3ISM3/Python-Asynchronous-Server-Client
Part 0x01: Starting our Server
Part 0x02: Logging
Part 0x03: Client Data & Server Broadcast
Part 0x04: Starting our Client <— You are here
Part 0x05 will be linked here when it is complete.
Python Asyncio Documentation: https://docs.python.org/3/library/asyncio.html

Advertisement

2 thoughts on “Asynchronous Server/Client with Python [0x04]: Starting our Client

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s