From 48e781a75c8b5d20c1e9c54c92dd31e641dfd121 Mon Sep 17 00:00:00 2001 From: Jon Bernard Date: Sun, 3 Jan 2016 14:33:01 -0500 Subject: Consolidate new implementation into a single file --- dotfiles.py | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 dotfiles.py (limited to 'dotfiles.py') 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 '' % 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=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__) -- cgit v1.2.3