#!/usr/bin/python3
"""Simple chat server for asyncio in Python 3.5.2, following
.
I wrote this as an example and to re-familiarize myself with asyncio;
you probably wouldn’t want to use this program in this form on the
open internet nowadays, because the data is unencrypted, the
connections are unauthenticated, nothing slows down malicious
flooding, and it happily redistributes any characters you feed into
it, including telnet IAC codes, escape sequences, right-to-left marks,
zalgo, ASCII SI and SO, and so on. Chat lines are limited to a
maximum of 65536 bytes, not including the trailing newline, asyncio’s
default line length limit.
However, if nobody malicious connects to your server or snoops on your
data, it should probably work fine.
This may have grown a bit out of control; I was trying to get the
error conditions under control, but haven’t had a lot of success.
When you hit ^C in it, you still get this kind of thing:
Task exception was never retrieved
future: exception=KeyboardInterrupt()>
...
KeyboardInterrupt
Task was destroyed but it is pending!
task: wait_for=>
...
Exception ignored in:
Traceback (most recent call last):
File "./asynciochat.py", line 185, in handle
File "./asynciochat.py", line 73, in kill
File "/usr/lib/python3.5/asyncio/streams.py", line 306, in close
File "/usr/lib/python3.5/asyncio/selector_events.py", line 566, in close
File "/usr/lib/python3.5/asyncio/base_events.py", line 497, in call_soon
File "/usr/lib/python3.5/asyncio/base_events.py", line 506, in _call_soon
File "/usr/lib/python3.5/asyncio/base_events.py", line 334, in _check_closed
RuntimeError: Event loop is closed
This experience has done nothing to reduce my perception that the
Python asyncio module is messy, bug-prone, and hard to use correctly.
"""
import asyncio
users = {}
def broadcast(b):
print('»', repr(b))
for user, conn in list(users.items()):
conn.send(b)
help_string = b'''This is a simple chat server with one channel.
What you type gets sent to everyone else. Commands include:
/quit to disconnect
/m user msg to send a private message to user
/w to list the users
/ /text to send a message starting with /
/me action to take an action
'''
def splitword(b):
if b' ' in b:
return b.split(b' ', 1)
return b, b''
class Connection:
def __init__(self, reader, writer):
self.reader = reader
self.writer = writer
self.nickname = None
self.live = True
self.host = writer.get_extra_info('peername')[0].encode('utf-8')
def send(self, b):
self.send_no_newline(b + b'\n')
def send_no_newline(self, b):
if self.reader.at_eof():
# This handles the case where, due to some other bug in
# the chat server that failed to remove the connection
# from users, the socket is closed but still in the dict.
# This can produce the message “socket.send() raised
# exception.” when someone tries to write to the closed
# socket:
# File "/usr/lib/python3.5/asyncio/selector_events.py", line 693, in write
# logger.warning('socket.send() raised exception.')
# Figuring this out took me an hour of groveling through
# the asyncio code and experimentation, even though in
# theory I’m already familiar with asyncio. Also, I still
# get the message if I flood the server with data and then
# close the connection. I just don’t get it simply from
# failing to remove a connection from the users dict.
self.kill()
return
self.writer.write(b)
def kill(self):
if self.nickname and self.nickname in users:
del users[self.nickname]
broadcast(b'%s has disconnected.' % self.nickname)
self.writer.close() # idempotent
self.nickname = None
self.live = False
def readline(self):
return self.reader.readline()
async def login(self):
while True:
self.send_no_newline(b'Please enter your nickname: ')
nickname = await self.readline()
if not nickname:
return # EOF
nickname = nickname.strip()
if not nickname:
continue
if nickname in users:
self.send(b'That nickname is already in use.')
continue
if len(nickname) > 40:
self.send(b'That nickname is too long.')
continue
if b' ' in nickname:
self.send(b'Nicknames must not contain spaces.')
continue
return nickname
async def run(self):
nickname = await self.login()
if not nickname:
return
broadcast(b'%s connected from %s.' % (nickname, self.host))
users[nickname] = self
self.nickname = nickname
self.send(b'Welcome %s. Type /help for help.' % nickname)
while self.live:
line = await self.readline()
if not line: # EOF, connection closed
break
while line and line[-1] in b'\r\n':
line = line[:-1]
if line.startswith(b'/') and not line.startswith(b'/ '):
cmd, args = splitword(line[1:])
if cmd in commands:
commands[cmd](self, args)
else:
self.send(b'Command not understood.')
continue
if line.startswith(b'/ '):
line = line[2:]
broadcast(b'<%s> %s' % (nickname, line))
commands = {}
def command(fun):
commands[fun.__name__.encode('utf-8')] = fun
return fun
@command
def quit(conn, args):
conn.send(b'bye!')
conn.live = False
commands[b'disconnect'] = commands[b'bye'] = commands[b'exit'] = commands[b'quit']
@command
def m(conn, args):
dest, msg = splitword(args)
if dest not in users:
conn.send(b'No such user.')
return
users[dest].send(b'<*%s*> %s' % (conn.nickname, msg))
conn.send(b'Sent.')
@command
def crash(conn, args):
raise ValueError(conn)
@command
def w(conn, args):
for user in users:
conn.send(b'- %s is connected from %s' % (user, users[user].host))
conn.send(b'The end.')
@command
def help(conn, args):
conn.send(help_string)
@command
def me(conn, args):
broadcast(b'* %s %s' % (conn.nickname, args))
async def handle(reader, writer):
conn = Connection(reader, writer)
try:
await conn.run()
finally:
conn.kill()
async def main():
server = await asyncio.start_server(handle, '', 7666)
print("listening on", server.sockets[0].getsockname())
# In 3.5.2, asyncio.base_events.Server isn’t an async context
# manager, and there’s no Server.serve_forever.
await server.wait_closed()
def asynciorun(main):
"There’s no asyncio.run in Python 3.5."
#
# shows how to clean things up nicely.
loop = asyncio.get_event_loop()
loop.run_until_complete(main)
loop.close()
if __name__ == '__main__':
asynciorun(main())