From a3af71d420c4ef85433e79161c02563be595cc98 Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 15:10:51 +0100 Subject: * reorganized modules a bit * added --dry-run option * added support for packages --- dotfiles/cli.py | 35 +++--- dotfiles/compat.py | 205 +++++++++++++++++++++++++++++++++ dotfiles/core.py | 325 ++++++++++++++++------------------------------------- dotfiles/utils.py | 23 ++++ 4 files changed, 345 insertions(+), 243 deletions(-) create mode 100644 dotfiles/compat.py create mode 100644 dotfiles/utils.py 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..d29c6ae 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -12,243 +12,71 @@ import os.path import shutil import fnmatch +from dotfiles.utils import realpath_expanduser, compare_path, is_link_to +from dotfiles.compat import symlink + __version__ = '0.5.6' __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: + 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 +85,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 +124,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`.""" - all_repofiles = os.listdir(self.repository) + 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(src_dir) repofiles_to_symlink = set(all_repofiles) for pat in self.ignore: @@ -298,19 +144,26 @@ 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)) + 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)) for dotfile in self.externals.keys(): self.dotfiles.append(Dotfile(dotfile, os.path.expanduser(self.externals[dotfile]), - self.homedir)) + dst_dir, add_dot=not bool(sub_dir), dry_run=self.dry_run)) - def _fqpn(self, dotfile): + 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,8 +198,20 @@ 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) @@ -355,9 +220,13 @@ class Dotfiles(object): 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) + 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 diff --git a/dotfiles/utils.py b/dotfiles/utils.py new file mode 100644 index 0000000..15a82d8 --- /dev/null +++ b/dotfiles/utils.py @@ -0,0 +1,23 @@ +""" +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(target) + -- cgit v1.2.3 From 532b90b85ea2431202e9797869dabc069b064fe2 Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 15:11:09 +0100 Subject: Added Luper Rouch to authors --- AUTHORS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index ad4512c..a3a1a54 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -16,3 +16,4 @@ Patches and Suggestions - Daniel Harding - Gary Oberbrunner - Alexandre Rossi +- Luper Rouch -- cgit v1.2.3 From 874926074b973240973ea822400b825a6158e018 Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 15:21:09 +0100 Subject: updated readme --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.rst b/README.rst index c496cb7..8357768 100644 --- a/README.rst +++ b/README.rst @@ -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,21 @@ I have the following in my ``~/.dotfilesrc``: :: Any file you list in ``ignore`` will be skipped. The ``ignore`` option supports glob file patterns. +Packages +-------- + +Most recent 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``. + Contribute ---------- -- cgit v1.2.3 From 3a74687a1fd38df28b30e239fbb77ebfea853d1c Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 15:24:36 +0100 Subject: fixed error with --move + --dry-run --- dotfiles/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dotfiles/core.py b/dotfiles/core.py index d29c6ae..90a9ba4 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -230,5 +230,6 @@ class Dotfiles(object): self.repository = target - self._load() - self.sync(force=True) + if not self.dry_run: + self._load() + self.sync(force=True) -- cgit v1.2.3 From 68365ac62745d38dc0ab7a9a0eab90f9032a86a1 Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 15:30:46 +0100 Subject: move: copying source files with symlinks=True, and expanding target to an absolute path --- dotfiles/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotfiles/core.py b/dotfiles/core.py index 90a9ba4..21c96fd 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -217,12 +217,13 @@ class Dotfiles(object): 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) + shutil.copytree(self.repository, target, symlinks=True) shutil.rmtree(self.repository) else: print("Recursive copy %s => %s" % (self.repository, target)) -- cgit v1.2.3 From 9832e47a2d3071023ed828df24f44d5a3258626a Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 15:49:03 +0100 Subject: fixed tests --- test_dotfiles.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test_dotfiles.py b/test_dotfiles.py index b892003..2bfdb8b 100755 --- a/test_dotfiles.py +++ b/test_dotfiles.py @@ -52,7 +52,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 +68,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 +113,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 +163,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 +179,8 @@ class DotfilesTestCase(unittest.TestCase): self.assertPathEqual( os.path.join(self.repository, original), os.path.join(self.homedir, symlink)) + + def suite(): suite = unittest.TestLoader().loadTestsFromTestCase(DotfilesTestCase) -- cgit v1.2.3 From 08e02ef848e9af19592bf4ba809dc7574592fff2 Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 16:34:07 +0100 Subject: added packages tests --- test_dotfiles.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test_dotfiles.py b/test_dotfiles.py index 2bfdb8b..4ecb9d7 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): @@ -180,6 +181,56 @@ class DotfilesTestCase(unittest.TestCase): 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(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(): -- cgit v1.2.3 From 01a349882e929d507605fb015c14be71006f854a Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 16:34:33 +0100 Subject: checking that the target directory exists before creating a symlink --- dotfiles/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotfiles/core.py b/dotfiles/core.py index 21c96fd..44fd978 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -12,7 +12,7 @@ import os.path import shutil import fnmatch -from dotfiles.utils import realpath_expanduser, compare_path, is_link_to +from dotfiles.utils import realpath_expanduser, is_link_to from dotfiles.compat import symlink @@ -42,6 +42,9 @@ class Dotfile(object): 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)) -- cgit v1.2.3 From 23775a043edca5c979b3d47288364d0484e7a919 Mon Sep 17 00:00:00 2001 From: Luper Rouch Date: Sat, 9 Feb 2013 18:35:34 +0100 Subject: typo in test --- test_dotfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_dotfiles.py b/test_dotfiles.py index 4ecb9d7..88ffe67 100755 --- a/test_dotfiles.py +++ b/test_dotfiles.py @@ -208,7 +208,7 @@ class DotfilesTestCase(unittest.TestCase): # Verify it created what we expect def check_all(files, symlinks): - self.assertTrue(join(self.homedir, '.package')) + 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))) -- cgit v1.2.3 From 22b92437c1ae672297bed8567b335ef7da100103 Mon Sep 17 00:00:00 2001 From: Jon Bernard Date: Sat, 9 Feb 2013 15:46:02 -0500 Subject: Fix link detection when target is itself a symlink This shows up on OSX where /tmp is actually a symlink to /private/tmp. --- dotfiles/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotfiles/utils.py b/dotfiles/utils.py index 15a82d8..28f2212 100644 --- a/dotfiles/utils.py +++ b/dotfiles/utils.py @@ -19,5 +19,4 @@ 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) - + normalize(realpath(path)) == normalize(realpath(target)) -- cgit v1.2.3 From 4e271e534ee5ec19f1cd76f384186a8d71352422 Mon Sep 17 00:00:00 2001 From: Jon Bernard Date: Sat, 9 Feb 2013 15:48:00 -0500 Subject: Remove distinction from AUTHORS --- AUTHORS.rst | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index a3a1a54..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 - - -Patches and Suggestions -``````````````````````` - - Anaƫl Beutot - Remco Wendt - Sebastian Rahlf -- cgit v1.2.3 From 4db331138d9ef9d2ae66dacaea108f57822fabfd Mon Sep 17 00:00:00 2001 From: Jon Bernard Date: Sat, 9 Feb 2013 21:24:27 -0500 Subject: Update packages section in README This commit adds a note about current limitations and explains what currently works and what remains a todo item. --- README.rst | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 8357768..dca0e9b 100644 --- a/README.rst +++ b/README.rst @@ -149,9 +149,9 @@ glob file patterns. Packages -------- -Most recent 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:: +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'] @@ -161,6 +161,16 @@ 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 ---------- -- cgit v1.2.3 From e0f3fbee32be2aabb853a837eb6dd137ecb454be Mon Sep 17 00:00:00 2001 From: Jon Bernard Date: Sun, 10 Feb 2013 08:58:56 -0500 Subject: Whitespace --- dotfiles/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotfiles/core.py b/dotfiles/core.py index 44fd978..831ebf6 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -138,7 +138,7 @@ class Dotfiles(object): 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) @@ -224,7 +224,7 @@ class Dotfiles(object): 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) -- cgit v1.2.3 From fc0f227c93c1590ed33dfe505dbb95a7706bdc50 Mon Sep 17 00:00:00 2001 From: Jon Bernard Date: Sun, 10 Feb 2013 09:27:01 -0500 Subject: Prevent externals from showing up in packages The load routine was made recursive to support packages, so externals should only be added during the first pass and not within package directories. External processing should probably be done before recursion begins to avoid this check on every call. --- dotfiles/core.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dotfiles/core.py b/dotfiles/core.py index 831ebf6..1341fab 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -155,10 +155,12 @@ class Dotfiles(object): os.path.join(src_dir, dotfile), dst_dir, add_dot=not bool(sub_dir), dry_run=self.dry_run)) - 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)) + # 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.""" -- cgit v1.2.3 From 088543ecc6435ea45d2f102a5e5ac015a4782275 Mon Sep 17 00:00:00 2001 From: Jon Bernard Date: Mon, 11 Feb 2013 12:23:04 -0500 Subject: Update history and bump version number --- HISTORY.rst | 7 +++++++ dotfiles/core.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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 +++++ diff --git a/dotfiles/core.py b/dotfiles/core.py index 1341fab..1dfcea7 100644 --- a/dotfiles/core.py +++ b/dotfiles/core.py @@ -16,7 +16,7 @@ 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' -- cgit v1.2.3