Today I'm announcing Layabout, my first official Python library. Layabout is a small event handling library on top of the Slack Real Time Messaging (RTM) API. You can get it right now on PyPI.
You can think of Layabout as a micro framework for building Slack bots. Since it wraps Slack’s RTM API it does best with tasks like interacting with users, responding to channel messages, and monitoring events. If you want more ideas on what you can do with it keep reading or check out the examples.
Why choose Layabout when the Slack Events API exists and there's already an officially supported events library? If these points resonate with you then Layabout is for you.
Layabout won't be for everone and that's OK. If these points resonate with you then you probably do want to use the official events library.
If you want to download it and start playing with it as you read the rest of this blog post you can install it by running
pip install layabout
Once you've got Layabout installed let's take a look at what it's capable of by borrowing the code example right from its README.rst
.
from pprint import pprint
from layabout import Layabout
app = Layabout()
@app.handle('*')
def debug(slack, event):
""" Pretty print every event seen by the app. """
pprint(event)
@app.handle('message')
def echo(slack, event):
""" Echo all messages seen by the app except our own. """
if event.get('subtype') != 'bot_message':
slack.rtm_send_message(event['channel'], event['text'])
def someone_leaves(events):
""" Return False if a member leaves, otherwise True. """
return not any(e.get('type') == 'member_left_channel'
for e in events)
if __name__ == '__main__':
# Automatically load app token from $LAYABOUT_TOKEN and run!
app.run(until=someone_leaves)
print("Looks like someone left a channel!")
In 28 lines of code we've used Layabout to do the following:
debug
handler that triggers on all RTM events to pretty print them.echo
handler that triggers on message
events to echo them back into the channel they came from (unless of course we generated them).$LAYABOUT_TOKEN
by default).someone_leaves
a channel we have access to.Now that we've looked at what Layabout is, why you might want to use it, and how to use it let's look a bit deeper into its design and implementation.
If you're familiar with the superb Flask library then Layabout probably looks eerily similar to you. That's no accident and hopefully Armin Ronacher thinks imitation is the sincerest form of flattery.
More concretely, I think Python decorators are a powerful combination of simplicity and flexibility. They also lend themselves particularly well to event-driven workflows.
The heart of Layabout is its aggressively simple Layabout.handle
method. Its normal invocation just guarantees the decorated function will accept a SlackClient
and an event as arguments before registering it as a particular type of handler.
Having access to those two arguments alone opens up a wealth of possibilities. To maximize developer freedom I wanted to provide as thin a wrapper as I could on top of the already excellent slackclient library. Giving direct access to a SlackClient
instance meant I didn't have to write my own functions for calling out to the RTM API and I could also take advantage of its ability to call the Slack Web API as well.
I also took inspiration from pytest's pytest.mark.parametrize
decorator to give handlers more versatility by adding an extra kwargs
parameter.
from layabout import Layabout
app = Layabout()
if __name__ == '__main__':
name = input('What is your name? ')
@app.handle('hello', kwargs={'name': name})
def hello(slack, event, name):
print(f"Hello! My name is {name}.")
app.run() # Run forever.
By adding a kwargs
parameter we can not only use Layabout.handle
as a decorator, but also to register functions at runtime with dynamic data. For example, this code logs events that happen, but only if they're in particular channels:
from layabout import Layabout
app = Layabout()
def log_for_channels(slack, event, channels):
""" Log the event if it happened in a channel we care about. """
if event['channel'] in channels:
print(f"{event['type']} happened in {event['channel']}!")
if __name__ == '__main__':
# A mapping of events to their respective channels.
event_channels = (
('star_added', ('G1A8FG8AE', 'C03QZSL29')),
('star_removed' ('C47CSFJRK', 'C045BMR29', 'G13RTMGXY')),
)
# For each event register a new handler for specific channels.
for event, channels in event_channels:
app.handle(event, kwargs={'channels': channels})(log_for_channels)
app.run() # Run forever.
You could also use a closure or default arguments on a normal function definition for this and it might look a little cleaner, but for passing runtime data to a lot of functions those can be tedious options.
Ultimately, I tried to write a library that I would want to use. I'm more excited now than ever to work with Slack's APIs, so in that regard I think this library is already a success.
One of the hallmarks of this library is that it only supports Python 3.6+. I specifically chose to use only the most recent Python for three reasons:
I normally try to drink as little of the Object Oriented Kool-Aid as possible, so I tried a functional approach first, but keeping track of what was going on with connection state with a class just made sense to me. It also ended up being cleaner to keep a handler registry on an instance. Since they're self-contained you could conceivably spawn multiple instances into their own threads/processes and run them all simultaneously if you're careful with your global mutable state.
Unfortunately I didn't see an easy way to use Python 3's async def
because of the synchronous nature of slackclient
's SlackClient.rtm_read
method. This is a Python 3 feature I'd really like to learn more about and event handling and async seem like a natural fit to me. If there's ever a reason to release a Layabout v2.0 I will probably push harder in this direction.
From a development stance, the best part about this entire project so far has been learning how to use Python 3 type annotations. I miss them whenever I'm working with a project that doesn't have them.
I did have one minor annoyance while working with type annotations. Layabout keeps an internal collection of all the event handlers that have been registered to it with this signature.
# Private type alias for the complex type of the handlers defaultdict.
_Handlers = DefaultDict[str, List[Tuple[Callable, dict]]]
I wanted to be even more restrictive and specify exactly what was required of the Callable
by defining a type alias for a Handler
. The restrictions I sought to specify were:
SlackClient
.Dict[str, Any]
) as that's what the Slack RTM API events are.Any
type.Any
type.I took a stab at expressing this as
Handler = Callable[[SlackClient, Dict[str, Any], ...], Any]
Sadly, it would seem this doesn't work. Right now mypy complains with a
error: Unexpected '...'
I've been up and down the Python typing project, but even after visiting issue #193 and issue #264 can't find a simple syntax for expressing a function that has a minimum arity of two with required types, but is variadic thereafter and generic in the types it accepts.
There may, in fact, be a way to express this type with current annotations, but I haven't figured out what it is yet. It may also be the case that the difficulty in expressing this type is an indicator that a better API exists and should be preferred. For now I've settled on just declaring that a Handler
is a Callable
. I've got an auxiliary function that validates handlers to let users know if they've omitted a required positional argument.
Despite that small inconvenience type annotations are awesome! Go use them! I now firmly believe that supplemental static analysis makes for better software, even in dynamically typed languages.
As a final note on implementation, the Layabout.run
method only has an until
parameter because it made it so much easier for me to unit test. If you go read the tests you'll notice many of them get called with
layabout.run(until=lambda e: False)
which saves me from the headache of trying to test an otherwise infinite loop. If until
is None
then Layabout.run
just uses its own private function
def _forever(events: List[Dict[str, Any]]) -> bool: # pragma: no cover
""" Run Layabout in an infinite loop. """
return True
Giving the looping conditional access to the events opened up enough possibilities that I decided to keep it as part of the design.
I want to extend a special thank you to Alex LordThorsen, Geoff Shannon, Kyle Rader, and Mike Canoy for their help during the initial development of this library. In particular the feedback I got on PR #2 was incredible and radically changed the library for the better.
If you're still here and Layabout sounds like fun to you then check out these links to get started.
I happily entertain pull requests, so if something's not quite right feel free to jump in and submit your own fix if you're able. Happy Slacking!