diff options
| -rw-r--r-- | AUTHORS.rst | 12 | ||||
| -rw-r--r-- | HISTORY.rst | 7 | ||||
| -rw-r--r-- | README.rst | 28 | ||||
| -rw-r--r-- | dotfiles/cli.py | 35 | ||||
| -rw-r--r-- | dotfiles/compat.py | 205 | ||||
| -rw-r--r-- | dotfiles/core.py | 346 | ||||
| -rw-r--r-- | dotfiles/utils.py | 22 | ||||
| -rwxr-xr-x | test_dotfiles.py | 64 | 
8 files changed, 456 insertions, 263 deletions
| diff --git a/AUTHORS.rst b/AUTHORS.rst index ad4512c..ce2656b 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,14 +1,7 @@ -Dotfiles is written and maintained by Jon Bernard and various contributors: - -Development -``````````` +Dotfiles was written by Jon Bernard and is developed and maintained by the +following folks:  - Jon Bernard <jbernard@tuxion.com> - - -Patches and Suggestions -``````````````````````` -  - Anaƫl Beutot  - Remco Wendt <remco@maykinmedia.nl>  - Sebastian Rahlf @@ -16,3 +9,4 @@ Patches and Suggestions  - Daniel Harding  - Gary Oberbrunner  - Alexandre Rossi <alexandre.rossi@gmail.com> +- Luper Rouch <luper.rouch@gmail.com> diff --git a/HISTORY.rst b/HISTORY.rst index ca040a8..963ad28 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,13 @@  History  ------- +0.6.0 ++++++ + +* Add "packages" feature +* Add --dry-run option +* Much needed code cleanup +  0.5.6  +++++ @@ -36,6 +36,9 @@ Interface  ``-m, --move``      Move dotfiles repository to another location. +For all commands you can use the ``--dry-run`` option, which will print actions +and won't modify anything on your drive. +  Installation  ------------ @@ -143,6 +146,31 @@ I have the following in my ``~/.dotfilesrc``: ::  Any file you list in ``ignore`` will be skipped. The ``ignore`` option supports  glob file patterns. +Packages +-------- + +Many programs store their configuration in ``~/.config``. It's quite cluttered +and you probably don't want to keep all its content in your repository. For this +situation you can use the ``packages`` setting:: + +    [dotfiles] +    packages = ['config'] + +This tells ``dotfiles`` that the contents of the ``config`` subdirectory of +your repository must be symlinked to ``~/.config``. If for example you have a +directory ``config/awesome`` in your repository, it will be symlinked to +``~/.config/awesome``. + +This feature allows one additional level of nesting, but further subdirectories +are not eligible for being a package.  For example, ``config`` is valid, but +``config/transmission`` is not valid.  Arbitrary nesting is a feature under +current consideration. + +At the moment, packages can not be added or removed through the command line +interface.  They must be constructed and configured manually.  Once this is +done, ``sync``, ``list``, ``check``, and ``move`` will do the right thing. +Support for ``add`` and ``remove`` is a current TODO item. +  Contribute  ---------- diff --git a/dotfiles/cli.py b/dotfiles/cli.py index 00baf99..940495e 100644 --- a/dotfiles/cli.py +++ b/dotfiles/cli.py @@ -16,6 +16,9 @@ except ImportError:      import configparser  from optparse import OptionParser, OptionGroup +from dotfiles.utils import compare_path, realpath_expanduser + +  defaults = {          'prefix': '',          'homedir': '~/', @@ -28,7 +31,8 @@ settings = {          'repository': None,          'config_file': None,          'ignore': set(['.dotfilesrc']), -        'externals': dict()} +        'externals': dict(), +        'packages': set()}  def missing_default_repo(): @@ -84,6 +88,10 @@ def add_global_flags(parser):              help="set home directory location (default: %s)" % (                  defaults['homedir'])) +    parser.add_option("-d", "--dry-run", +            action="store_true", default=False, +            help="don't modify anything, just print commands") +  def add_action_group(parser):      action_group = OptionGroup(parser, "Actions") @@ -144,7 +152,8 @@ def parse_config(config_file):      opts = {'repository': None,              'prefix': None,              'ignore': set(), -            'externals': dict()} +            'externals': dict(), +            'packages': set()}      for entry in ('repository', 'prefix'):          try: @@ -154,7 +163,7 @@ def parse_config(config_file):          except configparser.NoSectionError:              break -    for entry in ('ignore', 'externals'): +    for entry in ('ignore', 'externals', 'packages'):          try:              opts[entry] = eval(parser.get('dotfiles', entry))          except configparser.NoOptionError: @@ -182,15 +191,6 @@ def dispatch(dotfiles, action, force, args):          exit(-1) -def compare_path(path1, path2): -    return (os.path.realpath(os.path.expanduser(path1)) == -            os.path.realpath(os.path.expanduser(path2))) - - -def realpath(path): -    return os.path.realpath(os.path.expanduser(path)) - -  def check_repository_exists():      if not os.path.exists(settings['repository']):          print('Error: Could not find dotfiles repository \"%s\"' % ( @@ -212,19 +212,21 @@ def main():      (cli_opts, args) = parse_args() -    settings['homedir'] = realpath(cli_opts.homedir or defaults['homedir']) -    settings['config_file'] = realpath(cli_opts.config_file or +    settings['homedir'] = realpath_expanduser(cli_opts.homedir or  +            defaults['homedir']) +    settings['config_file'] = realpath_expanduser(cli_opts.config_file or              defaults['config_file'])      config_opts = parse_config(settings['config_file']) -    settings['repository'] = realpath(cli_opts.repository or +    settings['repository'] = realpath_expanduser(cli_opts.repository or              config_opts['repository'] or defaults['repository'])      check_repository_exists()      update_settings(config_opts, 'ignore')      update_settings(config_opts, 'externals') +    update_settings(config_opts, 'packages')      repo_config_file = os.path.join(settings['repository'], '.dotfilesrc')      repo_config_opts = parse_config(repo_config_file) @@ -234,8 +236,11 @@ def main():                            config_opts['prefix'] or                            defaults['prefix']) +    settings['dry_run'] = cli_opts.dry_run +      update_settings(repo_config_opts, 'ignore')      update_settings(repo_config_opts, 'externals') +    update_settings(repo_config_opts, 'packages')      dotfiles = core.Dotfiles(**settings) diff --git a/dotfiles/compat.py b/dotfiles/compat.py new file mode 100644 index 0000000..c640586 --- /dev/null +++ b/dotfiles/compat.py @@ -0,0 +1,205 @@ +""" +Provides :func:`os.symlink`, :func:`os.path.islink` and +:func:`os.path.realpath` implementations for win32. +""" + +import os +import os.path + + +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, ctypes, 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 type(name) == unicode: +            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 type(path) == unicode: +            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 type(path) == unicode: +            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 the 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, SubstituteNameLength +        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 index 20f9a1a..1dfcea7 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -12,243 +12,74 @@ import os.path  import shutil  import fnmatch +from dotfiles.utils import realpath_expanduser, is_link_to +from dotfiles.compat import symlink -__version__ = '0.5.6' + +__version__ = '0.6.0'  __author__ = 'Jon Bernard'  __license__ = 'ISC' -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, ctypes, 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 type(name) == unicode: -            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 type(path) == unicode: -            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 type(path) == unicode: -            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 the 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, SubstituteNameLength -        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 - - -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(target)  class Dotfile(object): -    def __init__(self, name, target, home): +    def __init__(self, name, target, home, add_dot=True, dry_run=False):          if name.startswith('/'):              self.name = name          else: -            self.name = home + '/.%s' % name.strip('.') +            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': -            symlink(self.target, self.name) +            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): -                shutil.rmtree(self.name) +                self._rmtree(self.name)              else: -                os.remove(self.name) -            symlink(self.target, self.name) +                self._remove(self.name) +            self._symlink(self.target, self.name)      def add(self):          if self.status == 'missing': @@ -257,24 +88,31 @@ class Dotfile(object):          if self.status == '':              print("Skipping \"%s\", already managed" % self.basename)              return -        shutil.move(self.name, self.target) -        symlink(self.target, self.name) +        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 -        os.remove(self.name) -        shutil.move(self.target, self.name) +        self._remove(self.name) +        self._move(self.target, self.name)      def __str__(self): -        return '%-18s %-s' % (self.name.split('/')[-1], self.status) +        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.""" -    __attrs__ = ['homedir', 'repository', 'prefix', 'ignore', 'externals'] +    __attrs__ = ['homedir', 'repository', 'prefix', 'ignore', 'externals', +            'packages', 'dry_run']      def __init__(self, **kwargs): @@ -289,8 +127,19 @@ class Dotfiles(object):          """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: +            # 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(self.repository) +        all_repofiles = os.listdir(src_dir)          repofiles_to_symlink = set(all_repofiles)          for pat in self.ignore: @@ -298,19 +147,28 @@ class Dotfiles(object):                      fnmatch.filter(all_repofiles, pat))          for dotfile in repofiles_to_symlink: -            self.dotfiles.append(Dotfile(dotfile[len(self.prefix):], -                os.path.join(self.repository, dotfile), self.homedir)) - -        for dotfile in self.externals.keys(): -            self.dotfiles.append(Dotfile(dotfile, -                os.path.expanduser(self.externals[dotfile]), -                self.homedir)) - -    def _fqpn(self, dotfile): +            pkg_path = os.path.join(sub_dir, dotfile) +            if pkg_path in self.packages: +                self._load_recursive(pkg_path) +            else: +                self.dotfiles.append(Dotfile(dotfile[len(self.prefix):], +                    os.path.join(src_dir, dotfile), dst_dir,  +                    add_dot=not bool(sub_dir), 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.""" - -        return os.path.join(self.repository, -                            self.prefix + os.path.basename(dotfile).strip('.')) +        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.""" @@ -345,21 +203,39 @@ class Dotfiles(object):      def _perform_action(self, action, files):          for file in files:              file = file.rstrip('/') -            if os.path.basename(file).startswith('.'): -                getattr(Dotfile(file, self._fqpn(file), self.homedir), action)() +            # 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) +            else: +                home = self.homedir +                target = self._fqpn(file) +            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)) -        shutil.copytree(self.repository, target) -        shutil.rmtree(self.repository) +        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 -        self._load() -        self.sync(force=True) +        if not self.dry_run: +            self._load() +            self.sync(force=True) diff --git a/dotfiles/utils.py b/dotfiles/utils.py new file mode 100644 index 0000000..28f2212 --- /dev/null +++ b/dotfiles/utils.py @@ -0,0 +1,22 @@ +""" +Misc utility functions. +""" + +import os.path + +from dotfiles.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)) diff --git a/test_dotfiles.py b/test_dotfiles.py index b892003..88ffe67 100755 --- a/test_dotfiles.py +++ b/test_dotfiles.py @@ -9,6 +9,7 @@ import tempfile  import unittest  from dotfiles import core +from dotfiles.utils import is_link_to  def touch(fname, times=None): @@ -52,7 +53,8 @@ class DotfilesTestCase(unittest.TestCase):          dotfiles = core.Dotfiles(                  homedir=self.homedir, repository=self.repository, -                prefix='', ignore=[], externals=externals) +                prefix='', ignore=[], externals=externals, packages=[], +                dry_run=False)          dotfiles.sync(force=True) @@ -67,7 +69,8 @@ class DotfilesTestCase(unittest.TestCase):          dotfiles = core.Dotfiles(                  homedir=self.homedir, repository=self.repository, -                prefix='', ignore=[], force=True, externals={}) +                prefix='', ignore=[], force=True, externals={}, packages=[], +                dry_run=False)          dotfiles.sync() @@ -111,7 +114,7 @@ class DotfilesTestCase(unittest.TestCase):          dotfiles = core.Dotfiles(                  homedir=self.homedir, repository=self.repository, -                prefix='', ignore=[], externals={}) +                prefix='', ignore=[], externals={}, packages=[], dry_run=False)          dotfiles.sync(force=True) @@ -161,7 +164,8 @@ class DotfilesTestCase(unittest.TestCase):          dotfiles = core.Dotfiles(                  homedir=self.homedir, repository=self.repository, -                prefix='', ignore=ignore, externals={}) +                prefix='', ignore=ignore, externals={}, packages=[], +                dry_run=False)          dotfiles.sync() @@ -176,6 +180,58 @@ class DotfilesTestCase(unittest.TestCase):              self.assertPathEqual(                  os.path.join(self.repository, original),                  os.path.join(self.homedir, symlink)) +         +    def test_packages(self): +        """ +        Test packages. +        """ +        files = ['foo', 'package/bar'] +        symlinks = ['.foo', '.package/bar'] +        join = os.path.join + +        # Create files +        for filename in files: +            path = join(self.repository, filename) +            dirname = os.path.dirname(path) +            if not os.path.exists(dirname): +                os.makedirs(dirname) +            touch(path) + +        # Create Dotiles object +        dotfiles = core.Dotfiles( +                homedir=self.homedir, repository=self.repository, +                prefix='', ignore=[], externals={}, packages=['package'], +                dry_run=False) + +        # Create symlinks in homedir +        dotfiles.sync() + +        # Verify it created what we expect +        def check_all(files, symlinks): +            self.assertTrue(os.path.isdir(join(self.homedir, '.package'))) +            for src, dst in zip(files, symlinks): +                self.assertTrue(is_link_to(join(self.homedir, dst), +                    join(self.repository, src))) +        check_all(files, symlinks) + +        # Add files to the repository +        new_files = [join(self.homedir, f) for f in ['.bar', '.package/foo']] +        for filename in new_files: +            path = join(self.homedir, filename) +            touch(path) +        new_repo_files = ['bar', 'package/foo'] +        dotfiles.add(new_files) +        check_all(files + new_repo_files, symlinks + new_files) + +        # Remove them from the repository +        dotfiles.remove(new_files) +        check_all(files, symlinks) + +        # Move the repository +        self.repository = join(self.homedir, 'Dotfiles2') +        dotfiles.move(self.repository) +        check_all(files, symlinks) +  def suite():      suite = unittest.TestLoader().loadTestsFromTestCase(DotfilesTestCase) | 
