import py import os import click import errno from operator import attrgetter 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 '' % 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 '' % 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=DEFAULT_HOME, show_default=True) @click.option('--repository', type=click.Path(), default=DEFAULT_REPO, show_default=True) @click.version_option() @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() if not dotfiles: click.echo('[no dotfiles found]') 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='Show 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: click.echo('long output not yet implemeted, using --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)