summaryrefslogtreecommitdiff
path: root/dotbot
diff options
context:
space:
mode:
Diffstat (limited to 'dotbot')
-rw-r--r--dotbot/__init__.py2
-rw-r--r--dotbot/cli.py46
-rw-r--r--dotbot/dispatcher.py3
-rw-r--r--dotbot/messenger/messenger.py11
-rw-r--r--dotbot/plugins/__init__.py3
-rw-r--r--dotbot/plugins/clean.py57
-rw-r--r--dotbot/plugins/link.py238
-rw-r--r--dotbot/plugins/shell.py68
8 files changed, 412 insertions, 16 deletions
diff --git a/dotbot/__init__.py b/dotbot/__init__.py
index 1d03464..525e3cc 100644
--- a/dotbot/__init__.py
+++ b/dotbot/__init__.py
@@ -1,2 +1,4 @@
from .cli import main
from .plugin import Plugin
+
+__version__ = '1.14.1'
diff --git a/dotbot/cli.py b/dotbot/cli.py
index d77ab42..fdc2a13 100644
--- a/dotbot/cli.py
+++ b/dotbot/cli.py
@@ -7,25 +7,31 @@ from .messenger import Messenger
from .messenger import Level
from .util import module
+import dotbot
+import yaml
+
def add_options(parser):
- parser.add_argument('-Q', '--super-quiet', dest='super_quiet', action='store_true',
+ parser.add_argument('-Q', '--super-quiet', action='store_true',
help='suppress almost all output')
- parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
+ parser.add_argument('-q', '--quiet', action='store_true',
help='suppress most output')
- parser.add_argument('-v', '--verbose', dest='verbose', action='store_true',
+ parser.add_argument('-v', '--verbose', action='store_true',
help='enable verbose output')
- parser.add_argument('-d', '--base-directory', nargs=1,
- dest='base_directory', help='execute commands from within BASEDIR',
- metavar='BASEDIR', required=True)
- parser.add_argument('-c', '--config-file', nargs=1, dest='config_file',
- help='run commands given in CONFIGFILE', metavar='CONFIGFILE',
- required=True)
+ parser.add_argument('-d', '--base-directory',
+ help='execute commands from within BASEDIR',
+ metavar='BASEDIR')
+ parser.add_argument('-c', '--config-file',
+ help='run commands given in CONFIGFILE', metavar='CONFIGFILE')
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',
+ parser.add_argument('--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')
+ parser.add_argument('--no-color', dest='no_color', action='store_true',
+ help='disable color output')
+ parser.add_argument('--version', action='store_true',
+ help='show program\'s version number and exit')
def read_config(config_file):
reader = ConfigReader(config_file)
@@ -37,15 +43,20 @@ def main():
parser = ArgumentParser()
add_options(parser)
options = parser.parse_args()
+ if options.version:
+ print('Dotbot version %s (yaml: %s)' % (dotbot.__version__, yaml.__version__))
+ exit(0)
if options.super_quiet:
log.set_level(Level.WARNING)
if options.quiet:
log.set_level(Level.INFO)
if options.verbose:
log.set_level(Level.DEBUG)
+ if options.no_color:
+ log.use_color(False)
plugin_directories = list(options.plugin_dirs)
if not options.disable_built_in_plugins:
- plugin_directories.append(os.path.join(os.path.dirname(__file__), '..', 'plugins'))
+ from .plugins import Clean, Link, Shell
plugin_paths = []
for directory in plugin_directories:
for plugin_path in glob.glob(os.path.join(directory, '*.py')):
@@ -55,10 +66,19 @@ def main():
for plugin_path in plugin_paths:
abspath = os.path.abspath(plugin_path)
module.load(abspath)
- tasks = read_config(options.config_file[0])
+ if not options.config_file:
+ log.error('No configuration file specified')
+ exit(1)
+ tasks = read_config(options.config_file)
if not isinstance(tasks, list):
raise ReadingError('Configuration file must be a list of tasks')
- dispatcher = Dispatcher(options.base_directory[0])
+ if options.base_directory:
+ base_directory = options.base_directory
+ else:
+ # default to directory of config file
+ base_directory = os.path.dirname(os.path.realpath(options.config_file))
+ os.chdir(base_directory)
+ dispatcher = Dispatcher(base_directory)
success = dispatcher.dispatch(tasks)
if success:
log.info('\n==> All tasks executed successfully')
diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py
index cc07435..d1a4f95 100644
--- a/dotbot/dispatcher.py
+++ b/dotbot/dispatcher.py
@@ -30,10 +30,11 @@ class Dispatcher(object):
try:
success &= plugin.handle(action, task[action])
handled = True
- except Exception:
+ except Exception as err:
self._log.error(
'An error was encountered while executing action %s' %
action)
+ self._log.debug(err)
if not handled:
success = False
self._log.error('Action %s not handled' % action)
diff --git a/dotbot/messenger/messenger.py b/dotbot/messenger/messenger.py
index f87a367..b83e3f2 100644
--- a/dotbot/messenger/messenger.py
+++ b/dotbot/messenger/messenger.py
@@ -7,10 +7,14 @@ from .level import Level
class Messenger(with_metaclass(Singleton, object)):
def __init__(self, level = Level.LOWINFO):
self.set_level(level)
+ self.use_color(True)
def set_level(self, level):
self._level = level
+ def use_color(self, yesno):
+ self._use_color = yesno
+
def log(self, level, message):
if (level >= self._level):
print('%s%s%s' % (self._color(level), message, self._reset()))
@@ -30,11 +34,14 @@ class Messenger(with_metaclass(Singleton, object)):
def error(self, message):
self.log(Level.ERROR, message)
+ def _should_use_color(self):
+ return self._use_color and sys.stdout.isatty()
+
def _color(self, level):
'''
Get a color (terminal escape sequence) according to a level.
'''
- if not sys.stdout.isatty():
+ if not self._should_use_color():
return ''
elif level < Level.DEBUG:
return ''
@@ -53,7 +60,7 @@ class Messenger(with_metaclass(Singleton, object)):
'''
Get a reset color (terminal escape sequence).
'''
- if not sys.stdout.isatty():
+ if not self._should_use_color():
return ''
else:
return Color.RESET
diff --git a/dotbot/plugins/__init__.py b/dotbot/plugins/__init__.py
new file mode 100644
index 0000000..93bd981
--- /dev/null
+++ b/dotbot/plugins/__init__.py
@@ -0,0 +1,3 @@
+from .clean import Clean
+from .link import Link
+from .shell import Shell
diff --git a/dotbot/plugins/clean.py b/dotbot/plugins/clean.py
new file mode 100644
index 0000000..22c975e
--- /dev/null
+++ b/dotbot/plugins/clean.py
@@ -0,0 +1,57 @@
+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
+ defaults = self._context.defaults().get(self._directive, {})
+ force = defaults.get('force', False)
+ for target in targets:
+ if isinstance(targets, dict):
+ force = targets[target].get('force', force)
+ success &= self._clean(target, force)
+ 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, force):
+ '''
+ Cleans all the broken symbolic links in target if they point to
+ a subdirectory of the base directory or if forced to clean.
+ '''
+ if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))):
+ self._log.debug('Ignoring nonexistent directory %s' % target)
+ return True
+ for item in os.listdir(os.path.expandvars(os.path.expanduser(target))):
+ path = os.path.join(os.path.expandvars(os.path.expanduser(target)), item)
+ if not os.path.exists(path) and os.path.islink(path):
+ points_at = os.path.join(os.path.dirname(path), os.readlink(path))
+ if self._in_directory(path, self._context.base_directory()) or force:
+ self._log.lowinfo('Removing invalid link %s -> %s' % (path, points_at))
+ os.remove(path)
+ else:
+ self._log.lowinfo('Link %s -> %s not removed.' % (path, points_at))
+ 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/plugins/link.py b/dotbot/plugins/link.py
new file mode 100644
index 0000000..2506237
--- /dev/null
+++ b/dotbot/plugins/link.py
@@ -0,0 +1,238 @@
+import os
+import glob
+import shutil
+import dotbot
+import subprocess
+
+
+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
+ defaults = self._context.defaults().get('link', {})
+ for destination, source in links.items():
+ destination = os.path.expandvars(destination)
+ relative = defaults.get('relative', False)
+ force = defaults.get('force', False)
+ relink = defaults.get('relink', False)
+ create = defaults.get('create', False)
+ use_glob = defaults.get('glob', False)
+ test = defaults.get('if', None)
+ if isinstance(source, dict):
+ # extended config
+ test = source.get('if', test)
+ relative = source.get('relative', relative)
+ force = source.get('force', force)
+ relink = source.get('relink', relink)
+ create = source.get('create', create)
+ use_glob = source.get('glob', use_glob)
+ path = self._default_source(destination, source.get('path'))
+ else:
+ path = self._default_source(destination, source)
+ if test is not None and not self._test_success(test):
+ self._log.lowinfo('Skipping %s' % destination)
+ continue
+ path = os.path.expandvars(os.path.expanduser(path))
+ if use_glob:
+ self._log.debug("Globbing with path: " + str(path))
+ glob_results = glob.glob(path)
+ if len(glob_results) is 0:
+ self._log.warning("Globbing couldn't find anything matching " + str(path))
+ success = False
+ continue
+ glob_star_loc = path.find('*')
+ if glob_star_loc is -1 and destination[-1] is '/':
+ self._log.error("Ambiguous action requested.")
+ self._log.error("No wildcard in glob, directory use undefined: " +
+ destination + " -> " + str(glob_results))
+ self._log.warning("Did you want to link the directory or into it?")
+ success = False
+ continue
+ elif glob_star_loc is -1 and len(glob_results) is 1:
+ # perform a normal link operation
+ if create:
+ success &= self._create(destination)
+ if force or relink:
+ success &= self._delete(path, destination, relative, force)
+ success &= self._link(path, destination, relative)
+ else:
+ self._log.lowinfo("Globs from '" + path + "': " + str(glob_results))
+ glob_base = path[:glob_star_loc]
+ for glob_full_item in glob_results:
+ glob_item = glob_full_item[len(glob_base):]
+ glob_link_destination = os.path.join(destination, glob_item)
+ if create:
+ success &= self._create(glob_link_destination)
+ if force or relink:
+ success &= self._delete(glob_full_item, glob_link_destination, relative, force)
+ success &= self._link(glob_full_item, glob_link_destination, relative)
+ else:
+ if create:
+ success &= self._create(destination)
+ if not self._exists(os.path.join(self._context.base_directory(), path)):
+ success = False
+ self._log.warning('Nonexistent target %s -> %s' %
+ (destination, path))
+ continue
+ if force or relink:
+ success &= self._delete(path, destination, relative, force)
+ success &= self._link(path, destination, relative)
+ 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 _test_success(self, command):
+ with open(os.devnull, 'w') as devnull:
+ ret = subprocess.call(
+ command,
+ shell=True,
+ stdout=devnull,
+ stderr=devnull,
+ executable=os.environ.get('SHELL'),
+ )
+ if ret != 0:
+ self._log.debug('Test \'%s\' returned false' % command)
+ return ret == 0
+
+ def _default_source(self, destination, source):
+ if source is None:
+ basename = os.path.basename(destination)
+ if basename.startswith('.'):
+ return basename[1:]
+ else:
+ return basename
+ else:
+ return source
+
+ 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 destination of the symbolic link.
+ '''
+ path = os.path.expanduser(path)
+ return os.readlink(path)
+
+ 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):
+ self._log.debug("Try to create parent: " + str(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, relative, force):
+ success = True
+ source = os.path.join(self._context.base_directory(), source)
+ fullpath = os.path.expanduser(path)
+ if relative:
+ source = self._relative_path(source, fullpath)
+ if ((self._is_link(path) and self._link_destination(path) != source) or
+ (self._exists(path) and not self._is_link(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 _relative_path(self, source, destination):
+ '''
+ Returns the relative path to get to the source file from the
+ destination file.
+ '''
+ destination_dir = os.path.dirname(destination)
+ return os.path.relpath(source, destination_dir)
+
+ def _link(self, source, link_name, relative):
+ '''
+ Links link_name to source.
+
+ Returns true if successfully linked files.
+ '''
+ success = False
+ destination = os.path.expanduser(link_name)
+ absolute_source = os.path.join(self._context.base_directory(), source)
+ if relative:
+ source = self._relative_path(absolute_source, destination)
+ else:
+ source = absolute_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)))
+ # we need to use absolute_source below because our cwd is the dotfiles
+ # directory, and if source is relative, it will be relative to the
+ # destination directory
+ elif not self._exists(link_name) and self._exists(absolute_source):
+ try:
+ os.symlink(source, destination)
+ 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)))
+ # again, we use absolute_source to check for existence
+ elif not self._exists(absolute_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/plugins/shell.py b/dotbot/plugins/shell.py
new file mode 100644
index 0000000..06a9a89
--- /dev/null
+++ b/dotbot/plugins/shell.py
@@ -0,0 +1,68 @@
+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
+ defaults = self._context.defaults().get('shell', {})
+ with open(os.devnull, 'w') as devnull:
+ for item in data:
+ stdin = stdout = stderr = devnull
+ quiet = False
+ if defaults.get('stdin', False) == True:
+ stdin = None
+ if defaults.get('stdout', False) == True:
+ stdout = None
+ if defaults.get('stderr', False) == True:
+ stderr = None
+ if defaults.get('quiet', False) == True:
+ quiet = True
+ if isinstance(item, dict):
+ cmd = item['command']
+ msg = item.get('description', None)
+ if 'stdin' in item:
+ stdin = None if item['stdin'] == True else devnull
+ if 'stdout' in item:
+ stdout = None if item['stdout'] == True else devnull
+ if 'stderr' in item:
+ stderr = None if item['stderr'] == True else devnull
+ if 'quiet' in item:
+ quiet = True if item['quiet'] == True else False
+ 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)
+ elif quiet:
+ self._log.lowinfo('%s' % msg)
+ else:
+ self._log.lowinfo('%s [%s]' % (msg, cmd))
+ executable = os.environ.get('SHELL')
+ ret = subprocess.call(cmd, shell=True, stdin=stdin, stdout=stdout,
+ stderr=stderr, cwd=self._context.base_directory(),
+ executable=executable)
+ 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