diff options
-rw-r--r-- | dotfiles.py | 226 | ||||
-rw-r--r-- | dotfiles/__init__.py | 14 | ||||
-rw-r--r-- | dotfiles/cli.py | 53 | ||||
-rw-r--r-- | dotfiles/cli_orig.py | 254 | ||||
-rw-r--r-- | dotfiles/compat.py | 214 | ||||
-rw-r--r-- | dotfiles/core.py | 269 | ||||
-rw-r--r-- | dotfiles/dotfile.py | 77 | ||||
-rw-r--r-- | dotfiles/repository.py | 61 | ||||
-rw-r--r-- | dotfiles/utils.py | 22 | ||||
-rw-r--r-- | setup.py | 10 |
10 files changed, 232 insertions, 968 deletions
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 '<Repository %r>' % 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 '<Dotfile %r>' % 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__) diff --git a/dotfiles/__init__.py b/dotfiles/__init__.py deleted file mode 100644 index 226c06e..0000000 --- a/dotfiles/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" - dotfiles - ~~~~~~~~ - - Dotfiles is a tool to make managing your dotfile symlinks in $HOME easy, - allowing you to keep all your dotfiles in a single directory. Hosting is - up to you. You can use a VCS like git, Dropbox, or even rsync to distribute - your dotfiles repository across multiple hosts. - - :copyright: (c) 2011-2014 by Jon Bernard. - :license: ISC, see LICENSE.rst for more details. -""" - -__version__ = '0.6.4' diff --git a/dotfiles/cli.py b/dotfiles/cli.py deleted file mode 100644 index 5ca6771..0000000 --- a/dotfiles/cli.py +++ /dev/null @@ -1,53 +0,0 @@ -import sys -import click - -from . import __version__ -from .repository import Repository - - -pass_repo = click.make_pass_decorator(Repository) - - -@click.group() -@click.option('-r', '--repository', type=click.Path(), default='~/Dotfiles', - help='Sets the repository folder location.') -@click.pass_context -def main(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. - - The default repository is ~/Dotfiles unless specified otherwise and will be - created on demand. If you prefer a different location, you can put your - repository wherever you like using the --repository flag or using the - ~/.dotfilesrc configuration file with a contents of: - - \b - [dotfiles] - repository = ~/Dotfiles - """ - ctx.obj = Repository(repository) - - -@main.command() -@click.option('--color', is_flag=True, help='Enables colored output.') -@pass_repo -def check(repo, color): - """Shows any broken or unsyned dotfiles.""" - list = repo.check() - if list: - click.echo_via_pager(list) - sys.exit(1) - - -@main.command() -@click.option('--color', is_flag=True, help='Enables colored output.') -@pass_repo -def status(repo, color): - """Shows the status of each dotfile.""" - click.echo_via_pager(repo.status()) - - -@main.command() -def version(): - """Shows the current version number.""" - click.echo("dotfiles v%s" % __version__) diff --git a/dotfiles/cli_orig.py b/dotfiles/cli_orig.py deleted file mode 100644 index 19bf234..0000000 --- a/dotfiles/cli_orig.py +++ /dev/null @@ -1,254 +0,0 @@ -import os -try: - import ConfigParser as configparser -except ImportError: - import configparser -from optparse import OptionParser, OptionGroup - -from .utils import compare_path, realpath_expanduser -from .core import Dotfiles as Repository -from . import __version__ - - -CONFIG_FILE = '.dotfilesrc' - - -# Users can define configuration at several different levels to overlay -# specific configuration for a particular repository. These settings are -# accumulated and passed to the Repository constructor once parsing has -# completed. -repo_settings = { - 'path': Repository.defaults['path'], - 'prefix': Repository.defaults['prefix'], - 'ignore': Repository.defaults['ignore'], - 'homedir': Repository.defaults['homedir'], - 'packages': Repository.defaults['packages'], - 'externals': Repository.defaults['externals'], -} - - -def missing_default_repo(): - """Print a helpful message when the default repository is missing. - - For a first-time user, this is the first message they're likely to see, so - it should be as helpful as possible. - """ - - print(""" -If this is your first time running dotfiles, you must first create -a repository. By default, dotfiles will look for '{0}'. -Something like: - - $ mkdir {0} - -is all you need to do. If you don't like the default, you can put your -repository wherever you like. You have two choices once you've created your -repository. You can specify the path to the repository on the command line -using the '-R' flag. Alternatively, you can create a configuration file at -'~/{1}' and place the path to your repository in there. The contents would -look like: - - [dotfiles] - repository = {0} - -Type 'dotfiles -h' to see detailed usage information.""".format - (repo_settings['path'], CONFIG_FILE)) - - -def add_global_flags(parser): - parser.add_option("-v", "--version", - action="store_true", dest="show_version", default=False, - help="show version number and exit") - - parser.add_option("-f", "--force", - action="store_true", dest="force", default=False, - help="overwrite colliding dotfiles (use with --sync)") - - parser.add_option("-R", "--repo", - type="string", dest="repository", - help="set repository location (default: %s)" % ( - repo_settings['path'])) - - parser.add_option("-p", "--prefix", - type="string", dest="prefix", - help="set prefix character (default: %s)" % ( - None if not repo_settings['prefix'] else - repo_settings['prefix'])) - - parser.add_option("-C", "--config", - type="string", dest="config_file", - help="set configuration file (default: ~/%s)" % ( - CONFIG_FILE)) - - parser.add_option("-H", "--home", - type="string", dest="homedir", - help="set home directory location (default: %s)" % ( - repo_settings['homedir'])) - - parser.add_option("-d", "--dry-run", - action="store_true", default=False, - help="don't modify anything, just print commands") - - parser.add_option("-n", "--no-dot-prefix", - action="store_true", default=False, - help="don't prefix symlinks in target directory " + - "with a '.'") - - -def add_action_group(parser): - action_group = OptionGroup(parser, "Actions") - - action_group.add_option("-a", "--add", - action="store_const", dest="action", const="add", - help="add dotfile(s) to the repository") - - action_group.add_option("-c", "--check", - action="store_const", dest="action", const="check", - help="check for broken and unsynced dotfiles") - - action_group.add_option("-l", "--list", - action="store_const", dest="action", const="list", - help="list currently managed dotfiles") - - action_group.add_option("-r", "--remove", - action="store_const", dest="action", - const="remove", - help="remove dotfile(s) from the repository") - - action_group.add_option("-s", "--sync", - action="store_const", dest="action", const="sync", - help="update dotfile symlinks") - - action_group.add_option("-m", "--move", - action="store_const", dest="action", const="move", - help="move (rename) dotfiles repository") - - parser.add_option_group(action_group) - - -def parse_args(): - - parser = OptionParser(usage="%prog ACTION [OPTION...] [FILE...]") - - add_global_flags(parser) - add_action_group(parser) - - (opts, args) = parser.parse_args() - - if opts.show_version: - print('dotfiles v%s' % __version__) - exit(0) - - if not opts.action: - print("Error: An action is required. Type 'dotfiles -h' to see " - "detailed usage information.") - exit(-1) - - return (opts, args) - - -def parse_config(config_file): - parser = configparser.SafeConfigParser() - parser.read(config_file) - - opts = dict() - - for entry in ('repository', 'prefix'): - try: - opts[entry] = parser.get('dotfiles', entry) - except configparser.NoOptionError: - pass - except configparser.NoSectionError: - break - - for entry in ('ignore', 'externals', 'packages'): - try: - opts[entry] = eval(parser.get('dotfiles', entry)) - except configparser.NoOptionError: - pass - except configparser.NoSectionError: - break - - return opts - - -def dispatch(repo, opts, args): - - # TODO: handle/pass dry_run - - if opts.action in ['list', 'check']: - getattr(repo, opts.action)() - - elif opts.action in ['add', 'remove']: - getattr(repo, opts.action)(args) - - elif opts.action == 'sync': - getattr(repo, opts.action)(files=args, force=opts.force) - - elif opts.action == 'move': - if len(args) > 1: - print("Error: Move cannot handle multiple targets.") - exit(-1) - repo.move(args[0]) - - else: - print("Error: Something truly terrible has happened.") - exit(-1) - - -def check_repository_exists(): - if not os.path.exists(repo_settings['path']): - print('Error: Could not find dotfiles repository \"%s\"' % ( - repo_settings['path'])) - if compare_path(repo_settings['path'], Repository.defaults['path']): - missing_default_repo() - exit(-1) - - -def update_settings(opts, key): - global repo_settings - - value = opts.get(key) - if value: - repo_settings[key].update(value) - - -def main(): - - global repo_settings - - (cli_opts, args) = parse_args() - - repo_settings['homedir'] = realpath_expanduser( - cli_opts.homedir or repo_settings['homedir']) - - config_opts = parse_config(cli_opts.config_file or - realpath_expanduser('~/%s' % CONFIG_FILE)) - - repo_settings['path'] = realpath_expanduser( - cli_opts.repository or - config_opts.get('repository') or - repo_settings['path']) - - check_repository_exists() - - update_settings(config_opts, 'ignore') - update_settings(config_opts, 'externals') - update_settings(config_opts, 'packages') - - repo_config_file = os.path.join(repo_settings['path'], CONFIG_FILE) - repo_config_opts = parse_config(repo_config_file) - - repo_settings['prefix'] = (cli_opts.prefix or - repo_config_opts.get('prefix') or - config_opts.get('prefix') or - repo_settings['prefix']) - repo_settings['no_dot_prefix'] = cli_opts.no_dot_prefix - - update_settings(repo_config_opts, 'ignore') - update_settings(repo_config_opts, 'externals') - update_settings(repo_config_opts, 'packages') - - repo = Repository(**repo_settings) - - dispatch(repo, cli_opts, args) diff --git a/dotfiles/compat.py b/dotfiles/compat.py deleted file mode 100644 index 456b9c9..0000000 --- a/dotfiles/compat.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Provides :func:`os.symlink`, :func:`os.path.islink` and -:func:`os.path.realpath` implementations for win32. -""" - -import os -import os.path - - -def is_unicode(s): - return type(s) == unicode # noqa - -if hasattr(os, 'symlink'): - symlink = os.symlink - islink = os.path.islink - realpath = os.path.realpath -else: - # Windows symlinks -- ctypes version - # symlink, islink, readlink, realpath, is_link_to - - win32_verbose = False # set to True to debug symlink stuff - import os - import ctypes - import struct - from ctypes import windll, wintypes - - FSCTL_GET_REPARSE_POINT = 0x900a8 - - FILE_ATTRIBUTE_READONLY = 0x0001 - FILE_ATTRIBUTE_HIDDEN = 0x0002 - FILE_ATTRIBUTE_DIRECTORY = 0x0010 - FILE_ATTRIBUTE_NORMAL = 0x0080 - FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 - - GENERIC_READ = 0x80000000 - GENERIC_WRITE = 0x40000000 - OPEN_EXISTING = 3 - FILE_READ_ATTRIBUTES = 0x80 - FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000 - INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value - - INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF - - FILE_FLAG_OPEN_REPARSE_POINT = 2097152 - FILE_FLAG_BACKUP_SEMANTICS = 33554432 - # FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTI - FILE_FLAG_REPARSE_BACKUP = 35651584 - - kdll = windll.LoadLibrary("kernel32.dll") - CreateSymbolicLinkA = windll.kernel32.CreateSymbolicLinkA - CreateSymbolicLinkA.restype = wintypes.BOOLEAN - CreateSymbolicLinkW = windll.kernel32.CreateSymbolicLinkW - CreateSymbolicLinkW.restype = wintypes.BOOLEAN - GetFileAttributesA = windll.kernel32.GetFileAttributesA - GetFileAttributesW = windll.kernel32.GetFileAttributesW - CloseHandle = windll.kernel32.CloseHandle - _CreateFileW = windll.kernel32.CreateFileW - _CreateFileA = windll.kernel32.CreateFileA - _DevIoCtl = windll.kernel32.DeviceIoControl - _DevIoCtl.argtypes = [ - wintypes.HANDLE, # HANDLE hDevice - wintypes.DWORD, # DWORD dwIoControlCode - wintypes.LPVOID, # LPVOID lpInBuffer - wintypes.DWORD, # DWORD nInBufferSize - wintypes.LPVOID, # LPVOID lpOutBuffer - wintypes.DWORD, # DWORD nOutBufferSize - ctypes.POINTER(wintypes.DWORD), # LPDWORD lpBytesReturned - wintypes.LPVOID] # LPOVERLAPPED lpOverlapped - _DevIoCtl.restype = wintypes.BOOL - - def CreateSymbolicLink(name, target, is_dir): - assert type(name) == type(target) - if is_unicode(name): - stat = CreateSymbolicLinkW(name, target, is_dir) - else: - stat = CreateSymbolicLinkA(name, target, is_dir) - if win32_verbose: - print("CreateSymbolicLink(name=%s, target=%s, is_dir=%d) = %#x" % - (name, target, is_dir, stat)) - if not stat: - print("Can't create symlink %s -> %s" % (name, target)) - raise ctypes.WinError() - - def symlink(target, name): - CreateSymbolicLink(name, target, 0) - - def GetFileAttributes(path): - if is_unicode(path): - return GetFileAttributesW(path) - else: - return GetFileAttributesA(path) - - def islink(path): - assert path - has_link_attr = GetFileAttributes(path) & FILE_ATTRIBUTE_REPARSE_POINT - if win32_verbose: - print("islink(%s): attrs=%#x: %s" % - (path, GetFileAttributes(path), has_link_attr != 0)) - return has_link_attr != 0 - - def DeviceIoControl(hDevice, ioControlCode, input, output): - # DeviceIoControl Function - # http://msdn.microsoft.com/en-us/library/aa363216(v=vs.85).aspx - if input: - input_size = len(input) - else: - input_size = 0 - if isinstance(output, int): - output = ctypes.create_string_buffer(output) - output_size = len(output) - assert isinstance(output, ctypes.Array) - bytesReturned = wintypes.DWORD() - status = _DevIoCtl(hDevice, ioControlCode, input, input_size, output, - output_size, bytesReturned, None) - if win32_verbose: - print("DeviceIOControl: status = %d" % status) - if status != 0: - return output[:bytesReturned.value] - else: - return None - - def CreateFile(path, access, sharemode, creation, flags): - if is_unicode(path): - return _CreateFileW(path, access, sharemode, None, creation, - flags, None) - else: - return _CreateFileA(path, access, sharemode, None, creation, - flags, None) - - SymbolicLinkReparseFormat = "LHHHHHHL" - SymbolicLinkReparseSize = struct.calcsize(SymbolicLinkReparseFormat) - - def readlink(path): - """ Windows readlink implementation. """ - # This wouldn't return true if the file didn't exist, as far as I know. - if not islink(path): - if win32_verbose: - print("readlink(%s): not a link." % path) - return None - - # Open the file correctly depending on the string type. - hfile = CreateFile(path, GENERIC_READ, 0, OPEN_EXISTING, - FILE_FLAG_OPEN_REPARSE_POINT) - - # MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16384 = (16*1024) - buffer = DeviceIoControl(hfile, FSCTL_GET_REPARSE_POINT, None, 16384) - CloseHandle(hfile) - - # Minimum possible length (assuming length of target is bigger than 0) - if not buffer or len(buffer) < 9: - if win32_verbose: - print("readlink(%s): no reparse buffer." % path) - return None - - # Parse and return our result. - # typedef struct _REPARSE_DATA_BUFFER { - # ULONG ReparseTag; - # USHORT ReparseDataLength; - # USHORT Reserved; - # union { - # struct { - # USHORT SubstituteNameOffset; - # USHORT SubstituteNameLength; - # USHORT PrintNameOffset; - # USHORT PrintNameLength; - # ULONG Flags; - # WCHAR PathBuffer[1]; - # } SymbolicLinkReparseBuffer; - # struct { - # USHORT SubstituteNameOffset; - # USHORT SubstituteNameLength; - # USHORT PrintNameOffset; - # USHORT PrintNameLength; - # WCHAR PathBuffer[1]; - # } MountPointReparseBuffer; - # struct { - # UCHAR DataBuffer[1]; - # } GenericReparseBuffer; - # } DUMMYUNIONNAME; - # } REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER; - - # Only handle SymbolicLinkReparseBuffer - (tag, dataLength, reserver, SubstituteNameOffset, SubstituteNameLength, - PrintNameOffset, PrintNameLength, - Flags) = struct.unpack(SymbolicLinkReparseFormat, - buffer[:SymbolicLinkReparseSize]) - # print(tag, dataLength, reserver, SubstituteNameOffset, - # iSubstituteNameLength) - start = SubstituteNameOffset + SymbolicLinkReparseSize - actualPath = buffer[start: - start + SubstituteNameLength].decode("utf-16") - # This utf-16 string is null terminated - index = actualPath.find("\0") - if index > 0: - actualPath = actualPath[:index] - if actualPath.startswith("\\??\\"): # ASCII 92, 63, 63, 92 - ret = actualPath[4:] # strip off leading junk - else: - ret = actualPath - if win32_verbose: - print("readlink(%s->%s->%s): index(null) = %d" % - (path, repr(actualPath), repr(ret), index)) - return ret - - def realpath(fpath): - while islink(fpath): - rpath = readlink(fpath) - if rpath is None: - return fpath - if not os.path.isabs(rpath): - rpath = os.path.abspath(os.path.join(os.path.dirname(fpath), - rpath)) - fpath = rpath - return fpath diff --git a/dotfiles/core.py b/dotfiles/core.py deleted file mode 100644 index 138332d..0000000 --- a/dotfiles/core.py +++ /dev/null @@ -1,269 +0,0 @@ -import os -import os.path -import shutil -import fnmatch - -from .utils import realpath_expanduser, is_link_to -from .compat import symlink - - -class Dotfile(object): - - def __init__(self, name, target, home, add_dot=True, dry_run=False): - if name.startswith('/'): - self.name = name - else: - if add_dot: - self.name = os.path.join(home, '.%s' % name.strip('.')) - else: - self.name = os.path.join(home, name) - self.basename = os.path.basename(self.name) - self.target = target.rstrip('/') - self.dry_run = dry_run - self.status = '' - if not os.path.lexists(self.name): - self.status = 'missing' - elif not is_link_to(self.name, self.target): - self.status = 'unsynced' - - def _symlink(self, target, name): - if not self.dry_run: - dirname = os.path.dirname(name) - if not os.path.isdir(dirname): - os.makedirs(dirname) - symlink(target, name) - else: - print("Creating symlink %s => %s" % (target, name)) - - def _rmtree(self, path): - if not self.dry_run: - shutil.rmtree(path) - else: - print("Removing %s and everything under it" % path) - - def _remove(self, path): - if not self.dry_run: - os.remove(path) - else: - print("Removing %s" % path) - - def _move(self, src, dst): - if not self.dry_run: - shutil.move(src, dst) - else: - print("Moving %s => %s" % (src, dst)) - - def sync(self, force): - if self.status == 'missing': - self._symlink(self.target, self.name) - elif self.status == 'unsynced': - if not force: - print("Skipping \"%s\", use --force to override" - % self.basename) - return - if os.path.isdir(self.name) and not os.path.islink(self.name): - self._rmtree(self.name) - else: - self._remove(self.name) - self._symlink(self.target, self.name) - - def add(self): - if self.status == 'missing': - print("Skipping \"%s\", file not found" % self.basename) - return - if self.status == '': - print("Skipping \"%s\", already managed" % self.basename) - return - self._move(self.name, self.target) - self._symlink(self.target, self.name) - - def remove(self): - - if self.status != '': - print("Skipping \"%s\", file is %s" % (self.basename, self.status)) - return - - # remove the existing symlink - self._remove(self.name) - - # return dotfile to its original location - if os.path.exists(self.target): - self._move(self.target, self.name) - - def __str__(self): - user_home = os.environ['HOME'] - common_prefix = os.path.commonprefix([user_home, self.name]) - if common_prefix: - name = '~%s' % self.name[len(common_prefix):] - else: - name = self.name - return '%-18s %-s' % (name, self.status) - - -class Dotfiles(object): - """A Dotfiles Repository.""" - - defaults = { - 'prefix': '', - 'packages': set(), - 'externals': dict(), - 'ignore': set(['.dotfilesrc']), - 'homedir': os.path.expanduser('~/'), - 'path': os.path.expanduser('~/Dotfiles'), - 'no_dot_prefix': False - } - - def __init__(self, **kwargs): - - # merge provided arguments with defaults into configuration - configuration = {key: kwargs.get(key, self.defaults[key]) - for key in self.defaults} - - # map configuration items to instance-local variables - for k, v in configuration.items(): - setattr(self, k, v) - - # FIXME: compatibility shims, remove these - self.dry_run = False - self.repository = self.path - - self._load() - - def _load(self): - """Load each dotfile in the repository.""" - - self.dotfiles = list() - self._load_recursive() - - def _load_recursive(self, sub_dir=''): - """Recursive helper for :meth:`_load`.""" - - src_dir = os.path.join(self.repository, sub_dir) - if sub_dir and not self.prefix: - # Add a dot to first level of packages - dst_dir = os.path.join(self.homedir, '.%s' % sub_dir) - else: - dst_dir = os.path.join(self.homedir, sub_dir) - - all_repofiles = os.listdir(src_dir) - repofiles_to_symlink = set(all_repofiles) - - for pat in self.ignore: - repofiles_to_symlink.difference_update( - fnmatch.filter(all_repofiles, pat)) - - for dotfile in repofiles_to_symlink: - pkg_path = os.path.join(sub_dir, dotfile) - if pkg_path in self.packages: - self._load_recursive(pkg_path) - else: - add_dot = False if self.no_dot_prefix else not bool(sub_dir) - self.dotfiles.append(Dotfile(dotfile, - os.path.join(src_dir, dotfile), - dst_dir, add_dot=add_dot, - dry_run=self.dry_run)) - - # Externals are top-level only - if not sub_dir: - for dotfile in self.externals.keys(): - self.dotfiles.append( - Dotfile(dotfile, - os.path.expanduser(self.externals[dotfile]), - dst_dir, add_dot=not bool(sub_dir), - dry_run=self.dry_run)) - - def _fqpn(self, dotfile, pkg_name=None): - """Return the fully qualified path to a dotfile.""" - if pkg_name is None: - return os.path.join(self.repository, - self.prefix + - os.path.basename(dotfile).strip('.')) - return os.path.join(self.repository, self.prefix + pkg_name, - os.path.basename(dotfile)) - - def list(self, verbose=True): - """List the contents of this repository.""" - - for dotfile in sorted(self.dotfiles, key=lambda dotfile: dotfile.name): - if dotfile.status or verbose: - print(dotfile) - - def check(self): - """List only unsynced and/or missing dotfiles.""" - - self.list(verbose=False) - - def sync(self, files=None, force=False): - """ - Synchronize this repository, creating and updating the necessary - symbolic links. - """ - - # unless a set of files is specified, operate on all files - if not files: - dotfiles = self.dotfiles - else: - files = set(map(lambda x: os.path.join(self.homedir, x), files)) - dotfiles = [x for x in self.dotfiles if x.name in files] - if not dotfiles: - raise Exception("file not found") - - for dotfile in dotfiles: - dotfile.sync(force) - - def add(self, files): - """Add dotfile(s) to the repository.""" - - self._perform_action('add', files) - - def remove(self, files): - """Remove dotfile(s) from the repository.""" - - self._perform_action('remove', files) - - def _perform_action(self, action, files): - for file in files: - file = file.rstrip('/') - # See if file is inside a package - file_dir, file_name = os.path.split(file) - common_prefix = os.path.commonprefix([self.homedir, file_dir]) - sub_dir = file_dir[len(common_prefix) + 1:] - pkg_name = sub_dir.lstrip('.') - if pkg_name in self.packages: - home = os.path.join(self.homedir, sub_dir) - target = self._fqpn(file, pkg_name=pkg_name) - dirname = os.path.dirname(target) - if not os.path.exists(dirname): - os.makedirs(dirname) - else: - home = self.homedir - target = self._fqpn(file) - if (action == 'add' and - os.path.split(target)[1] in self.packages): - print("Skipping \"%s\", packages not yet supported" % file) - return - if sub_dir.startswith('.') or file_name.startswith('.'): - dotfile = Dotfile(file, target, home, dry_run=self.dry_run) - getattr(dotfile, action)() - else: - print("Skipping \"%s\", not a dotfile" % file) - - def move(self, target): - """Move the repository to another location.""" - target = realpath_expanduser(target) - - if os.path.exists(target): - raise ValueError('Target already exists: %s' % (target)) - - if not self.dry_run: - shutil.copytree(self.repository, target, symlinks=True) - shutil.rmtree(self.repository) - else: - print("Recursive copy %s => %s" % (self.repository, target)) - print("Removing %s and everything under it" % self.repository) - - self.repository = target - - if not self.dry_run: - self._load() - self.sync(force=True) diff --git a/dotfiles/dotfile.py b/dotfiles/dotfile.py deleted file mode 100644 index ddb2bb2..0000000 --- a/dotfiles/dotfile.py +++ /dev/null @@ -1,77 +0,0 @@ -import errno - - -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 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 '<Dotfile %r>' % 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() diff --git a/dotfiles/repository.py b/dotfiles/repository.py deleted file mode 100644 index d9750ef..0000000 --- a/dotfiles/repository.py +++ /dev/null @@ -1,61 +0,0 @@ -import py -from operator import attrgetter - -from .dotfile import Dotfile - - -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) - - Both of the above attributes are received as string objects and converted - to py.path.local objects for internal use. - """ - - 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 '<Repository %r>' % self.repodir - - @property - def repodir(self): - return str(self._repodir) - - @repodir.setter - def repodir(self, path): - self._repodir = py.path.local(path, expanduser=True) - - @property - def homedir(self): - return str(self._homedir) - - @homedir.setter - def homedir(self, path): - self._homedir = py.path.local(path, expanduser=True) - - def _expected_name(self, target): - 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')) - - def rename(self): - raise NotImplementedError diff --git a/dotfiles/utils.py b/dotfiles/utils.py deleted file mode 100644 index fd5909f..0000000 --- a/dotfiles/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Misc utility functions. -""" - -import os.path - -from .compat import islink, realpath - - -def compare_path(path1, path2): - return (realpath_expanduser(path1) == realpath_expanduser(path2)) - - -def realpath_expanduser(path): - return realpath(os.path.expanduser(path)) - - -def is_link_to(path, target): - def normalize(path): - return os.path.normcase(os.path.normpath(path)) - return islink(path) and \ - normalize(realpath(path)) == normalize(realpath(target)) @@ -13,19 +13,21 @@ setup( open('LICENSE.rst').read() + '\n\n' + open('HISTORY.rst').read()), license='ISC', - packages=['dotfiles'], + py_modules=['dotifles'], install_requires=[ 'click', 'py', ], entry_points={ 'console_scripts': [ - 'dotfiles=dotfiles.cli_orig:main', + 'dotfiles=dotfiles:cli', ], }, classifiers=[ - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'License :: OSI Approved :: ISC License (ISCL)' ], ) |