diff options
-rw-r--r-- | dev-requirements.txt | 1 | ||||
-rw-r--r-- | dotfiles/__init__.py | 2 | ||||
-rw-r--r-- | dotfiles/cli.py | 167 | ||||
-rw-r--r-- | dotfiles/dotfile.py | 106 | ||||
-rw-r--r-- | dotfiles/exceptions.py | 20 | ||||
-rw-r--r-- | dotfiles/repository.py | 101 | ||||
-rw-r--r-- | tests/pathutils.py (renamed from dotfiles/pathutils.py) | 1 |
7 files changed, 205 insertions, 193 deletions
diff --git a/dev-requirements.txt b/dev-requirements.txt index 95bea23..4c4525d 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,3 @@ -e . pytest pytest-flake8 -pytest-runner diff --git a/dotfiles/__init__.py b/dotfiles/__init__.py index 0b5e150..e8cf894 100644 --- a/dotfiles/__init__.py +++ b/dotfiles/__init__.py @@ -9,4 +9,4 @@ :license: ISC, see LICENSE.md for more details. """ -__version__ = '0.9.dev2' +__version__ = '0.9.dev3' diff --git a/dotfiles/cli.py b/dotfiles/cli.py index 9a8fd17..cd9b187 100644 --- a/dotfiles/cli.py +++ b/dotfiles/cli.py @@ -2,11 +2,15 @@ import click from .exceptions import DotfileException from .repository import Repositories -from .repository import PATH as DEFAULT_PATH -from .repository import REMOVE_LEADING_DOT as DEFAULT_REMOVE_LEADING_DOT -def get_single_repo(repos): +def single(repos): + """Raise an exception if multiple repositories are provided. + + Certain operations (add, remove, etc...) can only be applied to a + single repository while other operations (list) can be applied + across multiple repositories. + """ if len(repos) > 1: raise click.BadParameter('Must specify exactly one repository.', param_hint=['-r', '--repo']) @@ -14,7 +18,11 @@ def get_single_repo(repos): def confirm(method, files, repo): - """Return a list of files, or all files if none were specified.""" + """Return a list of files, or all files if none were specified. + + When no files are specified, all files are assumed. But before we + go ahead, confirm to make sure this is the intended operation. + """ if files: # user has specified specific files, so we are not assuming all return files @@ -24,14 +32,28 @@ def confirm(method, files, repo): return str(repo).split() -def perform(method, files, repo, debug): - """Perform an operation on a set of dotfiles.""" +def show(repo, state): + """TODO""" + for dotfile in repo.contents(): + try: + display = state[dotfile.state] + except KeyError: + continue + char = display['char'] + name = dotfile.short_name(repo.home) + fg = display.get('color', None) + bold = display.get('bold', False) + click.secho('%c %s' % (char, name), fg=fg, bold=bold) + + +def perform(method, files, repo, copy, debug): + """Perform an operation on one or more dotfiles.""" for dotfile in repo.dotfiles(files): try: - getattr(dotfile, method)(debug) + getattr(dotfile, method)(copy, debug) if not debug: msg = '%s%s' % (method, 'd' if method[-1] == 'e' else 'ed') - click.echo('%s %s' % (msg, dotfile.short_name(repo.homedir))) + click.echo('%s %s' % (msg, dotfile.short_name(repo.home))) except DotfileException as err: click.echo(err) @@ -43,28 +65,31 @@ CONTEXT_SETTINGS = dict(auto_envvar_prefix='DOTFILES', @click.group(context_settings=CONTEXT_SETTINGS) @click.option('--repo', '-r', type=click.Path(), multiple=True, - help='A repository path. Default: %s' % (DEFAULT_PATH)) -@click.option('--dot/--no-dot', '-d/-D', default=None, - help='Whether to remove the leading dot. Default: %s' % ( - DEFAULT_REMOVE_LEADING_DOT)) + help='A repository path.', default=['~/Dotfiles'], + show_default=True) @click.version_option(None, '-v', '--version') @click.pass_context -def cli(ctx, repo, dot): +def cli(ctx, repo): """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 = Repositories(repo, dot) + try: + ctx.obj = Repositories(repo) + except FileNotFoundError as e: + raise click.ClickException('Directory not found: %s' % e) @cli.command() +@click.option('-c', '--copy', is_flag=True, + help='Copy files instead of creating symlinks.') @click.option('-d', '--debug', is_flag=True, help='Show what would be executed.') -@click.argument('files', nargs=-1, type=click.Path(exists=True)) +@click.argument('files', nargs=-1, type=click.Path()) @pass_repos -def add(repos, debug, files): +def add(repos, copy, debug, files): """Add dotfiles to a repository.""" - repo = get_single_repo(repos) - perform('add', files, repo, debug) + repo = single(repos) + perform('add', files, repo, copy, debug) @cli.command() @@ -74,84 +99,76 @@ def add(repos, debug, files): @pass_repos def remove(repos, debug, files): """Remove dotfiles from a repository.""" - repo = get_single_repo(repos) + repo = single(repos) files = confirm('remove', files, repo) - perform('remove', files, repo, debug) + perform('remove', files, repo, False, debug) if not debug: + # pruning will remove any remaining empty directories repo.prune() @cli.command() -@click.option('-d', '--debug', is_flag=True, - help='Show what would be executed.') -@click.argument('files', nargs=-1, type=click.Path()) -@pass_repos -def link(repos, debug, files): - """Create missing symlinks.""" - # TODO: allow all repos? It *could* be fine... - repo = get_single_repo(repos) - files = confirm('link', files, repo) - perform('link', files, repo, debug) - - -@cli.command() -@click.option('-d', '--debug', is_flag=True, - help='Show what would be executed.') -@click.argument('files', nargs=-1, type=click.Path(exists=True)) -@pass_repos -def unlink(repos, debug, files): - """Remove existing symlinks.""" - repo = get_single_repo(repos) - files = confirm('unlink', files, repo) - perform('unlink', files, repo, debug) - - -@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_repos def status(repos, all, color): - """Show all dotfiles in a non-OK state. + """Show current status of dotfiles. - Legend: + By default only non-OK dotfiles are shown. This can be overridden + with the '-a, --all' flag. - ?: missing !: conflict E: error + Legend: - Meaning: + l: symlink c: copy e: external symlink - * Missing: A dotfile in the repository is not present in your home - directory. + ?: missing !: conflict - * Conflict: A dotfile in the repository is different from the file - in your home directory. + Meaning: - * Error: A dotfile expected in the repository is not present. You - should never see this.""" + * Missing: Not found in your home directory. - state_info = { - 'error': {'char': 'E', 'color': None}, - 'missing': {'char': '?', 'color': None}, - 'conflict': {'char': '!', 'color': None}, + * Conflict: Different from the file in your home directory. + """ + bold = True if all and not color else False + state = { + 'missing': {'char': '?', 'bold': bold}, + 'conflict': {'char': '!', 'bold': bold}, } if all: - state_info['ok'] = {'char': ' ', 'color': None} + state['link'] = {'char': 'l'} + state['copy'] = {'char': 'c'} + state['external'] = {'char': 'e'} if color: - state_info['error']['color'] = 'red' - state_info['missing']['color'] = 'yellow' - state_info['conflict']['color'] = 'magenta' - - # XXX: could display tree [https://realpython.com/python-pathlib/] + state['missing'].update( {'color': 'yellow'}) + state['conflict'].update({'color': 'magenta'}) for repo in repos: - if len(repos) > 1: - click.secho('%s:' % repo.path) - for dotfile in repo.contents(): - try: - name = dotfile.short_name(repo.homedir) - char = state_info[dotfile.state]['char'] - color = state_info[dotfile.state]['color'] - click.secho('%c %s' % (char, name), fg=color) - except KeyError: - continue + show(repo, state) + + +@cli.command() +@click.option('-c', '--copy', is_flag=True, + help='Copy files instead of creating symlinks.') +@click.option('-d', '--debug', is_flag=True, + help='Show what would be executed.') +@click.argument('files', nargs=-1, type=click.Path()) +@pass_repos +def enable(repos, copy, debug, files): + """Link dotfiles into your home directory.""" + repo = single(repos) + files = confirm('enable', files, repo) + perform('enable', files, repo, copy, debug) + + +@cli.command() +@click.option('-d', '--debug', is_flag=True, + help='Show what would be executed.') +@click.argument('files', nargs=-1, type=click.Path()) +@pass_repos +def disable(repos, debug, files): + """Unlink dotfiles from your home directory.""" + repo = single(repos) + files = confirm('disable', files, repo) + perform('disable', files, repo, False, debug) diff --git a/dotfiles/dotfile.py b/dotfiles/dotfile.py index 3932d2a..d81e585 100644 --- a/dotfiles/dotfile.py +++ b/dotfiles/dotfile.py @@ -1,9 +1,14 @@ +import os + from click import echo from hashlib import md5 from pathlib import Path from .exceptions import \ - IsSymlink, NotASymlink, TargetExists, TargetMissing, Exists + IsSymlink, NotASymlink, Exists, NotFound, Dangling, \ + TargetExists, TargetMissing + +UNUSED = False class Dotfile(object): @@ -12,8 +17,11 @@ class Dotfile(object): :param name: name of the symlink in the home directory (~/.vimrc) :param target: where the symlink should point to (~/Dotfiles/vimrc) """ + RELATIVE_SYMLINKS = True def __init__(self, name, target): + # if not name.is_file() and not name.is_symlink(): + # raise NotFound(name) self.name = Path(name) self.target = Path(target) @@ -39,13 +47,22 @@ class Dotfile(object): ensure(self.name.parent, debug) ensure(self.target.parent, debug) - def _link(self, debug): + def _prune_dirs(self, debug): + # TODO + if debug: + echo('PRUNE <TODO>') + + def _link(self, debug, home): """Create a symlink from name to target, no error checking.""" source = self.name target = self.target + if self.name.is_symlink(): source = self.target - target = self.name.realpath() + target = self.name.resolve() + elif self.RELATIVE_SYMLINKS: + target = os.path.relpath(target, source.parent) + if debug: echo('LINK %s -> %s' % (source, target)) else: @@ -58,44 +75,49 @@ class Dotfile(object): else: self.name.unlink() - def short_name(self, homedir): + def short_name(self, home): """A shorter, more readable name given a home directory.""" - return self.name.relative_to(homedir) - # return homedir.bestrelpath(self.name) + return self.name.relative_to(home) - def is_present(self): + def _is_present(self): """Is this dotfile present in the repository?""" - # return self.name.islink() and (self.name.realpath() == self.target) return self.name.is_symlink() and (self.name.resolve() == self.target) + def _same_contents(self): + return (md5(self.name.read_bytes()).hexdigest() == \ + md5(self.target.read_bytes()).hexdigest()) + @property def state(self): """The current state of this dotfile.""" - if not self.target.exists(): - # only for testing, cli should never reach this state - return 'error' - # elif self.name.check(exists=0): - elif not self.name.exists(): + if self.target.is_symlink(): + return 'external' + + if not self.name.exists(): # no $HOME file or symlink return 'missing' - # elif self.name.islink(): - elif self.name.is_symlink(): + + if self.name.is_symlink(): # name exists, is a link, but isn't a link to the target if not self.name.samefile(self.target): return 'conflict' - else: + return 'link' + + if not self._same_contents(): # name exists, is a file, but differs from the target - # if self.name.computehash() != self.target.computehash(): - if md5(self.name.read_bytes()).hexdigest() != \ - md5(self.target.read_bytes()).hexdigest(): - return 'conflict' - return 'ok' + return 'conflict' + + return 'copy' + + def add(self, copy=False, debug=False, home=Path.home()): + """Move a dotfile to its target and create a link. - def add(self, debug=False): - """Move a dotfile to it's target and create a symlink.""" - if self.is_present(): + The link is either a symlink or a copy. + """ + if copy: + raise NotImplementedError() + if self._is_present(): raise IsSymlink(self.name) - # if self.target.check(exists=1): if self.target.exists(): raise TargetExists(self.name) self._ensure_dirs(debug) @@ -103,16 +125,13 @@ class Dotfile(object): if debug: echo('MOVE %s -> %s' % (self.name, self.target)) else: - # self.name.move(self.target) self.name.replace(self.target) - self._link(debug) + self._link(debug, home) - def remove(self, debug=False): - """Remove a symlink and move the target back to its name.""" - # if self.name.check(link=0): + def remove(self, copy=UNUSED, debug=False): + """Remove a dotfile and move target to its original location.""" if not self.name.is_symlink(): raise NotASymlink(self.name) - # if self.target.check(exists=0): if not self.target.is_file(): raise TargetMissing(self.name) self._unlink(debug) @@ -121,24 +140,25 @@ class Dotfile(object): else: self.target.replace(self.name) - def link(self, debug=False): - """Create a symlink from name to target.""" - # if self.name.check(exists=1): + def enable(self, copy=False, debug=False, home=Path.home()): + """Create a symlink or copy from name to target.""" + if copy: + raise NotImplementedError() if self.name.exists(): raise Exists(self.name) - # if self.target.check(exists=0): if not self.target.exists(): raise TargetMissing(self.name) self._ensure_dirs(debug) - self._link(debug) + self._link(debug, home) - def unlink(self, debug=False): - """Remove a symlink from name to target.""" - # if self.name.check(link=0): + def disable(self, copy=UNUSED, debug=False): + """Remove a dotfile from name to target.""" if not self.name.is_symlink(): raise NotASymlink(self.name) - if not self.target.exists(): - raise TargetMissing(self.name) - if not self.name.samefile(self.target): - raise RuntimeError + if self.name.exists(): + if not self.target.exists(): + raise TargetMissing(self.name) + if not self.name.samefile(self.target): + raise RuntimeError self._unlink(debug) + self._prune_dirs(debug) diff --git a/dotfiles/exceptions.py b/dotfiles/exceptions.py index 9623ecc..cb6021c 100644 --- a/dotfiles/exceptions.py +++ b/dotfiles/exceptions.py @@ -1,6 +1,5 @@ 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) @@ -10,54 +9,55 @@ class DotfileException(Exception): 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 Exists(DotfileException): - def __init__(self, path): DotfileException.__init__(self, path, 'already exists') -class TargetIgnored(DotfileException): +class NotFound(DotfileException): + def __init__(self, path): + DotfileException.__init__(self, path, 'not found') + + +class Dangling(DotfileException): + def __init__(self, path): + DotfileException.__init__(self, path, 'is a dangling symlink') + +class TargetIgnored(DotfileException): def __init__(self, path): DotfileException.__init__(self, path, 'targets an ignored file') 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 index b06d250..8d118cf 100644 --- a/dotfiles/repository.py +++ b/dotfiles/repository.py @@ -1,12 +1,5 @@ import os -# import sys -# if sys.version_info < (3, 4): -# from pathlib2 import Path -# #import pathlib2 as pathlib -# else: -# from pathlib import Path - from click import echo from pathlib import Path from fnmatch import fnmatch @@ -16,24 +9,13 @@ from .dotfile import Dotfile from .exceptions import DotfileException, TargetIgnored from .exceptions import NotRootedInHome, InRepository, IsDirectory -PATH = '~/Dotfiles' -HOMEDIR = Path.home() -REMOVE_LEADING_DOT = True -IGNORE_PATTERNS = ['.git', '.gitignore', 'README*', '*~'] - class Repositories(object): """An iterable collection of repository objects.""" - - def __init__(self, paths, dot): - if not paths: - paths = [PATH] - if dot is None: - dot = REMOVE_LEADING_DOT - + def __init__(self, paths, home=Path.home()): self.repos = [] for path in paths: - self.repos.append(Repository(path, remove_leading_dot=dot)) + self.repos.append(Repository(path, home)) def __len__(self): return len(self.repos) @@ -43,25 +25,21 @@ class Repositories(object): class Repository(object): - """A repository is a directory that contains dotfiles. - - :param path: the location of the repository directory - :param homedir: the location of the home directory - :param remove_leading_dot: whether to remove the target's leading dot - :param ignore_patterns: a list of glob patterns to ignore - """ - - def __init__(self, path, - homedir=HOMEDIR, - remove_leading_dot=REMOVE_LEADING_DOT, - ignore_patterns=IGNORE_PATTERNS): - self.path = Path(path).expanduser() - self.homedir = Path(homedir) - self.remove_leading_dot = remove_leading_dot - self.ignore_patterns = ignore_patterns - - # create repository directory if missing - self.path.mkdir(parents=True, exist_ok=True) + """A repository is a directory that contains dotfiles.""" + REMOVE_LEADING_DOT = True + IGNORE_PATTERNS = ['.git/*', '.gitignore', 'README*', '*~'] + + def __init__(self, path, home=Path.home()): + self.path = Path(path).expanduser().resolve() + self.home = Path(home).expanduser().resolve() + + if not self.path.exists(): + echo('Creating new repository: %s' % self.path) + self.path.mkdir(parents=True, exist_ok=True) + + if not self.home.exists(): + raise FileNotFoundError(self.home) + def __str__(self): """Return human-readable repository contents.""" @@ -71,39 +49,38 @@ class Repository(object): return '<Repository %r>' % str(self.path) def _ignore(self, path): - for pattern in self.ignore_patterns: + """Test whether a dotfile should be ignored.""" + for pattern in self.IGNORE_PATTERNS: if fnmatch(str(path), '*/%s' % pattern): return True return False def _dotfile_path(self, target): """Return the expected symlink for the given repository target.""" - relpath = target.relative_to(self.path) - if self.remove_leading_dot: - return self.homedir / ('.%s' % relpath) + if self.REMOVE_LEADING_DOT: + return self.home / ('.%s' % relpath) else: - return self.homedir / relpath + return self.home / relpath def _dotfile_target(self, path): """Return the expected repository target for the given symlink.""" - try: - relpath = str(path.relative_to(self.homedir)) + # is the dotfile within the home directory? + relpath = str(path.relative_to(self.home)) except ValueError: raise NotRootedInHome(path) - if self.remove_leading_dot: - return self.path / relpath[1:] - else: - return self.path / relpath + if self.REMOVE_LEADING_DOT and relpath[0] == '.': + relpath = relpath[1:] + + return self.path / relpath def _dotfile(self, path): """Return a valid dotfile for the given path.""" - target = self._dotfile_target(path) - if not fnmatch(str(path), '%s/*' % self.homedir): + if not fnmatch(str(path), '%s/*' % self.home): raise NotRootedInHome(path) if fnmatch(str(path), '%s/*' % self.path): raise InRepository(path) @@ -116,15 +93,13 @@ class Repository(object): def _contents(self, dir): """Return all unignored files contained below a directory.""" - def skip(path): return path.is_dir() or self._ignore(path) return [x for x in dir.rglob('*') if not skip(x)] def contents(self): - """Return a list of dotfiles for each file in the repository.""" - + """Return dotfile objects for each file in the repository.""" def construct(target): return Dotfile(self._dotfile_path(target), target) @@ -134,14 +109,13 @@ class Repository(object): def dotfiles(self, paths): """Return a collection of dotfiles given a list of paths. - This function takes a list of paths where each path can be a file or a - directory. Each directory is recursively expaned into file paths. - Once the list is converted into only files, dotifles are constructed - for each path in the set. This set of dotfiles is returned to the - caller. + This function takes a list of paths where each path can be a + file or a directory. Each directory is recursively expaned into + file paths. Once the list is converted into only files, Dotfile + objects are constructed for each item in the set. This set of + dotfiles is returned to the caller. """ - - paths = list(set(map(Path, paths))) + paths = [Path(x).expanduser().absolute() for x in paths] for path in paths: if path.is_dir(): @@ -164,10 +138,11 @@ class Repository(object): The Dotfile class has no knowledge of other dotfiles in the repository, so pruning must take place explicitly after such operations occur. """ - def skip(path): return self._ignore(path) or path == str(self.path) + # TODO: this *could* use pathlib instead + dirs = reversed([dir for dir, subdirs, files in os.walk(str(self.path)) if not skip(dir)]) diff --git a/dotfiles/pathutils.py b/tests/pathutils.py index 0b051b1..87f82af 100644 --- a/dotfiles/pathutils.py +++ b/tests/pathutils.py @@ -1,4 +1,5 @@ # TODO: docstrings +# XXX: can this move into tests/? def is_file(path): |