diff --git a/.python-version b/.python-version
index fef12e2..d2286d0 100644
--- a/.python-version
+++ b/.python-version
@@ -1,1 +1,2 @@
 bottom
+3.5.0
diff --git a/.travis.yml b/.travis.yml
index f37e3e8..a14cce9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,8 +1,8 @@
 language: python
-python: 3.5-dev
+python: 3.5
 env:
-  - TOXENV=py35
+  - TOX_ENV=py35
 install: pip install tox coveralls
-script: tox -e $TOXENV
+script: tox -e $TOX_ENV
 after_success:
   - coveralls
diff --git a/MANIFEST.in b/MANIFEST.in
index 564aaf0..736edbe 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,2 @@
-include README.markdown
+include README.rst
 recursive-exclude tests *
diff --git a/README.markdown b/README.markdown
deleted file mode 100644
index 06f249c..0000000
--- a/README.markdown
+++ /dev/null
@@ -1,808 +0,0 @@
-# bottom 0.9.13
-
-[![Build Status]
-(https://travis-ci.org/numberoverzero/bottom.svg?branch=master)]
-(https://travis-ci.org/numberoverzero/bottom)[![Coverage Status]
-(https://coveralls.io/repos/numberoverzero/bottom/badge.png?branch=master)]
-(https://coveralls.io/r/numberoverzero/bottom?branch=master)
-
-Downloads https://pypi.python.org/pypi/bottom
-
-Source https://github.com/numberoverzero/bottom
-
-asyncio-based rfc2812-compliant IRC Client
-
-# Installation
-
-`pip install bottom`
-
-# Getting Started
-
-bottom isn't a kitchen-sink library.  Instead, it provides a consistent API with a small surface area, tuned for performance and ease of extension.  Similar to the routing style of bottle.py, hooking into events is one line.
-
-```python
-import bottom
-import asyncio
-
-NICK = 'bottom-bot'
-CHANNEL = '#python'
-
-bot = bottom.Client('localhost', 6697)
-
-
-@bot.on('CLIENT_CONNECT')
-def connect():
-    bot.send('NICK', nick=NICK)
-    bot.send('USER', user=NICK, realname='Bot using bottom.py')
-    bot.send('JOIN', channel=CHANNEL)
-
-
-@bot.on('PING')
-def keepalive(message):
-    bot.send('PONG', message=message)
-
-
-@bot.on('PRIVMSG')
-def message(nick, target, message):
-    ''' Echo all messages '''
-
-    # Don't echo ourselves
-    if nick == NICK:
-        return
-    # Direct message to bot
-    if target == NICK:
-        bot.send("PRIVMSG", target=nick, message=message)
-    # Message in channel
-    else:
-        bot.send("PRIVMSG", target=target, message=message)
-
-asyncio.get_event_loop().run_until_complete(bot.run())
-```
-
-# Versioning  and RFC2812
-
-* Bottom follows semver for its **public** API.
-
-  * Currently, `Client` is the only public member of bottom.
-  * IRC replies/codes which are not yet implemented may be added at any time, and will correspond to a patch - the function contract of `@on` method does not change.
-  * You should not rely on the internal api staying the same between minor versions.
-  * Over time, private apis may be raised to become public.  The reverse will never occur.
-
-* There are a number of changes from RFC2812 - none should noticeably change how you interact with a standard IRC server.  For specific adjustments, see the notes section of each command in [`Supported Commands`](#supported-commands).
-
-# Contributing
-Contributions welcome!  When reporting issues, please provide enough detail to reproduce the bug - sample code is ideal.  When submitting a PR, please make sure `tox` passes (including flake8).
-
-### Development
-bottom uses `tox`, `pytest` and `flake8`.  To get everything set up:
-
-```
-# RECOMMENDED: create a virtualenv with:
-#     mkvirtualenv bottom
-git clone https://github.com/numberoverzero/bottom.git
-pip install tox
-tox
-```
-
-### TODO
-* Better `Client` docstrings
-* Add missing replies/errors to `unpack.py:unpack_command`
-  * Add reply/error parameters to `unpack.py:parameters`
-  * Document [`Supported Events`](#supported-events)
-
-
-### Contributors
-* [fahhem](https://github.com/fahhem)
-* [thebigmunch](https://github.com/thebigmunch)
-* [tilal6991](https://github.com/tilal6991)
-
-# API
-
-### Client.run()
-
-*This is a coroutine.*
-
-Start the magic.  This will connect the client, and then read until it disconnects.  The `CLIENT_DISCONNECT` event will fire before the loop exits, allowing you to `yield from Client.connect()` and keep the client running.
-
-If you want to call this synchronously (block until it's complete) use the following:
-
-```python
-import asyncio
-# ... client is defined somewhere
-
-loop = asyncio.get_event_loop()
-task = client.run()
-loop.run_until_complete(task)
-```
-
-### Client.on(event)(func)
-
-This `@decorator` is the main way you'll interact with a `Client`.  It takes a string, returning a function wrapper that validates the function and registers it for the given event.  When that event occurs, the function will be called, mapping any arguments the function may expect from the set of available arguments for the event.
-
-Not all available arguments need to be used.  For instance, both of the following are valid:
-
-```python
-@bot.on('PRIVMSG')
-def event(nick, message, target):
-    ''' Doesn't use user, host.  argument order is different '''
-    # message sent to bot - echo message
-    if target == bot.nick:
-        bot.send('PRIVMSG', target, message=message)
-    # Some channel we're watching
-    elif target == bot.monitored_channel:
-        logger.info("{} -> {}: {}".format(nick, target, message))
-
-
-@bot.on('PRIVMSG')
-def func(message, target):
-    ''' Just waiting for the signal '''
-    if message == codeword && target == secret_channel:
-        execute_heist()
-```
-
-VAR_KWARGS can be used, as long as the name doesn't mask an actual parameter.  VAR_ARGS may not be used.
-
-```python
-# OK - kwargs, no masking
-@bot.on('PRIVMSG')
-def event(message, **everything_else):
-    logger.log(everything_else['nick'] + " said " + message)
-
-
-# NOT OK - kwargs, masking parameter <nick>
-@bot.on('PRIVMSG')
-def event(message, **nick):
-    logger.log(nick['target'])
-
-
-# NOT OK - uses VAR_ARGS
-@bot.on('PRIVMSG')
-def event(message, *args):
-    logger.log(args)
-```
-
-Decorated functions will be invoked asynchronously, and may optionally use the `yield from` syntax.  Functions do not need to be wrapped with `@ayncio.coroutine` - this is handled as part of the function caching process.
-
-### Client.trigger(event, **kwargs)
-
-*This is a coroutine.*
-
-Manually inject a command or reply as if it came from the server.  This is useful for invoking other handlers.
-
-```python
-# Manually trigger `PRIVMSG` handlers:
-yield from bot.trigger('privmsg', nick="always_says_no", message="yes")
-```
-
-```python
-# Rename !commands to !help
-@bot.on('privmsg')
-def parse(nick, target, message):
-    if message == '!commands':
-        bot.send('privmsg', target=nick,
-                 message="!commands was renamed to !help in 1.2")
-        # Don't make them retype it, just make it happen
-        yield from bot.trigger('privmsg', nick=nick,
-                               target=target, message="!help")
-```
-
-```python
-# While testing the auto-reconnect module, simulate a disconnect:
-def test_reconnect(bot):
-    loop = asyncio.get_event_loop()
-    loop.run_until_complete(bot.trigger("client_disconnect"))
-    assert bot.connected
-```
-
-### Client.connect()
-
-*This is a coroutine.*
-
-Attempt to reconnect using the client's host, port.
-
-```python
-@bot.on('client_disconnect')
-def reconnect():
-    # Wait a few seconds
-    yield from asyncio.sleep(3)
-    yield from bot.connect()
-```
-
-### Client.disconnect()
-
-*This is a coroutine.*
-
-Disconnect from the server if connected.
-
-```python
-@bot.on('privmsg')
-def suicide_pill(nick, message):
-    if nick == "spy_handler" and message == "last stop":
-        yield from bot.disconnect()
-```
-
-### Client.send(command, **kwargs)
-
-Send a command to the server.  See [`Supported Commands`](#supported-commands) for a detailed breakdown of available commands and their parameters.
-
-# Supported Commands
-
-These commands can be sent to the server using [`Client.send`](#clientsendcommand-kwargs).
-
-For incoming signals and messages, see [`Supported Events`](#supported-events) below.
-
-#### Documentation Layout
-There are three parts to each command's documentation:
-
-1. **Python syntax** - sample calls using available parameters
-2. **Normalized IRC wire format** - the normalized translation from python keywords to a literal string that will be constructed by the client and sent to the server.  The following syntax is used:
-  * `<parameter>` the location of the `parameter` passed to `send`.  Literal `<>` are not transferred.
-  * `[value]` an optional value, which may be excluded.  In some cases, such as [`LINKS`](#links), an optional value may only be provided if another dependant value is present.  Literal `[]` are not transferred.
-  * `:` the start of a field which may contain spaces.  This is always the last field of an IRC line.
-  * `"value"` literal value as printed.  Literal `""` are not transferred.
-3. **Notes** - additional options or restrictions on commands that do not fit a pre-defined convention.  Common notes include keywords for ease of searching:
-  * `RFC_DELTA` - Some commands have different parameters from their RFC2812 definitions.  **Please pay attention to these notes, since they are the most likely to cause issues**.  These changes can include:
-    * Addition of new required or optional parameters
-    * Default values for new or existing parameters
-  * `CONDITIONAL_OPTION` - there are some commands whose values depend on each other.  For example, [`LINKS`](#links), `<mask>` REQUIRES `<remote>`.
-  * `MULTIPLE_VALUES` - Some commands can handle non-string iterables, such as [`WHOWAS`](#whowas) where `<nick>` can handle both `"WiZ"` and `["WiZ", "WiZ-friend"]`.
-  * `PARAM_RENAME` - Some commands have renamed parameters from their RFC2812 specification to improve comsistency.
-
-## Local Events
-*(trigger only)*
-
-#### CLIENT_CONNECT
-```python
-yield from client.trigger('CLIENT_CONNECT', host='localhost', port=6697)
-```
-#### CLIENT_DISCONNECT
-```python
-yield from client.trigger('CLIENT_DISCONNECT', host='localhost', port=6697)
-```
-
-## Connection Registration
-#### [PASS]
-```python
-client.send('PASS', password='hunter2')
-```
-
-    PASS <password>
-
-#### [NICK]
-```python
-client.send('nick', nick='WiZ')
-```
-
-    NICK <nick>
-
-* PARAM_RENAME `nickname -> nick`
-
-#### [USER]
-```python
-client.send('USER', user='WiZ-user', realname='Ronnie')
-client.send('USER', user='WiZ-user', mode='8', realname='Ronnie')
-```
-
-    USER <user> [<mode>] :<realname>
-
-* RFC_DELTA `mode` is optional - default is `0`
-
-#### [OPER]
-```python
-client.send('OPER', user='WiZ', password='hunter2')
-```
-
-    OPER <user> <password>
-
-* PARAM_RENAME `name -> user`
-
-#### [USERMODE][USERMODE] (renamed from [MODE][USERMODE])
-```python
-client.send('USERMODE', nick='WiZ')
-client.send('USERMODE', nick='WiZ', modes='+io')
-```
-
-    MODE <nick> [<modes>]
-
-* RFC_DELTA rfc did not name `modes` parameter
-
-#### [SERVICE]
-```python
-client.send('SERVICE', nick='CHANSERV', distribution='*.en',
-            type='0', info='manages channels')
-```
-
-    SERVICE <nick> <distribution> <type> :<info>
-
-* PARAM_RENAME `nickname -> nick`
-
-#### [QUIT]
-```python
-client.send('QUIT')
-client.send('QUIT', message='Gone to Lunch')
-```
-
-    QUIT :[<message>]
-
-* PARAM_RENAME `Quit Message -> message`
-
-#### [SQUIT]
-```python
-client.send('SQUIT', server='tolsun.oulu.fi')
-client.send('SQUIT', server='tolsun.oulu.fi', message='Bad Link')
-```
-
-    SQUIT <server> :[<message>]
-
-* PARAM_RENAME `Comment -> message`
-* RFC_DELTA `message` is optional - rfc says comment SHOULD be supplied; syntax shows required
-
-## Channel Operations
-
-#### [JOIN]
-```python
-client.send('JOIN', channel='0')  # send PART to all joined channels
-client.send('JOIN', channel='#foo-chan')
-client.send('JOIN', channel='#foo-chan', key='foo-key')
-client.send('JOIN', channel=['#foo-chan', '#other'], key='key-for-both')
-client.send('JOIN', channel=['#foo-chan', '#other'], key=['foo-key', 'other-key'])
-```
-
-    JOIN <channel> [<key>]
-
-* MULTIPLE_VALUES `channel` and `key`
-* If `channel` has n > 1 values, `key` MUST have 1 or n values
-
-#### [PART]
-```python
-client.send('PART', channel='#foo-chan')
-client.send('PART', channel=['#foo-chan', '#other'])
-client.send('PART', channel='#foo-chan', message='I lost')
-```
-
-    PART <channel> :[<message>]
-
-* MULTIPLE_VALUES `channel`
-
-#### [CHANNELMODE][CHANNELMODE] (renamed from [MODE][CHANNELMODE])
-```python
-client.send('CHANNELMODE', channel='#foo-chan', modes='+b')
-client.send('CHANNELMODE', channel='#foo-chan', modes='+l', params='10')
-```
-
-    MODE <channel> <modes> [<params>]
-
-* PARAM_RENAME `modeparams -> params`
-
-#### [TOPIC]
-```python
-client.send('TOPIC', channel='#foo-chan')
-client.send('TOPIC', channel='#foo-chan', message='')  # Clear channel message
-client.send('TOPIC', channel='#foo-chan', message='Yes, this is dog')
-```
-
-    TOPIC <channel> :[<message>]
-
-* PARAM_RENAME `topic -> message`
-
-#### [NAMES]
-```python
-client.send('NAMES')
-client.send('NAMES', channel='#foo-chan')
-client.send('NAMES', channel=['#foo-chan', '#other'])
-client.send('NAMES', channel=['#foo-chan', '#other'], target='remote.*.edu')
-```
-
-    NAMES [<channel>] [<target>]
-
-* MULTIPLE_VALUES `channel`
-* CONDITIONAL_OPTION `target` requires `channel`
-
-#### [LIST]
-```python
-client.send('LIST')
-client.send('LIST', channel='#foo-chan')
-client.send('LIST', channel=['#foo-chan', '#other'])
-client.send('LIST', channel=['#foo-chan', '#other'], target='remote.*.edu')
-```
-
-    LIST [<channel>] [<target>]
-
-* MULTIPLE_VALUES `channel`
-* CONDITIONAL_OPTION `target` requires `channel`
-
-#### [INVITE]
-```python
-client.send('INVITE', nick='WiZ-friend', channel='#bar-chan')
-```
-
-    INVITE <nick> <channel>
-
-* PARAM_RENAME `nickname -> nick`
-
-#### [KICK]
-```python
-client.send('KICK', channel='#foo-chan', nick='WiZ')
-client.send('KICK', channel='#foo-chan', nick='WiZ', message='Spamming')
-client.send('KICK', channel='#foo-chan', nick=['WiZ', 'WiZ-friend'])
-client.send('KICK', channel=['#foo', '#bar'], nick=['WiZ', 'WiZ-friend'])
-```
-
-    KICK <channel> <nick> :[<message>]
-
-* PARAM_RENAME `nickname -> nick`
-* PARAM_RENAME `comment -> message`
-* MULTIPLE_VALUES `channel` and `nick`
-* If `nick` has n > 1 values, channel MUST have 1 or n values
-* `channel` can have n > 1 values IFF `nick` has n values
-
-## Sending Messages
-#### [PRIVMSG]
-```python
-client.send('PRIVMSG', target='WiZ-friend', message='Hello, friend!')
-```
-
-    PRIVMSG <target> :<message>
-
-* PARAM_RENAME `msgtarget -> target`
-* PARAM_RENAME `text to be sent -> message`
-
-#### [NOTICE]
-```python
-client.send('NOTICE', target='#foo-chan', message='Maintenance in 5 mins')
-```
-
-    NOTICE <target> :<message>
-
-* PARAM_RENAME `msgtarget -> target`
-* PARAM_RENAME `text -> message`
-
-## Server Queries and Commands
-#### [MOTD]
-```python
-client.send('MOTD')
-client.send('MOTD', target='remote.*.edu')
-```
-
-    MOTD [<target>]
-
-#### [LUSERS]
-```python
-client.send('LUSERS')
-client.send('LUSERS', mask='*.edu')
-client.send('LUSERS', mask='*.edu', target='remote.*.edu')
-```
-
-    LUSERS [<mask>] [<target>]
-
-* CONDITIONAL_OPTION `target` requires `mask`
-
-#### [VERSION]
-```python
-client.send('VERSION')
-```
-
-    VERSION [<target>]
-
-#### [STATS]
-```python
-client.send('STATS')
-client.send('STATS', query='m')
-client.send('STATS', query='m', target='remote.*.edu')
-```
-
-    STATS [<query>] [<target>]
-
-* CONDITIONAL_OPTION `target` requires `query`
-
-#### [LINKS]
-```python
-client.send('LINKS')
-client.send('LINKS', mask='*.bu.edu')
-client.send('LINKS', remote='*.edu', mask='*.bu.edu')
-```
-
-    LINKS [<remote>] [<mask>]
-
-* PARAM_RENAME `remote server -> remote`
-* PARAM_RENAME `server mask -> mask`
-* CONDITIONAL_OPTION `remote` requires `mask`
-
-#### [TIME]
-```python
-client.send('TIME')
-client.send('TIME', target='remote.*.edu')
-```
-
-    TIME [<target>]
-
-#### [CONNECT]
-```python
-client.send('CONNECT', target='tolsun.oulu.fi', port=6667)
-client.send('CONNECT', target='tolsun.oulu.fi', port=6667, remote='*.edu')
-```
-
-    CONNECT <target> <port> [<remote>]
-
-* PARAM_RENAME `target server -> target`
-* PARAM_RENAME `remote server -> remote`
-
-#### [TRACE]
-```python
-client.send('TRACE')
-client.send('TRACE', target='remote.*.edu')
-```
-
-    TRACE [<target>]
-
-#### [ADMIN]
-```python
-client.send('ADMIN')
-client.send('ADMIN', target='remote.*.edu')
-```
-
-    ADMIN [<target>]
-
-#### [INFO]
-```python
-client.send('INFO')
-client.send('INFO', target='remote.*.edu')
-```
-
-    INFO [<target>]
-
-## Service Query and Commands
-#### [SERVLIST]
-```python
-client.send('SERVLIST', mask='*SERV')
-client.send('SERVLIST', mask='*SERV', type=3)
-```
-
-    SERVLIST [<mask>] [<type>]
-
-* CONDITIONAL_OPTION `type` requires `mask`
-
-#### [SQUERY]
-```python
-client.send('SQUERY', target='irchelp', message='HELP privmsg')
-```
-
-    SQUERY <target> :<message>
-
-* PARAM_RENAME `servicename -> target`
-* PARAM_RENAME `text -> message`
-
-## User Based Queries
-#### [WHO]
-```python
-client.send('WHO')
-client.send('WHO', mask='*.fi')
-client.send('WHO', mask='*.fi', o=True)
-```
-
-    WHO [<mask>] ["o"]
-
-* Optional positional parameter "o" is included if the kwarg "o" is Truthy
-
-#### [WHOIS]
-```python
-client.send('WHOIS', mask='*.fi')
-client.send('WHOIS', mask=['*.fi', '*.edu'], target='remote.*.edu')
-```
-
-    WHOIS <mask> [<target>]
-
-* MULTIPLE_VALUES `mask`
-
-#### [WHOWAS]
-```python
-client.send('WHOWAS', nick='WiZ')
-client.send('WHOWAS', nick='WiZ', count=10)
-client.send('WHOWAS', nick=['WiZ', 'WiZ-friend'], count=10)
-client.send('WHOWAS', nick='WiZ', count=10, target='remote.*.edu')
-```
-
-    WHOWAS <nick> [<count>] [<target>]
-
-* PARAM_RENAME `nickname -> nick`
-* MULTIPLE_VALUES `nick`
-* CONDITIONAL_OPTION `target` requires `count`
-
-## Miscellaneous Messages
-#### [KILL]
-```python
-client.send('KILL', nick='WiZ', message='Spamming Joins')
-```
-
-    KILL <nick> :<message>
-
-* PARAM_RENAME `nickname -> nick`
-* PARAM_RENAME `comment -> message`
-
-#### [PING]
-```python
-client.send('PING', message='Test..')
-client.send('PING', server2='tolsun.oulu.fi')
-client.send('PING', server1='WiZ', server2='tolsun.oulu.fi')
-```
-
-    PING [<server1>] [<server2>] :[<message>]
-
-* RFC_DELTA `server1` is optional
-* RFC_DELTA `message` is new, and optional
-* CONDITIONAL_OPTION `server2` requires `server1`
-
-#### [PONG]
-```python
-client.send('PONG', message='Test..')
-client.send('PONG', server2='tolsun.oulu.fi')
-client.send('PONG', server1='WiZ', server2='tolsun.oulu.fi')
-```
-
-    PONG [<server1>] [<server2>] :[<message>]
-
-* RFC_DELTA `server1` is optional
-* RFC_DELTA `message` is new, and optional
-* CONDITIONAL_OPTION `server2` requires `server1`
-
-## Optional Features
-#### [AWAY]
-```python
-client.send('AWAY')
-client.send('AWAY', message='Gone to Lunch')
-```
-
-    AWAY :[<message>]
-
-* PARAM_RENAME `text -> message`
-
-#### [REHASH]
-```python
-client.send('REHASH')
-```
-
-    REHASH
-
-#### [DIE]
-```python
-client.send('DIE')
-```
-
-    DIE
-
-#### [RESTART]
-```python
-client.send('RESTART')
-```
-
-    RESTART
-
-#### [SUMMON]
-```python
-client.send('SUMMON', nick='WiZ')
-client.send('SUMMON', nick='WiZ', target='remote.*.edu')
-client.send('SUMMON', nick='WiZ', target='remote.*.edu', channel='#foo-chan')
-```
-
-    SUMMON <nick> [<target>] [<channel>]
-
-* PARAM_RENAME `user -> nick`
-* CONDITIONAL_OPTION `channel` requires `target`
-
-#### [USERS]
-```python
-client.send('USERS')
-client.send('USERS', target='remote.*.edu')
-```
-
-    USERS [<target>]
-
-#### [WALLOPS]
-```python
-client.send('WALLOPS', message='Maintenance in 5 minutes')
-```
-
-    WALLOPS :<message>
-
-* PARAM_RENAME `Text to be sent -> message`
-
-#### [USERHOST]
-```python
-client.send('USERHOST', nick='WiZ')
-client.send('USERHOST', nick=['WiZ', 'WiZ-friend'])
-```
-
-    USERHOST <nick>
-
-* PARAM_RENAME `nickname -> nick`
-* MULTIPLE_VALUES `nick`
-
-#### [ISON]
-```python
-client.send('ISON', nick='WiZ')
-client.send('ISON', nick=['WiZ', 'WiZ-friend'])
-```
-
-    ISON <nick>
-
-* PARAM_RENAME `nickname -> nick`
-* MULTIPLE_VALUES `nick`
-
-[PASS]: https://tools.ietf.org/html/rfc2812#section-3.1.1
-[NICK]: https://tools.ietf.org/html/rfc2812#section-3.1.2
-[USER]: https://tools.ietf.org/html/rfc2812#section-3.1.3
-[OPER]: https://tools.ietf.org/html/rfc2812#section-3.1.4
-[USERMODE]: https://tools.ietf.org/html/rfc2812#section-3.1.5
-[SERVICE]: https://tools.ietf.org/html/rfc2812#section-3.1.6
-[QUIT]: https://tools.ietf.org/html/rfc2812#section-3.1.7
-[SQUIT]: https://tools.ietf.org/html/rfc2812#section-3.1.8
-
-[JOIN]: https://tools.ietf.org/html/rfc2812#section-3.2.1
-[PART]: https://tools.ietf.org/html/rfc2812#section-3.2.2
-[CHANNELMODE]: https://tools.ietf.org/html/rfc2812#section-3.2.3
-[TOPIC]: https://tools.ietf.org/html/rfc2812#section-3.2.4
-[NAMES]: https://tools.ietf.org/html/rfc2812#section-3.2.5
-[LIST]: https://tools.ietf.org/html/rfc2812#section-3.2.6
-[INVITE]: https://tools.ietf.org/html/rfc2812#section-3.2.7
-[KICK]: https://tools.ietf.org/html/rfc2812#section-3.2.8
-
-[PRIVMSG]: https://tools.ietf.org/html/rfc2812#section-3.3.1
-[NOTICE]: https://tools.ietf.org/html/rfc2812#section-3.3.2
-
-[MOTD]: https://tools.ietf.org/html/rfc2812#section-3.4.1
-[LUSERS]: https://tools.ietf.org/html/rfc2812#section-3.4.2
-[VERSION]: https://tools.ietf.org/html/rfc2812#section-3.4.3
-[STATS]: https://tools.ietf.org/html/rfc2812#section-3.4.4
-[LINKS]: https://tools.ietf.org/html/rfc2812#section-3.4.5
-[TIME]: https://tools.ietf.org/html/rfc2812#section-3.4.6
-[CONNECT]: https://tools.ietf.org/html/rfc2812#section-3.4.7
-[TRACE]: https://tools.ietf.org/html/rfc2812#section-3.4.8
-[ADMIN]: https://tools.ietf.org/html/rfc2812#section-3.4.9
-[INFO]: https://tools.ietf.org/html/rfc2812#section-3.4.10
-
-[SERVLIST]: https://tools.ietf.org/html/rfc2812#section-3.5.1
-[SQUERY]: https://tools.ietf.org/html/rfc2812#section-3.5.2
-
-[WHO]: https://tools.ietf.org/html/rfc2812#section-3.6.1
-[WHOIS]: https://tools.ietf.org/html/rfc2812#section-3.6.2
-[WHOWAS]: https://tools.ietf.org/html/rfc2812#section-3.6.3
-
-[KILL]: https://tools.ietf.org/html/rfc2812#section-3.7.1
-[PING]: https://tools.ietf.org/html/rfc2812#section-3.7.2
-[PONG]: https://tools.ietf.org/html/rfc2812#section-3.7.3
-
-[AWAY]: https://tools.ietf.org/html/rfc2812#section-4.1
-[REHASH]: https://tools.ietf.org/html/rfc2812#section-4.2
-[DIE]: https://tools.ietf.org/html/rfc2812#section-4.3
-[RESTART]: https://tools.ietf.org/html/rfc2812#section-4.4
-[SUMMON]: https://tools.ietf.org/html/rfc2812#section-4.5
-[USERS]: https://tools.ietf.org/html/rfc2812#section-4.6
-[WALLOPS]: https://tools.ietf.org/html/rfc2812#section-4.7
-[USERHOST]: https://tools.ietf.org/html/rfc2812#section-4.8
-[ISON]: https://tools.ietf.org/html/rfc2812#section-4.9
-
-
-# Supported Events
-
-These commands are received from the server, or dispatched using `Client.trigger(...)`.
-
-For sending commands, see [`Supported Commands`](#supported-commands) above.
-
-* PING
-* JOIN
-* PART
-* PRIVMSG
-* NOTICE
-* RPL_WELCOME (001)
-* RPL_YOURHOST (002)
-* RPL_CREATED (003)
-* RPL_MYINFO (004)
-* RPL_BOUNCE (005)
-* RPL_MOTDSTART (375)
-* RPL_MOTD (372)
-* RPL_ENDOFMOTD (376)
-* RPL_LUSERCLIENT (251)
-* RPL_LUSERME (255)
-* RPL_LUSEROP (252)
-* RPL_LUSERUNKNOWN (253)
-* RPL_LUSERCHANNELS (254)
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..8331488
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,524 @@
+.. image:: https://img.shields.io/travis/numberoverzero/bottom/master.svg?style=flat-square
+    :target: https://travis-ci.org/numberoverzero/bottom
+.. image:: https://img.shields.io/coveralls/numberoverzero/bottom/master.svg?style=flat-square
+    :target: https://coveralls.io/github/numberoverzero/bottom
+.. image:: https://img.shields.io/pypi/v/bottom.svg?style=flat-square
+    :target: https://pypi.python.org/pypi/bottom
+.. image:: https://img.shields.io/github/issues-raw/numberoverzero/bottom.svg?style=flat-square
+    :target: https://github.com/numberoverzero/bottom/issues
+.. image:: https://img.shields.io/pypi/l/bottom.svg?style=flat-square
+    :target: https://github.com/numberoverzero/bottom/blob/master/LICENSE
+
+asyncio-based rfc2812-compliant IRC Client (3.5+)
+
+Installation
+============
+::
+
+    pip install bottom
+
+Getting Started
+===============
+
+bottom isn't a kitchen-sink library.  Instead, it provides a consistent API with a small surface area, tuned for performance and ease of extension.  Similar to the routing style of bottle.py, hooking into events is one line.
+
+::
+
+    import bottom
+    import asyncio
+
+    NICK = 'bottom-bot'
+    CHANNEL = '#python'
+
+    bot = bottom.Client('localhost', 6697)
+
+
+    @bot.on('CLIENT_CONNECT')
+    def connect():
+        bot.send('NICK', nick=NICK)
+        bot.send('USER', user=NICK, realname='Bot using bottom.py')
+        bot.send('JOIN', channel=CHANNEL)
+
+
+    @bot.on('PING')
+    def keepalive(message):
+        bot.send('PONG', message=message)
+
+
+    @bot.on('PRIVMSG')
+    def message(nick, target, message):
+        ''' Echo all messages '''
+
+        # Don't echo ourselves
+        if nick == NICK:
+            return
+        # Direct message to bot
+        if target == NICK:
+            bot.send("PRIVMSG", target=nick, message=message)
+        # Message in channel
+        else:
+            bot.send("PRIVMSG", target=target, message=message)
+
+    asyncio.get_event_loop().run_until_complete(bot.run())
+
+Versioning  and RFC2812
+=======================
+
+* Bottom follows semver for its **public** API.
+
+  * Currently, ``Client`` is the only public member of bottom.
+  * IRC replies/codes which are not yet implemented may be added at any time, and will correspond to a patch - the function contract of ``@on`` method does not change.
+  * You should not rely on the internal api staying the same between minor versions.
+  * Over time, private apis may be raised to become public.  The reverse will never occur.
+
+* There are a number of changes from RFC2812 - none should noticeably change how you interact with a standard IRC server.  For specific adjustments, see the notes above each command in supported_commands_.
+
+Contributing
+============
+
+Contributions welcome!  When reporting issues, please provide enough detail to reproduce the bug - sample code is ideal.  When submitting a PR, please make sure ``tox`` passes (including flake8).
+
+Development
+-----------
+
+bottom uses ``tox``, ``pytest`` and ``flake8``.  To get everything set up::
+
+    # RECOMMENDED: create a virtualenv with:
+    #     mkvirtualenv bottom
+    git clone https://github.com/numberoverzero/bottom.git
+    pip install tox
+    tox
+
+
+TODO
+----
+
+* Better `Client` docstrings
+* Add missing replies/errors to `unpack.py:unpack_command`
+
+  * Add reply/error parameters to `unpack.py:parameters`
+  * Document supported_events_
+
+
+Contributors
+------------
+* `fahhem <https://github.com/fahhem>`_
+* `thebigmunch <https://github.com/thebigmunch>`_
+* `tilal6991 <https://github.com/tilal6991>`_
+* `AMorporkian <https://github.com/AMorporkian>`_
+
+API
+===
+
+Client.run()
+------------
+
+*This is a coroutine.*
+
+Start the magic.  This will connect the client, and then read until it disconnects.  The ``CLIENT_DISCONNECT`` event will fire before the loop exits, allowing you to ``await Client.connect()`` and keep the client running.
+
+If you want to call this synchronously (block until it's complete) use the following::
+
+    import asyncio
+    # ... client is defined somewhere
+
+    loop = asyncio.get_event_loop()
+    task = client.run()
+    loop.run_until_complete(task)
+
+
+Client.on(event)(func)
+----------------------
+
+This ``@decorator`` is the main way you'll interact with a ``Client``.  It takes a string, returning a function wrapper that validates the function and registers it for the given event.  When that event occurs, the function will be called, mapping any arguments the function may expect from the set of available arguments for the event.
+
+Not all available arguments need to be used.  For instance, both of the following are valid::
+
+    @bot.on('PRIVMSG')
+    def event(nick, message, target):
+        ''' Doesn't use user, host.  argument order is different '''
+        # message sent to bot - echo message
+        if target == bot.nick:
+            bot.send('PRIVMSG', target, message=message)
+        # Some channel we're watching
+        elif target == bot.monitored_channel:
+            logger.info("{} -> {}: {}".format(nick, target, message))
+
+
+    @bot.on('PRIVMSG')
+    def func(message, target):
+        ''' Just waiting for the signal '''
+        if message == codeword && target == secret_channel:
+            execute_heist()
+
+
+VAR_KWARGS can be used, as long as the name doesn't mask an actual parameter.  VAR_ARGS may not be used.
+
+::
+
+    # OK - kwargs, no masking
+    @bot.on('PRIVMSG')
+    def event(message, **everything_else):
+        logger.log(everything_else['nick'] + " said " + message)
+
+
+    # NOT OK - kwargs, masking parameter <nick>
+    @bot.on('PRIVMSG')
+    def event(message, **nick):
+        logger.log(nick['target'])
+
+
+    # NOT OK - uses VAR_ARGS
+    @bot.on('PRIVMSG')
+    def event(message, *args):
+        logger.log(args)
+
+
+Decorated functions will be invoked asynchronously, and may optionally use the ``await`` syntax.  Functions do not need to be wrapped with ``@ayncio.coroutine`` - this is handled as part of the function caching process.
+
+Client.trigger(event, \*\*kwargs)
+-------------------------------
+
+Manually inject a command or reply as if it came from the server.  This is useful for invoking other handlers.
+Note that because trigger doesn't block, registered callbacks for the event won't run until
+the event loop yields to them.
+
+::
+
+    # Manually trigger `PRIVMSG` handlers:
+    bot.trigger('privmsg', nick="always_says_no", message="yes")
+
+::
+
+    # Rename !commands to !help
+    @bot.on('privmsg')
+    def parse(nick, target, message):
+        if message == '!commands':
+            bot.send('privmsg', target=nick,
+                     message="!commands was renamed to !help in 1.2")
+            # Don't make them retype it, just make it happen
+            bot.trigger('privmsg', nick=nick,
+                        target=target, message="!help")
+
+::
+
+    # While testing the auto-reconnect module, simulate a disconnect:
+    def test_reconnect(bot):
+        bot.trigger("client_disconnect")
+        # Clear out the pending callbacks
+        bot.loop.run_until_complete(asyncio.sleep(0, loop=bot.loop))
+        assert bot.connected
+
+Client.connect()
+----------------
+
+*This is a coroutine.*
+
+Attempt to reconnect using the client's host, port::
+
+    @bot.on('client_disconnect')
+    async def reconnect():
+        # Wait a few seconds
+        await asyncio.sleep(3)
+        await bot.connect()
+
+
+Client.disconnect()
+-------------------
+
+*This is a coroutine.*
+
+Disconnect from the server if connected::
+
+    @bot.on('privmsg')
+    async def suicide_pill(nick, message):
+        if nick == "spy_handler" and message == "last stop":
+            await bot.disconnect()
+
+Client.send(command, \*\*kwargs)
+------------------------------
+
+Send a command to the server.
+
+.. _supported_commands:
+
+Supported Commands
+==================
+
+::
+
+    client.send('PASS', password='hunter2')
+
+::
+
+    client.send('NICK', nick='WiZ')
+
+::
+
+    # mode is optional, default is 0
+    client.send('USER', user='WiZ-user', realname='Ronnie')
+    client.send('USER', user='WiZ-user', mode='8', realname='Ronnie')
+
+::
+
+    client.send('OPER', user='WiZ', password='hunter2')
+
+::
+
+    # Renamed from MODE
+    client.send('USERMODE', nick='WiZ')
+    client.send('USERMODE', nick='WiZ', modes='+io')
+
+::
+
+    client.send('SERVICE', nick='CHANSERV', distribution='*.en',
+                type='0', info='manages channels')
+
+::
+
+    client.send('QUIT')
+    client.send('QUIT', message='Gone to Lunch')
+
+::
+
+    client.send('SQUIT', server='tolsun.oulu.fi')
+    client.send('SQUIT', server='tolsun.oulu.fi', message='Bad Link')
+
+::
+
+    # If channel has n > 1 values, key MUST have 1 or n values
+    client.send('JOIN', channel='0')  # send PART to all joined channels
+    client.send('JOIN', channel='#foo-chan')
+    client.send('JOIN', channel='#foo-chan', key='foo-key')
+    client.send('JOIN', channel=['#foo-chan', '#other'], key='key-for-both')
+    client.send('JOIN', channel=['#foo-chan', '#other'], key=['foo-key', 'other-key'])
+
+::
+
+    client.send('PART', channel='#foo-chan')
+    client.send('PART', channel=['#foo-chan', '#other'])
+    client.send('PART', channel='#foo-chan', message='I lost')
+
+::
+
+    # Renamed from MODE
+    client.send('CHANNELMODE', channel='#foo-chan', modes='+b')
+    client.send('CHANNELMODE', channel='#foo-chan', modes='+l', params='10')
+
+::
+
+    client.send('TOPIC', channel='#foo-chan')
+    client.send('TOPIC', channel='#foo-chan', message='')  # Clear channel message
+    client.send('TOPIC', channel='#foo-chan', message='Yes, this is dog')
+
+::
+
+    # target requires channel
+    client.send('NAMES')
+    client.send('NAMES', channel='#foo-chan')
+    client.send('NAMES', channel=['#foo-chan', '#other'])
+    client.send('NAMES', channel=['#foo-chan', '#other'], target='remote.*.edu')
+
+::
+
+    # target requires channel
+    client.send('LIST')
+    client.send('LIST', channel='#foo-chan')
+    client.send('LIST', channel=['#foo-chan', '#other'])
+    client.send('LIST', channel=['#foo-chan', '#other'], target='remote.*.edu')
+
+::
+
+    client.send('INVITE', nick='WiZ-friend', channel='#bar-chan')
+
+::
+
+    # nick and channel must have the same number of elements
+    client.send('KICK', channel='#foo-chan', nick='WiZ')
+    client.send('KICK', channel='#foo-chan', nick='WiZ', message='Spamming')
+    client.send('KICK', channel='#foo-chan', nick=['WiZ', 'WiZ-friend'])
+    client.send('KICK', channel=['#foo', '#bar'], nick=['WiZ', 'WiZ-friend'])
+
+::
+
+    client.send('PRIVMSG', target='WiZ-friend', message='Hello, friend!')
+
+::
+
+    client.send('NOTICE', target='#foo-chan', message='Maintenance in 5 mins')
+
+::
+
+    client.send('MOTD')
+    client.send('MOTD', target='remote.*.edu')
+
+::
+
+    client.send('LUSERS')
+    client.send('LUSERS', mask='*.edu')
+    client.send('LUSERS', mask='*.edu', target='remote.*.edu')
+
+::
+
+    client.send('VERSION')
+
+::
+
+    # target requires query
+    client.send('STATS')
+    client.send('STATS', query='m')
+    client.send('STATS', query='m', target='remote.*.edu')
+
+::
+
+    # remote requires mask
+    client.send('LINKS')
+    client.send('LINKS', mask='*.bu.edu')
+    client.send('LINKS', remote='*.edu', mask='*.bu.edu')
+
+::
+
+    client.send('TIME')
+    client.send('TIME', target='remote.*.edu')
+
+::
+
+    client.send('CONNECT', target='tolsun.oulu.fi', port=6667)
+    client.send('CONNECT', target='tolsun.oulu.fi', port=6667, remote='*.edu')
+
+::
+
+    client.send('TRACE')
+    client.send('TRACE', target='remote.*.edu')
+
+::
+
+    client.send('ADMIN')
+    client.send('ADMIN', target='remote.*.edu')
+
+::
+
+    client.send('INFO')
+    client.send('INFO', target='remote.*.edu')
+
+::
+
+    # type requires mask
+    client.send('SERVLIST', mask='*SERV')
+    client.send('SERVLIST', mask='*SERV', type=3)
+
+::
+
+    client.send('SQUERY', target='irchelp', message='HELP privmsg')
+
+::
+
+    client.send('WHO')
+    client.send('WHO', mask='*.fi')
+    client.send('WHO', mask='*.fi', o=True)
+
+::
+
+    client.send('WHOIS', mask='*.fi')
+    client.send('WHOIS', mask=['*.fi', '*.edu'], target='remote.*.edu')
+
+::
+
+    # target requires count
+    client.send('WHOWAS', nick='WiZ')
+    client.send('WHOWAS', nick='WiZ', count=10)
+    client.send('WHOWAS', nick=['WiZ', 'WiZ-friend'], count=10)
+    client.send('WHOWAS', nick='WiZ', count=10, target='remote.*.edu')
+
+::
+
+    client.send('KILL', nick='WiZ', message='Spamming Joins')
+
+::
+
+    # server2 requires server1
+    client.send('PING', message='Test..')
+    client.send('PING', server2='tolsun.oulu.fi')
+    client.send('PING', server1='WiZ', server2='tolsun.oulu.fi')
+
+::
+
+    # server2 requires server1
+    client.send('PONG', message='Test..')
+    client.send('PONG', server2='tolsun.oulu.fi')
+    client.send('PONG', server1='WiZ', server2='tolsun.oulu.fi')
+
+::
+
+    client.send('AWAY')
+    client.send('AWAY', message='Gone to Lunch')
+
+::
+
+    client.send('REHASH')
+
+::
+
+    client.send('DIE')
+
+::
+
+    client.send('RESTART')
+
+::
+
+    # target requires channel
+    client.send('SUMMON', nick='WiZ')
+    client.send('SUMMON', nick='WiZ', target='remote.*.edu')
+    client.send('SUMMON', nick='WiZ', target='remote.*.edu', channel='#foo-chan')
+
+::
+
+    client.send('USERS')
+    client.send('USERS', target='remote.*.edu')
+
+::
+
+    client.send('WALLOPS', message='Maintenance in 5 minutes')
+
+::
+
+    client.send('USERHOST', nick='WiZ')
+    client.send('USERHOST', nick=['WiZ', 'WiZ-friend'])
+
+::
+
+    client.send('ISON', nick='WiZ')
+    client.send('ISON', nick=['WiZ', 'WiZ-friend'])
+
+.. _supported_events:
+
+Supported Events
+================
+
+These commands are received from the server, or dispatched using ``Client.trigger(...)``.
+
+::
+
+    # Local only events
+    client.trigger('CLIENT_CONNECT', host='localhost', port=6697)
+    client.trigger('CLIENT_DISCONNECT', host='localhost', port=6697)
+
+* PING
+* JOIN
+* PART
+* PRIVMSG
+* NOTICE
+* RPL_WELCOME (001)
+* RPL_YOURHOST (002)
+* RPL_CREATED (003)
+* RPL_MYINFO (004)
+* RPL_BOUNCE (005)
+* RPL_MOTDSTART (375)
+* RPL_MOTD (372)
+* RPL_ENDOFMOTD (376)
+* RPL_LUSERCLIENT (251)
+* RPL_LUSERME (255)
+* RPL_LUSEROP (252)
+* RPL_LUSERUNKNOWN (253)
+* RPL_LUSERCHANNELS (254)
diff --git a/bottom/__init__.py b/bottom/__init__.py
index 90729cf..5974c2e 100644
--- a/bottom/__init__.py
+++ b/bottom/__init__.py
@@ -1,25 +1,22 @@
 """ asyncio-based rfc2812-compliant IRC Client """
-import logging
 import asyncio
 from . import connection
 from . import event
 from . import pack
 from . import unpack
 __all__ = ["Client"]
-logger = logging.getLogger(__name__)
+__version__ = "1.0.0"
 
 
 class Client(event.EventsMixin):
-    __conn_cls__ = connection.Connection
+    __conn_cls = connection.Connection
 
-    def __init__(self, host, port, encoding='UTF-8', ssl=True):
-        # It's ok that unpack.parameters isn't cached, since it's only
-        # called when adding an event handler (which should __usually__
-        # only occur during setup)
-        super().__init__(unpack.parameters)
-        # trigger events on the client
-        self.connection = self.__conn_cls__(host, port, self,
-                                            encoding=encoding, ssl=ssl)
+    def __init__(self, host, port, *, encoding='UTF-8', ssl=True, loop=None):
+        if loop is None:
+            loop = asyncio.get_event_loop()
+        super().__init__(unpack.parameters, loop=loop)
+        self.connection = self.__conn_cls(host, port, self, ssl=ssl,
+                                          encoding=encoding, loop=loop)
 
     def send(self, command, **kwargs):
         '''
@@ -34,22 +31,19 @@ class Client(event.EventsMixin):
         packed_command = pack.pack_command(command, **kwargs)
         self.connection.send(packed_command)
 
-    @asyncio.coroutine
-    def connect(self):
-        yield from self.connection.connect()
+    async def connect(self):
+        await self.connection.connect()
 
-    @asyncio.coroutine
-    def disconnect(self):
-        yield from self.connection.disconnect()
+    async def disconnect(self):
+        await self.connection.disconnect()
 
     @property
     def connected(self):
         return self.connection.connected
 
-    @asyncio.coroutine
-    def run(self, loop=None):
+    async def run(self):
         ''' Run the client until it disconnects (without reconnecting) '''
-        yield from self.connection.run(loop=loop)
+        await self.connection.run()
 
     def on(self, command):
         '''
diff --git a/bottom/connection.py b/bottom/connection.py
index 72434fa..f50b2f3 100644
--- a/bottom/connection.py
+++ b/bottom/connection.py
@@ -3,65 +3,61 @@ from . import unpack
 
 
 class Connection(object):
-    def __init__(self, host, port, events, encoding, ssl):
+    def __init__(self, host, port, events, encoding, ssl, *, loop):
         self.events = events
         self._connected = False
         self.host, self.port = host, port
         self.reader, self.writer = None, None
         self.encoding = encoding
         self.ssl = ssl
+        self.loop = loop
 
-    @asyncio.coroutine
-    def connect(self, loop=None):
+    async def connect(self):
         if self.connected:
             return
-        self.reader, self.writer = yield from asyncio.open_connection(
-            self.host, self.port, ssl=self.ssl, loop=loop)
+        self.reader, self.writer = await asyncio.open_connection(
+            self.host, self.port, ssl=self.ssl, loop=self.loop)
         self._connected = True
-        yield from self.events.trigger(
-            "CLIENT_CONNECT", host=self.host, port=self.port)
+        self.events.trigger("CLIENT_CONNECT", host=self.host, port=self.port)
 
-    @asyncio.coroutine
-    def disconnect(self):
+    async def disconnect(self):
         if not self.connected:
             return
         self.writer.close()
         self.writer = None
         self.reader = None
         self._connected = False
-        yield from self.events.trigger(
+        self.events.trigger(
             "CLIENT_DISCONNECT", host=self.host, port=self.port)
 
     @property
     def connected(self):
         return self._connected
 
-    @asyncio.coroutine
-    def run(self, loop=None):
-        yield from self.connect(loop=loop)
+    async def run(self):
+        await self.connect()
         while self.connected:
-            msg = yield from self.read()
+            msg = await self.read()
             if msg:
                 try:
                     event, kwargs = unpack.unpack_command(msg)
                 except ValueError:
                     print("PARSE ERROR {}".format(msg))
                 else:
-                    yield from self.events.trigger(event, **kwargs)
+                    self.events.trigger(event, **kwargs)
             else:
                 # Lost connection
-                yield from self.disconnect()
+                await self.disconnect()
 
     def send(self, msg):
         if self.writer:
             self.writer.write((msg.strip() + '\n').encode(self.encoding))
 
-    @asyncio.coroutine
-    def read(self):
+    async def read(self):
         if not self.reader:
             return ''
         try:
-            msg = yield from self.reader.readline()
+            msg = await self.reader.readline()
             return msg.decode(self.encoding, 'ignore').strip()
         except EOFError:
             return ''
diff --git a/bottom/event.py b/bottom/event.py
index 79370da..f94cbf1 100644
--- a/bottom/event.py
+++ b/bottom/event.py
@@ -5,7 +5,7 @@ missing = object()
 
 
 class EventsMixin(object):
-    def __init__(self, getparams):
+    def __init__(self, getparams, *, loop):
         '''
         getparams is a function that takes a single argument (event) and
         returns a list of parameters for the event.  It should raise on unknown
@@ -15,28 +15,25 @@ class EventsMixin(object):
         # where event is a string, and list(func) is the list of functions
         # (wrapped and decorated) that will be invoked when the given event
         # is triggered.
-        self.__partials__ = collections.defaultdict(list)
-        self.__getparams__ = getparams
+        self._partials = collections.defaultdict(list)
+        self._getparams = getparams
+        self.loop = loop
 
-    def __add_event__(self, event, func):
+    def _add_event(self, event, func):
         '''
         Validate the func's signature, then partial_bind the function to speed
         up argument injection.
 
         '''
-        parameters = self.__getparams__(event)
+        parameters = self._getparams(event)
         validate_func(event, func, parameters)
-        self.__partials__[event].append(partial_bind(func))
+        self._partials[event].append(partial_bind(func))
         return func
 
-    @asyncio.coroutine
     def trigger(self, event, **kwargs):
-        ''' This is a coroutine so that we can `yield from` its execution '''
-        partials = self.__partials__[event]
-        tasks = [func(**kwargs) for func in partials]
-        if not tasks:
-            return
-        yield from asyncio.wait(tasks)
+        partials = self._partials[event]
+        for func in partials:
+            self.loop.create_task(func(**kwargs))
 
     def on(self, event):
         '''
@@ -60,12 +57,15 @@ class EventsMixin(object):
         event = 'test'
         kwargs = {'one': 1, 'two': 2, 'arg': 'arg'}
 
+        events.trigger(event, **kwargs)
         loop = asyncio.get_event_loop()
-        loop.run_until_complete(events.trigger(event, **kwargs))
+        # Run all queued events
+        loop.stop()
+        loop.run_forever()
 
         '''
         def wrap_function(func):
-            self.__add_event__(event, func)
+            self._add_event(event, func)
             return func
         return wrap_function
 
@@ -110,7 +110,7 @@ def validate_func(event, func, parameters):
 
 def partial_bind(func):
     sig = inspect.signature(func)
-    # Wrap non-coroutines so we can always `yield from func(**kw)`
+    # Wrap non-coroutines so we can always `await func(**kw)`
     if not asyncio.iscoroutinefunction(func):
         func = asyncio.coroutine(func)
     base = {}
@@ -122,8 +122,7 @@ def partial_bind(func):
         else:
             base[key] = default
 
-    @asyncio.coroutine
-    def wrapper(**kwargs):
+    async def wrapper(**kwargs):
         unbound = base.copy()
         # Only map params this function expects
         for key in base:
@@ -131,6 +130,6 @@ def partial_bind(func):
             if new_value is not missing:
                 unbound[key] = new_value
         bound = sig.bind(**unbound)
-        yield from func(*bound.args, **bound.kwargs)
+        await func(*bound.args, **bound.kwargs)
 
     return wrapper
diff --git a/bottom/plugins/router.py b/bottom/plugins/router.py
index b345e25..cd367d5 100644
--- a/bottom/plugins/router.py
+++ b/bottom/plugins/router.py
@@ -33,13 +33,13 @@ class Router(object):
         self.routes = {}
         bot.on("PRIVMSG")(self.handle)
 
-    async def handle(self, nick, target, message):
+    def handle(self, nick, target, message):
         ''' bot callback entrance '''
         for regex, (func, pattern) in self.routes.items():
             match = regex.match(message)
             if match:
                 fields = match.groupdict()
-                await func(nick, target, fields)
+                self.bot.loop.create_task(func(nick, target, fields))
 
     def route(self, pattern, **kwargs):
         '''
diff --git a/bottom/unpack.py b/bottom/unpack.py
index 9c481f2..d5ecc8d 100644
--- a/bottom/unpack.py
+++ b/bottom/unpack.py
@@ -1,7 +1,6 @@
 """ Simplified support for rfc2812 """
 # https://tools.ietf.org/html/rfc2812
 import re
-missing = object()
 
 RE_IRCLINE = re.compile(
     """
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 8f19ef8..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,6 +0,0 @@
-[run]
-source = bottom
-branch = True
-
-[flake8]
-exclude = .tox, dist, doc, build, *.egg
diff --git a/setup.py b/setup.py
index 34a90cc..f79ea10 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,13 @@ import os
 from setuptools import setup, find_packages
 
 HERE = os.path.abspath(os.path.dirname(__file__))
-README = open(os.path.join(HERE, 'README.markdown')).read()
+README = open(os.path.join(HERE, 'README.rst')).read()
+
+def get_version():
+    with open("bottom/__init__.py") as f:
+        for line in f:
+            if line.startswith("__version__"):
+                return eval(line.split("=")[-1])
 
 REQUIREMENTS = [
     'simplex'
@@ -20,7 +26,7 @@ TEST_REQUIREMENTS = [
 if __name__ == "__main__":
     setup(
         name='bottom',
-        version='0.9.13',
+        version=get_version(),
         description="asyncio-based rfc2812-compliant IRC Client",
         long_description=README,
         classifiers=[
diff --git a/tox.ini b/tox.ini
index 37f47e8..8dd916b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,11 +2,10 @@
 envlist = py35
 
 [testenv]
-deps = git+https://github.com/pytest-dev/pytest
+deps = pytest
        flake8
-       coverage>=4.0a1
+       coverage
 commands =
     coverage run --branch --source=bottom -m py.test
     coverage report -m
-    # disabled until flake8 supports py3.5
-    # flake8 bottom tests
+    flake8 bottom tests

Dispatch blocks, incorrect async
After unpacking, the reading loop will [`yield from`](https://github.com/numberoverzero/bottom/blob/master/bottom/connection.py#L50) the event dispatch, which means it waits until all handlers run for the event, before continuing.

Instead, `events.trigger` should be a non-blocking call.  That is, it should push the event into a queue, then return execution to the caller.

---

I'm working on [accordian](https://github.com/numberoverzero/accordian) to fix this - however, in trying to use the 3.5 syntax (`await` and `async` as keywords) I've run into other problems.  Once I've finished testing the library, I'll integrate it into bottom.  This will require 3.5+ to use bottom, but the migration from 3.4 (current requirement) to 3.5 is virtually painless (I haven't seen any reports of problems migrating).
diff --git a/tests/conftest.py b/tests/conftest.py
index 03eea58..3bf0c0f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,5 @@
+from bottom import Client
+from bottom.connection import Connection
 from bottom.event import EventsMixin
 import pytest
 import asyncio
@@ -5,38 +7,39 @@ import collections
 
 
 @pytest.fixture
-def run():
+def loop():
     '''
-    Run a coro until it completes.
-
-    Returns result from coro, if it produces one.
+    Keep things clean by using a new event loop
     '''
-    def run_in_loop(coro):
-        # For more details on what's going on:
-        # https://docs.python.org/3/library/asyncio-task.html\
-        #       #example-future-with-run-until-complete
-        def capture_return(future):
-            ''' Push coro result into future for return '''
-            result = yield from coro
-            future.set_result(result)
-        # Kick off the coro, wrapped in the future above
-        future = asyncio.Future()
-        asyncio.async(capture_return(future))
-
-        # Block until coro completes and dumps return in future
-        loop = asyncio.get_event_loop()
-        loop.run_until_complete(future)
-
-        # Hand result back
-        return future.result()
-    return run_in_loop
+    loop = asyncio.new_event_loop()
+    loop.set_debug(True)
+    return loop
 
 
 @pytest.fixture
-def loop():
-    # TODO: fix to use a new event loop.  Because the loop fix will require
-    # touching a lot of code, this is an easy way to get the build green again
-    return asyncio.new_event_loop()
+def flush(loop):
+    """Run loop once, to execute any pending tasks"""
+
+    async def sentinel():
+        pass
+
+    def _flush():
+        loop.run_until_complete(sentinel())
+    return _flush
+
+
+@pytest.fixture
+def schedule(loop):
+    def _schedule(*coros):
+        for coro in coros:
+            loop.create_task(coro)
+    return _schedule
+
+
+@pytest.fixture
+def connection(patch_connection, events, loop):
+    print("connection")
+    return Connection("host", "port", events, "UTF-8", True, loop=loop)
 
 
 @pytest.fixture
@@ -45,9 +48,9 @@ def eventparams():
 
 
 @pytest.fixture
-def events(eventparams):
+def events(eventparams, loop):
     ''' Return a no-op EventsMixin that tracks triggers '''
-    return MockEvents(lambda e: eventparams[e])
+    return MockEvents(lambda e: eventparams[e], loop=loop)
 
 
 @pytest.fixture
@@ -60,6 +63,17 @@ def writer():
     return MockStreamWriter()
 
 
+@pytest.fixture
+def client(patch_connection, loop):
+    '''
+    Return a client with mocked out asyncio.
+
+    Pulling in patch_connection here mocks out asyncio.open_connection,
+    so that we can use reader, writer, run in tests.
+    '''
+    return Client("host", "port", loop=loop)
+
+
 @pytest.fixture
 def patch_connection(reader, writer, monkeypatch):
     '''
@@ -75,13 +89,13 @@ def patch_connection(reader, writer, monkeypatch):
 
 
 class MockEvents(EventsMixin):
-    def __init__(self, getparams):
+    def __init__(self, getparams, *, loop=None):
         self.triggered_events = collections.defaultdict(int)
-        super().__init__(getparams)
+        super().__init__(getparams, loop=loop)
 
     def trigger(self, event, **kwargs):
         self.triggered_events[event] += 1
-        yield from super().trigger(event, **kwargs)
+        super().trigger(event, **kwargs)
 
     def triggered(self, event, n=1):
         '''
@@ -105,8 +119,7 @@ class MockStreamReader():
         self.encoding = encoding
         self.used = False
 
-    @asyncio.coroutine
-    def readline(self):
+    async def readline(self):
         self.used = True
         try:
             line = self.lines.pop(0)
diff --git a/tests/test_client.py b/tests/test_client.py
index 1b38970..bfda26b 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1,36 +1,32 @@
-from bottom import Client
+import asyncio
 import pytest
+from bottom import Client
 
 
-@pytest.fixture
-def client(patch_connection, run):
-    '''
-    Return a client with mocked out asyncio.
-
-    Pulling in patch_connection here mocks out asyncio.open_connection,
-    so that we can use reader, writer, run in tests.
-    '''
-    return Client("host", "port")
+def test_default_event_loop():
+    default_loop = asyncio.get_event_loop()
+    client = Client(host="host", port="port")
+    assert client.loop is default_loop
 
 
-def test_send_unknown_command(client, run):
+def test_send_unknown_command(client, loop):
     ''' Sending an unknown command raises '''
-    run(client.connect())
+    loop.run_until_complete(client.connect())
     assert client.connected
     with pytest.raises(ValueError):
         client.send("Unknown_Command")
 
 
-def test_send_before_connected(client, writer, run):
+def test_send_before_connected(client, writer):
     ''' Sending before connected does not invoke writer '''
     client.send("PONG")
     assert not writer.used
 
 
-def test_send_after_disconnected(client, writer, run):
+def test_send_after_disconnected(client, writer, loop):
     ''' Sending after disconnect does not invoke writer '''
-    run(client.connect())
-    run(client.disconnect())
+    loop.run_until_complete(client.connect())
+    loop.run_until_complete(client.disconnect())
     client.send("PONG")
     assert not writer.used
 
@@ -41,13 +37,13 @@ def test_on(client):
     @client.on('privmsg')
     def route(nick, target, message):
         pass
-    assert len(client.__partials__["PRIVMSG"]) == 1
+    assert len(client._partials["PRIVMSG"]) == 1
 
     with pytest.raises(ValueError):
         client.on("UNKNOWN_COMMAND")(route)
 
 
-def test_run_(client, reader, eventparams, run):
+def test_run_(client, reader, eventparams, loop):
     ''' run delegates to Connection, which triggers events on the Client '''
     reader.push(":nick!user@host PRIVMSG #target :this is message")
     received = []
@@ -56,7 +52,7 @@ def test_run_(client, reader, eventparams, run):
     def receive(nick, user, host, target, message):
         received.extend([nick, user, host, target, message])
 
-    run(client.run())
+    loop.run_until_complete(client.run())
 
     assert reader.has_read(":nick!user@host PRIVMSG #target :this is message")
     assert received == ["nick", "user", "host", "#target", "this is message"]
diff --git a/tests/test_connection.py b/tests/test_connection.py
index 6eb4cb3..cce800e 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -1,130 +1,115 @@
-from bottom.connection import Connection
-import pytest
-
-
-@pytest.fixture
-def conn(patch_connection, events, run):
-    ''' Generic connection that is ready to read/send '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.connect())
-    assert conn.connected
-    return conn
-
-
-def test_connect(patch_connection, writer, events, run):
+def test_connect(connection, events, writer, schedule, flush):
     ''' Connection.Connect opens a writer, triggers CLIENT_CONNECT '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.connect())
-    assert conn.connected
+    schedule(connection.connect())
+    flush()
+    assert connection.connected
     assert not writer.closed
     assert events.triggered("CLIENT_CONNECT")
 
 
-def test_connect_already_connected(patch_connection, writer, events, run):
+def test_already_connected(connection, events, writer, schedule, flush):
     ''' Does not trigger CLIENT_CONNECT multiple times '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.connect())
-    run(conn.connect())
+    schedule(connection.connect(), connection.connect())
+    flush()
     assert not writer.closed
     assert events.triggered("CLIENT_CONNECT")
 
 
-def test_disconnect_before_connect(patch_connection, events, run):
+def test_disconnect_before_connect(connection, events, schedule, flush):
     ''' disconnect before connect does nothing '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.disconnect())
-    assert not conn.connected
+    schedule(connection.disconnect())
+    flush()
+    assert not connection.connected
     assert not events.triggered("CLIENT_CONNECT")
     assert not events.triggered("CLIENT_DISCONNECT")
 
 
-def test_disconnect(writer, patch_connection, events, run):
+def test_disconnect(writer, patch_connection, events, connection,
+                    schedule, flush):
     ''' Connection.disconnect closes writer, triggers CLIENT_DISCONNECT '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.connect())
-    run(conn.disconnect())
-    assert not conn.connected
+    schedule(connection.connect(), connection.disconnect())
+    flush()
+    assert not connection.connected
     assert writer.closed
-    assert conn.writer is None
+    assert connection.writer is None
     assert events.triggered("CLIENT_CONNECT")
     assert events.triggered("CLIENT_DISCONNECT")
 
 
-def test_disconnect_already_disconnected(patch_connection, events, run):
+def test_already_disconnected(connection, events, schedule, flush):
     ''' Does not trigger CLIENT_DISCONNECT multiple times '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.connect())
-    run(conn.disconnect())
-    run(conn.disconnect())
+    schedule(connection.connect(),
+             connection.disconnect(),
+             connection.disconnect())
+    flush()
     assert events.triggered("CLIENT_CONNECT")
     assert events.triggered("CLIENT_DISCONNECT")
 
 
-def test_send_before_connected(patch_connection, writer, events, run):
+def test_send_before_connected(connection, writer):
     ''' Nothing happens when sending before connecting '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    assert not conn.connected
-    conn.send("test")
+    assert not connection.connected
+    connection.send("test")
     assert not writer.used
 
 
-def test_send_disconnected(patch_connection, writer, events, run):
+def test_send_disconnected(connection, writer, schedule, flush):
     ''' Nothing happens when sending after disconnecting '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.connect())
-    run(conn.disconnect())
-    conn.send("test")
+    schedule(connection.connect(), connection.disconnect())
+    flush()
+    connection.send("test")
     assert not writer.used
 
 
-def test_send_strips(conn, writer):
+def test_send_strips(connection, writer, loop):
     ''' Send strips whitespace from string '''
-    conn.send("  a b  c | @#$ d  ")
+    loop.run_until_complete(connection.connect())
+    connection.send("  a b  c | @#$ d  ")
     assert writer.used
     assert writer.has_written("a b  c | @#$ d\n")
 
 
-def test_read_before_connected(patch_connection, reader, events, run):
+def test_read_before_connected(connection, reader, loop):
     ''' Nothing happens when reading before connecting '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    value = run(conn.read())
+    value = loop.run_until_complete(connection.read())
     assert not value
     assert not reader.used
 
 
-def test_read_disconnected(patch_connection, reader, events, run):
+def test_read_disconnected(connection, reader, schedule, flush, loop):
     ''' Nothing happens when reading after disconnecting '''
-    conn = Connection("host", "port", events, "UTF-8", True)
-    run(conn.connect())
-    run(conn.disconnect())
-    value = run(conn.read())
+    schedule(connection.connect(), connection.disconnect())
+    flush()
+    value = loop.run_until_complete(connection.read())
     assert not value
     assert not reader.used
 
 
-def test_read_eoferror(conn, reader, run):
+def test_read_eoferror(connection, reader, loop):
     ''' Nothing to read '''
-    value = run(conn.read())
+    loop.run_until_complete(connection.connect())
+    value = loop.run_until_complete(connection.read())
     assert not value
     assert reader.used
 
 
-def test_read_strips(conn, reader, run):
+def test_read_strips(connection, reader, loop):
     ''' newline and space characters are stripped off '''
     reader.push("  a b  c | @#$ d  \n")
-    value = run(conn.read())
+    loop.run_until_complete(connection.connect())
+    value = loop.run_until_complete(connection.read())
     assert value == "a b  c | @#$ d"
     assert reader.has_read("  a b  c | @#$ d  \n")
 
 
-def test_run_without_message(conn, events, run):
+def test_run_without_message(connection, events, loop):
     ''' Connection.run should connect, read empty, disconnect, return '''
-    run(conn.run())
+    loop.run_until_complete(connection.run())
     assert events.triggered("CLIENT_CONNECT")
     assert events.triggered("CLIENT_DISCONNECT")
 
 
-def test_run_trigger_command(conn, reader, events, eventparams, run):
+def test_run_trigger_command(connection, reader, events, eventparams, loop):
     eventparams["PRIVMSG"] = ["nick", "user", "host", "target", "message"]
     reader.push(":nick!user@host PRIVMSG #target :this is message")
     received = []
@@ -133,16 +118,15 @@ def test_run_trigger_command(conn, reader, events, eventparams, run):
     def receive(nick, user, host, target, message):
         received.extend([nick, user, host, target, message])
 
-    run(conn.run())
-
+    loop.run_until_complete(connection.run())
     assert reader.has_read(":nick!user@host PRIVMSG #target :this is message")
     assert events.triggered("PRIVMSG")
     assert received == ["nick", "user", "host", "#target", "this is message"]
 
 
-def test_run_trigger_unknown_command(conn, reader, events, run):
+def test_run_trigger_unknown_command(connection, reader, events, loop):
     reader.push("unknown_command")
-    run(conn.run())
+    loop.run_until_complete(connection.run())
 
     assert reader.has_read("unknown_command")
     assert not events.triggered("unknown_command")
diff --git a/tests/test_event.py b/tests/test_event.py
index 1859eb3..5d496fe 100644
--- a/tests/test_event.py
+++ b/tests/test_event.py
@@ -14,14 +14,8 @@ def getparams():
 
 
 @pytest.fixture
-def events(getparams):
-    return event.EventsMixin(getparams)
-
-
-@pytest.fixture
-def run():
-    loop = asyncio.get_event_loop()
-    return lambda coro: loop.run_until_complete(coro)
+def events(getparams, loop):
+    return event.EventsMixin(getparams, loop=loop)
 
 
 @pytest.fixture
@@ -43,7 +37,6 @@ def watch():
 # EventsMixin.on
 # ==============
 
-
 def test_on_subset(events):
     ''' register a handler with a subset of available parameters '''
     for e in ["0", "1", "2"]:
@@ -90,7 +83,8 @@ def test_defaults(events):
 
 def test_on_coroutine(events):
     ''' coroutines are fine '''
-    handle = asyncio.coroutine(lambda one: None)
+    async def handle(one):
+        pass
     events.on("1")(handle)
 
 
@@ -98,95 +92,84 @@ def test_on_coroutine(events):
 # EventsMixin.trigger
 # ===================
 
-def test_trigger(events, run, watch):
+def test_trigger(events, watch, flush):
     ''' trigger calls registered handler '''
     w = watch()
-    # Register handler - increment call counter when called
     events.on("0")(lambda: w.call())
-    # Trigger handler
-    run(events.trigger("0"))
-    # Make sure we called once
+    events.trigger("0")
+    flush()
     assert w.called
 
 
-def test_trigger_multiple_calls(events, run, watch):
+def test_trigger_multiple_calls(events, watch, flush):
     ''' trigger calls re-registered handler twice '''
     w = watch()
-    # Register handler twice - increment call counter when called
     events.on("0")(lambda: w.call())
     events.on("0")(lambda: w.call())
-    # Trigger handler
-    run(events.trigger("0"))
-    # Make sure we called twice
+    events.trigger("0")
+    flush()
     assert w.calls == 2
 
 
-def test_trigger_multiple_handlers(events, run, watch):
+def test_trigger_multiple_handlers(events, watch, flush):
     ''' trigger calls re-registered handler twice '''
     w1 = watch()
     w2 = watch()
-    # Register two handlers
     events.on("0")(lambda: w1.call())
     events.on("0")(lambda: w2.call())
-    # Trigger handler
-    run(events.trigger("0"))
-    # Make sure we called each once
+    events.trigger("0")
+    flush()
     assert w1.calls == 1
     assert w2.calls == 1
 
 
-def test_trigger_no_handlers(events, run):
+def test_trigger_no_handlers(events, flush):
     ''' trigger an event with no handlers '''
-    run(events.trigger("some event"))
+    events.trigger("some event")
+    flush()
 
 
-def test_trigger_superset_params(events, run):
+def test_trigger_superset_params(events, flush):
     ''' trigger an event with kwarg keys that aren't in event params '''
     params = {}
 
     def func(one, two):
         params["one"] = one
         params["two"] = two
-
     events.on("2")(func)
-
     kwargs = {"one": 1, "two": 2, "unused": "value"}
-    run(events.trigger("2", **kwargs))
-
+    events.trigger("2", **kwargs)
+    flush()
     assert params["one"] == 1
     assert params["two"] == 2
 
 
-def test_trigger_subset_params(events, run):
+def test_trigger_subset_params(events, flush):
     ''' trigger an event with missing kwargs pads with None '''
     params = {}
 
     def func(one, two):
         params["one"] = one
         params["two"] = two
-
     events.on("2")(func)
-
     kwargs = {"one": 1}
-    run(events.trigger("2", **kwargs))
-
+    events.trigger("2", **kwargs)
+    flush()
     assert params["one"] == 1
     assert params["two"] is None
 
 
-def test_trigger_subset_params_with_defaults(events, run):
+def test_trigger_subset_params_with_defaults(events, flush):
     ''' trigger an event with missing kwargs uses function defaults '''
     params = {}
 
     def func(one, two="default"):
         params["one"] = one
         params["two"] = two
-
     events.on("2")(func)
-
     kwargs = {"one": 1}
-    run(events.trigger("2", **kwargs))
-
+    events.trigger("2", **kwargs)
+    flush()
     assert params["one"] == 1
     assert params["two"] == "default"
 
@@ -196,7 +179,7 @@ def test_trigger_subset_params_with_defaults(events, run):
 # ===================
 
 
-def test_bound_method_of_instance(events, run):
+def test_bound_method_of_instance(events, flush):
     ''' verify bound methods are correctly inspected '''
     params = {}
 
@@ -207,9 +190,38 @@ def test_bound_method_of_instance(events, run):
     instance = Class()
     bound_method = instance.method
     events.on("2")(bound_method)
-
     kwargs = {"one": 1}
-    run(events.trigger("2", **kwargs))
-
+    events.trigger("2", **kwargs)
+    flush()
     assert params["one"] == 1
     assert params["two"] == "default"
+
+
+# ===================
+# Ordering + Blocking
+# ===================
+
+
+def test_callback_ordering(events, flush, loop):
+    ''' Callbacks for a second event don't queue behind the first event '''
+    second_complete = asyncio.Event(loop=loop)
+    call_order = []
+    complete_order = []
+
+    async def first():
+        call_order.append("first")
+        await second_complete.wait()
+        complete_order.append("first")
+
+    async def second():
+        call_order.append("second")
+        complete_order.append("second")
+        second_complete.set()
+
+    events.on("0")(first)
+    events.on("0")(second)
+
+    events.trigger("0")
+    flush()
+    assert call_order == ["first", "second"]
+    assert complete_order == ["second", "first"]
diff --git a/tests/test_plugins/test_router.py b/tests/test_plugins/test_router.py
index eb6a434..955becb 100644
--- a/tests/test_plugins/test_router.py
+++ b/tests/test_plugins/test_router.py
@@ -1,42 +1,12 @@
 import pytest
-from bottom import Client
 from bottom.plugins.router import Router
 
 
-class MockConnection():
-    def __init__(self, *a, **kw):
-        pass
-
-
-class MockClient(Client):
-        __conn_cls__ = MockConnection
-
-        def __init__(self, *args, **kwargs):
-            self.handlers = []
-            super().__init__(*args, **kwargs)
-
-        def on(self, command):
-            def wrap(function):
-                self.handlers.append((command, function))
-                return function
-            return wrap
-
-
-@pytest.fixture
-def client():
-    return MockClient("host", "port")
-
-
 @pytest.fixture
 def router(client):
     return Router(client)
 
 
-def test_init_registers_privmsg(client):
-    router = Router(client)
-    assert ("PRIVMSG", router.handle) in client.handlers
-
-
 def test_decorator_returns_original(router):
     def original_func(nick, target, fields):
         pass
@@ -45,43 +15,42 @@ def test_decorator_returns_original(router):
     assert wrapped_func is original_func
 
 
-def test_handle_no_routes(router, loop):
-    loop.run_until_complete(
-        router.handle("nick", "target", "message"))
+def test_handle_no_routes(router, loop, flush):
+    router.handle("nick", "target", "message")
+    flush()
 
 
-def test_handle_no_matching_route(router, loop):
+def test_handle_no_matching_route(router, loop, flush):
     @router.route("hello, [name]")
     async def handle(nick, target, fields):
         # Should not be called
         assert False
 
-    loop.run_until_complete(
-        router.handle("nick", "target", "does not match"))
+    router.handle("nick", "target", "does not match")
+    flush()
 
 
-def test_handle_with_matching_route(router, loop):
+def test_handle_with_matching_route(router, loop, flush):
     names = []
 
     @router.route("hello, [name]")
     def handle(nick, target, fields):
         names.append(fields['name'])
 
-    loop.run_until_complete(
-        router.handle("nick", "target", "hello, jack"))
-    loop.run_until_complete(
-        router.handle("nick", "target", "hello, hello, recursion"))
+    router.handle("nick", "target", "hello, jack")
+    router.handle("nick", "target", "hello, hello, recursion")
+    flush()
 
     assert ["jack", "hello, recursion"] == names
 
 
-def test_back_reference(router, loop):
+def test_back_reference(router, loop, flush):
     actual_fields = {}
 
     @router.route("<[tag]>[field]</[:ref(tag)]>")
     def handle(nick, target, fields):
         actual_fields.update(fields)
 
-    loop.run_until_complete(
-        router.handle("nick", "target", "<element>some value here</element>"))
+    router.handle("nick", "target", "<element>some value here</element>")
+    flush()
     assert {"field": "some value here", "tag": "element"} == actual_fields

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov flake8
apt-get update apt-get install -y gcc
3.10
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
-e git+https://github.com/numberoverzero/bottom.git@4293d2726d2a7222faa55ca509871ee03f4e66e1#egg=bottom
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
coverage==7.8.0
exceptiongroup==1.2.2
flake8==7.2.0
idna==3.10
iniconfig==2.1.0
mccabe==0.7.0
packaging==24.2
pluggy==1.5.0
pycodestyle==2.13.0
pyflakes==3.3.1
pytest==8.3.5
pytest-cov==6.0.0
python-dotenv==1.1.0
requests==2.32.3
simplex==1.2.55
tomli==2.2.1
urllib3==2.3.0

name: bottom
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - bzip2=1.0.8=h5eee18b_6
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - libuuid=1.41.5=h5eee18b_0
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py310h06a4308_0
  - python=3.10.16=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py310h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py310h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - click==8.1.8
      - colorama==0.4.6
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - flake8==7.2.0
      - idna==3.10
      - iniconfig==2.1.0
      - mccabe==0.7.0
      - packaging==24.2
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.1
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - python-dotenv==1.1.0
      - requests==2.32.3
      - simplex==1.2.55
      - tomli==2.2.1
      - urllib3==2.3.0
prefix: /opt/conda/envs/bottom

tests/test_client.py::test_default_event_loop tests/test_client.py::test_send_unknown_command tests/test_client.py::test_send_before_connected tests/test_client.py::test_send_after_disconnected tests/test_client.py::test_on tests/test_client.py::test_run_ tests/test_connection.py::test_connect tests/test_connection.py::test_already_connected tests/test_connection.py::test_disconnect_before_connect tests/test_connection.py::test_disconnect tests/test_connection.py::test_already_disconnected tests/test_connection.py::test_send_before_connected tests/test_connection.py::test_send_disconnected tests/test_connection.py::test_send_strips tests/test_connection.py::test_read_before_connected tests/test_connection.py::test_read_disconnected tests/test_connection.py::test_read_eoferror tests/test_connection.py::test_read_strips tests/test_connection.py::test_run_without_message tests/test_connection.py::test_run_trigger_command tests/test_connection.py::test_run_trigger_unknown_command tests/test_event.py::test_on_subset tests/test_event.py::test_on_all tests/test_event.py::test_on_superset tests/test_event.py::test_on_ordering tests/test_event.py::test_with_kwargs tests/test_event.py::test_with_kwargs_masking tests/test_event.py::test_var_args tests/test_event.py::test_defaults tests/test_event.py::test_on_coroutine tests/test_event.py::test_trigger tests/test_event.py::test_trigger_multiple_calls tests/test_event.py::test_trigger_multiple_handlers tests/test_event.py::test_trigger_no_handlers tests/test_event.py::test_trigger_superset_params tests/test_event.py::test_trigger_subset_params tests/test_event.py::test_trigger_subset_params_with_defaults tests/test_event.py::test_bound_method_of_instance tests/test_plugins/test_router.py::test_handle_no_routes
tests/test_event.py::test_callback_ordering tests/test_plugins/test_router.py::test_decorator_returns_original tests/test_plugins/test_router.py::test_handle_no_matching_route tests/test_plugins/test_router.py::test_handle_with_matching_route tests/test_plugins/test_router.py::test_back_reference
README.rst bottom/event.py MANIFEST.in bottom/connection.py setup.py .python-version bottom/__init__.py .travis.yml bottom/plugins/router.py setup.cfg tox.ini README.markdown bottom/unpack.py
README.rst bottom/event.py MANIFEST.in bottom/connection.py setup.py .python-version bottom/__init__.py .travis.yml bottom/plugins/router.py setup.cfg tox.ini README.markdown bottom/unpack.py