aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore89
-rw-r--r--README.org23
-rwxr-xr-xbin/dbolla132
-rw-r--r--dbolla.conf.example20
-rw-r--r--warmachine/addons/__init__.py0
-rw-r--r--warmachine/addons/base.py6
-rw-r--r--warmachine/addons/giphy.py33
-rw-r--r--warmachine/addons/standup.py28
-rw-r--r--warmachine/config.py20
-rw-r--r--warmachine/connections/__init__.py0
-rw-r--r--warmachine/connections/base.py40
-rw-r--r--warmachine/connections/irc.py64
-rw-r--r--warmachine/connections/slack.py281
-rw-r--r--warmachine/utils/__init__.py0
-rw-r--r--warmachine/utils/decorators.py40
15 files changed, 776 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..72364f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,89 @@
1# Byte-compiled / optimized / DLL files
2__pycache__/
3*.py[cod]
4*$py.class
5
6# C extensions
7*.so
8
9# Distribution / packaging
10.Python
11env/
12build/
13develop-eggs/
14dist/
15downloads/
16eggs/
17.eggs/
18lib/
19lib64/
20parts/
21sdist/
22var/
23*.egg-info/
24.installed.cfg
25*.egg
26
27# PyInstaller
28# Usually these files are written by a python script from a template
29# before PyInstaller builds the exe, so as to inject date/other infos into it.
30*.manifest
31*.spec
32
33# Installer logs
34pip-log.txt
35pip-delete-this-directory.txt
36
37# Unit test / coverage reports
38htmlcov/
39.tox/
40.coverage
41.coverage.*
42.cache
43nosetests.xml
44coverage.xml
45*,cover
46.hypothesis/
47
48# Translations
49*.mo
50*.pot
51
52# Django stuff:
53*.log
54local_settings.py
55
56# Flask stuff:
57instance/
58.webassets-cache
59
60# Scrapy stuff:
61.scrapy
62
63# Sphinx documentation
64docs/_build/
65
66# PyBuilder
67target/
68
69# IPython Notebook
70.ipynb_checkpoints
71
72# pyenv
73.python-version
74
75# celery beat schedule file
76celerybeat-schedule
77
78# dotenv
79.env
80
81# virtualenv
82venv/
83ENV/
84
85# Spyder project settings
86.spyderproject
87
88# Rope project settings
89.ropeproject
diff --git a/README.org b/README.org
new file mode 100644
index 0000000..18641d4
--- /dev/null
+++ b/README.org
@@ -0,0 +1,23 @@
1* Setting up the bot
2D'bolla is a no bullshit extensible IRC/Slack bot written for Python 3.5 using
3asyncio.
4
5** Install dependencies:
6
7#+BEGIN_SRC bash
8 pip install websockets
9#+END_SRC
10
11** Configure
12Copy dbolla.conf.example to dbolla.conf and edit this file. You can
13specify multiples of the same connection by prefixing the config section with
14'slack:' or 'irc:' followed by a unique name for this connection.
15
16** Running
17Simply run the command:
18
19#+BEGIN_SRC bash
20 ./bin/dbolla -c /path/to/my/dbolla.conf
21#+END_SRC
22
23* Writing a Plugin
diff --git a/bin/dbolla b/bin/dbolla
new file mode 100755
index 0000000..7a1eba6
--- /dev/null
+++ b/bin/dbolla
@@ -0,0 +1,132 @@
1#!/usr/bin/env python3
2# -*- mode: python -*-
3import asyncio
4import functools
5import logging.config
6
7from warmachine.config import Config
8from warmachine.connections.irc import AioIRC
9from warmachine.connections.slack import SlackWS
10
11logging.config.dictConfig({
12 'version': 1,
13 'disable_existing_loggers': False,
14
15 'formatters': {
16 'standard': {
17 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s',
18 }
19 },
20 'handlers': {
21 'console': {
22 'class': 'logging.StreamHandler',
23 'level': 'DEBUG',
24 'formatter': 'standard',
25 },
26 },
27 'loggers': {
28 'websockets': {
29 'level': 'INFO',
30 'handlers': ['console', ]
31 },
32 '': {
33 'level': 'DEBUG',
34 'handlers': ['console', ]
35 }
36 }
37})
38
39
40class Bot(object):
41 def __init__(self, settings):
42 self.log = logging.getLogger(self.__class__.__name__)
43 self._loop = asyncio.get_event_loop()
44
45 self.settings = settings
46
47 self.connections = {}
48 self.tasks = []
49
50 self.loaded_plugins = []
51
52 self.load_plugin('asdf')
53
54 def start(self):
55 for connection in self.connections:
56 t = asyncio.ensure_future(connection.connect())
57 t.add_done_callback(functools.partial(self.on_connect, connection))
58
59 self._loop.run_forever()
60
61 def add_connection(self, connection):
62 self.connections[connection] = {}
63
64 def on_connect(self, connection, task):
65 asyncio.ensure_future(self.process_message(connection))
66
67 async def process_message(self, connection):
68 """
69 Constantly read new messages from the connection in a non-blocking way
70 """
71 while True:
72 message = await connection.read()
73 if not message:
74 continue
75
76 for p in self.loaded_plugins:
77 self.log.debug('Calling {}'.format(p.__class__.__name__))
78 await p.recv_msg(connection, message)
79
80 self.log.debug('MSG {}: {}'.format(
81 connection.__class__.__name__, message))
82
83 def load_plugin(self, path):
84 """
85 Loads plugins
86 """
87 from importlib import import_module
88
89 mod_path, cls_name = 'warmachine.addons.giphy.GiphySearch'.rsplit('.', 1)
90
91 mod = import_module(mod_path)
92
93 if hasattr(mod, cls_name):
94 cls = getattr(mod, cls_name)()
95
96 self.loaded_plugins.append(cls)
97
98 def reload_plugin(self, path):
99 """
100 Reload a plugin
101 """
102
103 def unload_plugin(self, path):
104 """
105 Unload a plugin
106 """
107
108if __name__ == "__main__":
109 import argparse
110 import sys
111
112 parser = argparse.ArgumentParser()
113 parser.add_argument('-c', '--config', help='define warmachine config file',
114 type=str)
115 args = parser.parse_args()
116
117 if args.config:
118 settings = Config(args.config)
119 else:
120 sys.exit(1)
121
122 bot = Bot(settings)
123
124 for s in settings.sections():
125 options = settings.options_as_dict(s)
126 if options.get('enable', False) == 'true':
127 if s.startswith('slack'):
128 bot.add_connection(SlackWS(options))
129 # elif s.startswith('irc'):
130 # bot.add_connection(AioIRC(options))
131
132 bot.start()
diff --git a/dbolla.conf.example b/dbolla.conf.example
new file mode 100644
index 0000000..206a394
--- /dev/null
+++ b/dbolla.conf.example
@@ -0,0 +1,20 @@
1[irc:freenode]
2# Address to the server Defaut: irc.freenode.org
3server=irc.freenode.org
4# Port to the server Default: 6697
5port=6697
6# Use SSL | default: true
7ssl=true
8# The nickname the bot should use
9nick=warmachine
10# Shows up in Real Name
11name=War Machine
12# Password to connect to the server
13password=
14
15[slack:myslack]
16# In your Slack account go to the admin section followed by
17# "Custom Integrations". Click on bots and create a new bot user.
18
19# Slack bot API token
20token=xoxb-random_acharacters
diff --git a/warmachine/addons/__init__.py b/warmachine/addons/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/warmachine/addons/__init__.py
diff --git a/warmachine/addons/base.py b/warmachine/addons/base.py
new file mode 100644
index 0000000..aa23c78
--- /dev/null
+++ b/warmachine/addons/base.py
@@ -0,0 +1,6 @@
1import logging
2
3
4class WarMachinePlugin(object):
5 def __init__(self):
6 self.log = logging.getLogger(self.__class__.__name__)
diff --git a/warmachine/addons/giphy.py b/warmachine/addons/giphy.py
new file mode 100644
index 0000000..a9bdac1
--- /dev/null
+++ b/warmachine/addons/giphy.py
@@ -0,0 +1,33 @@
1import urllib.request
2import json
3
4from .base import WarMachinePlugin
5
6__author__ = 'jason@zzq.org'
7__class_name__ = 'GiphySearch'
8__version__ = 1.0
9
10
11class GiphySearch(WarMachinePlugin):
12 async def recv_msg(self, connection, message):
13 if message['message'].startswith('!giphy '):
14 search_terms = ' '.join(message['message'].split(' ')[1:])
15
16 self.log.debug('Searching giphy.com for: {}'.format(search_terms))
17
18 url = ('http://api.giphy.com/v1/gifs/search?'
19 'q={}&api_key=dc6zaTOxFJmzC&limit=1'.format(
20 search_terms.replace(' ', '%20')))
21
22 # TODO: This blocks
23 req = urllib.request.Request(url)
24 data = urllib.request.urlopen(req).read().decode('utf-8')
25
26 data = json.loads(data)
27 self.log.debug(data)
28 try:
29 result = data['data'][0]['images']['original']['url']
30 await connection.say(result, message['channel'])
31 except IndexError as e:
32 await connection.say('No match for: {}'.format(search_terms),
33 message['channel'])
diff --git a/warmachine/addons/standup.py b/warmachine/addons/standup.py
new file mode 100644
index 0000000..a21efdd
--- /dev/null
+++ b/warmachine/addons/standup.py
@@ -0,0 +1,28 @@
1from .base import WarMachinePlugin
2
3
4class StandUpPlugin(WarMachinePlugin):
5 """
6 WarMachine stand up plugin.
7
8 Commands:
9 !standup-add <24 hr time to kick off> <SunMTWThFSat> [channel]
10 !standup-remove [channel]
11 """
12 async def recv_msg(self, connection, message):
13 if not message['message'].startswith('!standup'):
14 return
15
16 self.log.debug('standup recv: {}'.format(message))
17
18 cmd = message['message'].split(' ')[0]
19 parts = message['message'].split(' ')[1:]
20
21 if cmd == '!standup-add':
22 await connection.say('Scheduling standup for {} on {}'.format(
23 parts[1], parts[2]))
24
25 # await connection.say('{}, {}'.format(cmd, parts), message['channel'])
26
27 async def start_standup(self, connection):
28 pass
diff --git a/warmachine/config.py b/warmachine/config.py
new file mode 100644
index 0000000..6be84e3
--- /dev/null
+++ b/warmachine/config.py
@@ -0,0 +1,20 @@
1import configparser
2
3
4class Config(configparser.ConfigParser):
5 def __init__(self, config_path=None):
6 super().__init__()
7
8 self.config_path = config_path
9
10 if self.config_path:
11 self.read(self.config_path)
12
13 def options_as_dict(self, section):
14 """
15 Returns:
16 dict: Dictionary of the options defined in this config
17 """
18 d = dict(self.items(section))
19 d['section_name'] = section
20 return d
diff --git a/warmachine/connections/__init__.py b/warmachine/connections/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/warmachine/connections/__init__.py
diff --git a/warmachine/connections/base.py b/warmachine/connections/base.py
new file mode 100644
index 0000000..e68e9b9
--- /dev/null
+++ b/warmachine/connections/base.py
@@ -0,0 +1,40 @@
1INITALIZED = 'Initalized'
2CONNECTED = 'Connected'
3
4
5class Connection(object):
6 def connect(self, *args, **kwargs):
7 """
8 This is called by the main start method. It should prepare your
9 Connection and connect.
10 """
11 raise NotImplementedError('{} must implement `connect` method'.format(
12 self.__class__.__name__))
13
14 def read(self):
15 """
16 Dictionary of data in the following format:
17 {
18 'sender': 'username/id',
19 'channel': 'channel name' or None,
20 'message': 'actual message',
21 }
22
23 Returns:
24 dict: Data from the connection that the bot should consider.
25 """
26 raise NotImplementedError('{} must implement `read` method'.format(
27 self.__class__.__name__))
28
29 def id(self):
30 """
31 Unique ID for this connection. Since there can be more than one
32 connection it should be unique per actual connection to the server. For
33 example `bot nickname` + `server host:port`. This is used to store
34 settings and other information.
35
36 Returns:
37 str: Unique ID for this connection object
38 """
39 raise NotImplementedError('{} must implement `id` method'.format(
40 self.__class__.__name__))
diff --git a/warmachine/connections/irc.py b/warmachine/connections/irc.py
new file mode 100644
index 0000000..f7f682c
--- /dev/null
+++ b/warmachine/connections/irc.py
@@ -0,0 +1,64 @@
1import asyncio
2import logging
3
4from .base import Connection, INITALIZED
5from ..utils.decorators import memoize
6
7
8class AioIRC(Connection):
9 def __init__(self, host, port):
10 self._loop = asyncio.get_event_loop()
11 self.log = logging.getLogger(self.__class__.__name__)
12
13 self.status = INITALIZED
14
15 self.transport = None
16 self.protocol = None
17 self.reader = None
18 self.writer = None
19
20 self.host = host
21 self.port = port
22 self.nick = 'warmachin49'
23 self.user = 'WarMachine'
24
25 self.server_info = {
26 'host': ''
27 }
28
29 async def connect(self):
30 self.log.info('Connecting to {}:{}'.format(self.host, self.port))
31
32 self.reader, self.writer = await asyncio.open_connection(
33 self.host, self.port)
34
35 self.writer.write('NICK {} r\n'.format(self.nick).encode())
36 self.writer.write('USER {} 8 * :War Machine\r\n'.format(
37 self.user).encode())
38
39 return True
40
41 @asyncio.coroutine
42 def read(self):
43 if self.reader.at_eof():
44 raise Exception('eof')
45 if self.reader:
46 message = yield from self.reader.readline()
47
48 # if not self.server_info['host']:
49 # self.server_info['host'] = message.split(' ')[0].replace(':', '')
50
51 # if message.startswith('PING'):
52 # yield from self.send_pong()
53 # return
54
55 return message.decode().strip()
56
57 async def send_pong(self):
58 msg = 'PONG :{}'.format(self.server_info['host'])
59 return self.writer.write(msg)
60
61 @property
62 @memoize
63 def id(self):
64 return 'asdfasdf'
diff --git a/warmachine/connections/slack.py b/warmachine/connections/slack.py
new file mode 100644
index 0000000..b1a5390
--- /dev/null
+++ b/warmachine/connections/slack.py
@@ -0,0 +1,281 @@
1import asyncio
2import json
3import logging
4from pprint import pformat
5from urllib.parse import urlencode
6
7import websockets
8
9from .base import Connection, INITALIZED, CONNECTED
10
11
12#: Define slack as a config section prefix
13__config_prefix__ = 'slack'
14
15
16class SlackWS(Connection):
17 def __init__(self, options, *args, **kwargs):
18 super().__init__(*args, **kwargs)
19
20 self._loop = asyncio.get_event_loop()
21 self.log = logging.getLogger(self.__class__.__name__)
22 self.host = None
23 self.token = options['token']
24
25 self._info = None
26 self.reconnect_url = ''
27
28 self.channel_map = {} # channel and im info keyed by the slack id
29 self.user_map = {} # user info keyed by their slack id
30 self.user_nick_to_id = {} # slack user id mapped to the (nick)name
31
32 self.ws = None
33
34 self.status = INITALIZED
35
36 async def connect(self):
37 self.host = self.authenticate()
38 self.log.info('Connecting to {}'.format(self.host))
39 self.ws = await websockets.connect(self.host)
40
41 async def read(self):
42 if self.ws:
43 message = json.loads(await self.ws.recv())
44
45 # Slack is acknowledging a message was sent. Do nothing
46 if 'type' not in message and 'reply_to' in message:
47 # {'ok': True,
48 # 'reply_to': 1,
49 # 'text': "['!whois', 'synic']",
50 # 'ts': '1469743355.000150'}
51 return
52
53 # Handle actual messages
54 elif message['type'] == 'message' and 'subtype' not in message:
55 return await self.process_message(message)
56 else:
57 if 'subtype' in message:
58 # This is a message with a subtype and should be processed
59 # differently
60 msgtype = '{}_{}'.format(
61 message['type'], message['subtype'])
62 else:
63 msgtype = message['type']
64
65 # Look for on_{type} methods to pass the dictionary to for
66 # additional processing
67 func_name = 'on_{}'.format(msgtype)
68 if hasattr(self, func_name):
69 getattr(self, func_name)(message)
70 else:
71 self.log.debug('{} does not exist for message: {}'.format(
72 func_name, message))
73
74 async def say(self, message, destination_id):
75 """
76 Say something in the provided channel or IM by id
77 """
78 await self._send(json.dumps({
79 'id': 1, # TODO: this should be a get_msgid call or something
80 'type': 'message',
81 'channel': destination_id,
82 'text': message
83 }))
84
85 async def _send(self, message):
86 """
87 Send ``message`` to the connected slack server
88 """
89 await self.ws.send(message)
90
91 def authenticate(self):
92 """
93 Populate ``self._info``
94
95 Returns:
96 str: websocket url to connect to
97 """
98 import urllib.request
99 url = 'https://slack.com/api/rtm.start?{}'.format(
100 urlencode(
101 {'token':
102 self.token}))
103 self.log.debug('Connecting to {}'.format(url))
104 req = urllib.request.Request(url)
105
106 r = urllib.request.urlopen(req).read().decode('utf-8')
107 self._info = json.loads(r)
108
109 if not self._info.get('ok', True):
110 raise Exception('Slack Error: {}'.format(
111 self._info.get('error', 'Unknown Error')))
112
113 self.process_connect_info()
114
115 self.log.debug('Got websocket url: {}'.format(self._info.get('url')))
116 return self._info.get('url')
117
118 def process_connect_info(self):
119 """
120 Processes the connection info provided by slack
121 """
122 if not self._info:
123 return
124
125 self.status = CONNECTED
126
127 # Map users
128 for u in self._info.get('users', []):
129 self.user_map[u['id']] = u
130 self.user_nick_to_id[u['name']] = u['id']
131
132 # Map IM
133 for i in self._info.get('ims', []):
134 self.channel_map[i['id']] = i
135
136 # Map Channels
137 for c in self._info.get('channels', []):
138 self.channel_map[c['id']] = c
139
140 async def process_message(self, msg):
141 # Built-in !whois action
142 if 'text' not in msg:
143 raise Exception(msg)
144 if msg['text'].startswith('!whois'):
145 nicknames = msg['text'].split(' ')[1:]
146 for n in nicknames:
147 await self.say(pformat(self.user_map[self.user_nick_to_id[n]]),
148 msg['channel'])
149 return
150
151 retval = {
152 'sender': msg['user'],
153 'channel': msg['channel'],
154 'message': msg['text']
155 }
156 return retval
157
158 def on_user_change(self, msg):
159 """
160 The user_change event is sent to all connections for a team when a team
161 member updates their profile or data. Clients can use this to update
162 their local cache of team members.
163
164 https://api.slack.com/events/user_change
165 """
166 user_info = msg['user']
167 old_nick = self.user_map[user_info['id']]['nick']
168
169 self.user_map[user_info['id']] = user_info
170
171 # Update the nick mapping if the user changed their nickname
172 if old_nick != user_info['nick']:
173 del self.user_nick_to_id[old_nick]
174 self.user_nick_to_id[user_info['nick']] = user_info['id']
175
176 def on_reconnect_url(self, msg):
177 """
178 The reconnect_url event is currently unsupported and experimental.
179
180 https://api.slack.com/events/reconnect_url
181 """
182 # self.reconnect_url = msg['url']
183 # self.log.debug('updated_reconnect_url: {}'.format(self.reconnect_url))
184
185 def on_presence_change(self, msg):
186 self.log.debug('updated_presence: {} ({}) was: {} is_now: {}'.format(
187 msg['user'], self.user_map[msg['user']]['name'],
188 self.user_map[msg['user']]['presence'],
189 msg['presence']
190 ))
191 self.user_map[msg['user']]['presence'] = msg['presence']
192
193 async def on_group_join(self, channel):
194 """
195 The group_joined event is sent to all connections for a user when that
196 user joins a private channel. In addition to this message, all existing
197 members of the private channel will receive a group_join message event.
198
199 https://api.slack.com/events/group_joined
200 """
201 # {
202 # 'channel': {
203 # 'members': ['U0286NL58', 'U1U05AF5J'],
204 # 'id': 'G1W837CGP',
205 # 'is_group': True,
206 # 'is_archived': False,
207 # 'latest': {
208 # 'user': 'U0286NL58',
209 # 'subtype': 'group_join',
210 # 'ts': '1469746594 .000002',
211 # 'type': 'message',
212 # 'text': '<@U0286NL58|jason> has joined the group'
213 # },
214 # 'is_mpim': False,
215 # 'unread_count': 0,
216 # 'purpose': {
217 # 'creator': '',
218 # 'value': '',
219 # 'last_set': 0
220 # },
221 # 'is_open': True,
222 # 'topic': {
223 # 'creator': '',
224 # 'value': '',
225 # 'last_set': 0
226 # },
227 # 'creator': 'U0286NL58',
228 # 'unread_count_display': 0,
229 # 'name': 'wm-test',
230 # 'last_read': '1469746594.000002',
231 # 'created': 1469746594
232 # },
233 # 'type': 'group_joined'
234 # }
235
236 def on_message_message_changed(self, msg):
237 """
238 A message_changed message is sent when a message in a channel is edited
239 using the chat.update method. The message property contains the updated
240 message object.
241
242 When clients receive this message type, they should look for an
243 existing message with the same message.ts in that channel. If they
244 find one the existing message should be replaced with the new one.
245
246 https://api.slack.com/events/message/message_changed
247 """
248 # {
249 # 'hidden': True,
250 # 'event_ts': '1469748743.218081',
251 # 'subtype': 'message_changed',
252 # 'message': {
253 # 'attachments': [{
254 # 'id': 1,
255 # 'image_width': 800,
256 # 'fallback': '800x450px image',
257 # 'from_url':
258 # 'http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif',
259 # 'image_bytes': 4847496,
260 # 'image_url':
261 # 'http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif',
262 # 'image_height': 450,
263 # 'is_animated': True
264 # }],
265 # 'type': 'message',
266 # 'ts': '1469748743.000019',
267 # 'text':
268 # '<http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif>',
269 # 'user': 'U1U05AF5J'
270 # },
271 # 'channel': 'G1W837CGP',
272 # 'ts': '1469748743.000020',
273 # 'type': 'message',
274 # 'previous_message': {
275 # 'type': 'message',
276 # 'ts': '1469748743.000019',
277 # 'text':
278 # '<http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif>',
279 # 'user': 'U1U05AF5J'
280 # }
281 # }
diff --git a/warmachine/utils/__init__.py b/warmachine/utils/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/warmachine/utils/__init__.py
diff --git a/warmachine/utils/decorators.py b/warmachine/utils/decorators.py
new file mode 100644
index 0000000..e049e79
--- /dev/null
+++ b/warmachine/utils/decorators.py
@@ -0,0 +1,40 @@
1from collections import Hashable
2import functools
3from hashlib import sha1 as hash_
4import logging
5
6
7class memoize(object):
8 """
9 Decorator that caches a function's return value each time it is called with
10 the same arguments.
11 """
12 def __init__(self, func):
13 self.func = func
14 self.cache = {}
15 self.log = logging.getLogger('memoize')
16
17 @classmethod
18 def _hash(cls, string):
19 return hash_(string.encode()).hexdigest()
20
21 def __call__(self, *args, **kwargs):
22 # if not isinstance(args, Hashable) or not isinstance(kwargs, Hashable):
23 # self.log.debug('Uncacheable')
24 # return self.func(*args, **kwargs)
25
26 h = self._hash(str(args) + str(kwargs))
27 if h in self.cache:
28 self.log.debug('Using cached value for {}({}, {})'.format(
29 self.func.__name__, ', '.join(str(a) for a in args),
30 ','.join('{}={} '.format(k, v) for k, v in kwargs.items())))
31 return self.cache[h]
32 else:
33 self.log.debug('Caching value')
34 value = self.func(*args, **kwargs)
35 self.cache[h] = value
36
37 return value
38
39 def __get__(self, obj, objtype):
40 return functools.partial(self.__call__, obj)