It’s time to write about a more complete project. I’ll show you the websockets module for Python 3.4, which works very well with asyncio. To avoid interface problems, I decided to write the chat client in JavaScript. The example client was downloaded here. As usual, examples are very simple and leave us wanting more about what we could really do. Who has already tried writing a chat in JavaScript knows that WebSockets are a hand on the wheel.

This article is part of a series I’m writing about Python 3.4’s asyncio. You can read the other articles by clicking here: Python and asyncio, Asyncio and coroutines and Reading the keyboard.

The idea of using WebSockets is to demonstrate how easy it is to use the websockets module, which should be installed in Python 3.4:

pip install websockets

Once installed, we can create a server like the one shown in the module documentation:

import asyncio
import websockets

@asyncio.coroutine
def hello(websocket, path):
    name = yield from websocket.recv()
    print("< {}".format(name))
    greeting = "Hello {}!".format(name)
    yield from websocket.send(greeting)
    print("> {}".format(greeting))

start_server = websockets.serve(hello, 'localhost', 8765)

asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()

You can run the server with:

py -3 server.py

To test this program, we need a page with our chat client. I prepared a page with all the HTML and JavaScript code needed. Download the page client.html. Save the file and open it with your preferred browser: Chrome, Firefox or IE (>10).

As our server is very simple, the only thing we can do is send a message and receive it back, since it’s an Echo server. Also, note that the server of WebSockets is initialized like our other servers with the asyncio module, but this one does not follow an interface with methods defined for connection, receiving data etc. The object returned by the websockets module is an object with the client already connected. Let’s see how to create the server first:

start_server = websockets.serve(hello, 'localhost', 8765)

The line above creates our server, calling the function hello every time a new client connects. The name localhost refers to our computer and 8765 is the port we will use to receive connections.

Let’s see the function hello:

@asyncio.coroutine
def hello(websocket, path):
    ...

When a new connection is received, the function hello is called with two parameters: the first one is the client already connected and ready to be used; and the second one is the path or the path used (we will see this later in another article). In reality, the coroutine hello is responsible for the lifetime and management of the client’s connection. When the coroutine hello ends, the client is disconnected. Also note that we use yield from to send and receive data. The use of yield from allows us to write our code as if its execution was sequential, as we have already discussed in other articles.

An important detail to note is that the interface of the websockets module already delivers the data in the format of a message (as defined by the protocol). Unlike a common TCP/IP socket that works with streams, delivering bytes. When the recv method returns, a complete message has been received, regardless of how many read were done to complete this task. This characteristic will greatly simplify the implementation of our chat server, since we will not need to invent a delimiter of messages, nor separate the messages manually in our code.

Regarding our chat server, the example server is very limited. The biggest limitation is not allowing communication between clients. The idea of the chat server is to send messages to all connected clients. Therefore, the server must be informed about and register all connections and disconnections of the system. We will maintain a list of connected clients with a list. Our clients will be controlled by a class Client that we will see later. Observe the partial implementation of the class Server:

class Servidor:
    def __init__(self):
        self.conectados = []
    
    @property
    def nconectados(self):
        return len(self.conectados)
    
    @asyncio.coroutine
    def conecta(self, websocket, path):
        cliente = Cliente(self, websocket, path)
        if cliente not in self.conectados:
            self.conectados.append(cliente)
            print("Novo cliente conectado. Total: {0}".format(self.nconectados))            
        yield from cliente.gerencia()

    def desconecta(self, cliente):
        if cliente in self.conectados:
            self.conectados.remove(cliente)
        print("Cliente {1} desconectado. Total: {0}".format(self.nconectados, cliente.nome))

Only connect is a coroutine. In reality, all the work is done in the Client class, which provides the gerencia method as a coroutine.

Now let’s see the Client class (partial):

class Cliente:
    def __init__(self, servidor, websocket, path):
        self.cliente = websocket
        self.servidor = servidor
        self.nome = None        
        
    @property
    def conectado(self):
        return self.cliente.open

    @asyncio.coroutine
    def gerencia(self):
        try:
            yield from self.envia("Bem vindo ao servidor de chat escrito em Python 3.4 com asyncio e WebSockets. Identifique-se com /nome SeuNome")
            while True:
                mensagem = yield from self.recebe()
                if mensagem:
                    print("{0} < {1}".format(self.nome, mensagem))
                    yield from self.processa_comandos(mensagem)                                            
                else:
                    break
        except Exception:
            print("Erro")
            raise        
        finally:
            self.servidor.desconecta(self)

As each client has its own websocket and needs to communicate with the server, we will keep these references as attributes. We have also prepared the handling of names, although we have initialized the name of the Client with None. The gerencia method, which is a coroutine, sends a welcome message to the client and uses yield from to send it in the loop of events. Once the welcome message is sent, we enter an infinite loop that waits for a message from the client. When the connection is closed or an error occurs, the value of the message is None, so we test the value of the message to exit the infinite loop created by while True. From this point on, our code is very similar to the previous server.

To process messages received from the client, we use the processa_comandos method of the Client class:

@asyncio.coroutine
def processa_comandos(self, mensagem):
    if mensagem.strip().startswith("/"):
        comandos=shlex.split(mensagem.strip()[1:])
        if len(comandos)==0:
            yield from self.envia("Comando inválido")
            return
        print(comandos)
        comando = comandos[0].lower()            
        if comando == "horas":
            yield from self.envia("Hora atual: " + time.strftime("%H:%M:%S"))
        elif comando == "data":
            yield from self.envia("Data atual: " + time.strftime("%d/%m/%y"))
        elif comando == "clientes":
            yield from self.envia("{0} clientes conectados".format(self.servidor.nconectados))
        elif comando == "nome":
            yield from self.altera_nome(comandos)
        elif comando == "apenas":
            yield from self.apenas_para(comandos)
        else:
            yield from self.envia("Comando desconhecido")
    else:
        if self.nome:
            yield from self.servidor.envia_a_todos(self, mensagem)
        else:
            yield from self.envia("Identifique-se para enviar mensagens. Use o comando /nome SeuNome")

As we have already discussed in the previous server, a message received by the client is processed as if it were sent from a user typing on the keyboard of his computer. If the message starts with a slash, it’s a command that needs to be processed. We use the shlex module to split the command and its parameters into a list of strings.

To handle commands, we’ll see in a moment how to change the name of the client using the /nome command, but for now, let’s look at the processa_comandos method:

if mensagem.strip().startswith("/"):
    comandos=shlex.split(mensagem.strip()[1:])
    ...

We check if the message starts with a slash and split it into a list of strings. We then check how many elements are in this list.

if len(comandos)==0:
    yield from self.envia("Comando inválido")
    return

If there are no elements, we send an invalid command message to the client and exit the method.

print(comandos)
comando = comandos[0].lower()            
...

We print the list of commands for debugging purposes and get the first element, which is the name of the command. We convert it to lowercase, so we can easily check if it’s equal to a specific command.

if comando == "horas":
    yield from self.envia("Hora atual: " + time.strftime("%H:%M:%S"))
...

If the command is “horas”, we send the current time to the client. We use the strftime method of the datetime module to format the time as a string.

elif comando == "clientes":
    yield from self.envia("{0} clientes conectados".format(self.servidor.nconectados))

If the command is “clientes”, we send the number of connected clients to the client. We use the nconectados property of the Server class to get this information.

elif comando == "nome":
    yield from self.altera_nome(comandos)
...

If the command is “nome”, we change the name of the client using the altera_nome method that we’ll see in a moment.

elif comando == "apenas":
    yield from self.apenas_para(comandos)
...

If the command is “apenas”, we send a message to a specific client using the apenas_para method that we’ll also see in a moment.

else:
    yield from self.envia("Comando desconhecido")

If none of the above commands match, we send an unknown command message to the client.

Now let’s look at the altera_nome method of the Client class:

@asyncio.coroutine
def altera_nome(self, comandos):
    if len(comandos)>1 and self.servidor.verifica_nome(comandos[1]):
        self.nome = comandos[1]
        yield from self.envia("Nome alterado com sucesso para {0}".format(self.nome))
    else:
        yield from self.envia("Nome em uso ou inválido. Escolha um outro.")

We check if the number of elements in the list is greater than 1 and if the server has not already registered this name using the verifica_nome method that we’ll see in a moment.

if len(comandos)>1 and self.servidor.verifica_nome(comandos[1]):
    ...

If the conditions are met, we change the name of the client by assigning it to the nome property. We then send a message to the client indicating that its name has been changed.

self.nome = comandos[1]
yield from self.envia("Nome alterado com sucesso para {0}".format(self.nome))

If not, we send a message to the client indicating that its name is in use or invalid and suggesting that it choose another one.

Now let’s look at the apenas_para method of the Client class:

@asyncio.coroutine
def apenas_para(self, comandos):
    if len(comandos)<3:
        yield from self.envia("Comando incorreto. /apenas Destinatário mensagem")
        return
    destinatario = comandos[1]
    mensagem = " ".join(comandos[2:])
    enviado = yield from self.servidor.envia_a_destinatario(self, mensagem, destinatario)
    if not enviado:
        yield from self.envia("Destinatário {0} não encontrado. Mensagem não enviada.".format(destinatario))

We check if the number of elements in the list is less than 3 and exit the method if so.

if len(comandos)<3:
    ...

If not, we get the destination client from the second element of the list. We then join all the remaining elements into a single string to form the message.

destinatario = comandos[1]
mensagem = " ".join(comandos[2:])
...

We send this message to the destination client using the envia_a_destinatario method of the Server class that we’ll see in a moment.

Now let’s look at the verifica_nome method of the Server class:

def verifica_nome(self, nome):
    for cliente in self.conectados:
        if cliente.nome and cliente.nome == nome:
            return False
    return True

We iterate over all connected clients and check if any have this name. If so, we return False indicating that the name is already in use.

for cliente in self.conectados:
    if cliente.nome and cliente.nome == nome:
        return False

If not, we return True indicating that the name is available.

Now let’s look at the envia_a_destinatario method of the Server class:

@asyncio.coroutine
def envia_a_destinatario(self, origem, mensagem, destinatario):
    for cliente in self.conectados:
        if cliente.nome == destinatario and origem != cliente and cliente.conectado:
            print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
            yield from cliente.envia("PRIVADO de {0} >> {1}".format(origem.nome, mensagem))
            return True
    return False

We iterate over all connected clients and check if any have this name. If so, we send a message to it indicating that the message is private and who sent it.

for cliente in self.conectados:
    if cliente.nome == destinatario and origem != cliente and cliente.conectado:
        ...

If not, we return False indicating that the destination client was not found.

Now let’s look at the envia_a_todos method of the Server class:

@asyncio.coroutine
def envia_a_todos(self, origem, mensagem):
    print("Enviando a todos")
    for cliente in self.conectados:
        if origem != cliente and cliente.conectado:
            print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
            yield from cliente.envia("{0} >> {1}".format(origem.nome, mensagem))

We iterate over all connected clients and send a message to each one.

for cliente in self.conectados:
    if origem != cliente and cliente.conectado:
        ...

Finally, let’s look at the full code of the server:

class Servidor:
    def __init__(self):
        self.conectados = []
    
    @property
    def nconectados(self):
        return len(self.conectados)
    
    @asyncio.coroutine
    def conecta(self, websocket, path):
        cliente = Cliente(self, websocket, path)
        if cliente not in self.conectados:
            self.conectados.append(cliente)
            print("Novo cliente conectado. Total: {0}".format(self.nconectados))            
        yield from cliente.gerencia()

    def desconecta(self, cliente):
        if cliente in self.conectados:
            self.conectados.remove(cliente)
        print("Cliente {1} desconectado. Total: {0}".format(self.nconectados, cliente.nome))

    @asyncio.coroutine
    def envia_a_todos(self, origem, mensagem):
        print("Enviando a todos")
        for cliente in self.conectados:
            if origem != cliente and cliente.conectado:
                print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
                yield from cliente.envia("{0} >> {1}".format(origem.nome, mensagem))

    @asyncio.coroutine
    def envia_a_destinatario(self, origem, mensagem, destinatario):
        for cliente in self.conectados:
            if cliente.nome == destinatario and origem != cliente and cliente.conectado:
                print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
                yield from cliente.envia("PRIVADO de {0} >> {1}".format(origem.nome, mensagem))
                return True
        return False

    def verifica_nome(self, nome):
        for cliente in self.conectados:
            if cliente.nome and cliente.nome == nome:
                return False
        return True


class Cliente:
    def __init__(self, servidor, websocket, path):
        self.cliente = websocket
        self.servidor = servidor
        self.nome = None        
        
    @property
    def conectado(self):
        return self.cliente.open

    @asyncio.coroutine
    def gerencia(self):
        try:
            yield from self.envia("Bem vindo ao servidor de chat escrito em Python 3.4 com asyncio e WebSockets. Identifique-se com /nome SeuNome")
            while True:
                mensagem = yield from self.recebe()
                if mensagem:
                    print("{0} < {1}".format(self.nome, mensagem))
                    yield from self.processa_comandos(mensagem)                                            
                else:
                    break
        except Exception:
            print("Erro")
            raise        
        finally:
            self.servidor.desconecta(self)

    @asyncio.coroutine
    def envia(self, mensagem):
        yield from self.cliente.send(mensagem)

    @asyncio.coroutine
    def recebe(self):
        mensagem = yield from self.cliente.recv()
        return mensagem

    @asyncio.coroutine
    def processa_comandos(self, mensagem):
        if mensagem.strip().startswith("/"):
            comandos=shlex.split(mensagem.strip()[1:])
            if len(comandos)==0:
                yield from self.envia("Comando inválido")
                return
            print(comandos)
            comando = comandos[0].lower()            
            if comando == "horas":
                yield from self.envia("Hora atual: " + time.strftime("%H:%M:%S"))
            elif comando == "data":
                yield from self.envia("Data atual: " + time.strftime("%d/%m/%y"))
            elif comando == "clientes":
                yield from self.envia("{0} clientes conectados".format(self.servidor.nconectados))
            elif comando == "nome":
                yield from self.altera_nome(comandos)
            elif comando == "apenas":
                yield from self.apenas_para(comandos)
            else:
                yield from self.envia("Comando desconhecido")
        else:
            if self.nome:
                yield from self.servidor.envia_a_todos(self, mensagem)
            else:
                yield from self.envia("Identifique-se para enviar mensagens. Use o comando /nome SeuNome")

    @asyncio.coroutine
    def altera_nome(self, comandos):
        if len(comandos)>1 and self.servidor.verifica_nome(comandos[1]):
            self.nome = comandos[1]
            yield from self.envia("Nome alterado com sucesso para {0}".format(self.nome))
        else:
            yield from self.envia("Nome em uso ou inválido. Escolha um outro.")

    @asyncio.coroutine
    def apenas_para(self, comandos):
        if len(comandos)<3:
            yield from self.envia("Comando incorreto. /apenas Destinatário mensagem")
            return
        destinatario = comandos[1]
        mensagem = " ".join(comandos[2:])
        enviado = yield from self.servidor.envia_a_destinatario(self, mensagem, destinatario)
        if not enviado:
            yield from self.envia("Destinatário {0} não encontrado. Mensagem não enviada.".format(destinatario))

start_server = websockets.serve(Servidor.conecta, 'localhost', 8765)

loop=asyncio.get_event_loop()
try:
    loop.run_until_complete(start_server)
    loop.run_forever()
finally:
    start_server.close()

You can download the complete source code of the server from here.

To test, open the file client.html that you have saved in your computer. If you have already started the server with

py -3 server.py

then our client should already be connected. Otherwise, reload the page in the browser to force the reconnect.

When connected, the client displays a blue bar at the top of the page. To simulate multiple clients, open several instances of client.html. Let’s see an example session with 3 clients:

Client 1:

/nome Cliente1
/horas

Client 2:

/nome Cliente2
/data

Client 3:

/nome Cliente3
/apenas Cliente1 Olá 1
/apenas Cliente2 Olá 2
Olá todos!

Don’t forget to copy line by line in each window. Press ENTER to send the message, one at a time. Have fun!