├── .gitignore ├── MANIFEST.in ├── README.md ├── bin └── jeev ├── docs ├── environment_variables.md └── modules │ ├── README.md │ ├── api.md │ ├── web_endpoints.md │ └── your_first_module.md ├── jeev ├── __init__.py ├── adapter │ ├── __init__.py │ ├── console.py │ └── slack.py ├── dev │ ├── __init__.py │ ├── edit_test.py │ └── opt_test.py ├── events.py ├── jeev.py ├── message.py ├── module.py ├── starter_template │ ├── .gitignore │ ├── config.py │ ├── modules │ │ ├── __init__.py │ │ └── sample.py │ └── requirements.txt ├── storage │ ├── __init__.py │ ├── ephemeral.py │ ├── redis.py │ └── shelve.py ├── utils │ ├── __init__.py │ ├── date.py │ ├── env.py │ ├── g.py │ ├── importing.py │ └── periodic.py └── web.py ├── requirements.txt ├── run_dev.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /config.py 2 | shelve 3 | .idea 4 | *.pyc 5 | build 6 | dist 7 | *.egg-info -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include jeev/starter_template * 3 | recursive-exclude * __pycache__ 4 | recursive-exclude * *.py[co] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jeev 2 | 3 | Jeev is a python alternative to Github's famous Hubot, using Python+Gevent instead of Node+CoffeeScript. 4 | 5 | # Motivation 6 | 7 | I got tired of Hubot's callback spaghetti, and decided to write an alternative to work with my company's slack channel. 8 | This project is a work in progress, and is roughly documented. 9 | 10 | # Installing Jeev 11 | 12 | You will need Python 2.7, and setuptools. If you want, you can install Jeev in a virtual environment. 13 | 14 | Install jeev (and his built in modules) with pip: 15 | 16 | $ pip install jeev jeev-modules 17 | 18 | This will install jeev, and his dependencies. It will also give you the `jeev` command which can be used to create 19 | an initial jeev configuration, and run the bot. Let's create an instance of jeev in the folder "myjeev": 20 | 21 | $ jeev init myjeev 22 | 23 | If you want to use jeev with heroku, or just have your Jeev instance inside of a git repository, the newly created 24 | directory has everything you need: the configuration file, a few sample modules, a .gitignore file (so that you can safely add 25 | everything to git). 26 | 27 | $ cd myjeev 28 | $ git init 29 | $ git add . 30 | $ git commit -m "Jeev's initial commit." 31 | 32 | Now you can run Jeev by simply calling: 33 | 34 | $ jeev run 35 | 36 | This will start Jeev using the console adapter that will read messages from stdin, and print out Jeev's responses 37 | to stdout. 38 | 39 | $ jeev run 40 | >>> Jeev Console Adapater 41 | >>> Switch channel using \c channel_name 42 | >>> Switch user using \u user_name 43 | >>> Jeev will respond to the user name Jeev 44 | [user@test] > 45 | 46 | 47 | # License 48 | 49 | The MIT License (MIT) 50 | 51 | Copyright (c) 2014 Jacob Heinz 52 | 53 | Permission is hereby granted, free of charge, to any person obtaining a copy 54 | of this software and associated documentation files (the "Software"), to deal 55 | in the Software without restriction, including without limitation the rights 56 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 57 | copies of the Software, and to permit persons to whom the Software is 58 | furnished to do so, subject to the following conditions: 59 | 60 | The above copyright notice and this permission notice shall be included in 61 | all copies or substantial portions of the Software. 62 | 63 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 64 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 65 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 66 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 67 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 68 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 69 | THE SOFTWARE. -------------------------------------------------------------------------------- /bin/jeev: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | import argparse 3 | from importlib import import_module 4 | import os 5 | import sys 6 | import jeev 7 | 8 | parser = argparse.ArgumentParser(prog='jeev', description='Jeev') 9 | 10 | subparsers = parser.add_subparsers(dest='command') 11 | init_parser = subparsers.add_parser('init', help="Initializes an instance of jeev") 12 | init_parser.add_argument('directory') 13 | run_parser = subparsers.add_parser('run', help="Initializes an instance of jeev") 14 | run_parser.add_argument('--config', default='config.py') 15 | 16 | chkconfig_parser = subparsers.add_parser('chkconfig', 17 | help="Checks the config of Jeev to make sure there are no errors.") 18 | chkconfig_parser.add_argument('--config', default='config.py') 19 | 20 | modopts_parser = subparsers.add_parser('modopts', 21 | help="Shows the configuration available for the modules to be loaded.") 22 | modopts_parser.add_argument('--config', default='config.py') 23 | 24 | 25 | class Error(Exception): 26 | pass 27 | 28 | 29 | def init(ns): 30 | import shutil 31 | 32 | try: 33 | import_module(ns.directory) 34 | except ImportError: 35 | pass 36 | else: 37 | raise Error("%r conflicts with the name of an existing " 38 | "Python module and cannot be used as the name " 39 | "of the jeev directory." % ns.directory) 40 | 41 | if os.path.exists(ns.directory): 42 | raise Error("Directory %r already exists." % ns.directory) 43 | 44 | starter_template_path = os.path.join(jeev.__path__[0], 'starter_template') 45 | shutil.copytree(starter_template_path, ns.directory, ignore=shutil.ignore_patterns("*.pyc")) 46 | print("Initialized jeev in", ns.directory) 47 | print("You should now be able to change directory to it and then run 'jeev run'") 48 | 49 | 50 | def _find_config(ns): 51 | if os.getcwd() not in sys.path: 52 | sys.path.insert(0, os.getcwd()) 53 | 54 | import imp 55 | 56 | try: 57 | config = imp.load_source('config', ns.config) 58 | except IOError: 59 | config = object() 60 | 61 | return config 62 | 63 | 64 | def run(ns): 65 | config = _find_config(ns) 66 | jeev.run(config) 67 | 68 | 69 | def chkconfig(ns): 70 | config = _find_config(ns) 71 | jeev.chkconfig(config) 72 | 73 | 74 | def modopts(ns): 75 | config = _find_config(ns) 76 | jeev.modopts(config) 77 | 78 | 79 | try: 80 | ns = parser.parse_args(sys.argv[1:]) 81 | globals()[ns.command](ns) 82 | 83 | except Error as e: 84 | sys.stderr.write("ERROR: %s\n" % e.message) -------------------------------------------------------------------------------- /docs/environment_variables.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | Jeev can be configured with a python file, or directly from environment variables. This document lists all of the 4 | configuration options that can be set to configure Jeev. 5 | 6 | ## Jeev Core 7 | 8 | * `JEEV_ADAPTER`: The adapter to use to connect to the chat server. 9 | * Builtin options: `slack`, `console`. 10 | * Default: `console` 11 | 12 | * `JEEV_STORAGE`: The storage backend to use to serialize module data via `module.data`. 13 | * Builtin options: `shelve`, `redis` 14 | * Default: `shelve` 15 | 16 | * `JEEV_MODULES`: A comma seperated list of modules to load. 17 | * Default: `` 18 | * Example: `facts,eightball` 19 | 20 | ## Jeev Web-Server 21 | 22 | * `JEEV_WEB`: Should Jeev run it's built in web-server, that will allow modules to define web endpoints? 23 | * Default: `FALSE` 24 | * Possible Values: `FALSE`, `TRUE` 25 | 26 | * `JEEV_WEB_LISTEN_HOST`: The host that the built in web-server will bind to. 27 | * **REQUIRED** if `JEEV_WEB == TRUE`. 28 | * Example: `0.0.0.0` (to listen on all interfaces), `127.0.0.1` (to only listen on localhost) 29 | 30 | * `JEEV_WEB_LISTEN_PORT`: The port taht the built in web-server will bind to. 31 | * **REQUIRED** if `JEEV_WEB == TRUE` 32 | * Example: `8000` (note that if you are using the Slack adapter, by default port 8080 will already be in use) 33 | 34 | 35 | ## Adapter Options 36 | 37 | ### `jeev.adapter.console` 38 | 39 | * `JEEV_ADAPTER_CONSOLE_CHANNEL`: The default channel that the adapter will pretend you are in. 40 | * Default: `console` 41 | 42 | * `JEEV_ADAPTER_CONSOLE_USER`: The default user that the adapter will pretend you are. 43 | * Default: `user` 44 | 45 | ### `jeev.adapter.slack` 46 | 47 | * `JEEV_ADAPTER_SLACK_LISTEN_HOST`: The address to bind the web-server for slack's web hook. 48 | * Default: `0.0.0.0` (listens on all hosts) 49 | 50 | * `JEEV_ADAPTER_SLACK_LISTEN_PORT`: The port to bind the web-server for slack's web hook. 51 | * Default: `8080` 52 | 53 | * `JEEV_ADAPTER_SLACK_TEAM_NAME`: The team-name of your slack service. 54 | * **REQUIRED** 55 | 56 | * `JEEV_ADAPTER_SLACK_TOKEN`: The integration token for Jeev. 57 | * **REQUIRED** 58 | 59 | * `JEEV_ADAPTER_SLACK_LINK_NAMES`: Not sure what this does yet... 60 | * Default: `FALSE` 61 | * Possible Values: `FALSE`, `TRUE` 62 | 63 | ## Storage Options 64 | 65 | * `JEEV_STORAGE_SYNC_INTERVAL`: How often to periodically sync the storage in seconds. 66 | * Default: `600` 67 | 68 | ### `jeev.storage.shelve` 69 | 70 | * `JEEV_STORAGE_SHELVE_DATA_PATH`: Where shelve stores it's database files. 71 | * **REQUIRED** 72 | * Example: `./shelve` 73 | 74 | ### `jeev.storage.redis` 75 | 76 | * `JEEV_STORAGE_REDIS_KEY_PREFIX`: What to prefix all the redis keys with in the database 77 | * Default: `` (empty string, keys won't be prefixed) 78 | 79 | * `JEEV_STORAGE_REDIS_URL`: The redis URL to connect to 80 | * Default: `redis://127.0.0.1:6379/0` 81 | 82 | If you don't want to use a URL, you can set the connection kwargs for the `StrictRedis` connection by using 83 | `JEEV_STORAGE_REDIS_{KEY}`, where `{KEY}` is one of: `HOST`, `PORT`, `DB`, `PASSWORD`, `SOCKET_TIMEOUT`, 84 | `SOCKET_CONNECT_TIMEOUT`, `SOCKET_KEEPALIVE`, `SOCKET_KEEPALIVE_OPTIONS`, `CONNECTION_POOL`, `UNIX_SOCKET_PATH`, 85 | `ENCODING`, `ENCODING_ERRORS`, `ERRORS`, `DECODE_RESPONSES`, `RETRY_ON_TIMEOUT`, `SSL`, `SSL_KEYFILE`, `SSL_CERTFILE`, 86 | `SSL_CERT_REQS`, `SSL_CA_CERTS`. See https://github.com/andymccurdy/redis-py for more details. -------------------------------------------------------------------------------- /docs/modules/README.md: -------------------------------------------------------------------------------- 1 | ## Writing Jeev Modules 2 | 3 | Jeev exposes a really simple module interface. Every module can be defined as a single python file inside of a python package, or as an __init__ file inside of a python package. 4 | 5 | ### Getting Started 6 | 7 | Writing your first module? Check out [Your First Module](your_first_module.md), or check out the [API Documentation](api.md). 8 | 9 | Writing web endpoints? Check out [Web Endpoints](web_endpoints.md). 10 | 11 | ### Module Conventions and Practices 12 | COMING SOON. 13 | 14 | ### Publishing your modules 15 | 16 | Simply throw them up on pypi, and then people can install, then import and use your modules in their Jeevs. More examples on this coming soon. 17 | -------------------------------------------------------------------------------- /docs/modules/api.md: -------------------------------------------------------------------------------- 1 | Documentation for the public module API with examples. 2 | 3 | ## Properties 4 | ### `module.name` 5 | Gets the module's name. 6 | 7 | ### `module.jeev` 8 | The `Jeev` instance that loaded this module. 9 | 10 | ### `module.opts` 11 | A read only dictionary of options passed to the module (either via the config file, or ENVIRONMENT variables). All 12 | values of this dictionary are strings. 13 | 14 | ### `module.data` 15 | A persistent data store, that stores data using `SLACK_STORAGE`. 16 | 17 | #### Example 18 | ```python 19 | # Usually accessed like a `dict`. 20 | # Keys are persisted to the data store when they are written to: 21 | module.data['foo'] = 'bar' 22 | 23 | # Supports all the dict functions too: 24 | print(module.data.keys()) 25 | del module.data['foo'] 26 | print 'foo' in module.data # False 27 | 28 | # However, when setting mutable objects (aka, dicts or lists as a value) special care has to be taken to make sure 29 | # that they are properly persisted to storage right away. 30 | 31 | module.data['my_list'] = [1, 2, 3] # Saved to the database, since you are writing to a value. 32 | module.data['my_list'].append(4) # Does not save to the database, as my_list is accessed but not set. 33 | 34 | print module.data['my_list'] # [1, 2, 3, 4] Prints out the cached local copy, but the storage still has [1, 2, 3] 35 | 36 | # Most of the time, you don't have to worry about it though. When Jeev is stopped, and at a regular interval, the 37 | # data for each module are eventually persisted, and out of sync objects ('my_list' in this example) will be 38 | # properly saved to the storage. However, if you have important data that you want to make sure is saved right away, 39 | # you have two options: 40 | 41 | # 1) You can force a manual sync, which will persist all potentially out of sync items to the storage. 42 | module.data.sync() # Persists 'my_list' to the data store (note that this function is smart, and won't 43 | # try to re-write everything in modules.data) 44 | 45 | # 2) You can write the key to itself, which will only persist the key (my_list) to the storage. 46 | module.data['my_list'] = module.data['my_list'] # Since stuff is stored when its value is set, the storage will persist 47 | # the value. 48 | ``` 49 | 50 | ### `module.g` 51 | A namespace to temporarily store data for the life of the module. Good to store resources, descriptors, or other things 52 | that don't need to persist between restarts of Jeev or reloads of the module. 53 | 54 | ### Example 55 | ```python 56 | 57 | module.g.db = MyDatabaseResource() 58 | result = module.g.db.query('SELECT * FROM `bar`') 59 | 60 | module.g.counter = 0 61 | module.g.counter += 1 62 | ``` 63 | 64 | ## Callback Decorators 65 | ### `@module.loaded` 66 | Registers a function that will be called when the module is loaded. You can register more than one function to be 67 | called. Usually, this is used as a decorator. 68 | 69 | #### Example 70 | ```python 71 | 72 | @module.loaded 73 | def connect_to_some_database(): 74 | module.g.db = MyDatabaseResource() 75 | ``` 76 | 77 | ### `@module.unloaded` 78 | Registers a function that will be called when the module is unloaded. You can register more than one function to be 79 | called. 80 | 81 | #### Example 82 | ```python 83 | 84 | @module.unloaded 85 | def disconnect_from_database(): 86 | module.g.db.close() # You don't have to unset this variable, module.g is cleared when the module is unloaded. 87 | ``` 88 | 89 | ## Message Handler Decorators 90 | 91 | ### `@module.listen(priority=0)` 92 | Called whenever Jeev sees a message. 93 | 94 | #### Example 95 | ```python 96 | 97 | @module.listen() 98 | def on_message(message): 99 | print 'Got message from', message.user, ':', message.message 100 | message.reply_to_user('You said: %s' % message.message) 101 | ``` 102 | 103 | ### `@module.command(command, priority=0)` 104 | A handler that gets called if `command` is seen as the first word in a message. This runs faster than `match`, `hear`, 105 | and `respond`, as it uses a dict lookup that is pretty much constant time. 106 | 107 | #### Example 108 | ```python 109 | # This will get called whenever anyone says '!ping' in the channel. 110 | @module.command('!ping') 111 | def ping(message): 112 | module.reply('Pong!') 113 | ``` 114 | 115 | ### `@module.match(regex, flags=0, priority=0)` 116 | Registers a function that will be called when a message is seen that matches a specific regex. 117 | 118 | #### Example 119 | ```python 120 | 121 | # Case sensitive match for 'throw me the facts' 122 | # Will get called when a message contains 'throw me the facts' in all lower case. 123 | @module.match('throw me the facts') 124 | def the_facts(message): 125 | message.reply('just the basics?') 126 | 127 | # Captures are passed to the handler function as arguments. 128 | 129 | @module.match('what is (.*)') 130 | def what_is(message, thing): 131 | if thing == 'love': 132 | message.reply('BABY DONT HURT ME!') 133 | else: 134 | message.reply("I'm not really sure what %s is" % thing) 135 | 136 | 137 | # In addition, named captures work as well. 138 | @module.match('search for (?P.*) on (?Pgoogle|ddg)') 139 | def what_is(message, engine, thing): 140 | message.reply('searching for %s on %s' % (thing, engine)) 141 | 142 | # Or even... 143 | @module.match('search for (?P.*) on (?Pgoogle|ddg)') 144 | def what_is(message, **kwargs): 145 | message.reply('searching for %(thing)s on %(engine)s' % kwargs) 146 | 147 | ``` 148 | 149 | ### `@module.hear(regex, priority=0)` 150 | Same as `@module.match(...)` but defaults to a case-insensitive match. 151 | 152 | 153 | ### `@module.respond(regex, flags=re.I, priority=0)` 154 | Same as `@module.hear(...)` but only gets called if the message is addressing the bot (meaning the message starts with 155 | the bot name, eg. "jeev, throw me the facts!") 156 | 157 | ## Function Decorators 158 | ## `@module.async(sync_return_val=None, timeout=0)` 159 | Makes it so that the function is called inside a greenlet. This is useful for functions which make web requests. 160 | Although, a function that makes a request will never block Jeev, it will prevent the other message handlers from being 161 | called for a specific message. However, since each incoming message is processed in it's own greenlet, a message handler 162 | that performs code that can be made concurrent with gevent will never delay the processing of new messages. Where 163 | this function really is useful is to set a timeout to the processing time of a handler. 164 | 165 | #### Example 166 | 167 | **Making a web request timeout** 168 | ```python 169 | 170 | @module.hear('cat ?fact') 171 | @module.async(timeout=5) 172 | def cat_fact(message): 173 | # If this request takes more than 5 seconds, the greenlet that is running this function will be killed. 174 | response = requests.get('http://catfacts-api.appspot.com/api/facts?number=1') 175 | if response.status_code == requests.codes.ok: 176 | json = response.json() 177 | if json['success'] == 'true': 178 | message.reply_to_user(json['facts'][0]) 179 | ``` 180 | 181 | **You can also handle the timeout event** 182 | ```python 183 | 184 | from gevent import Timeout, sleep 185 | 186 | @module.hear('sleep for (\d+) seconds') 187 | @module.async(timeout=5) 188 | def sleep_for(message, seconds): 189 | try: 190 | message.reply("Okay, I'm going to try to sleep for %s seconds" % seconds) 191 | sleep(int(seconds)) 192 | 193 | except Timeout: 194 | message.reply('OOPS! I slept for too long! Oh well :C') 195 | ``` 196 | 197 | ## Greenlet Functions 198 | ### `module.spawn(f, *args, **kwargs)` 199 | Spawns a greenlet to run a function. The greenlet will be killed if it doesn't finish before the module unloads. 200 | 201 | #### Example 202 | ```python 203 | 204 | from gevent import sleep 205 | 206 | def background_task(what): 207 | print "I'm doing %s in the background... not sure what" % what 208 | sleep(50) 209 | print 'okay... it finished!' 210 | 211 | @module.hear('do (.*?) in the background') 212 | def do_background_task(message, what): 213 | module.spawn(background_task, what) 214 | message.reply('okay! started background task to do %s!' % what) 215 | ``` 216 | 217 | 218 | ### `module.spawn_after(delay, f, *args, **kwargs)` 219 | Schedules a greenlet that will run `f` after `delay` seconds. The greenlet will be killed/unscheduled if it doesn't 220 | start/finish before the module unloads. 221 | 222 | #### Example 223 | ```python 224 | import random 225 | 226 | @module.hear('reply to me slowly') 227 | def reply_slowly(message): 228 | module.spawn_after(random.randint(5, 10), message.reply, 'is this slow enough?') 229 | 230 | ``` 231 | 232 | ### `module.periodic(interval, f, *args, **kwargs)` 233 | TODO: Document this 234 | 235 | ## Web 236 | If `JEEV_WEB` is set to `TRUE`, Jeev runs a WSGI server that dispatches requests to loaded modules by their name. 237 | 238 | Basically when Jeev gets a request at "/foo/bar", it will look and see if the `foo` is loaded, and that it has a wsgi 239 | app bound (via `module.is_web`) and then call the module's WSGI handler (`module.app`) with an environment that has 240 | `SCRIPT_NAME` and `PATH_INFO` modified and set to `/foo`, and `/bar` respectively. 241 | 242 | ### `module.app` 243 | By default, module.app when accessed for the first time will initialize an empty Flask application to handle requests. 244 | This makes it super easy to write web hooks and functions. 245 | 246 | #### Example 247 | ```python 248 | 249 | # Assume the module name is `webtest` 250 | from flask import Response, request 251 | 252 | # Will get called when '/webtest' is requested. 253 | @module.app.route('/') 254 | def index(): 255 | return Response('Hello, I am {}'.format(module.jeev.name)) 256 | 257 | # Will get called when '/webtest/webhook' gets a POST request that will send a message to a channel specified 258 | # in the POST body. 259 | # $ curl -d "channel=jeev&message=hey+there+from+the+web" http://localhost:8080/webtest/webhook 260 | # OK 261 | @module.app.route('/webhook', methods=['POST']) 262 | def handle_webhook(): 263 | module.send_message(request.form['channel'], request.form['message']) 264 | return Response('OK') 265 | ``` 266 | 267 | *You can also set `module.app` to your own custom WSGI handler if you don't want to use Flask.* 268 | ```python 269 | from some.package.web import app 270 | 271 | module.app = app 272 | ``` 273 | 274 | ### `module.is_web` 275 | Returns True if the module has a WSGI handler bound. 276 | 277 | ### `module.set_wsgi_handler(handler)` 278 | This is essentially the same as setting `module.app` but it can be used as a decorator. 279 | 280 | #### Example 281 | ```python 282 | 283 | @module.set_wsgi_handler 284 | def wsgi_handler(environ, start_response): 285 | start_response('200 OK', []) 286 | return ['Hello World\n'] 287 | ``` 288 | 289 | ## Chat Functions 290 | 291 | ### `module.send_message(channel, message)` 292 | Sends `message` to `channel`. This usually isn't called. Instead, use `message.reply(message)` to send a message 293 | to the channel the message was received in. 294 | 295 | #### Example 296 | 297 | ```python 298 | @module.hear('lol') 299 | def handle(message): 300 | # These do the same thing: 301 | message.reply('what are you laughing about?') 302 | module.send_message(message.channel, 'what are you laughing about?') 303 | 304 | # These too, for addressing the user. 305 | message.reply_to_user('what?') 306 | module.send_message(message.channel, '%s: what?' % message.user) 307 | ``` 308 | -------------------------------------------------------------------------------- /docs/modules/web_endpoints.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhgg/jeev/d6c971f42388b5f0a4a50a6449ca778fa372fe4a/docs/modules/web_endpoints.md -------------------------------------------------------------------------------- /docs/modules/your_first_module.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhgg/jeev/d6c971f42388b5f0a4a50a6449ca778fa372fe4a/docs/modules/your_first_module.md -------------------------------------------------------------------------------- /jeev/__init__.py: -------------------------------------------------------------------------------- 1 | version = '0.3.0-dev' 2 | 3 | __author__ = 'mac' 4 | 5 | 6 | def run(config): 7 | import atexit 8 | import sys 9 | 10 | # Reset sys.modules so that g-event can re-monkeypatch. 11 | # This is needed because distribute's pkg_resources imports urllib & co, before we can properly monkey patch it. ;( 12 | modules_to_reset = {'urllib', 'socket', '_ssl', 'ssl', 'select', 'thread', 13 | 'threading', 'time', 'os', 'subprocess'} 14 | for k in sys.modules.keys(): 15 | if k.startswith('jeev.') or k in modules_to_reset: 16 | del sys.modules[k] 17 | 18 | from gevent.monkey import patch_all 19 | 20 | patch_all() 21 | 22 | from .jeev import Jeev 23 | import logging 24 | 25 | logging.basicConfig(**getattr(config, 'logging', {})) 26 | j = Jeev(config) 27 | 28 | try: 29 | j.run() 30 | atexit.register(j.stop) 31 | j.join() 32 | 33 | except KeyboardInterrupt: 34 | print "Got ^C. Stopping!" 35 | pass 36 | 37 | 38 | def _iter_modules(config): 39 | from .jeev import Jeev 40 | 41 | j = Jeev(config) 42 | 43 | for module_name, opts in j.modules.iter_module_names_and_opts(): 44 | try: 45 | module_instance = j.modules.load(module_name, opts, log_error=False, register=False) 46 | 47 | except ImportError: 48 | print 'ERROR: Module %s not found.' % module_name 49 | continue 50 | 51 | yield module_name, module_instance, j 52 | 53 | 54 | def chkconfig(config): 55 | from .module import Module 56 | 57 | for module_name, module_instance, j in _iter_modules(config): 58 | try: 59 | module_instance._register(j.modules) 60 | 61 | except Module.ConfigError as e: 62 | 63 | print "ERROR: Could not load module %s. Some options failed to validate:" % module_name 64 | if hasattr(e, 'error_dict'): 65 | for k, v in e.error_dict.iteritems(): 66 | print('\t%s: %s' % (k, ', '.join(v))) 67 | if k in module_instance.opts._opt_definitions: 68 | print('\t * description: %s' % module_instance.opts._opt_definitions[k].description) 69 | 70 | print('\t * environ key: %s' % module_instance.opts.environ_key(k)) 71 | pass 72 | 73 | else: 74 | print '%s OK' % module_name 75 | 76 | 77 | def modopts(config): 78 | from .module import Module 79 | 80 | for module_name, module_instance, j in _iter_modules(config): 81 | opt_definitions = module_instance.opts._opt_definitions 82 | if not opt_definitions: 83 | print 'Module %s has no options.' % module_name 84 | continue 85 | 86 | print 'Module %s:' % module_name 87 | for opt in opt_definitions.values(): 88 | print ' * %s:' % opt.name 89 | print ' - description: %s' % opt.description 90 | 91 | if opt.has_default: 92 | print ' - default: %s' % opt.default 93 | 94 | print ' - environ key: %s' % module_instance.opts.environ_key(opt.name) 95 | 96 | print -------------------------------------------------------------------------------- /jeev/adapter/__init__.py: -------------------------------------------------------------------------------- 1 | from ..utils import importing 2 | 3 | 4 | def get_adapter_by_name(name): 5 | if '.' not in name: 6 | name = 'jeev.adapter.%s.adapter' % name 7 | 8 | return importing.import_dotted_path(name) -------------------------------------------------------------------------------- /jeev/adapter/console.py: -------------------------------------------------------------------------------- 1 | from gevent import Greenlet 2 | from gevent.fileobject import FileObject 3 | import sys 4 | from ..message import Message 5 | 6 | 7 | class ConsoleAdapter(object): 8 | """ 9 | This adapter will run Jeev in console mode, listening to stdin for messages, 10 | and writing outgoing messages to stdout. 11 | """ 12 | def __init__(self, jeev, opts): 13 | self._jeev = jeev 14 | self._opts = opts 15 | self._stdin = None 16 | self._stdout = None 17 | self._reader = None 18 | self._channel = opts.get('console_channel', 'console') 19 | self._user = opts.get('console_user', 'user') 20 | 21 | def _read_stdin(self): 22 | self._stdout.write(">>> Jeev Console Adapater, running jeev v%s\n" % self._jeev.version) 23 | self._stdout.write(">>> Switch channel using \c channel_name\n") 24 | self._stdout.write(">>> Switch user using \u user_name\n") 25 | self._stdout.write(">>> Jeev will respond to the user name %s\n" % self._jeev.name) 26 | self._stdout.flush() 27 | 28 | while True: 29 | self._stdout.write('[%s@%s] > ' % (self._user, self._channel)) 30 | self._stdout.flush() 31 | 32 | line = self._stdin.readline() 33 | if not line: 34 | break 35 | 36 | if line.startswith('\c'): 37 | self._channel = line[2:].strip().lstrip('#') 38 | self._stdout.write("Switched channel to #%s\n" % self._channel) 39 | self._stdout.flush() 40 | 41 | elif line.startswith('\u'): 42 | self._user = line[2:].strip() 43 | self._stdout.write("Switched user %s\n" % self._user) 44 | self._stdout.flush() 45 | 46 | else: 47 | message = Message({}, self._channel, self._user, line.strip()) 48 | self._jeev._handle_message(message) 49 | 50 | def start(self): 51 | self._reader = Greenlet(self._read_stdin) 52 | self._stdin = FileObject(sys.stdin) 53 | self._stdout = FileObject(sys.stdout) 54 | self._reader.start() 55 | 56 | def stop(self): 57 | self._reader.kill() 58 | self._reader = None 59 | 60 | def send_message(self, channel, message): 61 | self._stdout.write('\r< [#%s] %s\n' % (channel, message)) 62 | self._stdout.write('[%s@%s] > ' % (self._user, self._channel)) 63 | self._stdout.flush() 64 | 65 | def send_messages(self, channel, *messages): 66 | for message in messages: 67 | self.send_message(channel, message) 68 | 69 | adapter = ConsoleAdapter -------------------------------------------------------------------------------- /jeev/adapter/slack.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import json 3 | import logging 4 | import weakref 5 | from gevent import Greenlet, sleep 6 | from slackclient._server import Server 7 | from websocket._exceptions import WebSocketConnectionClosedException 8 | 9 | from jeev.message import Message 10 | from jeev import events 11 | 12 | logger = logging.getLogger('jeev.adapter.slack') 13 | 14 | 15 | class SlackAdapter(object): 16 | """ 17 | This adapter exposes a webhook that listens for slack messages. 18 | 19 | The web listener for this is independent of Jeev's WSGI server. They cannot run on the same port. 20 | 21 | This adapter works the same as Slack's Hubot adapter. So, when integrating with Jeev, from Slack's integration, 22 | use Hubot, and point it to the the adapter's listen host and port. 23 | """ 24 | 25 | class SlackObject(object): 26 | def __init__(self, data): 27 | self.data = data 28 | self._in_name_sets = set() 29 | 30 | @property 31 | def id(self): 32 | return self.data['id'] 33 | 34 | @property 35 | def name(self): 36 | return self.data['name'] 37 | 38 | def _iter_name_sets(self): 39 | pass 40 | 41 | def _link(self, name): 42 | self._in_name_sets.add(name) 43 | 44 | def _unlink(self): 45 | self._in_name_sets.clear() 46 | 47 | def iter_names(self): 48 | return iter(self._in_name_sets) 49 | 50 | def _update(self, **kwargs): 51 | for k, v in kwargs.iteritems(): 52 | if k == 'ok': 53 | continue 54 | 55 | self.data[k] = v 56 | 57 | def __str__(self): 58 | return self.name 59 | 60 | class SlackUser(SlackObject): 61 | @property 62 | def presence(self): 63 | return self.data['presence'] 64 | 65 | def __repr__(self): 66 | return '' % (self.id, self.name, self.presence) 67 | 68 | class _SlackChannelBase(SlackObject): 69 | is_direct_message = False 70 | 71 | def __init__(self, data, adapter): 72 | super(SlackAdapter._SlackChannelBase, self).__init__(data) 73 | self._adapter = adapter 74 | 75 | @property 76 | def topic(self): 77 | if 'topic' in self.data: 78 | return self.data['topic']['value'] 79 | 80 | @topic.setter 81 | def topic(self, val): 82 | if val != self.data['topic']: 83 | self._adapter.api.channels.setTopic(channel=self, topic=val) 84 | 85 | @property 86 | def purpose(self): 87 | if 'purpose' in self.data: 88 | return self.data['purpose']['value'] 89 | 90 | @purpose.setter 91 | def purpose(self, val): 92 | raise NotImplementedError("Bots cannot set channel purpose.") 93 | 94 | class SlackChannel(_SlackChannelBase): 95 | @property 96 | def members(self): 97 | members = [] 98 | for m in self.data['members']: 99 | members.append(self._adapter._users[m]) 100 | 101 | return members 102 | 103 | def _left(self, archive=False): 104 | keep_keys = 'created', 'creator', 'id', 'is_archived', 'is_channel', 'is_general' 105 | for k in self.data.keys(): 106 | if k not in keep_keys: 107 | del self.data[k] 108 | 109 | self.data.update( 110 | members=[], 111 | is_member=False 112 | ) 113 | if archive: 114 | self.data['is_archived'] = True 115 | 116 | def __repr__(self): 117 | return "" % ( 118 | self.id, self.name, self.members 119 | ) 120 | 121 | class _SlackGroupBase(SlackObject): 122 | is_direct_message = False 123 | 124 | def __init__(self, data, adapter): 125 | super(SlackAdapter._SlackGroupBase, self).__init__(data) 126 | self._adapter = adapter 127 | 128 | @property 129 | def topic(self): 130 | if 'topic' in self.data: 131 | return self.data['topic']['value'] 132 | 133 | @topic.setter 134 | def topic(self, val): 135 | if val != self.data['topic']: 136 | self._adapter.api.groups.setTopic(channel=self, topic=val) 137 | 138 | @property 139 | def purpose(self): 140 | if 'purpose' in self.data: 141 | return self.data['purpose']['value'] 142 | 143 | @purpose.setter 144 | def purpose(self, val): 145 | raise NotImplementedError("Bots cannot set group purpose.") 146 | 147 | class SlackGroup(_SlackGroupBase): 148 | @property 149 | def members(self): 150 | members = [] 151 | for m in self.data['members']: 152 | members.append(self._adapter._users[m]) 153 | 154 | return members 155 | 156 | def _left(self, archive=False): 157 | keep_keys = 'created', 'creator', 'id', 'is_archived', 'is_group', 'is_general' 158 | for k in self.data.keys(): 159 | if k not in keep_keys: 160 | del self.data[k] 161 | 162 | self.data.update( 163 | members=[], 164 | is_member=False 165 | ) 166 | if archive: 167 | self.data['is_archived'] = True 168 | 169 | def __repr__(self): 170 | return "" % ( 171 | self.id, self.name, self.members 172 | ) 173 | 174 | class SlackDirectMessage(_SlackChannelBase): 175 | is_direct_message = True 176 | 177 | @property 178 | def user(self): 179 | return self._adapter._users[self.data['user']] 180 | 181 | @property 182 | def members(self): 183 | return [self.user] 184 | 185 | def __repr__(self): 186 | return '' % ( 187 | self.id, self.name, self.user 188 | ) 189 | 190 | class SlackObjectList(object): 191 | def __init__(self): 192 | self._obj_by_id = {} 193 | self._obj_by_name = defaultdict(set) 194 | 195 | def clear(self): 196 | self._obj_by_id.clear() 197 | self._obj_by_name.clear() 198 | 199 | def add(self, obj): 200 | if obj in self: 201 | self.remove(obj) 202 | 203 | self._obj_by_id[obj.id] = obj 204 | name = obj.name.lower() 205 | obj._link(name) 206 | self._obj_by_name[obj.name.lower()].add(obj) 207 | 208 | def remove(self, obj): 209 | self._obj_by_id.pop(obj.id) 210 | for name in obj.iter_names(): 211 | self._obj_by_name[name].discard(obj) 212 | 213 | obj._unlink() 214 | 215 | def __contains__(self, item): 216 | if isinstance(item, SlackAdapter.SlackObject): 217 | return item.id in self._obj_by_id and self._obj_by_id[item.id] is item 218 | 219 | else: 220 | return item in self._obj_by_id 221 | 222 | def __getitem__(self, key): 223 | if key in self._obj_by_id: 224 | return self._obj_by_id[key] 225 | 226 | raise KeyError(key) 227 | 228 | def __delitem__(self, key): 229 | if key in self._obj_by_id: 230 | obj = self._obj_by_id[key] 231 | self.remove(obj) 232 | else: 233 | raise KeyError(key) 234 | 235 | def find(self, name_or_id): 236 | if name_or_id in self: 237 | return self[name_or_id] 238 | 239 | name_or_id = name_or_id.lower() 240 | if name_or_id in self._obj_by_name: 241 | return next(iter(self._obj_by_name[name_or_id]), None) 242 | 243 | def names(self): 244 | return [k for k, v in self._obj_by_name.iteritems() if v] 245 | 246 | class SlackApi(object): 247 | def __init__(self, adapter=None, parent=None, part=None): 248 | 249 | if parent: 250 | self._adapter = parent._adapter 251 | self._parts = parent._parts[:] 252 | else: 253 | self._parts = [] 254 | self._adapter = adapter 255 | 256 | if part: 257 | self._parts.append(part) 258 | 259 | def __getattr__(self, item): 260 | return SlackAdapter.SlackApi(parent=self, part=item) 261 | 262 | def __call__(self, **kwargs): 263 | for k, v in kwargs.items(): 264 | if isinstance(v, SlackAdapter.SlackObject): 265 | kwargs[k] = v.id 266 | 267 | method = '.'.join(self._parts) or '?' 268 | logger.debug('Making API call %r with args %r', method, kwargs) 269 | result = json.loads(self._adapter._server.api_call(method, **kwargs)) 270 | logger.debug('Got response %r', result) 271 | result = self._adapter._process_post_method_hooks(method, kwargs, result) 272 | return result 273 | 274 | class MutableOutgoingMessage(object): 275 | def __init__(self, adapter, channel, message): 276 | self.channel = channel 277 | self.adapter = adapter 278 | self.id = adapter._generate_message_id() 279 | self.message = message 280 | self.needs_update = False 281 | self.ts = None 282 | 283 | def _recv_reply(self, data): 284 | self.ts = data['ts'] 285 | if self.needs_update: 286 | self._do_update() 287 | 288 | def _do_update(self): 289 | self.adapter.api.chat.update( 290 | ts=self.ts, 291 | channel=self.channel.id, 292 | text=self.message 293 | ) 294 | self.needs_update = False 295 | 296 | def update(self, message): 297 | self.message = message 298 | if self.ts: 299 | self._do_update() 300 | else: 301 | self.needs_update = True 302 | 303 | def serialize(self): 304 | return { 305 | 'text': self.message, 306 | 'channel': self.channel.id, 307 | 'type': 'message', 308 | 'id': self.id 309 | } 310 | 311 | def __repr__(self): 312 | return "" % ( 313 | self.id, self.channel, self.message 314 | ) 315 | 316 | def __init__(self, jeev, opts): 317 | self._jeev = jeev 318 | self._opts = opts 319 | self._server = None 320 | self._greenlet = None 321 | self._channels = self.SlackObjectList() 322 | self._dms = self.SlackObjectList() 323 | self._groups = self.SlackObjectList() 324 | self._users = self.SlackObjectList() 325 | self._outgoing_messages = {} 326 | self._last_id = 1 327 | self.api = self.SlackApi(self) 328 | 329 | def start(self): 330 | if self._greenlet: 331 | raise RuntimeError("SlackAdapter Already Started.") 332 | self._greenlet = Greenlet(self._run) 333 | self._greenlet.start() 334 | 335 | def stop(self): 336 | self._greenlet.kill() 337 | 338 | def _run(self): 339 | while True: 340 | self._do_slack_connection() 341 | sleep(10) 342 | 343 | def _do_slack_connection(self): 344 | if self._server: 345 | self._server.websocket.abort() 346 | 347 | self._server = Server(self._opts['slack_token'], False) 348 | self._server.rtm_connect() 349 | self._parse_login_data(self._server.login_data) 350 | self._server.websocket.sock.setblocking(1) 351 | self.api.im.close(channel='D038BM8HQ') 352 | 353 | while True: 354 | try: 355 | frame = self._server.websocket.recv() 356 | self._handle_frame(frame) 357 | except WebSocketConnectionClosedException: 358 | logger.error('WebSocket connection closed.') 359 | self._server.rtm_connect(reconnect=True) 360 | logger.info('Restarted WebSocket connection') 361 | 362 | def _handle_frame(self, frame): 363 | data = json.loads(frame) 364 | logger.debug("Got frame %r", frame) 365 | 366 | if 'reply_to' in data: 367 | message = self._outgoing_messages.pop(data['reply_to'], None) 368 | if message: 369 | logger.debug("Received reply for Message: %r", message) 370 | message._recv_reply(data) 371 | 372 | if 'type' not in data: 373 | return 374 | 375 | handler = getattr(self, '_handle_%s' % data['type'], None) 376 | if handler: 377 | return handler(data) 378 | 379 | else: 380 | logger.debug("No handler defined for message type %s", data['type']) 381 | 382 | def _handle_message(self, data): 383 | if 'subtype' not in data and 'reply_to' not in data: 384 | message = Message(data, self._get_channel_group_or_dm(data['channel']), self._users[data['user']], 385 | data['text']) 386 | 387 | return self._jeev._handle_message(message) 388 | 389 | def _handle_user_change(self, data): 390 | user = self._get_user(data['user']['id']) 391 | if user is None: 392 | return 393 | user._update(**data['user']) 394 | self._users.add(user) 395 | self._broadcast_event(events.User.Changed, user=user) 396 | 397 | def _handle_presence_change(self, data): 398 | # For reasons that aren't clear, slack does a presence change notification before telling jeev about a new user 399 | user = self._get_user(data['user']) 400 | if user is None: 401 | return 402 | user._update(presence=data['presence']) 403 | self._broadcast_event(events.User.PresenceChanged, user=user) 404 | 405 | def _handle_channel_created(self, data): 406 | channel = data['channel'].copy() 407 | channel.update(members=[], is_general=False, is_member=False, is_archived=False) 408 | channel = self.SlackChannel(channel, self) 409 | self._channels.add(channel) 410 | self._broadcast_event(events.Channel.Created, channel=channel) 411 | 412 | def _handle_channel_left(self, data): 413 | channel = self._channels[data['channel']] 414 | channel._left() 415 | self._broadcast_event(events.Channel.Left, channel=channel) 416 | 417 | def _handle_channel_deleted(self, data): 418 | self._handle_channel_left(data) 419 | channel = self._channels[data['channel']] 420 | self._channels.remove(channel) 421 | self._broadcast_event(events.Channel.Deleted, channel=channel) 422 | 423 | def _handle_channel_rename(self, data): 424 | channel = self._channels[data['channel']['id']] 425 | channel._update(**data['channel']) 426 | self._channels.add(channel) 427 | self._broadcast_event(events.Channel.Renamed, channel=channel) 428 | 429 | def _handle_channel_archive(self, data): 430 | channel = self._channels[data['channel']] 431 | channel._left(archive=True) 432 | self._broadcast_event(events.Channel.Archived, channel=channel) 433 | 434 | def _handle_channel_unarchive(self, data): 435 | channel = self._channels[data['channel']] 436 | channel._update(is_archived=False) 437 | self._broadcast_event(events.Channel.UnArchived, channel=channel) 438 | 439 | def _handle_channel_joined(self, data): 440 | channel_id = data['channel']['id'] 441 | if channel_id in self._channels: 442 | channel = self._channels[channel_id] 443 | channel._update(**data['channel']) 444 | self._channels.add(channel) 445 | else: 446 | channel = self.SlackChannel(data['channel'], self) 447 | self._channels.add(channel) 448 | self._broadcast_event(events.Channel.Created, channel=channel) 449 | 450 | self._broadcast_event(events.Channel.Joined, channel=channel) 451 | 452 | def _process_team_join(self, data): 453 | user = self.SlackUser(data['user']) 454 | self._users.add(user) 455 | self._broadcast_event(events.Team.Joined, user=user) 456 | 457 | def _parse_login_data(self, login_data): 458 | import pprint 459 | 460 | pprint.pprint(login_data) 461 | self._users.clear() 462 | self._channels.clear() 463 | self._dms.clear() 464 | self._outgoing_messages.clear() 465 | 466 | for user in login_data['users']: 467 | self._users.add(self.SlackUser(user)) 468 | 469 | for group in login_data['groups']: 470 | self._groups.add(self.SlackGroup(group, self)) 471 | 472 | for dm in login_data['ims']: 473 | self._dms.add(self.SlackDirectMessage(dm, self)) 474 | 475 | for channel in login_data['channels']: 476 | self._channels.add(self.SlackChannel(channel, self)) 477 | 478 | def _process_post_method_hooks(self, method, kwargs, data): 479 | if data['ok']: 480 | if method == 'channels.setTopic': 481 | channel = self._channels[kwargs['channel']] 482 | channel._update(**data) 483 | 484 | return data 485 | 486 | def _broadcast_event(self, event, **kwargs): 487 | pass 488 | 489 | def send_message(self, channel, message): 490 | if not isinstance(channel, SlackAdapter._SlackChannelBase) and \ 491 | not isinstance(channel, SlackAdapter._SlackGroupBase): 492 | channel = self._channels.find(channel) 493 | 494 | if not channel: 495 | raise RuntimeError("Channel with name or ID of %s not found." % channel) 496 | 497 | message = SlackAdapter.MutableOutgoingMessage(self, channel, message) 498 | logging.debug("Sending message %r", message) 499 | self._server.send_to_websocket(message.serialize()) 500 | self._outgoing_messages[message.id] = message 501 | return message 502 | 503 | def send_messages(self, channel, *messages): 504 | for message in messages: 505 | self.send_message(channel, message) 506 | 507 | def send_attachment(self, channel, *attachments): 508 | if not isinstance(channel, SlackAdapter._SlackChannelBase): 509 | channel = self._channels.find(channel) 510 | 511 | if not channel: 512 | raise RuntimeError("Channel with name or ID of %s not found." % channel) 513 | 514 | args = { 515 | 'type': 'message', 516 | 'channel': channel.id, 517 | 'attachments': [a.serialize() for a in attachments] 518 | } 519 | 520 | for a in attachments: 521 | if not a.has_message_overrides: 522 | continue 523 | 524 | for k, v in a.message_overrides.items(): 525 | args[k] = v 526 | 527 | self._server.send_to_websocket(args) 528 | 529 | def _generate_message_id(self): 530 | self._last_id += 1 531 | return self._last_id 532 | 533 | def _get_channel_group_or_dm(self, id): 534 | if id.startswith('D'): 535 | return self._get_dm(id) 536 | elif id.startswith('G'): 537 | return self._get_group(id) 538 | else: 539 | return self._get_channel(id) 540 | 541 | def _get_dm(self, id): 542 | if id not in self._dms: 543 | self._refresh_dms() 544 | return self._dms[id] 545 | 546 | def _refresh_dms(self): 547 | dms = self.api.im.list() 548 | for dm in dms['ims']: 549 | self._dms.add(self.SlackDirectMessage(dm, self)) 550 | 551 | def _get_group(self, id): 552 | if id not in self._groups: 553 | self._refresh_groups() 554 | return self._groups[id] 555 | 556 | def _refresh_groups(self): 557 | groups = self.api.groups.list() 558 | for group in groups['groups']: 559 | self._groups.add(self.SlackDirectMessage(group, self)) 560 | 561 | def _get_channel(self, id): 562 | if id not in self._channels: 563 | self._refresh_channels() 564 | return self._channels[id] 565 | 566 | def _refresh_channels(self): 567 | channels = self.api.channels.list() 568 | for channel in channels['channels']: 569 | self._channels.add(self.SlackDirectMessage(channel, self)) 570 | 571 | def _get_user(self, id): 572 | if id not in self._users: 573 | user = self.api.user.info(user=id) 574 | if not user['ok']: 575 | return None 576 | self._users.add(self.SlackUser(user['user'])) 577 | 578 | return self._users[id] 579 | 580 | 581 | adapter = SlackAdapter 582 | -------------------------------------------------------------------------------- /jeev/dev/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhgg/jeev/d6c971f42388b5f0a4a50a6449ca778fa372fe4a/jeev/dev/__init__.py -------------------------------------------------------------------------------- /jeev/dev/edit_test.py: -------------------------------------------------------------------------------- 1 | import module 2 | 3 | from gevent import Timeout, sleep 4 | 5 | 6 | @module.hear('countdown for (\d+) seconds') 7 | @module.async() 8 | def sleep_for(message, seconds): 9 | m = message.reply("Counting down from 0 -> %s" % seconds) 10 | for i in xrange(1, int(seconds) + 1): 11 | sleep(1) 12 | m.update("Counting down from %s -> %s" % (i, seconds)) 13 | 14 | m.update('=== COUNTDOWN DONE ===') -------------------------------------------------------------------------------- /jeev/dev/opt_test.py: -------------------------------------------------------------------------------- 1 | import module 2 | 3 | module.opt("foo", "A test variable", default=1) 4 | module.opt("bar", "A required variable.") 5 | 6 | 7 | @module.loaded 8 | def init(): 9 | print module.opts['foo'] 10 | 11 | 12 | @module.opt_validator('foo') 13 | def validate_foo(value): 14 | raise module.ConfigError('Foo sucks!') -------------------------------------------------------------------------------- /jeev/events.py: -------------------------------------------------------------------------------- 1 | class _Event(object): 2 | __slots__ = ['args', 'help_text', 'class_name', 'attribute_name'] 3 | 4 | def __init__(self, *args, **kwargs): 5 | self.args = args 6 | self.help_text = kwargs.pop('help_text', None) 7 | self.class_name = kwargs.pop('class_name', None) 8 | self.attribute_name = kwargs.pop('attribute_name', None) 9 | 10 | def bind(self, class_name, attribute_name): 11 | return self.__class__(*self.args, 12 | help_text=self.help_text, class_name=class_name, attribute_name=attribute_name) 13 | 14 | def __repr__(self): 15 | return '' % (self.class_name, self.attribute_name, self.help_text) 16 | 17 | 18 | class EventCategoryBase(type): 19 | def __new__(mcs, name, bases, dct): 20 | for k, v in dct.items(): 21 | if isinstance(v, _Event): 22 | dct[k] = v.bind(name, k) 23 | 24 | return super(EventCategoryBase, mcs).__new__(mcs, name, bases, dct) 25 | 26 | 27 | class Message(object): 28 | MessageChanged = _Event() 29 | MessageDeleted = _Event() 30 | 31 | 32 | class Channel(object): 33 | __metaclass__ = EventCategoryBase 34 | 35 | Marked = _Event(help_text="Your channel read marker was updated.") 36 | Created = _Event(help_text="A team channel was created.") 37 | Joined = _Event(help_text="You joined a channel.") 38 | Left = _Event(help_text="You left a channel.") 39 | Deleted = _Event(help_text="A team channel was deleted.") 40 | Renamed = _Event(help_text="A team channel was renamed.") 41 | Archived = _Event(help_text="A team channel was archived.") 42 | UnArchived = _Event(help_text="A team channel was unarchived.") 43 | HistoryChanged = _Event(help_text="Bulk updates were made to a channel's history.") 44 | 45 | BotMessage = _Event() 46 | MeMessage = _Event() 47 | Message = _Event(help_text="A message was sent to a channel.") 48 | 49 | UserJoined = _Event(help_text="A team member joined a channel.") 50 | UserLeft = _Event(help_text="A team member left a channel.") 51 | TopicUpdated = _Event(help_text="A channel topic was updated.") 52 | PurposeUpdated = _Event(help_text="A channel purpose was updated.") 53 | NameUpdated = _Event(help_text="A channel was renamed.") 54 | 55 | 56 | class User(object): 57 | __metaclass__ = EventCategoryBase 58 | Changed = _Event() 59 | PresenceChanged = _Event() 60 | 61 | 62 | class DirectMessage(object): 63 | __metaclass__ = EventCategoryBase 64 | 65 | Created = _Event("A direct message channel was created.") 66 | Opened = _Event("You opened a direct message channel.") 67 | Closed = _Event("You closed a direct message channel.") 68 | Marked = _Event("A direct message read marker was updated.") 69 | HistoryChanged = _Event("Bulk updates were made to a DM channel's history.") 70 | 71 | Message = _Event(help_text="A direct message was received.") 72 | MeMessage = _Event() 73 | 74 | 75 | class Group(object): 76 | __metaclass__ = EventCategoryBase 77 | 78 | Joined = _Event("You joined a private group.") 79 | Opened = _Event("You left a private group.") 80 | Closed = _Event("You opened a group channel.") 81 | Renamed = _Event("You closed a group channel.") 82 | Archived = _Event("A private group was archived.") 83 | UnArchived = _Event("A private group was unarchived.") 84 | HistoryChanged = _Event("A private group was renamed.") 85 | 86 | UserJoined = _Event(help_text="A team member joined a group.") 87 | UserLeft = _Event(help_text="A team member left a group.") 88 | TopicUpdated = _Event(help_text="A group topic was updated.") 89 | PurposeUpdated = _Event(help_text="A group purpose was updated.") 90 | NameUpdated = _Event(help_text="A group was renamed.") 91 | 92 | Message = _Event(help_text="A direct message was received.") 93 | MeMessage = _Event() 94 | 95 | 96 | class Team(object): 97 | __metaclass__ = EventCategoryBase 98 | 99 | Joined = _Event("A new team member has joined.") -------------------------------------------------------------------------------- /jeev/jeev.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import gevent 4 | import time 5 | from gevent.event import Event 6 | from .adapter import get_adapter_by_name 7 | from .utils.periodic import Periodic 8 | from .storage import get_store_by_name 9 | from .web import Web 10 | from .module import Modules 11 | from .utils.env import EnvFallbackDict 12 | from . import version 13 | 14 | logger = logging.getLogger('jeev.jeev') 15 | 16 | 17 | class Jeev(object): 18 | _name = None 19 | _web = None 20 | _targeting_me = None 21 | _targeting_me_re = None 22 | 23 | def __init__(self, config): 24 | opts_for = lambda name: EnvFallbackDict(name, getattr(config, '%s_opts' % name, {})) 25 | 26 | self._opts = EnvFallbackDict(None, getattr(config, 'jeev_opts', {})) 27 | storage_class = get_store_by_name(self._opts.get('storage', getattr(config, 'storage', 'shelve'))) 28 | adapter_class = get_adapter_by_name(self._opts.get('adapter', getattr(config, 'adapter', 'console'))) 29 | 30 | self.config = config 31 | 32 | self._storage = storage_class(self, opts_for('storage')) 33 | self.adapter = adapter_class(self, opts_for('adapter')) 34 | self.modules = Modules(self) 35 | self.name = self._opts.get('name', 'Jeev') 36 | self._storage_sync_periodic = Periodic(int(self._opts.get('storage_sync_interval', 600)), 37 | self.modules._save_loaded_module_data) 38 | self._stop_event = Event() 39 | self._stop_event.set() 40 | 41 | def _handle_message(self, message): 42 | # Schedule the handling of the message to occur during the next iteration of the event loop. 43 | gevent.spawn_raw(self.__handle_message, message) 44 | 45 | def __handle_message(self, message): 46 | logger.debug("Incoming message %r", message) 47 | start = time.time() 48 | 49 | message._jeev = self 50 | message.targeting_jeev = message.is_direct_message or bool(self._targeting_me(message.message)) 51 | self.modules._handle_message(message) 52 | end = time.time() 53 | 54 | logger.debug("Took %.5f seconds to handle message %r", end - start, message) 55 | 56 | def _get_module_data(self, module): 57 | logger.debug("Getting module data for module %s", module.name) 58 | return self._storage.get_data_for_module_name(module.name) 59 | 60 | @property 61 | def name(self): 62 | return self._name 63 | 64 | @name.setter 65 | def name(self, value): 66 | self._name = value 67 | self._targeting_me_re = re.compile('^%s[%s]' % (re.escape(self._name.lower()), re.escape('!:, ')), re.I) 68 | self._targeting_me = self._targeting_me_re.match 69 | 70 | @property 71 | def running(self): 72 | return not self._stop_event.is_set() 73 | 74 | @property 75 | def stopped(self): 76 | return not self.running 77 | 78 | @property 79 | def version(self): 80 | return version 81 | 82 | def run(self, auto_join=False): 83 | """ 84 | Runs Jeev, loading all the modules, starting the web service, and starting the adapter. 85 | 86 | If auto_join=True, this function will not return, and will run until jeev stops if starting jeev from 87 | outside of a greenlet. 88 | 89 | """ 90 | if self.running: 91 | raise RuntimeError("Jeev is already running!") 92 | 93 | logger.info("Starting Jeev v%s", self.version) 94 | 95 | logger.info("Starting storage %s", self._storage) 96 | self._storage.start() 97 | 98 | logger.info("Loading modules") 99 | self.modules.load_all() 100 | 101 | if getattr(self.config, 'web', False) or str(self._opts.get('web', False)).upper() == 'TRUE': 102 | self._web = Web(self, EnvFallbackDict('web', getattr(self.config, 'web_opts', {}))) 103 | self._web.start() 104 | 105 | logger.info("Starting adapter %s", self.adapter) 106 | self.adapter.start() 107 | self._storage_sync_periodic.start(right_away=False) 108 | self._stop_event.clear() 109 | 110 | # If we are the main greenlet, chances are we probably want to never return, 111 | # so the main greenlet won't exit, and tear down everything with it. 112 | if auto_join and gevent.get_hub().parent == gevent.getcurrent(): 113 | self.join() 114 | 115 | def join(self, timeout=None): 116 | """ 117 | Blocks until Jeev is stopped. 118 | """ 119 | if self.stopped: 120 | raise RuntimeError("Jeev is not running!") 121 | 122 | self._stop_event.wait(timeout) 123 | 124 | def stop(self): 125 | """ 126 | Stops jeev, turning off the web listener, unloading modules, and stopping the adapter. 127 | """ 128 | if self.stopped: 129 | raise RuntimeError("Jeev is not running!") 130 | 131 | logger.info('Stopping Jeev') 132 | try: 133 | self.modules.unload_all() 134 | 135 | if self._web: 136 | self._web.stop() 137 | self._web = None 138 | 139 | self.adapter.stop() 140 | self._storage_sync_periodic.stop() 141 | self._storage.stop() 142 | 143 | finally: 144 | self._stop_event.set() 145 | 146 | def send_message(self, channel, message): 147 | """ 148 | Convenience function to send a message to a channel. 149 | """ 150 | return self.adapter.send_message(channel, message) 151 | 152 | def send_attachment(self, channel, *attachments): 153 | if hasattr(self.adapter, 'send_attachment'): 154 | self.adapter.send_attachment(channel, *attachments) 155 | 156 | else: 157 | for a in attachments: 158 | self.adapter.send_message(channel, a.fallback) 159 | 160 | def on_module_error(self, module, e): 161 | print module, e 162 | -------------------------------------------------------------------------------- /jeev/message.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | class Message(object): 5 | """ 6 | Represents an incoming message. 7 | """ 8 | __slots__ = ['user', 'message', 'message_parts', 'channel', '_meta', '_jeev', 'targeting_jeev'] 9 | 10 | def __init__(self, meta, channel, user, message): 11 | self.channel = channel 12 | self.user = user 13 | self.message = message 14 | self.message_parts = message.split() 15 | self._meta = meta 16 | self._jeev = None 17 | self.targeting_jeev = False 18 | 19 | def __repr__(self): 20 | return "".format(m=self) 21 | 22 | def reply_to_user(self, message): 23 | if not self.is_direct_message: 24 | message = '%s: %s' % (self.user, message) 25 | 26 | return self.reply(message) 27 | 28 | def reply(self, message): 29 | return self._jeev.send_message(self.channel, message) 30 | 31 | def reply_with_attachment(self, *attachment): 32 | return self._jeev.send_attachment(self.channel, *attachment) 33 | 34 | def reply_random(self, choices): 35 | return self.reply_to_user(random.choice(choices)) 36 | 37 | def reply_with_one_of(self, *choices): 38 | return self.reply_random(choices) 39 | 40 | reply_with_attachments = reply_with_attachment 41 | 42 | @property 43 | def is_direct_message(self): 44 | return getattr(self.channel, 'is_direct_message', False) 45 | 46 | 47 | class Attachment(object): 48 | __slots__ = ['pretext', 'text', 'fallback', '_color', '_fields', '_message_overrides'] 49 | 50 | def __init__(self, pretext, text="", fallback="", fields=None): 51 | self.pretext = pretext 52 | self.text = text 53 | self.fallback = fallback or text or pretext 54 | self._color = 'good' 55 | self._fields = fields or [] 56 | self._message_overrides = None 57 | 58 | def serialize(self): 59 | return { 60 | 'pretext': self.pretext, 61 | 'text': self.text, 62 | 'color': self._color, 63 | 'fallback': self.fallback, 64 | 'fields': [ 65 | f.serialize() for f in self._fields 66 | ] 67 | } 68 | 69 | def color(self, color): 70 | self._color = color 71 | return self 72 | 73 | def field(self, *args, **kwargs): 74 | self._fields.append(Attachment.Field(*args, **kwargs)) 75 | return self 76 | 77 | @property 78 | def message_overrides(self): 79 | if self._message_overrides is None: 80 | self._message_overrides = {} 81 | 82 | return self._message_overrides 83 | 84 | @property 85 | def has_message_overrides(self): 86 | return self._message_overrides is not None 87 | 88 | def icon(self, icon): 89 | self.message_overrides['icon_url'] = icon 90 | return self 91 | 92 | def name(self, name): 93 | self.message_overrides['username'] = name 94 | return self 95 | 96 | class Field(object): 97 | __slots__ = ['title', 'value', 'short'] 98 | 99 | def __init__(self, title, value, short=False): 100 | self.title = title 101 | self.value = value 102 | self.short = short 103 | 104 | def serialize(self): 105 | return { 106 | 'title': self.title, 107 | 'short': self.short, 108 | 'value': self.value 109 | } -------------------------------------------------------------------------------- /jeev/module.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import bisect 3 | import functools 4 | import logging 5 | import re 6 | import gevent 7 | import sys 8 | from .utils.importing import import_first_matching_module, import_dotted_path 9 | from .utils.periodic import ModulePeriodic 10 | from .utils.g import G 11 | from .utils.env import EnvFallbackDict 12 | 13 | logger = logging.getLogger('jeev.module') 14 | _sentinel = object() 15 | 16 | 17 | class Modules(object): 18 | """ 19 | Holds all the loaded modules for a given Jeev instance. 20 | """ 21 | _reserved_module_names = {'jeev', 'web', 'adapter'} 22 | 23 | def __init__(self, jeev): 24 | self.jeev = jeev 25 | self._module_list = [] 26 | self._module_dict = {} 27 | 28 | def _handle_message(self, message): 29 | for module in self._module_list: 30 | module._handle_message(message) 31 | 32 | def _save_loaded_module_data(self): 33 | logger.info('Saving loaded module data') 34 | for module in self._module_list: 35 | module._save_data() 36 | 37 | def _import_module(self, name, module_instance): 38 | def remove_from_sys_modules(module_name): 39 | for k in sys.modules.keys(): 40 | if k.startswith(module_name): 41 | del sys.modules[k] 42 | 43 | try: 44 | sys.modules['module'] = module_instance 45 | return import_first_matching_module(mod_name=name, 46 | matches=['modules.%s', 'jeev_modules.%s'], 47 | try_mod_name=('.' in name), 48 | pre_import_hook=module_instance._set_module_name, 49 | post_import_hook=remove_from_sys_modules) 50 | 51 | finally: 52 | sys.modules.pop('module', None) 53 | 54 | def load_all(self, modules=None): 55 | """ 56 | Loads all the modules defined in Jeev's configuration 57 | """ 58 | for module_name, opts in self.iter_module_names_and_opts(modules): 59 | self.load(module_name, opts) 60 | 61 | def iter_module_names_and_opts(self, modules=None): 62 | if modules is None: 63 | if 'modules' in self.jeev._opts: 64 | modules = self.jeev._opts['modules'] 65 | 66 | else: 67 | modules = getattr(self.jeev.config, 'modules', {}) 68 | 69 | if isinstance(modules, str): 70 | modules = modules.split(',') 71 | 72 | if not isinstance(modules, dict): 73 | modules = {k: {} for k in modules} 74 | 75 | for module_name, opts in modules.iteritems(): 76 | yield module_name, opts 77 | 78 | def load(self, module_name, opts, log_error=True, register=True, call_loaded=True): 79 | """ 80 | Load a module by name. 81 | """ 82 | if module_name in self._module_dict: 83 | raise RuntimeError("Trying to load duplicate module!") 84 | 85 | if module_name in self._reserved_module_names: 86 | raise RuntimeError("Cannot load reserved module named %s" % module_name) 87 | 88 | logger.debug("Loading module %s", module_name) 89 | module_instance = Module(module_name, opts) 90 | 91 | try: 92 | imported_module = self._import_module(module_name, module_instance) 93 | 94 | module_instance.author = getattr( 95 | imported_module, 'author', getattr(imported_module, '__author__', None)) 96 | 97 | module_instance.description = getattr( 98 | imported_module, 'description', getattr(imported_module, '__doc__', None)) 99 | 100 | logger.debug("Registering module %s", module_name) 101 | if register: 102 | module_instance._register(self) 103 | 104 | if call_loaded: 105 | module_instance._loaded() 106 | 107 | self._module_list.append(module_instance) 108 | self._module_dict[module_name] = module_instance 109 | 110 | logger.info("Loaded module %s", module_name) 111 | 112 | except Module.ConfigError, e: 113 | if log_error: 114 | logger.error("Could not load module %s. Some options failed to validate:", module_name) 115 | if hasattr(e, 'error_dict'): 116 | for k, v in e.error_dict.iteritems(): 117 | logger.error('\t%s: %s' % (k, ', '.join(v))) 118 | if k in module_instance.opts._opt_definitions: 119 | logger.error('\t * description: %s' % module_instance.opts._opt_definitions[k].description) 120 | 121 | logger.error('\t * environ key: %s' % module_instance.opts.environ_key(k)) 122 | 123 | raise e 124 | except Exception, e: 125 | if log_error: 126 | logger.exception("Could not load module %s", module_name) 127 | 128 | raise e 129 | 130 | return module_instance 131 | 132 | def unload(self, module_name): 133 | """ 134 | Unload a module by name. 135 | """ 136 | logger.debug("Unloading module %s", module_name) 137 | module = self._module_dict[module_name] 138 | module._unload() 139 | del self._module_dict[module_name] 140 | self._module_list.remove(module) 141 | 142 | def get_module(self, name, default=None): 143 | """ 144 | Gets a module by name. 145 | """ 146 | return self._module_dict.get(name, default) 147 | 148 | def unload_all(self): 149 | """ 150 | Unloads all modules. 151 | """ 152 | for module in self._module_dict.keys(): 153 | self.unload(module) 154 | 155 | 156 | class Module(object): 157 | """ 158 | The brains of a Jeev module. 159 | 160 | This class is not subclassed, but rather is imported from a module's file, and then used to bind events and 161 | handlers to by using decorator functions. The injection magic happens in `Modules.load`. 162 | 163 | A simple module that replies teo hello would look like this: 164 | 165 | import module 166 | 167 | @module.hear('hello') 168 | def hello(message): 169 | message.reply_to_user('hey!') 170 | 171 | """ 172 | STOP = object() 173 | __slots__ = ['jeev', 'opts', '_name', 'author', 'description', '_module_name', 174 | '_commands', '_message_listeners', '_regex_listeners', '_loaded_callbacks', '_unload_callbacks', 175 | '_running_greenlets', '_data', '_app', '_g', '_opt_definitions'] 176 | 177 | def __init__(self, name, opts, author=None, description=None): 178 | self.author = author 179 | self.description = description 180 | self.jeev = None 181 | self.opts = OptFallbackDict(name, opts) 182 | 183 | self._name = name 184 | self._module_name = name 185 | self._g = None 186 | self._commands = defaultdict(list) 187 | self._message_listeners = [] 188 | self._regex_listeners = [] 189 | self._loaded_callbacks = [] 190 | self._unload_callbacks = [] 191 | self._running_greenlets = set() 192 | self._data = None 193 | self._app = None 194 | self._opt_definitions = None 195 | 196 | def _unload(self): 197 | for callback in self._unload_callbacks: 198 | self._call_function(callback) 199 | 200 | self._regex_listeners[:] = [] 201 | self._loaded_callbacks[:] = [] 202 | self._message_listeners[:] = [] 203 | self._commands.clear() 204 | self._save_data(close=True) 205 | self._clean_g() 206 | self._app = None 207 | self.jeev = None 208 | self.opts = None 209 | 210 | gevent.killall(list(self._running_greenlets), block=False) 211 | self._running_greenlets.clear() 212 | 213 | def _register(self, modules): 214 | self.jeev = modules.jeev 215 | self._validate_opts() 216 | 217 | def _loaded(self): 218 | for callback in self._loaded_callbacks: 219 | self._call_function(callback) 220 | 221 | def _validate_opts(self): 222 | error_dict = defaultdict(list) 223 | 224 | for definition in self.opts._opt_definitions.itervalues(): 225 | if definition.default is _sentinel and definition.name not in self.opts: 226 | error_dict[definition.name].append("This option is required.") 227 | 228 | defunct_keys = error_dict.keys() 229 | 230 | for validator in self.opts._opt_validators: 231 | if validator.name in defunct_keys: 232 | continue 233 | 234 | try: 235 | validator.clean(self.opts) 236 | except Module.ConfigError as e: 237 | error_dict[e.variable_name].append(e.error_message) 238 | 239 | if error_dict: 240 | raise Module.ConfigError(error_dict) 241 | 242 | def _call_function(self, f, *args, **kwargs): 243 | try: 244 | logger.debug("module %s calling %r with %r %r)", self._name, f, args, kwargs) 245 | return f(*args, **kwargs) 246 | except Exception, e: 247 | self._on_error(e) 248 | 249 | def _handle_message(self, message): 250 | for _, f in self._message_listeners: 251 | if self._call_function(f, message) is self.STOP: 252 | return 253 | 254 | if message.message_parts: 255 | 256 | command = message.message_parts[0] 257 | if command in self._commands: 258 | for _, f in self._commands[command]: 259 | if self._call_function(f, message) is self.STOP: 260 | return 261 | 262 | for _, regex, responder, f in self._regex_listeners: 263 | if responder and not message.targeting_jeev: 264 | continue 265 | 266 | match = regex.search(message.message) 267 | if match: 268 | kwargs = match.groupdict() 269 | 270 | if kwargs: 271 | args = () 272 | else: 273 | args = match.groups() 274 | 275 | if self._call_function(f, message, *args, **kwargs) is self.STOP: 276 | return 277 | 278 | def _on_error(self, e): 279 | """ 280 | Called when an error happens by something this module called. 281 | """ 282 | if isinstance(e, gevent.Greenlet): 283 | e = e.exception 284 | 285 | self.jeev.on_module_error(self, e) 286 | logger.exception("Exception raised %r", e) 287 | 288 | def _make_app(self): 289 | """ 290 | Make a flask application to be the wsgi handler of this module. 291 | If you want to use your own WSGI handler, you can simply call `module.set_wsgi_handler(handler)` before 292 | accessing `module.app`. 293 | """ 294 | return import_dotted_path('flask.Flask')(self.module_name) 295 | 296 | def _save_data(self, close=False): 297 | if self._data is not None: 298 | if close: 299 | logger.debug("Closing module data for module %s", self._name) 300 | self._data.close() 301 | self._data = None 302 | else: 303 | logger.debug("Syncing module data for module %s", self._name) 304 | self._data.sync() 305 | 306 | def _load_data(self): 307 | return self.jeev._get_module_data(self) 308 | 309 | def _clean_g(self): 310 | if self._g: 311 | self._g.__dict__.clear() 312 | self._g = None 313 | 314 | def _set_module_name(self, module_name): 315 | self._module_name = module_name 316 | 317 | @property 318 | def name(self): 319 | """ 320 | The name of the module set in the config to be loaded. 321 | """ 322 | return self._name 323 | 324 | @property 325 | def module_name(self): 326 | """ 327 | The full module name (import path) 328 | 329 | >>> print self.name 330 | sample 331 | >>> print self.module_name 332 | modules.sample 333 | """ 334 | return self._module_name 335 | 336 | @property 337 | def data(self): 338 | """ 339 | Persistent data store. Is really just a convenience accessor to a shelve. 340 | """ 341 | if self._data is None: 342 | self._data = self._load_data() 343 | 344 | return self._data 345 | 346 | @property 347 | def g(self): 348 | """ 349 | Module "globals", useful as a temporary namespace, put whatever you want here, objects, resources, 350 | descriptors. G will be cleared when the module is unloaded. If you want to persist data, use `module.data`. 351 | """ 352 | if self._g is None: 353 | self._g = G() 354 | 355 | return self._g 356 | 357 | def opt(self, *args, **kwargs): 358 | """ 359 | Registers an option validator that allows you to check for a given option to be present, and if it isn't, 360 | tell Jeev to automatically throw an exception when the module is trying to be loaded, providing the user 361 | with the required option name and a description. 362 | 363 | Optionally, you can set a casting function, to convert the option from its string form to something else, 364 | or even, a default value, if it does not exist. 365 | """ 366 | self.opts._register_opt(Opt(*args, **kwargs)) 367 | 368 | def opt_validator(self, *names): 369 | """ 370 | Registers a validator function that will be called with a given opt's value. The function must either raise 371 | a Module.ConfigError, or return a value that the opt should be set to. This lets you override (or clean) 372 | any option. 373 | """ 374 | 375 | def register_validator(callback): 376 | for name in names: 377 | self.opts._register_validator(OptValidator(name, callback)) 378 | 379 | return callback 380 | 381 | return register_validator 382 | 383 | def loaded(self, f): 384 | """ 385 | Register a function to be called when the module is loaded. 386 | """ 387 | self._loaded_callbacks.append(f) 388 | 389 | def unloaded(self, f): 390 | """ 391 | Register a function to be called before the module is unloaded. 392 | """ 393 | 394 | self._unload_callbacks.append(f) 395 | return f 396 | 397 | def command(self, command, priority=0): 398 | """ 399 | Register a command handler. 400 | """ 401 | 402 | def bind_command(f): 403 | bisect.insort(self._commands[command], (priority, f)) 404 | return f 405 | 406 | return bind_command 407 | 408 | def match(self, regex, flags=0, priority=0): 409 | """ 410 | Decorator that registers a function that will be called when Jeev sees a message that matches regex. 411 | """ 412 | regex = re.compile(regex, flags) 413 | 414 | def bind_matcher(f): 415 | bisect.insort(self._regex_listeners, (priority, regex, False, f)) 416 | return f 417 | 418 | return bind_matcher 419 | 420 | def hear(self, regex, priority=0): 421 | """ 422 | Same as match, except case insensitive matching. 423 | """ 424 | return self.match(regex, re.I, priority) 425 | 426 | def respond(self, regex, flags=re.I, priority=0): 427 | """ 428 | Decorator that registers a function that will be called when any message directed at Jeev matches the regex. 429 | """ 430 | regex = re.compile(regex, flags) 431 | 432 | def bind_matcher(f): 433 | bisect.insort(self._regex_listeners, (priority, regex, True, f)) 434 | return f 435 | 436 | return bind_matcher 437 | 438 | def listen(self, priority=0): 439 | """ 440 | Decorator that registers a function that will be called any time Jeev sees a message. 441 | """ 442 | 443 | def bind_listener(f): 444 | bisect.insort(self._message_listeners, (priority, f)) 445 | return f 446 | 447 | return bind_listener 448 | 449 | def async(self, sync_ret_val=None, timeout=0): 450 | """ 451 | Decorator that will call the wrapped function inside a greenlet, and do some book-keeping to make 452 | sure the greenlet is killed when the module is unloaded. 453 | """ 454 | 455 | def wrapper(o_fn): 456 | if timeout: 457 | f = functools.partial(gevent.with_timeout, timeout, o_fn, timeout_value=sync_ret_val) 458 | 459 | else: 460 | f = o_fn 461 | 462 | @functools.wraps(o_fn) 463 | def wrapped(*args, **kwargs): 464 | g = gevent.Greenlet(f, *args, **kwargs) 465 | g.link_exception(self._on_error) 466 | g.link(lambda v: self._running_greenlets.discard(g)) 467 | self._running_greenlets.add(g) 468 | g.start() 469 | return sync_ret_val 470 | 471 | return wrapped 472 | 473 | return wrapper 474 | 475 | def spawn(self, f, *args, **kwargs): 476 | """ 477 | Spawns a greenlet and does some book-keeping to make sure the greenlet is killed when the module is 478 | unloaded. 479 | """ 480 | g = gevent.Greenlet(f, *args, **kwargs) 481 | g.link_exception(self._on_error) 482 | g.link(lambda v: self._running_greenlets.discard(g)) 483 | self._running_greenlets.add(g) 484 | g.start() 485 | return g 486 | 487 | def spawn_after(self, delay, f, *args, **kwargs): 488 | """ 489 | Spawns a greenlet that will start after delay seconds. Otherwise, same as Module.spawn 490 | """ 491 | g = gevent.Greenlet(f, *args, **kwargs) 492 | g.link_exception(self._on_error) 493 | g.link(lambda v: self._running_greenlets.discard(g)) 494 | self._running_greenlets.add(g) 495 | g.start_later(delay) 496 | return g 497 | 498 | def periodic(self, interval, f, *args, **kwargs): 499 | """ 500 | Creates a Periodic that can be used to schedule the calling of the provided function 501 | at a regular interval. The periodic will be automatically unscheduled when the module 502 | is unloaded. 503 | """ 504 | return ModulePeriodic(self, interval, f, *args, **kwargs) 505 | 506 | @property 507 | def is_web(self): 508 | """ 509 | Returns True if the module has a web application bound to it. 510 | """ 511 | return self._app is not None 512 | 513 | @property 514 | def app(self): 515 | """ 516 | Initializes or returns the wsgi handler for this module. 517 | Will call module._make_app if the handler hasn't been created yet. 518 | """ 519 | if self._app is None: 520 | self._app = self._make_app() 521 | 522 | return self._app 523 | 524 | @app.setter 525 | def app(self, value): 526 | """ 527 | Sets the underlying wsgi handler for the module. Won't allow it to be set twice. 528 | """ 529 | if self._app is not None: 530 | raise AttributeError("Module.app has already been set.") 531 | 532 | self._app = value 533 | 534 | def set_wsgi_handler(self, handler): 535 | """ 536 | Use this to set the wsgi handler for the module (can be used as a decorator): 537 | 538 | @module.set_wsgi_handler 539 | def handle(environ, start_response): 540 | start_response('200 OK', []) 541 | return ['Hello World!'] 542 | """ 543 | self.app = handler 544 | 545 | def send_message(self, channel, message): 546 | """ 547 | Convenience function to send a message to a channel. 548 | """ 549 | self.jeev.send_message(channel, message) 550 | 551 | class ConfigError(Exception): 552 | module_instance = None 553 | 554 | def __init__(self, variable_name, error_message=None): 555 | if isinstance(variable_name, dict): 556 | self.error_dict = variable_name 557 | 558 | else: 559 | self.variable_name = variable_name 560 | self.error_message = error_message 561 | 562 | def __repr__(self): 563 | if hasattr(self, 'error_dict'): 564 | return '' % self.error_dict 565 | 566 | return '' % (self.variable_name, self.error_message) 567 | 568 | 569 | class Opt(object): 570 | """ 571 | Stores the metadata for a given option. 572 | """ 573 | __slots__ = ['name', 'description', 'cast', 'default'] 574 | 575 | def __init__(self, name, description, cast=None, default=_sentinel): 576 | self.name = name 577 | self.description = description 578 | self.cast = cast 579 | self.default = default 580 | 581 | @property 582 | def has_default(self): 583 | return self.default is not _sentinel 584 | 585 | 586 | class OptValidator(object): 587 | """ 588 | Stores a validator for a given option. 589 | """ 590 | __slots__ = ['name', 'callback'] 591 | 592 | def __init__(self, name, callback): 593 | self.name = name 594 | self.callback = callback 595 | 596 | def clean(self, opt_fallback_dict): 597 | value = opt_fallback_dict[self.name] 598 | try: 599 | new_value = self.callback(value) 600 | 601 | # If the value changed, we'll override it in the opt_fallback_dict's private _data. 602 | if new_value is not None and value != new_value: 603 | opt_fallback_dict._opt_overrides[self.name] = new_value 604 | 605 | except Module.ConfigError as e: 606 | if e.error_message is None and e.variable_name: 607 | e.error_message = e.variable_name 608 | e.variable_name = self.name 609 | 610 | raise e 611 | 612 | 613 | class OptFallbackDict(EnvFallbackDict): 614 | """ 615 | An addition to EnvFallbackDict that supports definitions, validators and overrides. 616 | """ 617 | 618 | def __init__(self, *args, **kwargs): 619 | # The Opt definitions. 620 | self._opt_definitions = {} 621 | # A list of OptValidators to call when the module is overriden. 622 | self._opt_validators = [] 623 | # A dict containing opt overrides that will be returned without being cast (set by the opt validators) 624 | self._opt_overrides = {} 625 | 626 | super(OptFallbackDict, self).__init__(*args, **kwargs) 627 | 628 | def __getitem__(self, key): 629 | # See if the key is overridden first. If it is, we take no further action, as it will be returned in it's 630 | # original, uncasted form. 631 | if key in self._opt_overrides: 632 | return self._opt_overrides[key] 633 | 634 | # Try to fetch it from _data, and the environ. 635 | try: 636 | return super(OptFallbackDict, self).__getitem__(key) 637 | 638 | except KeyError: 639 | # If it didn't exist, see if a definition has a default for the given key. 640 | if key in self._opt_definitions: 641 | opt = self._opt_definitions[key] 642 | if opt.has_default: 643 | return self.cast_val(key, opt.default) 644 | 645 | # Not anywhere, now we can raise KeyError like usual. 646 | raise KeyError(key) 647 | 648 | def __contains__(self, item): 649 | # Again, see if the item was somehow overridden. 650 | if item in self._opt_overrides: 651 | return True 652 | 653 | # Check EnvFallbackDict. 654 | if super(OptFallbackDict, self).__contains__(item): 655 | return True 656 | 657 | # See if it has a default. 658 | if item in self._opt_definitions: 659 | default = self._opt_definitions[item].default 660 | if default is not _sentinel: 661 | return True 662 | 663 | return False 664 | 665 | def cast_val(self, key, val): 666 | # See if the opt definitions specifcy a custom casting function. 667 | if key in self._opt_definitions: 668 | cast = self._opt_definitions[key].cast 669 | if cast: 670 | return cast(val) 671 | 672 | # Otherwise use the default cast to string implementation. 673 | return super(OptFallbackDict, self).cast_val(key, val) 674 | 675 | def _register_opt(self, opt): 676 | self._opt_definitions[opt.name] = opt 677 | 678 | def _unregister_opt(self, opt): 679 | if isinstance(opt, Opt): 680 | opt = opt.name 681 | 682 | self._opt_definitions.pop(opt) 683 | 684 | def _register_validator(self, opt_validator): 685 | self._opt_validators.append(opt_validator) -------------------------------------------------------------------------------- /jeev/starter_template/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | shelve -------------------------------------------------------------------------------- /jeev/starter_template/config.py: -------------------------------------------------------------------------------- 1 | adapter = 'console' 2 | adapter_opts = { 3 | 'console_channel': 'test', 4 | 'console_user': 'user', 5 | } 6 | 7 | modules = [ 8 | 'ping', 9 | 'kittens', 10 | 'sample' 11 | ] 12 | 13 | web = True 14 | web_opts = { 15 | 'listen_host': 'localhost', 16 | 'listen_port': 8080 17 | } 18 | 19 | storage = 'shelve' 20 | storage_opts = { 21 | 'shelve_data_path': './shelve' 22 | } 23 | -------------------------------------------------------------------------------- /jeev/starter_template/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhgg/jeev/d6c971f42388b5f0a4a50a6449ca778fa372fe4a/jeev/starter_template/modules/__init__.py -------------------------------------------------------------------------------- /jeev/starter_template/modules/sample.py: -------------------------------------------------------------------------------- 1 | import module 2 | 3 | @module.respond('hi') 4 | def hi(message): 5 | message.reply_to_user('Hey! Check out more about creating modules for Jeev here: ' 6 | 'https://github.com/jhgg/jeev/tree/master/docs/modules') -------------------------------------------------------------------------------- /jeev/starter_template/requirements.txt: -------------------------------------------------------------------------------- 1 | jeev -------------------------------------------------------------------------------- /jeev/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from ..utils import importing 2 | 3 | 4 | def get_store_by_name(name): 5 | if '.' not in name: 6 | name = 'jeev.storage.%s.storage' % name 7 | 8 | return importing.import_dotted_path(name) -------------------------------------------------------------------------------- /jeev/storage/ephemeral.py: -------------------------------------------------------------------------------- 1 | import UserDict 2 | 3 | 4 | class EphemeralStorage(object): 5 | def __init__(self, jeev, opts): 6 | self._jeev = jeev 7 | self._opts = opts 8 | 9 | def start(self): 10 | pass 11 | 12 | def stop(self): 13 | pass 14 | 15 | def get_data_for_module_name(self, module_name): 16 | return EphemeralDict() 17 | 18 | storage = EphemeralStorage 19 | 20 | 21 | class EphemeralDict(UserDict): 22 | def sync(self): 23 | pass 24 | 25 | def close(self): 26 | self.data.clear() 27 | self.data = None -------------------------------------------------------------------------------- /jeev/storage/redis.py: -------------------------------------------------------------------------------- 1 | import UserDict 2 | from gevent.lock import Semaphore 3 | from ..utils.importing import import_dotted_path 4 | try: 5 | from cStringIO import StringIO 6 | 7 | except ImportError: 8 | from StringIO import StringIO 9 | 10 | try: 11 | from cPickle import Unpickler, Pickler 12 | 13 | except ImportError: 14 | from pickle import Unpickler, Pickler 15 | 16 | try: 17 | StrictRedis = import_dotted_path('redis.StrictRedis') 18 | 19 | except ImportError: 20 | raise ImportError("redis-py is not installed. Install it using `pip install redis` " 21 | "(see https://github.com/andymccurdy/redis-py for more details)") 22 | 23 | 24 | class RedisStorage(object): 25 | _redis_opt_keys = 'host', 'port', 'db', 'password', 'socket_timeout', 'socket_connect_timeout', 'socket_keepalive',\ 26 | 'socket_keepalive_options', 'connection_pool', 'unix_socket_path', 'encoding', 'encoding_errors',\ 27 | 'errors', 'decode_responses', 'retry_on_timeout', 'ssl', 'ssl_keyfile', 'ssl_certfile', \ 28 | 'ssl_cert_reqs', 'ssl_ca_certs' 29 | 30 | _redis_int_opts = 'port', 'db', 'socket_timeout', 31 | _redis_float_opts = 'socket_timeout', 32 | _redis_bool_opts = 'decode_responses', 'ssl', 'retry_on_timeout' 33 | 34 | def __init__(self, jeev, opts): 35 | self._jeev = jeev 36 | self._opts = opts 37 | self._redis = None 38 | self._prefix = opts.get('redis_key_prefix', '') 39 | 40 | def _get_redis_kwargs(self): 41 | kwargs = {} 42 | for key in self._redis_opt_keys: 43 | opt_key = 'redis_%s' % key 44 | if opt_key in self._opts: 45 | kwargs[key] = self._opts[opt_key] 46 | 47 | for key in self._redis_int_opts: 48 | if key in kwargs: 49 | kwargs[key] = int(kwargs[key]) 50 | 51 | for key in self._redis_float_opts: 52 | if key in kwargs: 53 | kwargs[key] = float(kwargs[key]) 54 | 55 | return kwargs 56 | 57 | def _get_redis(self): 58 | if 'redis_url' in self._opts: 59 | return StrictRedis.from_url(self._opts['redis_url']) 60 | 61 | return StrictRedis(**self._get_redis_kwargs()) 62 | 63 | def _get_hash_key(self, module_name): 64 | return '%s%s' % (self._prefix, module_name) 65 | 66 | @property 67 | def redis(self): 68 | if self._redis is None: 69 | raise RuntimeError("Attempting to access RedisStorage.redis from a RedisStorage that has not been started.") 70 | 71 | return self._redis 72 | 73 | def start(self): 74 | if self._redis is None: 75 | self._redis = self._get_redis() 76 | 77 | def stop(self): 78 | if self._redis: 79 | self._redis.connection_pool.disconnect() 80 | self._redis = None 81 | 82 | def get_data_for_module_name(self, module_name): 83 | return RedisDict(self, self._get_hash_key(module_name)) 84 | 85 | storage = RedisStorage 86 | 87 | 88 | class RedisDict(UserDict.DictMixin): 89 | def __init__(self, storage, hash_key): 90 | self._storage = storage 91 | self._hash_key = hash_key 92 | self._protocol = 0 93 | self._cache = {} 94 | self._cache_write_lock = Semaphore() 95 | 96 | def keys(self): 97 | return self._storage.redis.hkeys(self._hash_key) 98 | 99 | def __len__(self): 100 | return self._storage.redis.hlen(self._hash_key) 101 | 102 | def has_key(self, key): 103 | return key in self 104 | 105 | def __contains__(self, key): 106 | if key in self._cache: 107 | return True 108 | 109 | return self._storage.redis.hexists(self._hash_key, key) 110 | 111 | def get(self, key, default=None): 112 | if key in self: 113 | return self[key] 114 | 115 | return default 116 | 117 | def __getitem__(self, key): 118 | try: 119 | value = self._cache[key] 120 | 121 | except KeyError: 122 | 123 | if key not in self: 124 | raise KeyError(key) 125 | 126 | f = StringIO(self._storage.redis.hget(self._hash_key, key)) 127 | value = Unpickler(f).load() 128 | self._cache[key] = value 129 | 130 | return value 131 | 132 | def __setitem__(self, key, value): 133 | with self._cache_write_lock: 134 | self._cache[key] = value 135 | 136 | f = StringIO() 137 | p = Pickler(f, self._protocol) 138 | p.dump(value) 139 | 140 | self._storage.redis.hset(self._hash_key, key, f.getvalue()) 141 | 142 | def __delitem__(self, key): 143 | self._storage.redis.hdel(self._hash_key, key) 144 | 145 | with self._cache_write_lock: 146 | self._cache.pop(key, None) 147 | 148 | def close(self): 149 | self.sync() 150 | self._storage = None 151 | 152 | def __del__(self): 153 | self.close() 154 | 155 | def sync(self): 156 | if not self._cache: 157 | return 158 | 159 | with self._cache_write_lock, self._storage.redis.pipeline() as pipeline: 160 | for key, entry in self._cache.iteritems(): 161 | f = StringIO() 162 | p = Pickler(f, self._protocol) 163 | p.dump(entry) 164 | pipeline.hset(self._hash_key, key, f.getvalue()) 165 | 166 | pipeline.execute() 167 | self._cache.clear() -------------------------------------------------------------------------------- /jeev/storage/shelve.py: -------------------------------------------------------------------------------- 1 | import UserDict 2 | from ..utils.importing import import_dotted_path 3 | import os 4 | 5 | # The solution to importing python's stdlib shelve from inside of a module named shelve :| 6 | shelve_open = import_dotted_path('shelve.open') 7 | 8 | 9 | class ShelveStore(object): 10 | def __init__(self, jeev, opts): 11 | self._jeev = jeev 12 | self._opts = opts 13 | self._module_data_path = opts['shelve_data_path'] 14 | 15 | def get_data_for_module_name(self, module_name): 16 | return UnicodeShelveWrapper(shelve_open(os.path.join(self._module_data_path, module_name), writeback=True)) 17 | 18 | def start(self): 19 | if not os.path.exists(self._module_data_path): 20 | os.makedirs(self._module_data_path) 21 | 22 | def stop(self): 23 | pass 24 | 25 | 26 | class UnicodeShelveWrapper(UserDict.DictMixin): 27 | def __init__(self, shelf): 28 | self.shelf = shelf 29 | 30 | def keys(self): 31 | return [d.encode('utf8') for d in self.shelf.keys()] 32 | 33 | def __len__(self): 34 | return len(self.shelf) 35 | 36 | def has_key(self, key): 37 | return key.encode('utf8') in self.shelf 38 | 39 | def __contains__(self, key): 40 | return key.encode('utf8') in self.shelf 41 | 42 | def get(self, key, default=None): 43 | if key.encode('utf8') in self.shelf: 44 | return self[key] 45 | 46 | return default 47 | 48 | def __getitem__(self, key): 49 | return self.shelf[key.encode('utf8')] 50 | 51 | def __setitem__(self, key, value): 52 | self.shelf[key.encode('utf8')] = value 53 | 54 | def __delitem__(self, key): 55 | del self.shelf[key.encode('utf8')] 56 | 57 | def sync(self): 58 | self.shelf.sync() 59 | 60 | def close(self): 61 | self.shelf.close() 62 | 63 | storage = ShelveStore -------------------------------------------------------------------------------- /jeev/utils/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'mac' 2 | -------------------------------------------------------------------------------- /jeev/utils/date.py: -------------------------------------------------------------------------------- 1 | 2 | _timeOrder = ( 3 | ('week', 60 * 60 * 24 * 7), 4 | ('day', 60 * 60 * 24), 5 | ('hour', 60 * 60), 6 | ('minute', 60), 7 | ('second', 1) 8 | ) 9 | 10 | 11 | def dateDiff(secs, n=True, short=False): 12 | """ 13 | Converts seconds to x hour(s) x minute(s) (and) x second(s) 14 | 15 | >>> dateDiff(5) 16 | '5 seconds' 17 | >>> dateDiff(61) 18 | '1 minute and 1 second' 19 | >>> dateDiff(3615, n = False) 20 | '1 hour 15 seconds' 21 | 22 | """ 23 | 24 | if not isinstance(secs, int): 25 | secs = int(secs) 26 | 27 | secs = abs(secs) 28 | if secs == 0: 29 | return '0 seconds' 30 | 31 | h = [] 32 | a = h.append 33 | for name, value in _timeOrder: 34 | x = secs / value 35 | if x > 0: 36 | if short: 37 | a("%i%s" % (x, name[0])) 38 | else: 39 | a('%i %s%s' % (x, name, ('s', '')[x is 1])) 40 | secs -= x * value 41 | z = len(h) 42 | if n is True and not short and z > 1: h.insert(z - 1, 'and') 43 | 44 | return ' '.join(h) 45 | -------------------------------------------------------------------------------- /jeev/utils/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class EnvFallbackDict(object): 5 | """ 6 | This dict will first search for a key inside of it, then search ENVIRON for it if the key isn't found. 7 | 8 | When constructed with a module_name of "test" 9 | Searching for key "foo" will search for "foo" inside of the dict, and if it's not there, it will then 10 | look in "JEEV_TEST_FOO" and return that value. If it's not inside the dict, or environ, it will raise 11 | KeyError like normal. 12 | 13 | This dict is read only, and will only return strings. 14 | """ 15 | __slots__ = ['_module_name', '_data'] 16 | 17 | def __init__(self, module_name, dict, **kwargs): 18 | self._module_name = module_name 19 | 20 | if self._module_name: 21 | self._module_name = self.module_name.replace('.', '_') 22 | 23 | self._data = {} 24 | 25 | if dict is not None: 26 | self._data.update(dict) 27 | 28 | if len(kwargs): 29 | self._data.update(kwargs) 30 | 31 | @property 32 | def module_name(self): 33 | return self._module_name 34 | 35 | @module_name.setter 36 | def module_name(self, value): 37 | self._module_name = value.replace('.', '_') 38 | 39 | def environ_key(self, key): 40 | if not self._module_name: 41 | return ('jeev_%s' % key).upper() 42 | 43 | return ('jeev_%s_%s' % (self._module_name, key)).upper() 44 | 45 | def __contains__(self, item): 46 | if item in self._data: 47 | return True 48 | 49 | if self.environ_key(item) in os.environ: 50 | return True 51 | 52 | return False 53 | 54 | def __repr__(self): 55 | return repr(self._data) 56 | 57 | def __cmp__(self, dict): 58 | if isinstance(dict, EnvFallbackDict): 59 | return cmp(self._data, dict._data) 60 | else: 61 | return cmp(self._data, dict) 62 | 63 | __hash__ = None # Avoid Py3k warning 64 | 65 | def __len__(self): 66 | return len(self._data) 67 | 68 | def __getitem__(self, key): 69 | if key in self._data: 70 | return self.cast_val(key, self._data[key]) 71 | 72 | env_key = self.environ_key(key) 73 | if env_key in os.environ: 74 | return self.cast_val(key, os.environ[env_key]) 75 | 76 | raise KeyError(key) 77 | 78 | def copy(self): 79 | if self.__class__ is EnvFallbackDict: 80 | return EnvFallbackDict(self.module_name, self._data.copy()) 81 | 82 | import copy 83 | 84 | data = self._data 85 | try: 86 | self._data = {} 87 | c = copy.copy(self) 88 | finally: 89 | self._data = data 90 | 91 | c.update(self) 92 | return c 93 | 94 | def keys(self): 95 | return self._data.keys() 96 | 97 | def items(self): 98 | return self._data.items() 99 | 100 | def iteritems(self): 101 | return self._data.iteritems() 102 | 103 | def iterkeys(self): 104 | return self._data.iterkeys() 105 | 106 | def itervalues(self): 107 | return self._data.itervalues() 108 | 109 | def values(self): 110 | return self._data.values() 111 | 112 | def has_key(self, key): 113 | return key in self._data 114 | 115 | def get(self, key, failobj=None): 116 | if key not in self: 117 | return failobj 118 | 119 | return self[key] 120 | 121 | def cast_val(self, key, val): 122 | if not isinstance(val, basestring): 123 | val = str(val) 124 | 125 | return val -------------------------------------------------------------------------------- /jeev/utils/g.py: -------------------------------------------------------------------------------- 1 | class G(object): 2 | """A plain object.""" 3 | 4 | def get(self, name, default=None): 5 | return self.__dict__.get(name, default) 6 | 7 | def __contains__(self, item): 8 | return item in self.__dict__ 9 | 10 | def __iter__(self): 11 | return iter(self.__dict__) -------------------------------------------------------------------------------- /jeev/utils/importing.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | import os 3 | import sys 4 | 5 | _import_dotted_path_cache = {} 6 | _get_model_cache = {} 7 | 8 | 9 | def path_for_import(name): 10 | """ 11 | Returns the directory path for the given package or module. 12 | """ 13 | return os.path.dirname(os.path.abspath(import_module(name).__file__)) 14 | 15 | 16 | def import_dotted_path(path): 17 | """ 18 | Takes a dotted path to a member name in a module, and returns 19 | the member after importing it. 20 | """ 21 | member = _import_dotted_path_cache.get(path, None) 22 | if member is not None: 23 | return member 24 | 25 | try: 26 | module_path, member_name = path.rsplit(".", 1) 27 | module = import_module(module_path) 28 | member = getattr(module, member_name) 29 | _import_dotted_path_cache[path] = member 30 | return member 31 | except (ValueError, ImportError, AttributeError) as e: 32 | raise ImportError("Could not import the name: %s: %s" % (path, e)) 33 | 34 | 35 | def _is_right_import_error(mod_name, tb): 36 | while tb is not None: 37 | g = tb.tb_frame.f_globals 38 | 39 | if '__name__' in g and g['__name__'] == mod_name: 40 | return True 41 | tb = tb.tb_next 42 | 43 | return False 44 | 45 | 46 | def import_first_matching_module(mod_name, matches, try_mod_name=True, pre_import_hook=None, post_import_hook=None): 47 | """ 48 | Tries to import a module by running multiple patterns, for example: 49 | 50 | >>> matched_name, module = import_first_matching_module('foo', ['test.%s', 'test.bar.%s']) 51 | 52 | Will try to import: 53 | * foo.py 54 | * test/foo.py 55 | * test/bar/foo.py 56 | 57 | And return the first one that exists. It will properly re-raise the ImportError that the module thew it 58 | because a dependency did not exist by inspecting the traceback. 59 | """ 60 | if mod_name in sys.modules: 61 | return sys.modules[mod_name] 62 | 63 | if try_mod_name: 64 | matches = ['%s'] + matches 65 | 66 | tries = [] 67 | for match in matches: 68 | match_name = match % mod_name 69 | tries.append(match_name) 70 | 71 | try: 72 | if pre_import_hook: 73 | pre_import_hook(match_name) 74 | 75 | __import__(match_name) 76 | return match_name, sys.modules[match_name] 77 | except ImportError: 78 | exc_info = sys.exc_info() 79 | sys.modules.pop(match_name, None) 80 | 81 | if _is_right_import_error(match_name, exc_info[2]): 82 | raise exc_info 83 | finally: 84 | 85 | if post_import_hook: 86 | post_import_hook(match_name) 87 | 88 | raise ImportError('No module named %s (tried: %s)' % (mod_name, ', '.join(tries))) -------------------------------------------------------------------------------- /jeev/utils/periodic.py: -------------------------------------------------------------------------------- 1 | from gevent import sleep, Greenlet, spawn_raw 2 | 3 | 4 | class Periodic(object): 5 | def __init__(self, interval, f, *args, **kwargs): 6 | self.interval = interval 7 | self.f = f 8 | self.args = args 9 | self.kwargs = kwargs 10 | self._greenlet = None 11 | 12 | def _run(self): 13 | while True: 14 | spawn_raw(self.f, *self.args, **self.kwargs) 15 | sleep(self.interval) 16 | 17 | def _discard_greenlet(self, val): 18 | self._greenlet = None 19 | 20 | @property 21 | def started(self): 22 | return bool(self._greenlet) 23 | 24 | def start(self, right_away=True): 25 | if self._greenlet: 26 | raise RuntimeError("Periodic already started.") 27 | 28 | self._greenlet = Greenlet(self._run) 29 | self._greenlet.link(self._discard_greenlet) 30 | 31 | if right_away: 32 | self._greenlet.start() 33 | else: 34 | self._greenlet.start_later(self.interval) 35 | 36 | def stop(self, block=True, timeout=None): 37 | if not self._greenlet: 38 | raise RuntimeError("Periodic is not started") 39 | 40 | self._greenlet.kill(block=block, timeout=timeout) 41 | self._greenlet = None 42 | 43 | def __repr__(self): 44 | return "" % (self.interval, 'running' if self.started else 'stopped', 45 | self.f, self.args, self.kwargs) 46 | 47 | 48 | class ModulePeriodic(Periodic): 49 | def __init__(self, module, *args, **kwargs): 50 | self.module = module 51 | super(ModulePeriodic, self).__init__(*args, **kwargs) 52 | 53 | def _discard_greenlet(self, val): 54 | self.module._running_greenlets.discard(self._greenlet) 55 | super(ModulePeriodic, self)._discard_greenlet(val) 56 | 57 | def start(self, right_away=True): 58 | super(ModulePeriodic, self).start(right_away) 59 | self.module._running_greenlets.add(self._greenlet) 60 | -------------------------------------------------------------------------------- /jeev/web.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from gevent.pywsgi import WSGIServer 3 | from werkzeug.exceptions import HTTPException, NotFound 4 | from werkzeug.routing import Map, Rule 5 | 6 | logger = logging.getLogger('jeev.web') 7 | 8 | 9 | class Web(object): 10 | """ 11 | Jeev's WSGI server. Routes requests to their appropriate module. See `jeev.module.Module.app` for more 12 | details. 13 | """ 14 | _url_map = Map([ 15 | Rule('//', endpoint='module', defaults={'rest': ''}), 16 | Rule('//', endpoint='module') 17 | ]) 18 | 19 | def __init__(self, jeev, opts): 20 | self._jeev = jeev 21 | self._opts = opts 22 | self._bind_addr = self._opts['listen_host'], int(self._opts['listen_port']) 23 | self._server = WSGIServer(self._bind_addr, self._wsgi_app) 24 | 25 | def _wsgi_app(self, environ, start_response): 26 | urls = self._url_map.bind_to_environ(environ) 27 | try: 28 | endpoint, args = urls.match() 29 | handler = getattr(self, '_handle_%s' % endpoint) 30 | return handler(args, environ, start_response) 31 | 32 | except HTTPException, e: 33 | return e(environ, start_response) 34 | 35 | def _handle_module(self, args, environ, start_response): 36 | module = self._jeev.modules.get_module(args['module']) 37 | 38 | if module and module.is_web: 39 | original_script_name = environ.get('SCRIPT_NAME', '') 40 | environ['SCRIPT_NAME'] = original_script_name + '/' + args['module'] 41 | environ['PATH_INFO'] = args['rest'] 42 | return module.app(environ, start_response) 43 | 44 | return NotFound()(environ, start_response) 45 | 46 | def start(self): 47 | logger.info("Starting web server on %s:%s", *self._bind_addr) 48 | self._server.start() 49 | 50 | def stop(self): 51 | logger.info("Stopping web server on %s:%s", *self._bind_addr) 52 | self._server.stop() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==14.5.14 2 | coloredlogs==1.0.1 3 | cssselect==0.9.1 4 | Flask==0.10.1 5 | geopy==1.1.3 6 | gevent==1.0.2 7 | greenlet==0.4.7 8 | humanfriendly==1.27 9 | itsdangerous==0.24 10 | Jinja2==2.7.3 11 | lxml==3.3.6 12 | MarkupSafe==0.23 13 | pytz==2014.4 14 | requests==2.7.0 15 | six==1.9.0 16 | slackclient==0.15 17 | websocket-client==0.32.0 18 | Werkzeug==0.9.6 19 | wheel==0.24.0 20 | -------------------------------------------------------------------------------- /run_dev.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import jeev 3 | 4 | if __name__ == '__main__': 5 | import coloredlogs 6 | coloredlogs.install(level=logging.DEBUG) 7 | try: 8 | import config 9 | except ImportError: 10 | config = object() 11 | 12 | jeev.run(config) 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import jeev 3 | from setuptools import setup, find_packages 4 | 5 | 6 | def read(fname): 7 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 8 | 9 | 10 | setup( 11 | name="jeev", 12 | version=jeev.version.split('-')[0] + 'b0', 13 | author="Jacob Heinz", 14 | author_email="me@jh.gg", 15 | description="A simple chat bot, at your service.", 16 | license="MIT", 17 | keywords="chat slack bot irc jeev", 18 | url="https://github.com/jhgg/jeev", 19 | packages=find_packages(exclude=['modules']), 20 | install_requires=[ 21 | 'certifi==14.5.14', 22 | 'coloredlogs==1.0.1', 23 | 'cssselect==0.9.1', 24 | 'Flask==0.10.1', 25 | 'geopy==1.1.3', 26 | 'gevent==1.0.2', 27 | 'greenlet==0.4.7', 28 | 'humanfriendly==1.27', 29 | 'itsdangerous==0.24', 30 | 'Jinja2==2.7.3', 31 | 'lxml==3.3.6', 32 | 'MarkupSafe==0.23', 33 | 'pytz==2014.4', 34 | 'requests==2.7.0', 35 | 'six==1.9.0', 36 | 'slackclient==0.15', 37 | 'websocket-client==0.32.0', 38 | 'Werkzeug==0.9.6', 39 | 'wheel==0.24.0', 40 | ], 41 | include_package_data=True, 42 | zip_safe=False, 43 | scripts=['bin/jeev'], 44 | long_description=read('README.md'), 45 | classifiers=[ 46 | "Development Status :: 4 - Beta", 47 | "Topic :: Communications :: Chat", 48 | "Topic :: Utilities", 49 | "Framework :: Flask", 50 | "License :: OSI Approved :: MIT License", 51 | "Programming Language :: Python :: 2.7", 52 | "Programming Language :: Python :: 2 :: Only", 53 | "License :: OSI Approved :: MIT License", 54 | ], 55 | ) --------------------------------------------------------------------------------