From eeb4c284fb71dd62eaf5ee7017e23b64c2c899d7 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 16 Jan 2016 19:00:15 -0800 Subject: Add plugin loader --- dotbot/__init__.py | 1 + dotbot/cli.py | 27 +++++++- dotbot/dispatcher.py | 4 +- dotbot/executor/__init__.py | 4 -- dotbot/executor/cleaner.py | 52 -------------- dotbot/executor/commandrunner.py | 53 --------------- dotbot/executor/executor.py | 24 ------- dotbot/executor/linker.py | 142 --------------------------------------- dotbot/plugin.py | 24 +++++++ dotbot/util/module.py | 29 ++++++++ plugins/clean.py | 51 ++++++++++++++ plugins/link.py | 141 ++++++++++++++++++++++++++++++++++++++ plugins/shell.py | 52 ++++++++++++++ 13 files changed, 324 insertions(+), 280 deletions(-) delete mode 100644 dotbot/executor/__init__.py delete mode 100644 dotbot/executor/cleaner.py delete mode 100644 dotbot/executor/commandrunner.py delete mode 100644 dotbot/executor/executor.py delete mode 100644 dotbot/executor/linker.py create mode 100644 dotbot/plugin.py create mode 100644 dotbot/util/module.py create mode 100644 plugins/clean.py create mode 100644 plugins/link.py create mode 100644 plugins/shell.py diff --git a/dotbot/__init__.py b/dotbot/__init__.py index 401da57..1d03464 100644 --- a/dotbot/__init__.py +++ b/dotbot/__init__.py @@ -1 +1,2 @@ from .cli import main +from .plugin import Plugin diff --git a/dotbot/cli.py b/dotbot/cli.py index dc90909..d77ab42 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -1,8 +1,11 @@ +import os, glob + from argparse import ArgumentParser from .config import ConfigReader, ReadingError from .dispatcher import Dispatcher, DispatchError from .messenger import Messenger from .messenger import Level +from .util import module def add_options(parser): parser.add_argument('-Q', '--super-quiet', dest='super_quiet', action='store_true', @@ -17,6 +20,12 @@ def add_options(parser): parser.add_argument('-c', '--config-file', nargs=1, dest='config_file', help='run commands given in CONFIGFILE', metavar='CONFIGFILE', required=True) + parser.add_argument('-p', '--plugin', action='append', dest='plugins', default=[], + help='load PLUGIN as a plugin', metavar='PLUGIN') + parser.add_argument('--disable-built-in-plugins', dest='disable_built_in_plugins', + action='store_true', help='disable built-in plugins') + parser.add_argument('--plugin-dir', action='append', dest='plugin_dirs', default=[], + metavar='PLUGIN_DIR', help='load all plugins in PLUGIN_DIR') def read_config(config_file): reader = ConfigReader(config_file) @@ -28,12 +37,24 @@ def main(): parser = ArgumentParser() add_options(parser) options = parser.parse_args() - if (options.super_quiet): + if options.super_quiet: log.set_level(Level.WARNING) - if (options.quiet): + if options.quiet: log.set_level(Level.INFO) - if (options.verbose): + if options.verbose: log.set_level(Level.DEBUG) + plugin_directories = list(options.plugin_dirs) + if not options.disable_built_in_plugins: + plugin_directories.append(os.path.join(os.path.dirname(__file__), '..', 'plugins')) + plugin_paths = [] + for directory in plugin_directories: + for plugin_path in glob.glob(os.path.join(directory, '*.py')): + plugin_paths.append(plugin_path) + for plugin_path in options.plugins: + plugin_paths.append(plugin_path) + for plugin_path in plugin_paths: + abspath = os.path.abspath(plugin_path) + module.load(abspath) tasks = read_config(options.config_file[0]) if not isinstance(tasks, list): raise ReadingError('Configuration file must be a list of tasks') diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index c3c7fe8..79231a0 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -1,5 +1,5 @@ import os -from .executor import Executor +from .plugin import Plugin from .messenger import Messenger class Dispatcher(object): @@ -37,7 +37,7 @@ class Dispatcher(object): def _load_plugins(self): self._plugins = [plugin(self._base_directory) - for plugin in Executor.__subclasses__()] + for plugin in Plugin.__subclasses__()] class DispatchError(Exception): pass diff --git a/dotbot/executor/__init__.py b/dotbot/executor/__init__.py deleted file mode 100644 index d87ca4b..0000000 --- a/dotbot/executor/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .executor import Executor -from .linker import Linker -from .cleaner import Cleaner -from .commandrunner import CommandRunner diff --git a/dotbot/executor/cleaner.py b/dotbot/executor/cleaner.py deleted file mode 100644 index 504c0de..0000000 --- a/dotbot/executor/cleaner.py +++ /dev/null @@ -1,52 +0,0 @@ -import os -from . import Executor - -class Cleaner(Executor): - ''' - Cleans broken symbolic links. - ''' - - _directive = 'clean' - - def can_handle(self, directive): - return directive == self._directive - - def handle(self, directive, data): - if directive != self._directive: - raise ValueError('Cleaner cannot handle directive %s' % directive) - return self._process_clean(data) - - def _process_clean(self, targets): - success = True - for target in targets: - success &= self._clean(target) - if success: - self._log.info('All targets have been cleaned') - else: - self._log.error('Some targets were not successfully cleaned') - return success - - def _clean(self, target): - ''' - Cleans all the broken symbolic links in target that point to - a subdirectory of the base directory. - ''' - if not os.path.isdir(os.path.expanduser(target)): - self._log.debug('Ignoring nonexistent directory %s' % target) - return True - for item in os.listdir(os.path.expanduser(target)): - path = os.path.join(os.path.expanduser(target), item) - if not os.path.exists(path) and os.path.islink(path): - if self._in_directory(path, self._base_directory): - self._log.lowinfo('Removing invalid link %s -> %s' % - (path, os.path.join(os.path.dirname(path), os.readlink(path)))) - os.remove(path) - return True - - def _in_directory(self, path, directory): - ''' - Returns true if the path is in the directory. - ''' - directory = os.path.join(os.path.realpath(directory), '') - path = os.path.realpath(path) - return os.path.commonprefix([path, directory]) == directory diff --git a/dotbot/executor/commandrunner.py b/dotbot/executor/commandrunner.py deleted file mode 100644 index 45d40f5..0000000 --- a/dotbot/executor/commandrunner.py +++ /dev/null @@ -1,53 +0,0 @@ -import os, subprocess -from . import Executor - -class CommandRunner(Executor): - ''' - Run arbitrary shell commands. - ''' - - _directive = 'shell' - - def can_handle(self, directive): - return directive == self._directive - - def handle(self, directive, data): - if directive != self._directive: - raise ValueError('CommandRunner cannot handle directive %s' % - directive) - return self._process_commands(data) - - def _process_commands(self, data): - success = True - with open(os.devnull, 'w') as devnull: - for item in data: - stdin = stdout = stderr = devnull - if isinstance(item, dict): - cmd = item['command'] - msg = item.get('description', None) - if item.get('stdin', False) is True: - stdin = None - if item.get('stdout', False) is True: - stdout = None - if item.get('stderr', False) is True: - stderr = None - elif isinstance(item, list): - cmd = item[0] - msg = item[1] if len(item) > 1 else None - else: - cmd = item - msg = None - if msg is None: - self._log.lowinfo(cmd) - else: - self._log.lowinfo('%s [%s]' % (msg, cmd)) - ret = subprocess.call(cmd, shell=True, stdin=stdin, stdout=stdout, - stderr=stderr, cwd=self._base_directory) - if ret != 0: - success = False - self._log.warning('Command [%s] failed' % cmd) - if success: - self._log.info('All commands have been executed') - else: - self._log.error('Some commands were not successfully executed') - return success diff --git a/dotbot/executor/executor.py b/dotbot/executor/executor.py deleted file mode 100644 index e6862c0..0000000 --- a/dotbot/executor/executor.py +++ /dev/null @@ -1,24 +0,0 @@ -from ..messenger import Messenger - -class Executor(object): - ''' - Abstract base class for commands that process directives. - ''' - - def __init__(self, base_directory): - self._base_directory = base_directory - self._log = Messenger() - - def can_handle(self, directive): - ''' - Returns true if the Executor can handle the directive. - ''' - raise NotImplementedError - - def handle(self, directive, data): - ''' - Executes the directive. - - Returns true if the Executor successfully handled the directive. - ''' - raise NotImplementedError diff --git a/dotbot/executor/linker.py b/dotbot/executor/linker.py deleted file mode 100644 index 5c12ea0..0000000 --- a/dotbot/executor/linker.py +++ /dev/null @@ -1,142 +0,0 @@ -import os, shutil -from . import Executor - -class Linker(Executor): - ''' - Symbolically links dotfiles. - ''' - - _directive = 'link' - - def can_handle(self, directive): - return directive == self._directive - - def handle(self, directive, data): - if directive != self._directive: - raise ValueError('Linker cannot handle directive %s' % directive) - return self._process_links(data) - - def _process_links(self, links): - success = True - for destination, source in links.items(): - source = os.path.expandvars(source) - destination = os.path.expandvars(destination) - if isinstance(source, dict): - # extended config - path = source['path'] - force = source.get('force', False) - relink = source.get('relink', False) - create = source.get('create', False) - if create: - success &= self._create(destination) - if force: - success &= self._delete(path, destination, force=True) - elif relink: - success &= self._delete(path, destination, force=False) - else: - path = source - success &= self._link(path, destination) - if success: - self._log.info('All links have been set up') - else: - self._log.error('Some links were not successfully set up') - return success - - def _is_link(self, path): - ''' - Returns true if the path is a symbolic link. - ''' - return os.path.islink(os.path.expanduser(path)) - - def _link_destination(self, path): - ''' - Returns the absolute path to the destination of the symbolic link. - ''' - path = os.path.expanduser(path) - rel_dest = os.readlink(path) - return os.path.join(os.path.dirname(path), rel_dest) - - def _exists(self, path): - ''' - Returns true if the path exists. - ''' - path = os.path.expanduser(path) - return os.path.exists(path) - - def _create(self, path): - success = True - parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir)) - if not self._exists(parent): - try: - os.makedirs(parent) - except OSError: - self._log.warning('Failed to create directory %s' % parent) - success = False - else: - self._log.lowinfo('Creating directory %s' % parent) - return success - - def _delete(self, source, path, force): - success = True - source = os.path.join(self._base_directory, source) - if ((self._is_link(path) and self._link_destination(path) != source) or - (self._exists(path) and not self._is_link(path))): - fullpath = os.path.expanduser(path) - removed = False - try: - if os.path.islink(fullpath): - os.unlink(fullpath) - removed = True - elif force: - if os.path.isdir(fullpath): - shutil.rmtree(fullpath) - removed = True - else: - os.remove(fullpath) - removed = True - except OSError: - self._log.warning('Failed to remove %s' % path) - success = False - else: - if removed: - self._log.lowinfo('Removing %s' % path) - return success - - def _link(self, source, link_name): - ''' - Links link_name to source. - - Returns true if successfully linked files. - ''' - success = False - source = os.path.join(self._base_directory, source) - if (not self._exists(link_name) and self._is_link(link_name) and - self._link_destination(link_name) != source): - self._log.warning('Invalid link %s -> %s' % - (link_name, self._link_destination(link_name))) - elif not self._exists(link_name) and self._exists(source): - try: - os.symlink(source, os.path.expanduser(link_name)) - except OSError: - self._log.warning('Linking failed %s -> %s' % (link_name, source)) - else: - self._log.lowinfo('Creating link %s -> %s' % (link_name, source)) - success = True - elif self._exists(link_name) and not self._is_link(link_name): - self._log.warning( - '%s already exists but is a regular file or directory' % - link_name) - elif self._is_link(link_name) and self._link_destination(link_name) != source: - self._log.warning('Incorrect link %s -> %s' % - (link_name, self._link_destination(link_name))) - elif not self._exists(source): - if self._is_link(link_name): - self._log.warning('Nonexistent target %s -> %s' % - (link_name, source)) - else: - self._log.warning('Nonexistent target for %s : %s' % - (link_name, source)) - else: - self._log.lowinfo('Link exists %s -> %s' % (link_name, source)) - success = True - return success diff --git a/dotbot/plugin.py b/dotbot/plugin.py new file mode 100644 index 0000000..a79639e --- /dev/null +++ b/dotbot/plugin.py @@ -0,0 +1,24 @@ +from .messenger import Messenger + +class Plugin(object): + ''' + Abstract base class for commands that process directives. + ''' + + def __init__(self, base_directory): + self._base_directory = base_directory + self._log = Messenger() + + def can_handle(self, directive): + ''' + Returns true if the Plugin can handle the directive. + ''' + raise NotImplementedError + + def handle(self, directive, data): + ''' + Executes the directive. + + Returns true if the Plugin successfully handled the directive. + ''' + raise NotImplementedError diff --git a/dotbot/util/module.py b/dotbot/util/module.py new file mode 100644 index 0000000..af6b0ed --- /dev/null +++ b/dotbot/util/module.py @@ -0,0 +1,29 @@ +import sys, os.path + +# We keep references to loaded modules so they don't get garbage collected. +loaded_modules = [] + +def load(path): + basename = os.path.basename(path) + module_name, extension = os.path.splitext(basename) + plugin = load_module(module_name, path) + loaded_modules.append(plugin) + +if sys.version_info >= (3, 5): + import importlib.util + + def load_module(module_name, path): + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module +elif sys.version_info >= (3, 3): + from importlib.machinery import SourceFileLoader + + def load_module(module_name, path): + return SourceFileLoader(module_name, path).load_module() +else: + import imp + + def load_module(module_name, path): + return imp.load_source(module_name, path) diff --git a/plugins/clean.py b/plugins/clean.py new file mode 100644 index 0000000..22ec450 --- /dev/null +++ b/plugins/clean.py @@ -0,0 +1,51 @@ +import os, dotbot + +class Clean(dotbot.Plugin): + ''' + Cleans broken symbolic links. + ''' + + _directive = 'clean' + + def can_handle(self, directive): + return directive == self._directive + + def handle(self, directive, data): + if directive != self._directive: + raise ValueError('Clean cannot handle directive %s' % directive) + return self._process_clean(data) + + def _process_clean(self, targets): + success = True + for target in targets: + success &= self._clean(target) + if success: + self._log.info('All targets have been cleaned') + else: + self._log.error('Some targets were not successfully cleaned') + return success + + def _clean(self, target): + ''' + Cleans all the broken symbolic links in target that point to + a subdirectory of the base directory. + ''' + if not os.path.isdir(os.path.expanduser(target)): + self._log.debug('Ignoring nonexistent directory %s' % target) + return True + for item in os.listdir(os.path.expanduser(target)): + path = os.path.join(os.path.expanduser(target), item) + if not os.path.exists(path) and os.path.islink(path): + if self._in_directory(path, self._base_directory): + self._log.lowinfo('Removing invalid link %s -> %s' % + (path, os.path.join(os.path.dirname(path), os.readlink(path)))) + os.remove(path) + return True + + def _in_directory(self, path, directory): + ''' + Returns true if the path is in the directory. + ''' + directory = os.path.join(os.path.realpath(directory), '') + path = os.path.realpath(path) + return os.path.commonprefix([path, directory]) == directory diff --git a/plugins/link.py b/plugins/link.py new file mode 100644 index 0000000..429158d --- /dev/null +++ b/plugins/link.py @@ -0,0 +1,141 @@ +import os, shutil, dotbot + +class Link(dotbot.Plugin): + ''' + Symbolically links dotfiles. + ''' + + _directive = 'link' + + def can_handle(self, directive): + return directive == self._directive + + def handle(self, directive, data): + if directive != self._directive: + raise ValueError('Link cannot handle directive %s' % directive) + return self._process_links(data) + + def _process_links(self, links): + success = True + for destination, source in links.items(): + source = os.path.expandvars(source) + destination = os.path.expandvars(destination) + if isinstance(source, dict): + # extended config + path = source['path'] + force = source.get('force', False) + relink = source.get('relink', False) + create = source.get('create', False) + if create: + success &= self._create(destination) + if force: + success &= self._delete(path, destination, force=True) + elif relink: + success &= self._delete(path, destination, force=False) + else: + path = source + success &= self._link(path, destination) + if success: + self._log.info('All links have been set up') + else: + self._log.error('Some links were not successfully set up') + return success + + def _is_link(self, path): + ''' + Returns true if the path is a symbolic link. + ''' + return os.path.islink(os.path.expanduser(path)) + + def _link_destination(self, path): + ''' + Returns the absolute path to the destination of the symbolic link. + ''' + path = os.path.expanduser(path) + rel_dest = os.readlink(path) + return os.path.join(os.path.dirname(path), rel_dest) + + def _exists(self, path): + ''' + Returns true if the path exists. + ''' + path = os.path.expanduser(path) + return os.path.exists(path) + + def _create(self, path): + success = True + parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir)) + if not self._exists(parent): + try: + os.makedirs(parent) + except OSError: + self._log.warning('Failed to create directory %s' % parent) + success = False + else: + self._log.lowinfo('Creating directory %s' % parent) + return success + + def _delete(self, source, path, force): + success = True + source = os.path.join(self._base_directory, source) + if ((self._is_link(path) and self._link_destination(path) != source) or + (self._exists(path) and not self._is_link(path))): + fullpath = os.path.expanduser(path) + removed = False + try: + if os.path.islink(fullpath): + os.unlink(fullpath) + removed = True + elif force: + if os.path.isdir(fullpath): + shutil.rmtree(fullpath) + removed = True + else: + os.remove(fullpath) + removed = True + except OSError: + self._log.warning('Failed to remove %s' % path) + success = False + else: + if removed: + self._log.lowinfo('Removing %s' % path) + return success + + def _link(self, source, link_name): + ''' + Links link_name to source. + + Returns true if successfully linked files. + ''' + success = False + source = os.path.join(self._base_directory, source) + if (not self._exists(link_name) and self._is_link(link_name) and + self._link_destination(link_name) != source): + self._log.warning('Invalid link %s -> %s' % + (link_name, self._link_destination(link_name))) + elif not self._exists(link_name) and self._exists(source): + try: + os.symlink(source, os.path.expanduser(link_name)) + except OSError: + self._log.warning('Linking failed %s -> %s' % (link_name, source)) + else: + self._log.lowinfo('Creating link %s -> %s' % (link_name, source)) + success = True + elif self._exists(link_name) and not self._is_link(link_name): + self._log.warning( + '%s already exists but is a regular file or directory' % + link_name) + elif self._is_link(link_name) and self._link_destination(link_name) != source: + self._log.warning('Incorrect link %s -> %s' % + (link_name, self._link_destination(link_name))) + elif not self._exists(source): + if self._is_link(link_name): + self._log.warning('Nonexistent target %s -> %s' % + (link_name, source)) + else: + self._log.warning('Nonexistent target for %s : %s' % + (link_name, source)) + else: + self._log.lowinfo('Link exists %s -> %s' % (link_name, source)) + success = True + return success diff --git a/plugins/shell.py b/plugins/shell.py new file mode 100644 index 0000000..a2d9c1a --- /dev/null +++ b/plugins/shell.py @@ -0,0 +1,52 @@ +import os, subprocess, dotbot + +class Shell(dotbot.Plugin): + ''' + Run arbitrary shell commands. + ''' + + _directive = 'shell' + + def can_handle(self, directive): + return directive == self._directive + + def handle(self, directive, data): + if directive != self._directive: + raise ValueError('Shell cannot handle directive %s' % + directive) + return self._process_commands(data) + + def _process_commands(self, data): + success = True + with open(os.devnull, 'w') as devnull: + for item in data: + stdin = stdout = stderr = devnull + if isinstance(item, dict): + cmd = item['command'] + msg = item.get('description', None) + if item.get('stdin', False) is True: + stdin = None + if item.get('stdout', False) is True: + stdout = None + if item.get('stderr', False) is True: + stderr = None + elif isinstance(item, list): + cmd = item[0] + msg = item[1] if len(item) > 1 else None + else: + cmd = item + msg = None + if msg is None: + self._log.lowinfo(cmd) + else: + self._log.lowinfo('%s [%s]' % (msg, cmd)) + ret = subprocess.call(cmd, shell=True, stdin=stdin, stdout=stdout, + stderr=stderr, cwd=self._base_directory) + if ret != 0: + success = False + self._log.warning('Command [%s] failed' % cmd) + if success: + self._log.info('All commands have been executed') + else: + self._log.error('Some commands were not successfully executed') + return success -- cgit v1.2.3