diff options
-rw-r--r-- | dotfiles/cli.py | 126 | ||||
-rw-r--r-- | dotfiles/dotfile.py | 113 | ||||
-rw-r--r-- | dotfiles/dotfiles.py | 365 | ||||
-rw-r--r-- | dotfiles/exceptions.py | 75 | ||||
-rw-r--r-- | dotfiles/repository.py | 95 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | tests/test_dotfiles.py | 6 |
7 files changed, 414 insertions, 368 deletions
diff --git a/dotfiles/cli.py b/dotfiles/cli.py new file mode 100644 index 0000000..1cdac89 --- /dev/null +++ b/dotfiles/cli.py @@ -0,0 +1,126 @@ +import os +import py +import click + +from .repository import Repository +from .exceptions import DotfileException + + +DEFAULT_HOMEDIR = os.path.expanduser('~/') +DEFAULT_REPO_PATH = os.path.expanduser('~/Dotfiles') +DEFAULT_REPO_IGNORE = ['.git'] + +pass_repo = click.make_pass_decorator(Repository) + + +@click.group(context_settings=dict(help_option_names=['-h', '--help'])) +@click.option('-r', '--repository', type=click.Path(), show_default=True, + default=DEFAULT_REPO_PATH) +@click.version_option() +@click.pass_context +def cli(ctx, 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(DEFAULT_HOMEDIR), + DEFAULT_REPO_IGNORE) + + +@cli.command() +@click.option('-d', '--debug', is_flag=True, + help='Show commands that would be executed.') +@click.argument('files', nargs=-1, type=click.Path(exists=True)) +@pass_repo +def add(repo, debug, files): + """Replace file with symlink.""" + for dotfile in repo.dotfiles(files): + dotfile.add(debug) + + # try: + # repo.dotfile(py.path.local(filename)).add(debug) + # if not debug: + # click.echo('added \'%s\'' % filename) + # except DotfileException as err: + # click.echo(err) + + +@cli.command() +@click.option('-d', '--debug', is_flag=True, + help='Show commands that would be executed.') +@click.argument('files', nargs=-1, type=click.Path(exists=True)) +@pass_repo +def remove(repo, debug, files): + """Replace symlink with file.""" + for filename in files: + try: + repo.dotfile(py.path.local(filename)).remove(debug) + if not debug: + click.echo('removed \'%s\'' % filename) + except DotfileException as err: + click.echo(err) + + +def show_dotfile(homedir, char, dotfile, color): + display_name = homedir.bestrelpath(dotfile.name) + click.secho('%c %s' % (char, display_name), fg=color) + + +@cli.command() +@click.option('-a', '--all', is_flag=True, help='Show all dotfiles.') +@click.option('-c', '--color', is_flag=True, help='Enable color output.') +@pass_repo +def status(repo, all, color): + """Show all dotfiles in a non-OK state.""" + + state_info = { + 'error': {'char': 'E', 'color': None}, + 'missing': {'char': '?', 'color': None}, + 'conflict': {'char': '!', 'color': None}, + } + + if all: + state_info['ok'] = {'char': ' ', 'color': None} + + if color: + state_info['error']['color'] = 'red' + state_info['missing']['color'] = 'yellow' + state_info['conflict']['color'] = 'magenta' + + for dotfile in repo.contents(): + try: + char = state_info[dotfile.state]['char'] + color = state_info[dotfile.state]['color'] + show_dotfile(repo.homedir, char, dotfile, color) + except KeyError: + continue + + +@cli.command() +@click.option('-v', '--verbose', is_flag=True, help='Show executed commands.') +@click.argument('files', nargs=-1, type=click.Path()) +@pass_repo +def link(repo, verbose, files): + """Create missing symlinks.""" + # TODO: no files should be interpreted as all files with confirmation + for filename in files: + try: + repo.dotfile(py.path.local(filename)).link(verbose) + click.echo('linked \'%s\'' % filename) + except DotfileException as err: + click.echo(err) + + +@cli.command() +@click.option('-v', '--verbose', is_flag=True, help='Show executed commands.') +@click.argument('files', nargs=-1, type=click.Path(exists=True)) +@pass_repo +def unlink(repo, verbose, files): + """Remove existing symlinks.""" + # TODO: no files should be interpreted as all files with confirmation + for filename in files: + try: + repo.dotfile(py.path.local(filename)).unlink(verbose) + click.echo('unlinked \'%s\'' % filename) + except DotfileException as err: + click.echo(err) diff --git a/dotfiles/dotfile.py b/dotfiles/dotfile.py new file mode 100644 index 0000000..bf1e9ac --- /dev/null +++ b/dotfiles/dotfile.py @@ -0,0 +1,113 @@ +import py +from click import echo +from errno import EEXIST, ENOENT + +from .exceptions import IsDirectory, IsSymlink, NotASymlink, DoesNotExist, \ + TargetExists, TargetMissing + + +class Dotfile(object): + """An configuration file managed within a repository. + + :param name: name of the symlink in the home directory (~/.vimrc) + :param target: where the symlink should point to (~/Dotfiles/vimrc) + """ + + def __init__(self, name, target): + self.name = name + self.target = target + + def __str__(self): + return str(self.name) + + def __repr__(self): + return '<Dotfile %r>' % self.name + + def _ensure_subdirs(self, debug): + target_dir = py.path.local(self.target.dirname) + if not target_dir.check(): + if debug: + echo('MKDIR %s' % self.target.dirname) + else: + target_dir.ensure_dir() + + def _remove_subdirs(self, debug): + # TODO + pass + + def _add(self, debug): + self._ensure_subdirs(debug) + if debug: + echo('MOVE %s -> %s' % (self.name, self.target)) + else: + self.name.move(self.target) + self._link(debug) + + def _remove(self, debug): + self._unlink(debug) + if debug: + echo('MOVE %s -> %s' % (self.target, self.name)) + else: + self.target.move(self.name) + self._remove_subdirs(debug) + + def _link(self, debug): + if debug: + echo('LINK %s -> %s' % (self.name, self.target)) + else: + self.name.mksymlinkto(self.target, absolute=0) + + def _unlink(self, debug): + if debug: + echo('UNLINK %s' % self.name) + else: + self.name.remove() + + @property + def state(self): + if self.target.check(exists=0): + # only for testing, cli should never reach this state + return 'error' + elif self.name.check(exists=0): + # no $HOME symlink + return '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 + return 'conflict' + return 'ok' + + def add(self, debug=False): + if self.name.check(file=0): + raise DoesNotExist(self.name) + if self.name.check(dir=1): + raise IsDirectory(self.name) + if self.name.check(link=1): + raise IsSymlink(self.name) + if self.target.check(exists=1): + raise TargetExists(self.name) + self._add(debug) + + def remove(self, debug=False): + if not self.name.check(link=1): + raise NotASymlink(self.name) + if self.target.check(exists=0): + raise TargetMissing(self.name) + self._remove(debug) + + # TODO: replace exceptions + + def link(self, debug=False): + if self.name.check(exists=1): + raise OSError(EEXIST, self.name) + if self.target.check(exists=0): + raise OSError(ENOENT, self.target) + self._link(debug) + + def unlink(self, debug=False): + if self.name.check(link=0): + raise Exception('%s is not a symlink' % self.name.basename) + if self.target.check(exists=0): + raise Exception('%s does not exist' % self.target) + if not self.name.samefile(self.target): + raise Exception('good lord') + self._unlink(debug) diff --git a/dotfiles/dotfiles.py b/dotfiles/dotfiles.py deleted file mode 100644 index 1805e97..0000000 --- a/dotfiles/dotfiles.py +++ /dev/null @@ -1,365 +0,0 @@ -import py -import os -import click -import errno -from operator import attrgetter - - -DEFAULT_HOMEDIR = os.path.expanduser('~/') -DEFAULT_REPO_PATH = os.path.expanduser('~/Dotfiles') -DEFAULT_REPO_IGNORE = ['.git', '.gitignore'] - - -class DotfileException(Exception): - """An exception the CLI can handle and show to the user.""" - - def __init__(self, path, message='an unknown error occurred'): - self.message = '\'%s\' %s' % (path, message) - Exception.__init__(self, self.message) - - def __str__(self): - return 'ERROR: %s' % self.message - - -class TargetIgnored(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'targets an ignored file') - - -class IsDirectory(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'is a directory') - - -class IsSymlink(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'is a symlink') - - -class NotASymlink(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'is not a symlink') - - -class InRepository(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'is within the repository') - - -class NotRootedInHome(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'not rooted in home directory') - - -class IsNested(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'is nested') - - -class NotADotfile(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'is not a dotfile') - - -class DoesNotExist(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'doest not exist') - - -class TargetExists(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'target already exists') - - -class TargetMissing(DotfileException): - - def __init__(self, path): - DotfileException.__init__(self, path, 'target is missing') - - -class Repository(object): - """A repository is a directory that contains dotfiles. - - :param repodir: the location of the repository directory - :param homedir: the location of the home directory (primarily for testing) - :param ignore: a list of targets to ignore - """ - - def __init__(self, repodir, homedir, ignore=[]): - self.ignore = ignore - self.homedir = homedir - - # create repository if needed - self.repodir = repodir.ensure(dir=1) - - def __str__(self): - """Return human-readable repository contents.""" - return ''.join('%s\n' % item for item in self.contents()).rstrip() - - def __repr__(self): - return '<Repository %r>' % self.repodir - - def _target_to_name(self, target): - """Return the expected symlink for the given repository target.""" - return self.homedir.join('.%s' % target.basename) - - def _name_to_target(self, name): - """Return the expected repository target for the given symlink.""" - return self.repodir.join(self.homedir.bestrelpath(name)[1:]) - - def dotfile(self, name): - """Return a valid dotfile for the given path.""" - - target = self._name_to_target(name) - if target.basename in self.ignore: - raise TargetIgnored(name) - if name.check(dir=1): - raise IsDirectory(name) - - for path in name.parts(): - try: - if self.repodir.samefile(path): - raise InRepository(name) - except py.error.ENOENT: - # this occurs when the symlink does not yet exist - continue - - # if not self.homedir.samefile(name.dirname): - # raise NotRootedInHome(name) - # if name.dirname != self.homedir: - # raise IsNested(name) - if name.basename[0] != '.': - raise NotADotfile(name) - - return Dotfile(name, target) - - def dotfiles(self, path): - """Return a list of dotfiles given a path.""" - - if path.check(dir=1): - raise IsDirectory(path) - - return self.dotfile(path) - - def contents(self): - """Return a list of all dotfiles in the repository path.""" - contents = [] - for target in self.repodir.listdir(): - target = py.path.local(target) - if target.basename not in self.ignore: - contents.append(Dotfile(self._target_to_name(target), target)) - return sorted(contents, key=attrgetter('name')) - - -class Dotfile(object): - """An configuration file managed within a repository. - - :param name: name of the symlink in the home directory (~/.vimrc) - :param target: where the symlink should point to (~/Dotfiles/vimrc) - """ - - def __init__(self, name, target): - self.name = name - self.target = target - - def __str__(self): - return self.name.basename - - def __repr__(self): - return '<Dotfile %r>' % self.name - - def _ensure_target_dir(self, verbose): - target_dir = py.path.local(self.target.dirname) - if not target_dir.check(): - if verbose: - click.echo('MKDIR %s' % self.target.dirname) - target_dir.ensure(dir=1) - - def _add(self, verbose): - self._ensure_target_dir(verbose) - if verbose: - click.echo('MOVE %s -> %s' % (self.name, self.target)) - self.name.move(self.target) - self._link(verbose) - - def _remove(self, verbose): - self._unlink(verbose) - if verbose: - click.echo('MOVE %s -> %s' % (self.target, self.name)) - self.target.move(self.name) - # TODO: remove directory if empty - - def _link(self, verbose): - if verbose: - click.echo('LINK %s -> %s' % (self.name, self.target)) - self.name.mksymlinkto(self.target, absolute=0) - - def _unlink(self, verbose): - if verbose: - click.echo('UNLINK %s' % self.name) - self.name.remove() - - @property - def state(self): - if self.target.check(exists=0): - # only for testing, cli should never reach this state - return 'error' - elif self.name.check(exists=0): - # no $HOME symlink - return '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 - return 'conflict' - return 'ok' - - def add(self, verbose=False): - if self.name.check(file=0): - raise DoesNotExist(self.name) - if self.name.check(dir=1): - raise IsDirectory(self.name) - if self.name.check(link=1): - raise IsSymlink(self.name) - if self.target.check(exists=1): - raise TargetExists(self.name) - self._add(verbose) - - def remove(self, verbose=False): - if not self.name.check(link=1): - raise NotASymlink(self.name) - if self.target.check(exists=0): - raise TargetMissing(self.name) - self._remove(verbose) - - # TODO: replace exceptions - - def link(self, verbose=False): - if self.name.check(exists=1): - raise OSError(errno.EEXIST, self.name) - if self.target.check(exists=0): - raise OSError(errno.ENOENT, self.target) - self._link(verbose) - - def unlink(self, verbose=False): - if self.name.check(link=0): - raise Exception('%s is not a symlink' % self.name.basename) - if self.target.check(exists=0): - raise Exception('%s does not exist' % self.target) - if not self.name.samefile(self.target): - raise Exception('good lord') - self._unlink(verbose) - - -pass_repo = click.make_pass_decorator(Repository) - - -@click.group(context_settings=dict(help_option_names=['-h', '--help'])) -@click.option('--repository', type=click.Path(), show_default=True, - default=DEFAULT_REPO_PATH) -@click.version_option() -@click.pass_context -def cli(ctx, 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(DEFAULT_HOMEDIR), - DEFAULT_REPO_IGNORE) - - -@cli.command() -@click.option('-v', '--verbose', is_flag=True, help='Show executed commands.') -@click.argument('files', nargs=-1, type=click.Path(exists=True)) -@pass_repo -def add(repo, verbose, files): - """Replace file with symlink.""" - # TODO: repo.dotfiles() for directories - for filename in files: - try: - repo.dotfile(py.path.local(filename)).add(verbose) - click.echo('added \'%s\'' % filename) - except DotfileException as err: - click.echo(err) - - -@cli.command() -@click.option('-v', '--verbose', is_flag=True, help='Show executed commands.') -@click.argument('files', nargs=-1, type=click.Path(exists=True)) -@pass_repo -def remove(repo, verbose, files): - """Replace symlink with file.""" - for filename in files: - try: - repo.dotfile(py.path.local(filename)).remove(verbose) - click.echo('removed \'%s\'' % filename) - except DotfileException as err: - click.echo(err) - - -@cli.command() -@click.option('-v', '--verbose', is_flag=True, help='Show all dotfiles.') -@click.option('-c', '--color', is_flag=True, help='Enable color output.') -@pass_repo -def status(repo, verbose, color): - """Show all dotfiles in a non-OK state.""" - - state_info = { - 'error': {'char': 'E', 'color': None}, - 'conflict': {'char': '!', 'color': None}, - 'missing': {'char': '?', 'color': None}, - } - - if verbose: - state_info['ok'] = {'char': ' ', 'color': None} - - if color: - state_info['error']['color'] = 'red' - state_info['conflict']['color'] = 'magenta' - state_info['missing']['color'] = 'yellow' - - for dotfile in repo.contents(): - try: - char = state_info[dotfile.state]['char'] - fg = state_info[dotfile.state]['color'] - click.secho('%c %s' % (char, dotfile), fg=fg) - except KeyError: - continue - - -@cli.command() -@click.option('-v', '--verbose', is_flag=True, help='Show executed commands.') -@click.argument('files', nargs=-1, type=click.Path()) -@pass_repo -def link(repo, verbose, files): - """Create missing symlinks.""" - # TODO: no files should be interpreted as all files with confirmation - for filename in files: - try: - repo.dotfile(py.path.local(filename)).link(verbose) - click.echo('linked \'%s\'' % filename) - except DotfileException as err: - click.echo(err) - - -@cli.command() -@click.option('-v', '--verbose', is_flag=True, help='Show executed commands.') -@click.argument('files', nargs=-1, type=click.Path(exists=True)) -@pass_repo -def unlink(repo, verbose, files): - """Remove existing symlinks.""" - # TODO: no files should be interpreted as all files with confirmation - for filename in files: - try: - repo.dotfile(py.path.local(filename)).unlink(verbose) - click.echo('unlinked \'%s\'' % filename) - except DotfileException as err: - click.echo(err) diff --git a/dotfiles/exceptions.py b/dotfiles/exceptions.py new file mode 100644 index 0000000..2469b80 --- /dev/null +++ b/dotfiles/exceptions.py @@ -0,0 +1,75 @@ +class DotfileException(Exception): + """An exception the CLI can handle and show to the user.""" + + def __init__(self, path, message='an unknown error occurred'): + self.message = '\'%s\' %s' % (path, message) + Exception.__init__(self, self.message) + + def __str__(self): + return 'ERROR: %s' % self.message + + +class TargetIgnored(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'targets an ignored file') + + +class IsDirectory(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'is a directory') + + +class IsSymlink(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'is a symlink') + + +class NotASymlink(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'is not a symlink') + + +class InRepository(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'is within the repository') + + +class NotRootedInHome(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'not rooted in home directory') + + +class IsNested(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'is nested') + + +class NotADotfile(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'is not a dotfile') + + +class DoesNotExist(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'doest not exist') + + +class TargetExists(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'target already exists') + + +class TargetMissing(DotfileException): + + def __init__(self, path): + DotfileException.__init__(self, path, 'target is missing') diff --git a/dotfiles/repository.py b/dotfiles/repository.py new file mode 100644 index 0000000..63fab23 --- /dev/null +++ b/dotfiles/repository.py @@ -0,0 +1,95 @@ +import py +from click import echo + +from .dotfile import Dotfile +from .exceptions import DotfileException, TargetIgnored, IsDirectory, \ + InRepository + + +class Repository(object): + """A repository is a directory that contains dotfiles. + + :param repodir: the location of the repository directory + :param homedir: the location of the home directory (primarily for testing) + :param ignore: a list of targets to ignore + """ + + def __init__(self, repodir, homedir, ignore=[]): + self.ignore = ignore + self.homedir = homedir + + # create repository if needed + self.repodir = repodir.ensure(dir=1) + + def __str__(self): + """Return human-readable repository contents.""" + return ''.join('%s\n' % item for item in self.contents()).rstrip() + + def __repr__(self): + return '<Repository %r>' % self.repodir + + def _target_to_name(self, target): + """Return the expected symlink for the given repository target.""" + return self.homedir.join(self.repodir.bestrelpath(target)) + + def _name_to_target(self, name): + """Return the expected repository target for the given symlink.""" + return self.repodir.join(self.homedir.bestrelpath(name)) + + def dotfile(self, name): + """Return a valid dotfile for the given path.""" + + # XXX: it must be below the home directory + # it cannot be contained in the repository + # it cannot be ignored + # it must be a file + + target = self._name_to_target(name) + if target.basename in self.ignore: + raise TargetIgnored(name) + if name.check(dir=1): + raise IsDirectory(name) + + for path in name.parts(): + try: + if self.repodir.samefile(path): + raise InRepository(name) + except py.error.ENOENT: + # this occurs when the symlink does not yet exist + continue + + # if not self.homedir.samefile(name.dirname): + # raise NotRootedInHome(name) + # if name.dirname != self.homedir: + # raise IsNested(name) + # if name.basename[0] != '.': + # raise NotADotfile(name) + + return Dotfile(name, target) + + def dotfiles(self, paths): + """Return a list of dotfiles given a path.""" + + paths = map(py.path.local, paths) + + rv = [] + for path in paths: + try: + rv.append(self.dotfile(path)) + except DotfileException as err: + echo(err) + + return rv + + def contents(self): + """Return a list of all dotfiles in the repository path.""" + def filter(node): + return node.check(dir=0) and node.basename not in self.ignore + + def recurse(node): + return node.basename not in self.ignore + + def construct(target): + return Dotfile(self._target_to_name(target), target) + + return map(construct, self.repodir.visit(filter, recurse, sort=True)) @@ -27,7 +27,7 @@ setup( ], entry_points={ 'console_scripts': [ - 'dotfiles=dotfiles.dotfiles:cli', + 'dotfiles=dotfiles.cli:cli', ], }, classifiers=[ diff --git a/tests/test_dotfiles.py b/tests/test_dotfiles.py index ffc9073..8323385 100644 --- a/tests/test_dotfiles.py +++ b/tests/test_dotfiles.py @@ -1,7 +1,9 @@ import pytest -from dotfiles.dotfiles import Repository, Dotfile, cli -from dotfiles.dotfiles import IsSymlink +from dotfiles.cli import cli +from dotfiles.dotfile import Dotfile +from dotfiles.repository import Repository +from dotfiles.exceptions import IsSymlink class TestCli(object): |