#!/usr/bin/env python2 # Event Manager for Uzbl # Copyright (c) 2009-2010, Mason Larobina # Copyright (c) 2009, Dieter Plaetinck # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . ''' E V E N T _ M A N A G E R . P Y =============================== Event manager for uzbl written in python. ''' import atexit import imp import logging import os import sys import time import weakref import re import errno from collections import defaultdict from functools import partial from glob import glob from itertools import count from optparse import OptionParser from select import select from signal import signal, SIGTERM, SIGINT, SIGKILL from socket import socket, AF_UNIX, SOCK_STREAM, error as socket_error from traceback import format_exc def xdghome(key, default): '''Attempts to use the environ XDG_*_HOME paths if they exist otherwise use $HOME and the default path.''' xdgkey = "XDG_%s_HOME" % key if xdgkey in os.environ.keys() and os.environ[xdgkey]: return os.environ[xdgkey] return os.path.join(os.environ['HOME'], default) # `make install` will put the correct value here for your system PREFIX = '/usr/local/' # Setup xdg paths. DATA_DIR = os.path.join(xdghome('DATA', '.local/share/'), 'uzbl/') CACHE_DIR = os.path.join(xdghome('CACHE', '.cache/'), 'uzbl/') # Define some globals. SCRIPTNAME = os.path.basename(sys.argv[0]) logger = logging.getLogger(SCRIPTNAME) def get_exc(): '''Format `format_exc` for logging.''' return "\n%s" % format_exc().rstrip() def expandpath(path): '''Expand and realpath paths.''' return os.path.realpath(os.path.expandvars(path)) def ascii(u): '''Convert unicode strings into ascii for transmission over ascii-only streams/sockets/devices.''' return u.encode('utf-8') def daemonize(): '''Daemonize the process using the Stevens' double-fork magic.''' logger.info('entering daemon mode') try: if os.fork(): os._exit(0) except OSError: logger.critical('failed to daemonize', exc_info=True) sys.exit(1) os.chdir('/') os.setsid() os.umask(0) try: if os.fork(): os._exit(0) except OSError: logger.critical('failed to daemonize', exc_info=True) sys.exit(1) if sys.stdout.isatty(): sys.stdout.flush() sys.stderr.flush() devnull = '/dev/null' stdin = file(devnull, 'r') stdout = file(devnull, 'a+') stderr = file(devnull, 'a+', 0) os.dup2(stdin.fileno(), sys.stdin.fileno()) os.dup2(stdout.fileno(), sys.stdout.fileno()) os.dup2(stderr.fileno(), sys.stderr.fileno()) logger.info('entered daemon mode') def make_dirs(path): '''Make all basedirs recursively as required.''' try: dirname = os.path.dirname(path) if not os.path.isdir(dirname): logger.debug('creating directories %r', dirname) os.makedirs(dirname) except OSError: logger.error('failed to create directories', exc_info=True) class EventHandler(object): '''Event handler class. Used to store args and kwargs which are merged come time to call the callback with the event args and kwargs.''' nextid = count().next def __init__(self, plugin, event, callback, args, kwargs): self.id = self.nextid() self.plugin = plugin self.event = event self.callback = callback self.args = args self.kwargs = kwargs def __repr__(self): elems = ['id=%d' % self.id, 'event=%s' % self.event, 'callback=%r' % self.callback] if self.args: elems.append('args=%s' % repr(self.args)) if self.kwargs: elems.append('kwargs=%s' % repr(self.kwargs)) elems.append('plugin=%s' % self.plugin.name) return u'' % ', '.join(elems) def call(self, uzbl, *args, **kwargs): '''Execute the handler function and merge argument lists.''' args = args + self.args kwargs = dict(self.kwargs.items() + kwargs.items()) self.callback(uzbl, *args, **kwargs) class Plugin(object): '''Plugin module wrapper object.''' # Special functions exported from the Plugin instance to the # plugin namespace. special_functions = ['require', 'export', 'export_dict', 'connect', 'connect_dict', 'logger', 'unquote', 'splitquoted'] def __init__(self, parent, name, path, plugin): self.parent = parent self.name = name self.path = path self.plugin = plugin self.logger = logging.getLogger('plugin.%s' % name) # Weakrefs to all handlers created by this plugin self.handlers = set([]) # Plugins init hook init = getattr(plugin, 'init', None) self.init = init if callable(init) else None # Plugins optional after hook after = getattr(plugin, 'after', None) self.after = after if callable(after) else None # Plugins optional cleanup hook cleanup = getattr(plugin, 'cleanup', None) self.cleanup = cleanup if callable(cleanup) else None assert init or after or cleanup, "missing hooks in plugin" # Export plugin's instance methods to plugin namespace for attr in self.special_functions: plugin.__dict__[attr] = getattr(self, attr) def __repr__(self): return u'' % self.plugin def export(self, uzbl, attr, obj, prepend=True): '''Attach `obj` to `uzbl` instance. This is the preferred method of sharing functionality, functions, data and objects between plugins. If the object is callable you may wish to turn the callable object in to a meta-instance-method by prepending `uzbl` to the call stack. You can change this behaviour with the `prepend` argument. ''' assert attr not in uzbl.exports, "attr %r already exported by %r" %\ (attr, uzbl.exports[attr][0]) prepend = True if prepend and callable(obj) else False uzbl.__dict__[attr] = partial(obj, uzbl) if prepend else obj uzbl.exports[attr] = (self, obj, prepend) uzbl.logger.info('exported %r to %r by plugin %r, prepended %r', obj, 'uzbl.%s' % attr, self.name, prepend) def export_dict(self, uzbl, exports): for (attr, object) in exports.items(): self.export(uzbl, attr, object) def find_handler(self, event, callback, args, kwargs): '''Check if a handler with the identical callback and arguments exists and return it.''' # Remove dead refs self.handlers -= set(filter(lambda ref: not ref(), self.handlers)) # Find existing identical handler for handler in [ref() for ref in self.handlers]: if handler.event == event and handler.callback == callback \ and handler.args == args and handler.kwargs == kwargs: return handler def connect(self, uzbl, event, callback, *args, **kwargs): '''Create an event handler object which handles `event` events. Arguments passed to the connect function (`args` and `kwargs`) are stored in the handler object and merged with the event arguments come handler execution. All handler functions must behave like a `uzbl` instance-method (that means `uzbl` is prepended to the callback call arguments).''' # Sanitise and check event name event = event.upper().strip() assert event and ' ' not in event assert callable(callback), 'callback must be callable' # Check if an identical handler already exists handler = self.find_handler(event, callback, args, kwargs) if not handler: # Create a new handler handler = EventHandler(self, event, callback, args, kwargs) self.handlers.add(weakref.ref(handler)) self.logger.info('new %r', handler) uzbl.handlers[event].append(handler) uzbl.logger.info('connected %r', handler) return handler def connect_dict(self, uzbl, connects): for (event, callback) in connects.items(): self.connect(uzbl, event, callback) def require(self, plugin): '''Check that plugin with name `plugin` has been loaded. Use this to ensure that your plugins dependencies have been met.''' assert plugin in self.parent.plugins, self.logger.critical( 'plugin %r required by plugin %r', plugin, self.name) @classmethod def unquote(cls, s): '''Removes quotation marks around strings if any and interprets \\-escape sequences using `string_escape`''' if s and s[0] == s[-1] and s[0] in ['"', "'"]: s = s[1:-1] return s.encode('utf-8').decode('string_escape').decode('utf-8') _splitquoted = re.compile("( |\"(?:\\\\.|[^\"])*?\"|'(?:\\\\.|[^'])*?')") @classmethod def splitquoted(cls, text): '''Splits string on whitespace while respecting quotations''' parts = cls._splitquoted.split(text) return [cls.unquote(p) for p in parts if p.strip()] class Uzbl(object): def __init__(self, parent, child_socket): self.opts = opts self.parent = parent self.child_socket = child_socket self.child_buffer = [] self.time = time.time() self.pid = None self.name = None # Flag if the instance has raised the INSTANCE_START event. self.instance_start = False # Use name "unknown" until name is discovered. self.logger = logging.getLogger('uzbl-instance[]') # Track plugin event handlers and exported functions. self.exports = {} self.handlers = defaultdict(list) # Internal vars self._depth = 0 self._buffer = '' def __repr__(self): return '' % ', '.join([ 'pid=%s' % (self.pid if self.pid else "Unknown"), 'name=%s' % ('%r' % self.name if self.name else "Unknown"), 'uptime=%f' % (time.time() - self.time), '%d exports' % len(self.exports.keys()), '%d handlers' % sum([len(l) for l in self.handlers.values()])]) def init_plugins(self): '''Call the init and after hooks in all loaded plugins for this instance.''' # Initialise each plugin with the current uzbl instance. for plugin in self.parent.plugins.values(): if plugin.init: self.logger.debug('calling %r plugin init hook', plugin.name) plugin.init(self) # Allow plugins to use exported features of other plugins by calling an # optional `after` function in the plugins namespace. for plugin in self.parent.plugins.values(): if plugin.after: self.logger.debug('calling %r plugin after hook', plugin.name) plugin.after(self) def send(self, msg): '''Send a command to the uzbl instance via the child socket instance.''' msg = msg.strip() assert self.child_socket, "socket inactive" if opts.print_events: print ascii(u'%s<-- %s' % (' ' * self._depth, msg)) self.child_buffer.append(ascii("%s\n" % msg)) def do_send(self): data = ''.join(self.child_buffer) try: bsent = self.child_socket.send(data) except socket_error as e: if e.errno in (errno.EAGAIN, errno.EINTR): self.child_buffer = [data] return else: self.logger.error('failed to send', exc_info=True) return self.close() else: if bsent == 0: self.logger.debug('write end of connection closed') self.close() elif bsent < len(data): self.child_buffer = [data[bsent:]] else: del self.child_buffer[:] def read(self): '''Read data from the child socket and pass lines to the parse_msg function.''' try: raw = unicode(self.child_socket.recv(8192), 'utf-8', 'ignore') if not raw: self.logger.debug('read null byte') return self.close() except: self.logger.error('failed to read', exc_info=True) return self.close() lines = (self._buffer + raw).split('\n') self._buffer = lines.pop() for line in filter(None, map(unicode.strip, lines)): try: self.parse_msg(line.strip()) except: self.logger.error(get_exc()) self.logger.error('erroneous event: %r' % line) def parse_msg(self, line): '''Parse an incoming message from a uzbl instance. Event strings will be parsed into `self.event(event, args)`.''' # Split by spaces (and fill missing with nulls) elems = (line.split(' ', 3) + [''] * 3)[:4] # Ignore non-event messages. if elems[0] != 'EVENT': logger.info('non-event message: %r', line) if opts.print_events: print '--- %s' % ascii(line) return # Check event string elements (name, event, args) = elems[1:] assert name and event, 'event string missing elements' if not self.name: self.name = name self.logger = logging.getLogger('uzbl-instance%s' % name) self.logger.info('found instance name %r', name) assert self.name == name, 'instance name mismatch' # Handle the event with the event handlers through the event method self.event(event, args) def event(self, event, *args, **kargs): '''Raise an event.''' event = event.upper() if not opts.daemon_mode and opts.print_events: elems = [event] if args: elems.append(unicode(args)) if kargs: elems.append(unicode(kargs)) print ascii(u'%s--> %s' % (' ' * self._depth, ' '.join(elems))) if event == "INSTANCE_START" and args: assert not self.instance_start, 'instance already started' self.pid = int(args[0]) self.logger.info('found instance pid %r', self.pid) self.init_plugins() elif event == "INSTANCE_EXIT": self.logger.info('uzbl instance exit') self.close() if event not in self.handlers: return for handler in self.handlers[event]: self._depth += 1 try: handler.call(self, *args, **kargs) except: self.logger.error('error in handler', exc_info=True) self._depth -= 1 def close_connection(self, child_socket): '''Close child socket and delete the uzbl instance created for that child socket connection.''' def close(self): '''Close the client socket and call the plugin cleanup hooks.''' self.logger.debug('called close method') # Remove self from parent uzbls dict. if self.child_socket in self.parent.uzbls: self.logger.debug('removing self from uzbls list') del self.parent.uzbls[self.child_socket] try: if self.child_socket: self.logger.debug('closing child socket') self.child_socket.close() except: self.logger.error('failed to close socket', exc_info=True) finally: self.child_socket = None # Call plugins cleanup hooks. for plugin in self.parent.plugins.values(): if plugin.cleanup: self.logger.debug('calling %r plugin cleanup hook', plugin.name) plugin.cleanup(self) logger.info('removed %r', self) class UzblEventDaemon(object): def __init__(self): self.opts = opts self.server_socket = None self._quit = False # Hold uzbl instances # {child socket: Uzbl instance, ..} self.uzbls = {} # Hold plugins # {plugin name: Plugin instance, ..} self.plugins = {} # Register that the event daemon server has started by creating the # pid file. make_pid_file(opts.pid_file) # Register a function to clean up the socket and pid file on exit. atexit.register(self.quit) # Add signal handlers. for sigint in [SIGTERM, SIGINT]: signal(sigint, self.quit) # Load plugins into self.plugins self.load_plugins(opts.plugins) def load_plugins(self, plugins): '''Load event manager plugins.''' for path in plugins: logger.debug('loading plugin %r', path) (dir, file) = os.path.split(path) name = file[:-3] if file.lower().endswith('.py') else file info = imp.find_module(name, [dir]) module = imp.load_module(name, *info) # Check if the plugin has a callable hook. hooks = filter(callable, [getattr(module, attr, None) \ for attr in ['init', 'after', 'cleanup']]) assert hooks, "no hooks in plugin %r" % module logger.debug('creating plugin instance for %r plugin', name) plugin = Plugin(self, name, path, module) self.plugins[name] = plugin logger.info('new %r', plugin) def create_server_socket(self): '''Create the event manager daemon socket for uzbl instance duplex communication.''' # Close old socket. self.close_server_socket() sock = socket(AF_UNIX, SOCK_STREAM) sock.bind(opts.server_socket) sock.listen(5) self.server_socket = sock logger.debug('bound server socket to %r', opts.server_socket) def run(self): '''Main event daemon loop.''' logger.debug('entering main loop') # Create and listen on the server socket self.create_server_socket() if opts.daemon_mode: # Daemonize the process daemonize() # Update the pid file make_pid_file(opts.pid_file) try: # Accept incoming connections and listen for incoming data self.listen() except: if not self._quit: logger.critical('failed to listen', exc_info=True) # Clean up and exit self.quit() logger.debug('exiting main loop') def listen(self): '''Accept incoming connections and constantly poll instance sockets for incoming data.''' logger.info('listening on %r', opts.server_socket) # Count accepted connections connections = 0 while (self.uzbls or not connections) or (not opts.auto_close): socks = [self.server_socket] + self.uzbls.keys() wsocks = [k for k, v in self.uzbls.items() if v.child_buffer] reads, writes, errors = select(socks, wsocks, socks, 1) if self.server_socket in reads: reads.remove(self.server_socket) # Accept connection and create uzbl instance. child_socket = self.server_socket.accept()[0] child_socket.setblocking(False) self.uzbls[child_socket] = Uzbl(self, child_socket) connections += 1 for uzbl in [self.uzbls[s] for s in writes if s in self.uzbls ]: uzbl.do_send() for uzbl in [self.uzbls[s] for s in reads if s in self.uzbls]: uzbl.read() for uzbl in [self.uzbls[s] for s in errors if s in self.uzbls]: uzbl.logger.error('socket read error') uzbl.close() logger.info('auto closing') def close_server_socket(self): '''Close and delete the server socket.''' try: if self.server_socket: logger.debug('closing server socket') self.server_socket.close() self.server_socket = None if os.path.exists(opts.server_socket): logger.info('unlinking %r', opts.server_socket) os.unlink(opts.server_socket) except: logger.error('failed to close server socket', exc_info=True) def quit(self, sigint=None, *args): '''Close all instance socket objects, server socket and delete the pid file.''' if sigint == SIGTERM: logger.critical('caught SIGTERM, exiting') elif sigint == SIGINT: logger.critical('caught SIGINT, exiting') elif not self._quit: logger.debug('shutting down event manager') self.close_server_socket() for uzbl in self.uzbls.values(): uzbl.close() del_pid_file(opts.pid_file) if not self._quit: logger.info('event manager shut down') self._quit = True def make_pid_file(pid_file): '''Creates a pid file at `pid_file`, fails silently.''' try: logger.debug('creating pid file %r', pid_file) make_dirs(pid_file) pid = os.getpid() fileobj = open(pid_file, 'w') fileobj.write('%d' % pid) fileobj.close() logger.info('created pid file %r with pid %d', pid_file, pid) except: logger.error('failed to create pid file', exc_info=True) def del_pid_file(pid_file): '''Deletes a pid file at `pid_file`, fails silently.''' if os.path.isfile(pid_file): try: logger.debug('deleting pid file %r', pid_file) os.remove(pid_file) logger.info('deleted pid file %r', pid_file) except: logger.error('failed to delete pid file', exc_info=True) def get_pid(pid_file): '''Reads a pid from pid file `pid_file`, fails None.''' try: logger.debug('reading pid file %r', pid_file) fileobj = open(pid_file, 'r') pid = int(fileobj.read()) fileobj.close() logger.info('read pid %d from pid file %r', pid, pid_file) return pid except (IOError, ValueError): logger.error('failed to read pid', exc_info=True) return None def pid_running(pid): '''Checks if a process with a pid `pid` is running.''' try: os.kill(pid, 0) except OSError: return False else: return True def term_process(pid): '''Asks nicely then forces process with pid `pid` to exit.''' try: logger.info('sending SIGTERM to process with pid %r', pid) os.kill(pid, SIGTERM) except OSError: logger.error(get_exc()) logger.debug('waiting for process with pid %r to exit', pid) start = time.time() while True: if not pid_running(pid): logger.debug('process with pid %d exit', pid) return True if (time.time() - start) > 5: logger.warning('process with pid %d failed to exit', pid) logger.info('sending SIGKILL to process with pid %d', pid) try: os.kill(pid, SIGKILL) except: logger.critical('failed to kill %d', pid, exc_info=True) raise if (time.time() - start) > 10: logger.critical('unable to kill process with pid %d', pid) raise OSError time.sleep(0.25) def stop_action(): '''Stop the event manager daemon.''' pid_file = opts.pid_file if not os.path.isfile(pid_file): logger.error('could not find running event manager with pid file %r', pid_file) return pid = get_pid(pid_file) if not pid_running(pid): logger.debug('no process with pid %r', pid) del_pid_file(pid_file) return logger.debug('terminating process with pid %r', pid) term_process(pid) del_pid_file(pid_file) logger.info('stopped event manager process with pid %d', pid) def start_action(): '''Start the event manager daemon.''' pid_file = opts.pid_file if os.path.isfile(pid_file): pid = get_pid(pid_file) if pid_running(pid): logger.error('event manager already started with pid %d', pid) return logger.info('no process with pid %d', pid) del_pid_file(pid_file) UzblEventDaemon().run() def restart_action(): '''Restart the event manager daemon.''' stop_action() start_action() def list_action(): '''List all the plugins that would be loaded in the current search dirs.''' names = {} for plugin in opts.plugins: (head, tail) = os.path.split(plugin) if tail not in names: names[tail] = plugin for plugin in sorted(names.values()): print plugin def make_parser(): parser = OptionParser('usage: %prog [options] {start|stop|restart|list}') add = parser.add_option add('-v', '--verbose', dest='verbose', default=2, action='count', help='increase verbosity') add('-d', '--plugin-dir', dest='plugin_dirs', action='append', metavar="DIR", default=[], help='add extra plugin search dir, same as `-l "DIR/*.py"`') add('-l', '--load-plugin', dest='load_plugins', action='append', metavar="PLUGIN", default=[], help='load plugin, loads before plugins in search dirs') socket_location = os.path.join(CACHE_DIR, 'event_daemon') add('-s', '--server-socket', dest='server_socket', metavar="SOCKET", default=socket_location, help='server AF_UNIX socket location') add('-p', '--pid-file', metavar="FILE", dest='pid_file', help='pid file location, defaults to server socket + .pid') add('-n', '--no-daemon', dest='daemon_mode', action='store_false', default=True, help='do not daemonize the process') add('-a', '--auto-close', dest='auto_close', action='store_true', default=False, help='auto close after all instances disconnect') add('-i', '--no-default-dirs', dest='default_dirs', action='store_false', default=True, help='ignore the default plugin search dirs') add('-o', '--log-file', dest='log_file', metavar='FILE', help='write logging output to a file, defaults to server socket +' ' .log') add('-q', '--quiet-events', dest='print_events', action="store_false", default=True, help="silence the printing of events to stdout") return parser def init_logger(): log_level = logging.CRITICAL - opts.verbose * 10 logger = logging.getLogger() logger.setLevel(max(log_level, 10)) # Console handler = logging.StreamHandler() handler.setLevel(max(log_level + 10, 10)) handler.setFormatter(logging.Formatter( '%(name)s: %(levelname)s: %(message)s')) logger.addHandler(handler) # Logfile handler = logging.FileHandler(opts.log_file, 'w', 'utf-8', 1) handler.setLevel(max(log_level, 10)) handler.setFormatter(logging.Formatter( '[%(created)f] %(name)s: %(levelname)s: %(message)s')) logger.addHandler(handler) def main(): global opts parser = make_parser() (opts, args) = parser.parse_args() opts.server_socket = expandpath(opts.server_socket) # Set default pid file location if not opts.pid_file: opts.pid_file = "%s.pid" % opts.server_socket else: opts.pid_file = expandpath(opts.pid_file) # Set default log file location if not opts.log_file: opts.log_file = "%s.log" % opts.server_socket else: opts.log_file = expandpath(opts.log_file) # Logging setup init_logger() logger.info('logging to %r', opts.log_file) plugins = {} # Load all `opts.load_plugins` into the plugins list for path in opts.load_plugins: path = expandpath(path) matches = glob(path) if not matches: parser.error('cannot find plugin(s): %r' % path) for plugin in matches: (head, tail) = os.path.split(plugin) if tail not in plugins: logger.debug('found plugin: %r', plugin) plugins[tail] = plugin else: logger.debug('ignoring plugin: %r', plugin) # Add default plugin locations if opts.default_dirs: logger.debug('adding default plugin dirs to plugin dirs list') opts.plugin_dirs += [os.path.join(DATA_DIR, 'plugins/'), os.path.join(PREFIX, 'share/uzbl/examples/data/plugins/')] else: logger.debug('ignoring default plugin dirs') # Load all plugins in `opts.plugin_dirs` into the plugins list for dir in opts.plugin_dirs: dir = expandpath(dir) logger.debug('searching plugin dir: %r', dir) for plugin in glob(os.path.join(dir, '*.py')): (head, tail) = os.path.split(plugin) if tail not in plugins: logger.debug('found plugin: %r', plugin) plugins[tail] = plugin else: logger.debug('ignoring plugin: %r', plugin) plugins = plugins.values() # Check all the paths in the plugins list are files for plugin in plugins: if not os.path.isfile(plugin): parser.error('plugin not a file: %r' % plugin) if opts.auto_close: logger.debug('will auto close') else: logger.debug('will not auto close') if opts.daemon_mode: logger.debug('will daemonize') else: logger.debug('will not daemonize') opts.plugins = plugins # init like {start|stop|..} daemon actions daemon_actions = {'start': start_action, 'stop': stop_action, 'restart': restart_action, 'list': list_action} if len(args) == 1: action = args[0] if action not in daemon_actions: parser.error('invalid action: %r' % action) elif not args: action = 'start' logger.warning('no daemon action given, assuming %r', action) else: parser.error('invalid action argument: %r' % args) logger.info('daemon action %r', action) # Do action daemon_actions[action]() logger.debug('process CPU time: %f', time.clock()) if __name__ == "__main__": main() # vi: set et ts=4: