diff options
-rw-r--r-- | README.rst | 10 | ||||
-rw-r--r-- | contrib/dotfilesrc | 1 | ||||
-rw-r--r-- | dotfiles/cli.py | 44 | ||||
-rw-r--r-- | dotfiles/core.py | 118 | ||||
-rwxr-xr-x[-rw-r--r--] | setup.py | 13 | ||||
-rwxr-xr-x | test_dotfiles.py | 85 |
6 files changed, 222 insertions, 49 deletions
@@ -4,11 +4,8 @@ Dotfile management made easy ``dotfiles`` is a tool to make managing your dotfile symlinks in ``$HOME`` easy, allowing you to keep all your dotfiles in a single directory. -Hosting is left to you. Yes, I've seen `<http://dotfiles.org>`_ but I don't -like that model. If you're advanced enough to need dotfile management, then -you probably already know how you want to host them. Using whatever VCS you -prefer, or even rsync, you can easily distribute your dotfiles repository -across multiple hosts. +Hosting is left to you. Using whatever VCS you prefer, or even rsync, you can +easily distribute your dotfiles repository across multiple hosts. Interface --------- @@ -29,6 +26,9 @@ Interface Update dotfile symlinks. You can overwrite unmanaged files with ``-f`` or ``--force``. +``-m, --move`` + Move dotfiles repository to another location. + Installation ------------ diff --git a/contrib/dotfilesrc b/contrib/dotfilesrc index fe1de3e..f26515e 100644 --- a/contrib/dotfilesrc +++ b/contrib/dotfilesrc @@ -8,5 +8,6 @@ ignore = [ externals = { '.adobe': '/tmp', '.bzr.log': '/dev/null', + '.lastpass': '/tmp', '.macromedia': '/tmp', '.uml': '/tmp'} diff --git a/dotfiles/cli.py b/dotfiles/cli.py index 7b8ca59..d00788e 100644 --- a/dotfiles/cli.py +++ b/dotfiles/cli.py @@ -14,12 +14,8 @@ from optparse import OptionParser, OptionGroup DEFAULT_REPO = "~/Dotfiles" -def method_list(object): - return [method for method in dir(object) - if callable(getattr(object, method))] - - def parse_args(): + parser = OptionParser(usage="%prog ACTION [OPTION...] [FILE...]") parser.set_defaults(config=os.path.expanduser("~/.dotfilesrc")) @@ -43,6 +39,9 @@ def parse_args(): parser.add_option("-C", "--config", type="string", dest="config", help="set configuration file location (default is ~/.dotfilesrc)") + parser.add_option("-H", "--home", type="string", dest="home", + help="set home directory location (default is ~/)") + action_group = OptionGroup(parser, "Actions") action_group.add_option("-a", "--add", action="store_const", dest="action", @@ -62,6 +61,10 @@ def parse_args(): action_group.add_option("-s", "--sync", action="store_const", dest="action", const="sync", help="update dotfile symlinks") + action_group.add_option("-m", "--move", action="store_const", + dest="action", const="move", help="move dotfiles repository to " \ + "another location") + parser.add_option_group(action_group) (opts, args) = parser.parse_args() @@ -94,6 +97,10 @@ def main(): if not opts.repo and parser.get('dotfiles', 'repository'): opts.repo = os.path.expanduser(parser.get('dotfiles', 'repository')) + if opts.action == 'move': + # TODO: update the configuration file after the move + print 'Remember to update the repository location ' \ + 'in your configuration file (%s).' % (opts.config) if not opts.prefix and parser.get('dotfiles', 'prefix'): opts.prefix = parser.get('dotfiles', 'prefix') @@ -122,11 +129,28 @@ def main(): print "Error: An action is required. Type 'dotfiles -h' to see detailed usage information." exit(-1) - getattr(core.Dotfiles(location=opts.repo, - prefix=opts.prefix, - ignore=opts.ignore, - externals=opts.externals, - force=opts.force), opts.action)(files=args) + dotfiles = core.Dotfiles(home='~/', repo=opts.repo, prefix=opts.prefix, + ignore=opts.ignore, externals=opts.externals) + + if opts.action in ['list', 'check']: + getattr(dotfiles, opts.action)() + + elif opts.action in ['add', 'remove']: + getattr(dotfiles, opts.action)(args) + + elif opts.action == 'sync': + dotfiles.sync(opts.force) + + elif opts.action == 'move': + if len(args) > 1: + print "Error: Move cannot handle multiple targets." + exit(-1) + if opts.repo != args[0]: + dotfiles.move(args[0]) + + else: + print "Error: Something truly terrible has happened." + exit(-1) def missing_default_repo(): diff --git a/dotfiles/core.py b/dotfiles/core.py index a1ee264..4c8add4 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -11,18 +11,18 @@ import os import shutil -__version__ = '0.3.1' +__version__ = '0.4.0' __author__ = "Jon Bernard" __license__ = "GPL" class Dotfile(object): - def __init__(self, name, target): + def __init__(self, name, target, home): if name.startswith('/'): self.name = name else: - self.name = os.path.expanduser('~/.%s' % name.strip('.')) + self.name = os.path.expanduser(home + '/.%s' % name.strip('.')) self.basename = os.path.basename(self.name) self.target = target.rstrip('/') self.status = '' @@ -38,7 +38,10 @@ class Dotfile(object): if not force: print "Skipping \"%s\", use --force to override" % self.basename return - os.remove(self.name) + if os.path.isdir(self.name): + shutil.rmtree(self.name) + else: + os.remove(self.name) os.symlink(self.target, self.name) def add(self): @@ -63,45 +66,92 @@ class Dotfile(object): class Dotfiles(object): + """A Dotfiles Repository.""" + + __attrs__ = ['home', 'repo', 'prefix', 'ignore', 'externals'] + + def __init__(self, **kwargs): + + # Map args from kwargs to instance-local variables + map(lambda k, v: (k in self.__attrs__) and setattr(self, k, v), + kwargs.iterkeys(), kwargs.itervalues()) + + self._load() + + + def _load(self): + """Load each dotfile in the repository.""" + + self.dotfiles = list() + + for dotfile in list(x for x in os.listdir(self.repo) if x not in self.ignore): + self.dotfiles.append(Dotfile(dotfile[len(self.prefix):], + os.path.join(self.repo, dotfile), self.home)) + + for dotfile in self.externals.keys(): + self.dotfiles.append(Dotfile(dotfile, self.externals[dotfile], self.home)) + + + def _fqpn(self, dotfile): + """Return the fully qualified path to a dotfile.""" + + return os.path.join(self.repo, + self.prefix + os.path.basename(dotfile).strip('.')) + + + def list(self, verbose=True): + """List the contents of this repository.""" - def __init__(self, location, prefix, ignore, externals, force): - self.location = location - self.prefix = prefix - self.force = force - self.dotfiles = [] - contents = [x for x in os.listdir(self.location) if x not in ignore] - for file in contents: - self.dotfiles.append(Dotfile(file[len(prefix):], - os.path.join(self.location, file))) - for file in externals.keys(): - self.dotfiles.append(Dotfile(file, externals[file])) - - def list(self, **kwargs): for dotfile in sorted(self.dotfiles, key=lambda dotfile: dotfile.name): - if dotfile.status or kwargs.get('verbose', True): + if dotfile.status or verbose: print dotfile - def check(self, **kwargs): + + def check(self): + """List only unmanaged and/or missing dotfiles.""" + self.list(verbose=False) - def sync(self, **kwargs): + + def sync(self, force=False): + + """Synchronize this repository, creating and updating the necessary + symbolic links.""" + for dotfile in self.dotfiles: - dotfile.sync(self.force) + dotfile.sync(force) - def add(self, **kwargs): - for file in kwargs.get('files', None): - if os.path.basename(file).startswith('.'): - Dotfile(file, - os.path.join(self.location, - self.prefix + os.path.basename(file).strip('.'))).add() - else: - print "Skipping \"%s\", not a dotfile" % file - def remove(self, **kwargs): - for file in kwargs.get('files', None): + def add(self, files): + """Add dotfile(s) to the repository.""" + + self._perform_action('add', files) + + + def remove(self, files): + """Remove dotfile(s) from the repository.""" + + self._perform_action('remove', files) + + + def _perform_action(self, action, files): + for file in files: if os.path.basename(file).startswith('.'): - Dotfile(file, - os.path.join(self.location, - self.prefix + os.path.basename(file).strip('.'))).remove() + getattr(Dotfile(file, self._fqpn(file), self.home), action)() else: print "Skipping \"%s\", not a dotfile" % file + + + def move(self, target): + """Move the repository to another location.""" + + if os.path.exists(target): + raise ValueError('Target already exists: %s' % (target)) + + shutil.copytree(self.repo, target) + shutil.rmtree(self.repo) + + self.repo = target + + self._load() + self.sync(force=True) @@ -1,6 +1,9 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import os +import sys + try: from setuptools import setup except ImportError: @@ -9,6 +12,16 @@ except ImportError: from dotfiles.core import __version__ +if sys.argv[-1] == "publish": + os.system("python setup.py sdist upload") + sys.exit() + + +if sys.argv[-1] == "test": + os.system("python test_dotfiles.py") + sys.exit() + + setup(name='dotfiles', version=__version__, description='Easily manage your dotfiles', diff --git a/test_dotfiles.py b/test_dotfiles.py new file mode 100755 index 0000000..360d7db --- /dev/null +++ b/test_dotfiles.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import shutil +import tempfile +import unittest + +from dotfiles import core + + +def touch(fname, times=None): + with file(fname, 'a'): + os.utime(fname, times) + + +class DotfilesTestCase(unittest.TestCase): + + def setUp(self): + """Create a temporary home directory.""" + + self.home = tempfile.mkdtemp() + + # create a repository for the tests to use + self.repo = os.path.join(self.home, 'Dotfiles') + os.mkdir(self.repo) + + def tearDown(self): + """Delete the temporary home directory and its contents.""" + + shutil.rmtree(self.home) + + def test_force_sync_directory(self): + """Test forced sync when the dotfile is a directory. + + I installed the lastpass chrome extension which stores a socket in + ~/.lastpass. So I added that directory as an external to /tmp and + attempted a forced sync. An error occurred because sync() calls + os.remove() as it mistakenly assumes the dotfile is a file and not + a directory. + """ + + os.mkdir(os.path.join(self.home, '.lastpass')) + externals = {'.lastpass': '/tmp'} + + dotfiles = core.Dotfiles(home=self.home, repo=self.repo, prefix='', + ignore=[], externals=externals) + + dotfiles.sync(force=True) + + self.assertEqual( + os.path.realpath(os.path.join(self.home, '.lastpass')), '/tmp') + + def test_move_repository(self): + """Test the move() method for a Dotfiles repository.""" + + touch(os.path.join(self.repo, 'bashrc')) + + dotfiles = core.Dotfiles( + home=self.home, repo=self.repo, prefix='', + ignore=[], force=True, externals={}) + + dotfiles.sync() + + # make sure sync() did the right thing + self.assertEqual( + os.path.realpath(os.path.join(self.home, '.bashrc')), + os.path.join(self.repo, 'bashrc')) + + target = os.path.join(self.home, 'MyDotfiles') + + dotfiles.move(target) + + self.assertTrue(os.path.exists(os.path.join(target, 'bashrc'))) + self.assertEqual( + os.path.realpath(os.path.join(self.home, '.bashrc')), + os.path.join(target, 'bashrc')) + + +def suite(): + suite = unittest.TestLoader().loadTestsFromTestCase(DotfilesTestCase) + return suite + +if __name__ == '__main__': + unittest.TextTestRunner().run(suite()) |