aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--dev-requirements.txt1
-rw-r--r--dotfiles/__init__.py2
-rw-r--r--dotfiles/cli.py167
-rw-r--r--dotfiles/dotfile.py106
-rw-r--r--dotfiles/exceptions.py20
-rw-r--r--dotfiles/repository.py101
-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):