diff --git a/.gitignore b/.gitignore index 9e3eb79..e398e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ dist/ build/ *.egg-info/ +tags +env +htmlcov + diff --git a/README.md b/README.md index 8c440c6..ca5120e 100644 --- a/README.md +++ b/README.md @@ -535,14 +535,77 @@ and the second using transformations (see [Transformations](#use-transformations ## Symlink dotfiles -Dotdrop allows to symlink dotfiles. Simply set the `link: true` under the -dotfile entry in the config file. +Dotdrop offers two ways to symlink dotfiles. The first simply links `dst` to +`src`. To enable it, simply set `link: true` under the dotfile entry in the +config file. + +The second symlink method is a little more complicated. It creates a symlink in +`dst` for every file/directory in `src`. + +### Why would I use `link_children`? +This feature can be very useful dotfiles such as vim where you may not want +plugins cluttering your dotfiles repository. First, the simpler `link: true` is +shown for comparison. With the `config.yaml` entry shown below, `~/.vim` gets +symlinked to `~/.dotfiles/vim/`. This means that using vim will now pollute the +dotfiles repository. `plugged` (if using +[vim-plug](https://github.com/junegunn/vim-plug)), `spell`, and `swap` +directories will appear `~/.dotfiles/vim/`. + +```yml +vim: + dst: ~/.vim/ + src: ./vim/ + actions: + - vim-plug-install + - vim-plug + link: true +``` +``` +$ readlink ~/.vim +~/.dotfiles/vim/ +$ ls ~/.dotfiles/vim/ +after autoload plugged plugin snippets spell swap vimrc +``` +Let's say we just want to store `after`, `plugin`, `snippets`, and `vimrc` in +our `~/.dotfiles` repository. This is where `link_children` comes in. Using the +configuration below, `~/.vim/` is a normal directory and only the children of +`~/.dotfiles/vim` are symlinked into it. +```yml +vim: + dst: ~/.vim/ + src: ./vim/ + actions: + - vim-plug-install + - vim-plug + link_children: true +``` + +As can be seen below, `~/.vim/` is a normal directory, not a symlink. Also, the +files/directories `after`, `plugin`, `snippets`, and `vimrc` are symlinked to +`~/.dotfiles/vim/`. +``` +$ readlink -f ~/.vim +~/.vim +$ tree -L 1 ~/.vim +~/.vim +├── after -> /.dotfiles/./vim/after +├── autoload +├── plugged +├── plugin -> /.dotfiles/./vim/plugin +├── snippets -> /.dotfiles/./vim/snippets +├── spell +├── swap +└── vimrc -> /.dotfiles/./vim/vimrc +``` + +### Templating symlinked dotfiles For dotfiles not using any templating directives, those are directly linked to dotdrop's `dotpath` directory (see [Config](#config)). When using templating directives, the dotfiles are first installed into `workdir` (defaults to *~/.config/dotdrop*, see [Config](#config)) -and then symlinked there. +and then symlinked there. This applies to both dotfiles with `link: true` and +`link_children: true`. For example ```bash diff --git a/dotdrop/config.py b/dotdrop/config.py index 0ba8835..dbd59e2 100644 --- a/dotdrop/config.py +++ b/dotdrop/config.py @@ -15,6 +15,7 @@ from dotdrop.templategen import Templategen from dotdrop.logger import Logger from dotdrop.action import Action, Transform from dotdrop.utils import * +from dotdrop.linktypes import LinkTypes class Cfg: @@ -52,6 +53,7 @@ class Cfg: key_dotfiles_src = 'src' key_dotfiles_dst = 'dst' key_dotfiles_link = 'link' + key_dotfiles_link_children = 'link_children' key_dotfiles_noempty = 'ignoreempty' key_dotfiles_cmpignore = 'cmpignore' key_dotfiles_actions = 'actions' @@ -68,7 +70,7 @@ class Cfg: default_backup = True default_create = True default_banner = True - default_link = False + default_link = LinkTypes.NOLINK default_longkey = False default_keepdot = False default_showdiff = False @@ -214,10 +216,24 @@ class Cfg: # ensures the dotfiles entry is a dict self.content[self.key_dotfiles] = {} for k, v in self.content[self.key_dotfiles].items(): - src = v[self.key_dotfiles_src] - dst = v[self.key_dotfiles_dst] - link = v[self.key_dotfiles_link] if self.key_dotfiles_link \ - in v else self.default_link + src = os.path.normpath(v[self.key_dotfiles_src]) + dst = os.path.normpath(v[self.key_dotfiles_dst]) + + # Fail if both `link` and `link_children` present + if self.key_dotfiles_link in v \ + and self.key_dotfiles_link_children in v: + msg = 'only one of `link` or `link_children` allowed per' + msg += ' dotfile, error on dotfile "{}".' + self.log.err(msg.format(k)) + + # Otherwise, get link type + link = LinkTypes.NOLINK + if self.key_dotfiles_link in v and v[self.key_dotfiles_link]: + link = LinkTypes.PARENTS + if self.key_dotfiles_link_children in v \ + and v[self.key_dotfiles_link_children]: + link = LinkTypes.CHILDREN + noempty = v[self.key_dotfiles_noempty] if \ self.key_dotfiles_noempty \ in v else self.lnk_settings[self.key_ignoreempty] @@ -266,7 +282,7 @@ class Cfg: return False # disable transformation when link is true - if link and (trans_r or trans_w): + if link != LinkTypes.NOLINK and (trans_r or trans_w): msg = 'transformations disabled for \"{}\"'.format(dst) msg += ' because link is True' self.log.warn(msg) @@ -527,7 +543,7 @@ class Cfg: return False, self._get_long_key(path) return False, self._get_short_key(path, self.dotfiles.keys()) - def new(self, dotfile, profile, link=False, debug=False): + def new(self, dotfile, profile, link=LinkTypes.NOLINK, debug=False): """import new dotfile dotfile key will change and can be empty""" # keep it short @@ -576,9 +592,9 @@ class Cfg: self.key_dotfiles_dst: dotfile.dst, self.key_dotfiles_src: dotfile.src, } - if link: + if link != LinkTypes.NOLINK: # set the link flag - dots[dotfile.key][self.key_dotfiles_link] = True + dots[dotfile.key][self.key_dotfiles_link] = link # link it to this profile in the yaml file pro = self.content[self.key_profiles][profile] diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index 18ea87c..b974be9 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -7,7 +7,6 @@ entry point import os import sys -import subprocess import socket from docopt import docopt @@ -20,7 +19,8 @@ from dotdrop.updater import Updater from dotdrop.comparator import Comparator from dotdrop.dotfile import Dotfile from dotdrop.config import Cfg -from dotdrop.utils import * +from dotdrop.utils import get_tmpdir, remove, strip_home, run +from dotdrop.linktypes import LinkTypes LOG = Logger() ENV_PROFILE = 'DOTDROP_PROFILE' @@ -108,8 +108,10 @@ def cmd_install(opts, conf, temporary=False, keys=[]): preactions.append(action) if opts['debug']: LOG.dbg('installing {}'.format(dotfile)) - if hasattr(dotfile, 'link') and dotfile.link: + if hasattr(dotfile, 'link') and dotfile.link == LinkTypes.PARENTS: r = inst.link(t, dotfile.src, dotfile.dst, actions=preactions) + elif hasattr(dotfile, 'link') and dotfile.link == LinkTypes.CHILDREN: + r = inst.linkall(t, dotfile.src, dotfile.dst, actions=preactions) else: src = dotfile.src tmp = None @@ -259,6 +261,9 @@ def cmd_importer(opts, conf, paths): # create a new dotfile dotfile = Dotfile('', dst, src) + + linktype = LinkTypes(opts['link']) + if opts['debug']: LOG.dbg('new dotfile: {}'.format(dotfile)) @@ -277,7 +282,7 @@ def cmd_importer(opts, conf, paths): cmd = ['cp', '-R', '-L', dst, srcf] if opts['dry']: LOG.dry('would run: {}'.format(' '.join(cmd))) - if opts['link']: + if linktype == LinkTypes.PARENTS: LOG.dry('would symlink {} to {}'.format(srcf, dst)) else: r, _ = run(cmd, raw=False, debug=opts['debug'], checkerr=True) @@ -285,11 +290,11 @@ def cmd_importer(opts, conf, paths): LOG.err('importing \"{}\" failed!'.format(path)) ret = False continue - if opts['link']: + if linktype == LinkTypes.PARENTS: remove(dst) os.symlink(srcf, dst) retconf, dotfile = conf.new(dotfile, opts['profile'], - link=opts['link'], debug=opts['debug']) + link=linktype, debug=opts['debug']) if retconf: LOG.sub('\"{}\" imported'.format(path)) cnt += 1 @@ -433,9 +438,16 @@ def main(): opts['profile'] = args['--profile'] opts['safe'] = not args['--force'] opts['installdiff'] = not args['--nodiff'] - opts['link'] = opts['link_by_default'] - if args['--inv-link']: - opts['link'] = not opts['link'] + opts['link'] = LinkTypes.NOLINK + if opts['link_by_default']: + opts['link'] = LinkTypes.PARENTS + + # Only invert link type from NOLINK to PARENTS and vice-versa + if args['--inv-link'] and opts['link'] == LinkTypes.NOLINK: + opts['link'] = LinkTypes.PARENTS + if args['--inv-link'] and opts['link'] == LinkTypes.PARENTS: + opts['link'] = LinkTypes.NOLINK + opts['debug'] = args['--verbose'] opts['variables'] = conf.get_variables(opts['profile']) opts['showdiff'] = opts['showdiff'] or args['--showdiff'] diff --git a/dotdrop/dotfile.py b/dotdrop/dotfile.py index 210aa9c..bf82146 100644 --- a/dotdrop/dotfile.py +++ b/dotdrop/dotfile.py @@ -5,12 +5,14 @@ Copyright (c) 2017, deadc0de6 represents a dotfile in dotdrop """ +from dotdrop.linktypes import LinkTypes + class Dotfile: def __init__(self, key, dst, src, actions={}, trans_r=None, trans_w=None, - link=False, cmpignore=[], noempty=False, + link=LinkTypes.NOLINK, cmpignore=[], noempty=False, upignore=[]): # key of dotfile in the config self.key = key @@ -35,7 +37,7 @@ class Dotfile: def __str__(self): msg = 'key:\"{}\", src:\"{}\", dst:\"{}\", link:\"{}\"' - return msg.format(self.key, self.src, self.dst, self.link) + return msg.format(self.key, self.src, self.dst, self.link.name) def __eq__(self, other): return self.__dict__ == other.__dict__ diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 080511b..959514d 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -80,6 +80,74 @@ class Installer: src = tmp return self._link(src, dst, actions=actions) + def linkall(self, templater, src, dst, actions=[]): + """link all dotfiles in a given directory""" + self.action_executed = False + parent = os.path.join(self.base, os.path.expanduser(src)) + + # Fail if source doesn't exist + if not os.path.exists(parent): + self.log.err('source dotfile does not exist: {}'.format(parent)) + return [] + + # Fail if source not a directory + if not os.path.isdir(parent): + if self.debug: + self.log.dbg('symlink children of {} to {}'.format(src, dst)) + + self.log.err('source dotfile is not a directory: {}' + .format(parent)) + return [] + + dst = os.path.normpath(os.path.expanduser(dst)) + if not os.path.lexists(dst): + self.log.sub('creating directory "{}"'.format(dst)) + os.makedirs(dst) + + if os.path.isfile(dst): + msg = ''.join([ + 'Remove regular file {} and ', + 'replace with empty directory?', + ]).format(dst) + + if self.safe and not self.log.ask(msg): + msg = 'ignoring "{}", nothing installed' + self.log.warn(msg.format(dst)) + return [] + os.unlink(dst) + os.mkdir(dst) + + children = os.listdir(parent) + srcs = [os.path.join(parent, child) for child in children] + dsts = [os.path.join(dst, child) for child in children] + + for i in range(len(children)): + src = srcs[i] + dst = dsts[i] + + if self.debug: + self.log.dbg('symlink child {} to {}'.format(src, dst)) + + if Templategen.is_template(src): + if self.debug: + self.log.dbg('dotfile is a template') + self.log.dbg('install to {} and symlink' + .format(self.workdir)) + tmp = self._pivot_path(dst, self.workdir, striphome=True) + i = self.install(templater, src, tmp, actions=actions) + if not i and not os.path.exists(tmp): + continue + src = tmp + + result = self._link(src, dst, actions) + + # Empty actions if dotfile installed + # This prevents from running actions multiple times + if len(result): + actions = [] + + return (src, dst) + def _link(self, src, dst, actions=[]): """set src as a link target of dst""" if os.path.lexists(dst): diff --git a/dotdrop/linktypes.py b/dotdrop/linktypes.py new file mode 100644 index 0000000..2e911a1 --- /dev/null +++ b/dotdrop/linktypes.py @@ -0,0 +1,7 @@ +from enum import IntEnum + + +class LinkTypes(IntEnum): + NOLINK = 0 + PARENTS = 1 + CHILDREN = 2 diff --git a/dotdrop/utils.py b/dotdrop/utils.py index c37c952..bf3e979 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -10,12 +10,13 @@ import tempfile import os import uuid import shlex +import functools +import operator import fnmatch from shutil import rmtree # local import from dotdrop.logger import Logger -from dotdrop.version import __version__ as VERSION LOG = Logger() diff --git a/tests/helpers.py b/tests/helpers.py index 472069b..d1f3ee4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -12,6 +12,7 @@ import tempfile from dotdrop.config import Cfg from dotdrop.utils import * +from dotdrop.linktypes import LinkTypes TMPSUFFIX = '.dotdrop' @@ -87,7 +88,7 @@ def load_config(confpath, profile): opts['profile'] = profile opts['safe'] = True opts['installdiff'] = True - opts['link'] = False + opts['link'] = LinkTypes.NOLINK.value opts['showdiff'] = True opts['debug'] = True opts['dopts'] = '' diff --git a/tests/test_compare.py b/tests/test_compare.py index c180bf3..9003a7e 100644 --- a/tests/test_compare.py +++ b/tests/test_compare.py @@ -7,17 +7,16 @@ basic unittest for the compare function import unittest import os -import yaml -from dotdrop.config import Cfg from dotdrop.dotdrop import cmd_importer from dotdrop.dotdrop import cmd_compare -from dotdrop.dotfile import Dotfile from dotdrop.installer import Installer from dotdrop.comparator import Comparator from dotdrop.templategen import Templategen -from tests.helpers import * +# from tests.helpers import * +from tests.helpers import create_dir, get_string, get_tempdir, clean, \ + create_random_file, create_fake_config, load_config, edit_content class TestCompare(unittest.TestCase): diff --git a/tests/test_install.py b/tests/test_install.py index 5ebef41..11a57d0 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4,14 +4,19 @@ Copyright (c) 2017, deadc0de6 basic unittest for the install function """ +import os import unittest +from unittest.mock import MagicMock, patch import filecmp -from tests.helpers import * +from dotdrop.config import Cfg +from tests.helpers import create_dir, get_string, get_tempdir, clean, \ + create_random_file, load_config from dotdrop.dotfile import Dotfile from dotdrop.installer import Installer from dotdrop.action import Action from dotdrop.dotdrop import cmd_install +from dotdrop.linktypes import LinkTypes class TestInstall(unittest.TestCase): @@ -50,7 +55,10 @@ exec bspwm f.write(' {}:\n'.format(d.key)) f.write(' dst: {}\n'.format(d.dst)) f.write(' src: {}\n'.format(d.src)) - f.write(' link: {}\n'.format(str(d.link).lower())) + f.write(' link: {}\n' + .format(str(d.link == LinkTypes.PARENTS).lower())) + f.write(' link_children: {}\n' + .format(str(d.link == LinkTypes.CHILDREN).lower())) if len(d.actions) > 0: f.write(' actions:\n') for action in d.actions: @@ -101,7 +109,7 @@ exec bspwm # to test backup f4, c4 = create_random_file(tmp) dst4 = os.path.join(dst, get_string(6)) - d4 = Dotfile(get_string(6), dst4, os.path.basename(f4)) + d4 = Dotfile(key=get_string(6), dst=dst4, src=os.path.basename(f4)) with open(dst4, 'w') as f: f.write(get_string(16)) @@ -220,6 +228,161 @@ exec bspwm tempcontent = open(dst10, 'r').read().rstrip() self.assertTrue(tempcontent == profile) + def test_link_children(self): + + # create source dir + src_dir = get_tempdir() + self.assertTrue(os.path.exists(src_dir)) + self.addCleanup(clean, src_dir) + + # where dotfiles will be installed + dst_dir = get_tempdir() + self.assertTrue(os.path.exists(dst_dir)) + self.addCleanup(clean, dst_dir) + + # create 3 random files in source + srcs = [create_random_file(src_dir)[0] for _ in range(3)] + + installer = Installer() + installer.linkall(templater=MagicMock(), src=src_dir, dst=dst_dir, + actions=[]) + + # Ensure all destination files point to source + for src in srcs: + dst = os.path.join(dst_dir, src) + self.assertEqual(os.path.realpath(dst), src) + + def test_fails_without_src(self): + src = '/some/non/existant/file' + + installer = Installer() + logger = MagicMock() + installer.log.err = logger + + res = installer.linkall(templater=MagicMock(), + src=src, + dst='/dev/null', actions=[]) + + self.assertEqual(res, []) + logger.assert_called_with('source dotfile does not exist: {}' + .format(src)) + + def test_fails_when_src_file(self): + + # create source dir + src_dir = get_tempdir() + self.assertTrue(os.path.exists(src_dir)) + self.addCleanup(clean, src_dir) + + src = create_random_file(src_dir)[0] + + logger = MagicMock() + templater = MagicMock() + installer = Installer() + installer.log.err = logger + + # pass src file not src dir + res = installer.linkall(templater=templater, src=src, dst='/dev/null', + actions=[]) + + # ensure nothing performed + self.assertEqual(res, []) + # ensure logger logged error + logger.assert_called_with('source dotfile is not a directory: {}' + .format(src)) + + def test_creates_dst(self): + src_dir = get_tempdir() + self.assertTrue(os.path.exists(src_dir)) + self.addCleanup(clean, src_dir) + + # where dotfiles will be installed + dst_dir = get_tempdir() + self.addCleanup(clean, dst_dir) + + # move dst dir to new (uncreated) dir in dst + dst_dir = os.path.join(dst_dir, get_string(6)) + self.assertFalse(os.path.exists(dst_dir)) + + installer = Installer() + installer.linkall(templater=MagicMock(), src=src_dir, dst=dst_dir, + actions=[]) + + # ensure dst dir created + self.assertTrue(os.path.exists(dst_dir)) + + def test_prompts_to_replace_dst(self): + + # create source dir + src_dir = get_tempdir() + self.assertTrue(os.path.exists(src_dir)) + self.addCleanup(clean, src_dir) + + # where dotfiles will be installed + dst_dir = get_tempdir() + self.addCleanup(clean, dst_dir) + + # Create destination file to be replaced + dst = os.path.join(dst_dir, get_string(6)) + with open(dst, 'w'): + pass + self.assertTrue(os.path.isfile(dst)) + + # setup mocks + ask = MagicMock() + ask.return_value = True + + # setup installer + installer = Installer() + installer.safe = True + installer.log.ask = ask + + installer.linkall(templater=MagicMock(), src=src_dir, dst=dst, + actions=[]) + + # ensure destination now a directory + self.assertTrue(os.path.isdir(dst)) + + # ensure prompted + ask.assert_called_with( + 'Remove regular file {} and replace with empty directory?' + .format(dst)) + + @patch('dotdrop.installer.Templategen') + def test_runs_templater(self, mocked_templategen): + + # create source dir + src_dir = get_tempdir() + self.assertTrue(os.path.exists(src_dir)) + self.addCleanup(clean, src_dir) + + # where dotfiles will be installed + dst_dir = get_tempdir() + self.assertTrue(os.path.exists(dst_dir)) + self.addCleanup(clean, dst_dir) + + # create 3 random files in source + srcs = [create_random_file(src_dir)[0] for _ in range(3)] + + # setup installer and mocks + installer = Installer() + templater = MagicMock() + templater.generate.return_value = b'content' + # make templategen treat everything as a template + mocked_templategen.is_template.return_value = True + + installer.linkall(templater=templater, src=src_dir, dst=dst_dir, + actions=[]) + + for src in srcs: + dst = os.path.join(dst_dir, os.path.basename(src)) + + # ensure dst is link + self.assertTrue(os.path.islink(dst)) + # ensure dst not directly linked to src + # TODO: maybe check that its actually linked to template folder + self.assertNotEqual(os.path.realpath(dst), src) + def main(): unittest.main() diff --git a/tests/test_update.py b/tests/test_update.py index 74f26ee..74808c3 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -7,14 +7,12 @@ basic unittest for the update function import unittest import os -import yaml -from dotdrop.config import Cfg from dotdrop.dotdrop import cmd_update from dotdrop.dotdrop import cmd_importer -from dotdrop.dotfile import Dotfile -from tests.helpers import * +from tests.helpers import create_dir, get_string, get_tempdir, clean, \ + create_random_file, create_fake_config, load_config, edit_content class TestUpdate(unittest.TestCase):