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) |