aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AUTHORS.rst12
-rw-r--r--HISTORY.rst7
-rw-r--r--README.rst28
-rw-r--r--dotfiles/cli.py35
-rw-r--r--dotfiles/compat.py205
-rw-r--r--dotfiles/core.py346
-rw-r--r--dotfiles/utils.py22
-rwxr-xr-xtest_dotfiles.py64
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
+++++
diff --git a/README.rst b/README.rst
index c496cb7..dca0e9b 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,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)