aboutsummaryrefslogtreecommitdiffstats
path: root/dotfiles.py
diff options
context:
space:
mode:
Diffstat (limited to 'dotfiles.py')
-rw-r--r--dotfiles.py226
1 files changed, 226 insertions, 0 deletions
diff --git a/dotfiles.py b/dotfiles.py
new file mode 100644
index 0000000..1e087a1
--- /dev/null
+++ b/dotfiles.py
@@ -0,0 +1,226 @@
+import py
+import os
+import click
+import errno
+from operator import attrgetter
+
+
+__version__ = '0.7-dev'
+
+default_home = os.path.expanduser('~/')
+default_repo = os.path.expanduser('~/Dotfiles')
+
+
+def unique_suffix(path_a, path_b):
+ discard = len(str(path_a.common(path_b))) + 1
+ return (str(path_a)[discard:], str(path_b)[discard:])
+
+
+class Repository(object):
+ """
+ This class implements the 'repository' abstraction.
+
+ A repository is a directory that contains dotfiles. It has two primary
+ attributes:
+
+ repodir -- the location of the repository directory
+ homedir -- the location of the home directory (primarily for testing)
+ """
+
+ def __init__(self, repodir, homedir):
+ self.repodir = repodir
+ self.homedir = homedir
+
+ def __str__(self):
+ """Convert repository contents to human readable form."""
+ return ''.join('%s\n' % item for item in self.contents()).rstrip()
+
+ def __repr__(self):
+ return '<Repository %r>' % self.repodir
+
+ def expected_name(self, target):
+ """Given a repository target, return the expected symlink name."""
+ return py.path.local("%s/.%s" % (self.homedir, target.basename))
+
+ def contents(self):
+ """Given a repository path, discover all existing dotfiles."""
+ contents = []
+ self.repodir.ensure(dir=1)
+ for target in self.repodir.listdir():
+ target = py.path.local(target)
+ contents.append(Dotfile(self.expected_name(target), target))
+ return sorted(contents, key=attrgetter('name'))
+
+
+class Dotfile(object):
+ """
+ This class implements the 'dotfile' abstraction.
+
+ A dotfile has two primary attributes:
+
+ name -- name of symlink in the home directory (~/.vimrc)
+ target -- where the symlink should point to (~/Dotfiles/vimrc)
+
+ The above attributes are both py.path.local objects.
+
+ The goal is for there to be no special logic or stored global state. Only
+ the implementation of three operations made available to the caller:
+
+ add -- move a dotfile into the repository and replace it with a symlink
+ remove -- the opposite of add
+ sync -- ensure that each repository file has a corresponding symlink
+ unsync -- remove the symlink leaving only the repository file
+
+ This is where most filesystem operations (link, delete, etc) should be
+ called, and not in the layers above.
+ """
+
+ def __init__(self, name, target):
+ self.name = name
+ self.target = target
+
+ def __str__(self):
+ short_name, _ = unique_suffix(self.name, self.target)
+ return '%s' % short_name
+
+ def __repr__(self):
+ return '<Dotfile %r>' % self.name
+
+ @property
+ def state(self):
+
+ # lets be optimistic
+ state = 'ok'
+
+ if self.target.check(exists=0):
+ # only for testing, cli should never reach this state
+ state = 'error'
+ elif self.name.check(exists=0):
+ # no $HOME symlink
+ state = 'missing'
+ elif self.name.check(link=0) or not self.name.samefile(self.target):
+ # if name exists but isn't a link to the target
+ state = 'conflict'
+
+ return state
+
+ def add(self):
+ if self.target.check(exists=1):
+ raise OSError(errno.EEXIST, self.target)
+ self.name.move(self.target)
+ self.sync()
+
+ def remove(self):
+ if self.target.check(exists=0):
+ raise OSError(errno.ENOENT, self.target)
+ self.name.remove()
+ self.target.move(self.name)
+
+ def sync(self):
+ self.name.mksymlinkto(self.target)
+
+ def unsync(self):
+ self.name.remove()
+
+
+pass_repo = click.make_pass_decorator(Repository)
+
+
+@click.group()
+@click.option('--home-directory', type=click.Path(), default=str(default_home),
+ show_default=True)
+@click.option('--repository', type=click.Path(), default=str(default_repo),
+ show_default=True)
+@click.pass_context
+def cli(ctx, home_directory, repository):
+ """Dotfiles is a tool to make managing your dotfile symlinks in $HOME easy,
+ allowing you to keep all your dotfiles in a single directory.
+ """
+ ctx.obj = Repository(py.path.local(repository),
+ py.path.local(home_directory))
+
+
+@cli.command()
+@click.argument('files', nargs=-1, type=click.Path(exists=True))
+@pass_repo
+def add(repo, files):
+ """Move dotifles into a repository."""
+ for filename in files:
+ Dotfile(filename, repo.target(filename)).add()
+
+
+@cli.command()
+@click.option('-v', '--verbose', is_flag=True, help='Show dotfile state.')
+@pass_repo
+def list(repo, verbose):
+ """Show the contents of a repository."""
+ dotfiles = repo.contents()
+ for dotfile in dotfiles:
+ if (verbose):
+ click.echo('%-18s (%s)' % (dotfile, dotfile.state))
+ else:
+ click.echo('%s' % dotfile)
+
+
+@cli.command()
+@click.argument('files', nargs=-1, type=click.Path(exists=True))
+@pass_repo
+def remove(repo, files):
+ """Remove dotfiles from a repository."""
+ for filename in files:
+ Dotfile(filename, repo.target(filename)).remove()
+
+
+@cli.command()
+@click.option('-c', '--color', is_flag=True, help='Enable color.')
+@click.option('-s', '--short', is_flag=True, help='Terse output.')
+@pass_repo
+def status(repo, color, short):
+ """Show all dotifles in a non-ok state."""
+
+ states = {
+ 'error': {'char': 'E', 'color': 'red'},
+ 'conflict': {'char': '!', 'color': 'magenta'},
+ 'missing': {'char': '?', 'color': 'yellow'},
+ }
+
+ if not short:
+ raise NotImplementedError('long output, use --short for now')
+
+ dotfiles = repo.contents()
+ for dotfile in dotfiles:
+ try:
+ state_str = states[dotfile.state]['char']
+ color_str = states[dotfile.state]['color']
+ if color:
+ click.secho('%s %s' % (state_str, dotfile), fg=color_str)
+ else:
+ click.echo('%s %s' % (state_str, dotfile))
+ except KeyError:
+ continue
+
+
+@cli.command()
+@click.argument('files', nargs=-1, type=click.Path())
+@pass_repo
+def sync(repo, files):
+ """TODO"""
+ for filename in files:
+ repo.sync(filename)
+
+ # TODO: path need not exist...
+
+
+@cli.command()
+@click.argument('files', nargs=-1, type=click.Path(exists=True))
+@pass_repo
+def unsync(repo, files):
+ """TODO"""
+ for filename in files:
+ repo.unsync(filename)
+
+
+@cli.command()
+def version():
+ """Show the version number."""
+ click.echo("dotfiles version %s" % __version__)