From fffe55ecd909415fb1977fd40edad24fcd238174 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sat, 29 Dec 2018 15:24:02 -0400 Subject: [PATCH 01/19] Add option to symlink only child files --- dotdrop/config.py | 22 +++++++++++++--------- dotdrop/dotdrop.py | 28 +++++++++++++++------------- dotdrop/dotfile.py | 7 +++++-- dotdrop/installer.py | 39 ++++++++++++++++++++++++++++++++++++++- dotdrop/linktypes.py | 8 ++++++++ dotdrop/utils.py | 9 ++++++++- 6 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 dotdrop/linktypes.py diff --git a/dotdrop/config.py b/dotdrop/config.py index 85c1510..b6e62ac 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: @@ -67,7 +68,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 @@ -212,10 +213,13 @@ 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]) + link = LinkTypes.PARENTS \ + if self.key_dotfiles_link in v and v[self.key_dotfiles_link] \ + else LinkTypes.CHILDREN \ + if 'link_children' in v and v['link_children'] \ + else LinkTypes.NOLINK noempty = v[self.key_dotfiles_noempty] if \ self.key_dotfiles_noempty \ in v else self.lnk_settings[self.key_ignoreempty] @@ -264,7 +268,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) @@ -520,7 +524,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 @@ -569,9 +573,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 ea37dd9..ff39eb2 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 * +import dotdrop.utils as dd +from dotdrop.linktypes import LinkTypes LOG = Logger() ENV_PROFILE = 'DOTDROP_PROFILE' @@ -92,7 +92,7 @@ def cmd_install(opts, conf, temporary=False, keys=[]): variables=opts['variables'], debug=opts['debug']) tmpdir = None if temporary: - tmpdir = get_tmpdir() + tmpdir = dd.get_tmpdir() inst = Installer(create=opts['create'], backup=opts['backup'], dry=opts['dry'], safe=opts['safe'], base=opts['dotpath'], workdir=opts['workdir'], @@ -106,8 +106,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 @@ -121,7 +123,7 @@ def cmd_install(opts, conf, temporary=False, keys=[]): if tmp: tmp = os.path.join(opts['dotpath'], tmp) if os.path.exists(tmp): - remove(tmp) + dd.remove(tmp) if len(r) > 0: if Cfg.key_actions_post in dotfile.actions: actions = dotfile.actions[Cfg.key_actions_post] @@ -189,7 +191,7 @@ def cmd_compare(opts, conf, tmp, focus=[], ignore=[]): # clean tmp transformed dotfile if any tmpsrc = os.path.join(opts['dotpath'], tmpsrc) if os.path.exists(tmpsrc): - remove(tmpsrc) + dd.remove(tmpsrc) if diff == '': if opts['debug']: LOG.dbg('diffing \"{}\" VS \"{}\"'.format(dotfile.key, @@ -243,7 +245,7 @@ def cmd_importer(opts, conf, paths): continue dst = path.rstrip(os.sep) dst = os.path.abspath(dst) - src = strip_home(dst) + src = dd.strip_home(dst) strip = '.' + os.sep if opts['keepdot']: strip = os.sep @@ -262,7 +264,7 @@ def cmd_importer(opts, conf, paths): if opts['dry']: LOG.dry('would run: {}'.format(' '.join(cmd))) else: - r, _ = run(cmd, raw=False, debug=opts['debug'], checkerr=True) + r, _ = dd.run(cmd, raw=False, debug=opts['debug'], checkerr=True) if not r: LOG.err('importing \"{}\" failed!'.format(path)) ret = False @@ -273,13 +275,13 @@ def cmd_importer(opts, conf, paths): if linkit: LOG.dry('would symlink {} to {}'.format(srcf, dst)) else: - r, _ = run(cmd, raw=False, debug=opts['debug'], checkerr=True) + r, _ = dd.run(cmd, raw=False, debug=opts['debug'], checkerr=True) if not r: LOG.err('importing \"{}\" failed!'.format(path)) ret = False continue if linkit: - remove(dst) + dd.remove(dst) os.symlink(srcf, dst) retconf, dotfile = conf.new(dotfile, opts['profile'], link=linkit, debug=opts['debug']) @@ -400,7 +402,7 @@ def apply_trans(opts, dotfile): msg = 'transformation \"{}\" failed for {}' LOG.err(msg.format(trans.key, dotfile.key)) if new_src and os.path.exists(new_src): - remove(new_src) + dd.remove(new_src) return None return new_src @@ -469,12 +471,12 @@ def main(): # compare local dotfiles with dotfiles stored in dotdrop if opts['debug']: LOG.dbg('running cmd: compare') - tmp = get_tmpdir() + tmp = dd.get_tmpdir() opts['dopts'] = args['--dopts'] ret = cmd_compare(opts, conf, tmp, focus=args['--file'], ignore=args['--ignore']) # clean tmp directory - remove(tmp) + dd.remove(tmp) elif args['import']: # import dotfile(s) diff --git a/dotdrop/dotfile.py b/dotdrop/dotfile.py index 45e14ee..bcd3329 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): # key of dotfile in the config self.key = key # path where to install this dotfile @@ -32,10 +34,11 @@ 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__ def __hash__(self): return hash(self.dst) ^ hash(self.src) ^ hash(self.key) + diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 71bb2d2..4a0a42e 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -64,7 +64,7 @@ class Installer: if not os.path.exists(src): self.log.err('source dotfile does not exist: {}'.format(src)) return [] - dst = os.path.expanduser(dst) + dst = os.path.normpath(os.path.expanduser(dst)) if self.totemp: # ignore actions return self.install(templater, src, dst, actions=[]) @@ -80,6 +80,42 @@ 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)) + if not os.path.exists(parent): + self.log.err('source dotfile does not exist: {}'.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 = '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] + + results = [] + for i in range(len(children)): + result = self._link(srcs[i], dsts[i], actions) + if len(result): + actions = [] + results.append(result) + + return utils.flatten(results) + def _link(self, src, dst, actions=[]): """set src as a link target of dst""" if os.path.lexists(dst): @@ -308,3 +344,4 @@ class Installer: self.comparing = False self.create = createsaved return ret, tmpdst + diff --git a/dotdrop/linktypes.py b/dotdrop/linktypes.py new file mode 100644 index 0000000..bd943a8 --- /dev/null +++ b/dotdrop/linktypes.py @@ -0,0 +1,8 @@ +from enum import IntEnum + + +class LinkTypes(IntEnum): + NOLINK = 0 + PARENTS = 1 + CHILDREN = 2 + diff --git a/dotdrop/utils.py b/dotdrop/utils.py index b88780b..77588f4 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -10,11 +10,12 @@ import tempfile import os import uuid import shlex +import functools +import operator from shutil import rmtree # local import from dotdrop.logger import Logger -from dotdrop.version import __version__ as VERSION LOG = Logger() @@ -109,3 +110,9 @@ def strip_home(path): if path.startswith(home): path = path[len(home):] return path + + +def flatten(a): + """flatten list""" + return functools.reduce(operator.iconcat, a, []) + From 67be049407e0d35c2861c005a68119000df275a5 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sat, 29 Dec 2018 15:35:29 -0400 Subject: [PATCH 02/19] Import only used functions --- dotdrop/dotdrop.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index ff39eb2..f044939 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -19,7 +19,7 @@ from dotdrop.updater import Updater from dotdrop.comparator import Comparator from dotdrop.dotfile import Dotfile from dotdrop.config import Cfg -import dotdrop.utils as dd +from dotdrop.utils import get_tmpdir, remove, strip_home, run from dotdrop.linktypes import LinkTypes LOG = Logger() @@ -92,7 +92,7 @@ def cmd_install(opts, conf, temporary=False, keys=[]): variables=opts['variables'], debug=opts['debug']) tmpdir = None if temporary: - tmpdir = dd.get_tmpdir() + tmpdir = get_tmpdir() inst = Installer(create=opts['create'], backup=opts['backup'], dry=opts['dry'], safe=opts['safe'], base=opts['dotpath'], workdir=opts['workdir'], @@ -123,7 +123,7 @@ def cmd_install(opts, conf, temporary=False, keys=[]): if tmp: tmp = os.path.join(opts['dotpath'], tmp) if os.path.exists(tmp): - dd.remove(tmp) + remove(tmp) if len(r) > 0: if Cfg.key_actions_post in dotfile.actions: actions = dotfile.actions[Cfg.key_actions_post] @@ -191,7 +191,7 @@ def cmd_compare(opts, conf, tmp, focus=[], ignore=[]): # clean tmp transformed dotfile if any tmpsrc = os.path.join(opts['dotpath'], tmpsrc) if os.path.exists(tmpsrc): - dd.remove(tmpsrc) + remove(tmpsrc) if diff == '': if opts['debug']: LOG.dbg('diffing \"{}\" VS \"{}\"'.format(dotfile.key, @@ -245,7 +245,7 @@ def cmd_importer(opts, conf, paths): continue dst = path.rstrip(os.sep) dst = os.path.abspath(dst) - src = dd.strip_home(dst) + src = strip_home(dst) strip = '.' + os.sep if opts['keepdot']: strip = os.sep @@ -264,7 +264,7 @@ def cmd_importer(opts, conf, paths): if opts['dry']: LOG.dry('would run: {}'.format(' '.join(cmd))) else: - r, _ = dd.run(cmd, raw=False, debug=opts['debug'], checkerr=True) + r, _ = run(cmd, raw=False, debug=opts['debug'], checkerr=True) if not r: LOG.err('importing \"{}\" failed!'.format(path)) ret = False @@ -275,13 +275,13 @@ def cmd_importer(opts, conf, paths): if linkit: LOG.dry('would symlink {} to {}'.format(srcf, dst)) else: - r, _ = dd.run(cmd, raw=False, debug=opts['debug'], checkerr=True) + r, _ = run(cmd, raw=False, debug=opts['debug'], checkerr=True) if not r: LOG.err('importing \"{}\" failed!'.format(path)) ret = False continue if linkit: - dd.remove(dst) + remove(dst) os.symlink(srcf, dst) retconf, dotfile = conf.new(dotfile, opts['profile'], link=linkit, debug=opts['debug']) @@ -402,7 +402,7 @@ def apply_trans(opts, dotfile): msg = 'transformation \"{}\" failed for {}' LOG.err(msg.format(trans.key, dotfile.key)) if new_src and os.path.exists(new_src): - dd.remove(new_src) + remove(new_src) return None return new_src @@ -471,12 +471,12 @@ def main(): # compare local dotfiles with dotfiles stored in dotdrop if opts['debug']: LOG.dbg('running cmd: compare') - tmp = dd.get_tmpdir() + tmp = get_tmpdir() opts['dopts'] = args['--dopts'] ret = cmd_compare(opts, conf, tmp, focus=args['--file'], ignore=args['--ignore']) # clean tmp directory - dd.remove(tmp) + remove(tmp) elif args['import']: # import dotfile(s) From 0cc5ec9e13dc514bbaf4f764b7ebf7eaee4df3c2 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sat, 29 Dec 2018 15:50:06 -0400 Subject: [PATCH 03/19] Conform to PEP8 Though [it is generally recommended to have a trailing newline](https://stackoverflow.com/questions/2287967/why-is-it-recommended-to-have-empty-line-in-the-end-of-file) --- dotdrop/dotfile.py | 1 - dotdrop/installer.py | 8 +++++--- dotdrop/linktypes.py | 1 - dotdrop/utils.py | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/dotdrop/dotfile.py b/dotdrop/dotfile.py index bcd3329..f758de7 100644 --- a/dotdrop/dotfile.py +++ b/dotdrop/dotfile.py @@ -41,4 +41,3 @@ class Dotfile: def __hash__(self): return hash(self.dst) ^ hash(self.src) ^ hash(self.key) - diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 4a0a42e..0c72e34 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -94,8 +94,11 @@ class Installer: os.makedirs(dst) if os.path.isfile(dst): - msg = 'Remove regular file "{}" and replace with empty directory?' \ - .format(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)) @@ -344,4 +347,3 @@ class Installer: self.comparing = False self.create = createsaved return ret, tmpdst - diff --git a/dotdrop/linktypes.py b/dotdrop/linktypes.py index bd943a8..2e911a1 100644 --- a/dotdrop/linktypes.py +++ b/dotdrop/linktypes.py @@ -5,4 +5,3 @@ class LinkTypes(IntEnum): NOLINK = 0 PARENTS = 1 CHILDREN = 2 - diff --git a/dotdrop/utils.py b/dotdrop/utils.py index 77588f4..e4afcad 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -115,4 +115,3 @@ def strip_home(path): def flatten(a): """flatten list""" return functools.reduce(operator.iconcat, a, []) - From e119b8829e240554b42552fe28a853ca5dfe74a1 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 20 Jan 2019 13:34:26 -0400 Subject: [PATCH 04/19] Fail if source isn't a directory --- dotdrop/installer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 0c72e34..9f7aa23 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -84,10 +84,18 @@ class Installer: """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): + 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)) From 08addde1d95a1c1594975b1aedabb3aa01e06a15 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 20 Jan 2019 13:34:56 -0400 Subject: [PATCH 05/19] Support templating --- dotdrop/installer.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 9f7aa23..7e9ebad 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -120,9 +120,27 @@ class Installer: results = [] for i in range(len(children)): - result = self._link(srcs[i], dsts[i], actions) + src = srcs[i] + dst = dsts[i] + + 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 = [] + results.append(result) return utils.flatten(results) From 247653c2c2d1dcdcb02b69b396fb18a8d72954c8 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 20 Jan 2019 15:00:50 -0400 Subject: [PATCH 06/19] Clean up imports --- tests/test_compare.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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): From 170df39c603a6a08e1cdf8a52683221766068280 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 20 Jan 2019 15:02:09 -0400 Subject: [PATCH 07/19] Move link from bool to enum --- dotdrop/dotdrop.py | 14 ++++++++++---- tests/helpers.py | 1 + tests/test_install.py | 13 ++++++++++--- tests/test_update.py | 6 ++---- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index f044939..b2d9683 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -253,7 +253,13 @@ def cmd_importer(opts, conf, paths): # create a new dotfile dotfile = Dotfile('', dst, src) - linkit = opts['link'] or opts['link_by_default'] + + linktype = LinkTypes.NOLINK + if opts['link'] or opts['link_by_default']: + linktype = LinkTypes.PARENTS + elif opts['link_children']: + linktype = LinkTypes.CHILDREN + if opts['debug']: LOG.dbg('new dotfile: {}'.format(dotfile)) @@ -272,7 +278,7 @@ def cmd_importer(opts, conf, paths): cmd = ['cp', '-R', '-L', dst, srcf] if opts['dry']: LOG.dry('would run: {}'.format(' '.join(cmd))) - if linkit: + if linktype == LinkTypes.PARENTS: LOG.dry('would symlink {} to {}'.format(srcf, dst)) else: r, _ = run(cmd, raw=False, debug=opts['debug'], checkerr=True) @@ -280,11 +286,11 @@ def cmd_importer(opts, conf, paths): LOG.err('importing \"{}\" failed!'.format(path)) ret = False continue - if linkit: + if linktype == LinkTypes.PARENTS: remove(dst) os.symlink(srcf, dst) retconf, dotfile = conf.new(dotfile, opts['profile'], - link=linkit, debug=opts['debug']) + link=linktype, debug=opts['debug']) if retconf: LOG.sub('\"{}\" imported'.format(path)) cnt += 1 diff --git a/tests/helpers.py b/tests/helpers.py index 472069b..be55796 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -88,6 +88,7 @@ def load_config(confpath, profile): opts['safe'] = True opts['installdiff'] = True opts['link'] = False + opts['link_children'] = False opts['showdiff'] = True opts['debug'] = True opts['dopts'] = '' diff --git a/tests/test_install.py b/tests/test_install.py index 5ebef41..51e0368 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4,14 +4,18 @@ Copyright (c) 2017, deadc0de6 basic unittest for the install function """ +import os import unittest 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 +54,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 +108,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)) 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): From ae00dce6a3c480a39cbfdbb86214975286d0ca5a Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 20 Jan 2019 15:02:43 -0400 Subject: [PATCH 08/19] Don't track tags or virtualenv --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9e3eb79..90a2baa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ build/ *.egg-info/ +tags +env + From 6d8afa484cb38f981ace89ba7c4b6364c49cd2ae Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 20 Jan 2019 16:03:15 -0400 Subject: [PATCH 09/19] Add unit tests --- .gitignore | 1 + dotdrop/installer.py | 2 +- tests/test_install.py | 155 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 90a2baa..e398e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ build/ *.egg-info/ tags env +htmlcov diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 7e9ebad..1b41ee4 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -103,7 +103,7 @@ class Installer: if os.path.isfile(dst): msg = ''.join([ - 'Remove regular file "{}" and ', + 'Remove regular file {} and ', 'replace with empty directory?', ]).format(dst) diff --git a/tests/test_install.py b/tests/test_install.py index 51e0368..956cc41 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -6,6 +6,7 @@ basic unittest for the install function import os import unittest +from unittest.mock import MagicMock, patch import filecmp from dotdrop.config import Cfg @@ -227,6 +228,160 @@ 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() + installer = Installer() + installer.log.err = logger + + # pass src file not src dir + res = installer.linkall(templater=MagicMock(), 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() From d31a0c70c39ae80a2a64b3e1636285134ee2dac7 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 20 Jan 2019 16:11:38 -0400 Subject: [PATCH 10/19] Document new feature in README.md --- README.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 75fd6e5..33e7248 100644 --- a/README.md +++ b/README.md @@ -520,14 +520,42 @@ and the second using transformations (see [Transformations](#use-transformations ## Symlink dotfiles -Dotdrop allows to symlink dotfiles. Simply set the `link: true` under the +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/folder in `src`. This feature can be very useful dotfiles +such as vim where you may not want plugins cluttering your dotfiles repository. +An example configuration and the corresponding result is given below. + +```yml +vim: + dst: ~/.vim/ + src: ./vim/ + actions: + - vim-plug-install + - vim-plug + link_children: true +``` +``` +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 From cd4f96487564e3127308c1668ed89eb2cdec2d48 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Fri, 25 Jan 2019 16:25:42 -0400 Subject: [PATCH 11/19] Change "folder" for "directory" --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 68a8d13..46da808 100644 --- a/README.md +++ b/README.md @@ -539,7 +539,7 @@ Dotdrop offers two ways to symlink dotfiles. The first simply links `dst` to `sr dotfile entry in the config file. The second symlink method is a little more complicated. It creates a symlink in -`dst` for every file/folder in `src`. This feature can be very useful dotfiles +`dst` for every file/directory in `src`. This feature can be very useful dotfiles such as vim where you may not want plugins cluttering your dotfiles repository. An example configuration and the corresponding result is given below. From eac9509c750651b0efe1ad72c19971739baadbcc Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Fri, 25 Jan 2019 16:27:02 -0400 Subject: [PATCH 12/19] `install()` to return single dotfile not flat list --- dotdrop/installer.py | 5 +---- dotdrop/utils.py | 5 ----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 1b41ee4..88daa50 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -118,7 +118,6 @@ class Installer: srcs = [os.path.join(parent, child) for child in children] dsts = [os.path.join(dst, child) for child in children] - results = [] for i in range(len(children)): src = srcs[i] dst = dsts[i] @@ -141,9 +140,7 @@ class Installer: if len(result): actions = [] - results.append(result) - - return utils.flatten(results) + return (src, dst) def _link(self, src, dst, actions=[]): """set src as a link target of dst""" diff --git a/dotdrop/utils.py b/dotdrop/utils.py index e08af6e..bf3e979 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -113,11 +113,6 @@ def strip_home(path): return path -def flatten(a): - """flatten list""" - return functools.reduce(operator.iconcat, a, []) - - def must_ignore(paths, ignores, debug=False): """return true if any paths in list matches any ignore patterns""" if not ignores: From 2be573ab9960af01e3e1f143a319f757fa310cb8 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Fri, 25 Jan 2019 16:27:18 -0400 Subject: [PATCH 13/19] Add debug logs --- dotdrop/installer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 88daa50..959514d 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -92,6 +92,9 @@ class Installer: # 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 [] @@ -122,6 +125,9 @@ class Installer: 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') From 63e2164b6acbb134f6a2959739361d0e9f733e5c Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sat, 26 Jan 2019 19:29:05 -0400 Subject: [PATCH 14/19] Explain `link_children` a bit more in README --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 46da808..ca5120e 100644 --- a/README.md +++ b/README.md @@ -535,14 +535,41 @@ and the second using transformations (see [Transformations](#use-transformations ## Symlink dotfiles -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. +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`. This feature can be very useful dotfiles -such as vim where you may not want plugins cluttering your dotfiles repository. -An example configuration and the corresponding result is given below. +`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/ @@ -552,15 +579,23 @@ vim: - 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/`. ``` -after -> ~/.dotfiles/vim/after -autoload -plugged -plugin -> ~/.dotfiles/vim/plugin -snippets -> ~/.dotfiles/vim/snippets -spell -swap -vimrc -> ~/.dotfiles/vim/vimrc +$ 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 From f8802ea1ac6b4fba2301f113bf23dcf6a60418fe Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sat, 26 Jan 2019 20:25:08 -0400 Subject: [PATCH 15/19] Invert link with `--link` flag (#80) --- dotdrop/dotdrop.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index c408c0a..8c8f123 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -263,11 +263,16 @@ def cmd_importer(opts, conf, paths): dotfile = Dotfile('', dst, src) linktype = LinkTypes.NOLINK - if opts['link'] or opts['link_by_default']: + if opts['link_by_default']: linktype = LinkTypes.PARENTS elif opts['link_children']: linktype = LinkTypes.CHILDREN + if opts['link'] and linktype == LinkTypes.PARENTS: + linktype = LinkTypes.NOLINK + else: + linktype = LinkTypes.PARENTS + if opts['debug']: LOG.dbg('new dotfile: {}'.format(dotfile)) From 96870ac2977114e064ccf9c264c0007634a1a42a Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Sun, 27 Jan 2019 12:58:20 -0400 Subject: [PATCH 16/19] Save LinkType enum in opts --- dotdrop/dotdrop.py | 8 ++------ tests/helpers.py | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index 8c8f123..9aa5fba 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -262,13 +262,9 @@ def cmd_importer(opts, conf, paths): # create a new dotfile dotfile = Dotfile('', dst, src) - linktype = LinkTypes.NOLINK - if opts['link_by_default']: - linktype = LinkTypes.PARENTS - elif opts['link_children']: - linktype = LinkTypes.CHILDREN + linktype = LinkTypes(opts['link']) - if opts['link'] and linktype == LinkTypes.PARENTS: + if opts['link_by_default'] and linktype == LinkTypes.PARENTS: linktype = LinkTypes.NOLINK else: linktype = LinkTypes.PARENTS diff --git a/tests/helpers.py b/tests/helpers.py index be55796..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,8 +88,7 @@ def load_config(confpath, profile): opts['profile'] = profile opts['safe'] = True opts['installdiff'] = True - opts['link'] = False - opts['link_children'] = False + opts['link'] = LinkTypes.NOLINK.value opts['showdiff'] = True opts['debug'] = True opts['dopts'] = '' From 881071ff45dbebec9bbad34192004ad5c51baffd Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Mon, 28 Jan 2019 18:44:28 -0400 Subject: [PATCH 17/19] Update where args parsed to conform to upstream --- dotdrop/dotdrop.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index 16f539b..b974be9 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -264,11 +264,6 @@ def cmd_importer(opts, conf, paths): linktype = LinkTypes(opts['link']) - if opts['link_by_default'] and linktype == LinkTypes.PARENTS: - linktype = LinkTypes.NOLINK - else: - linktype = LinkTypes.PARENTS - if opts['debug']: LOG.dbg('new dotfile: {}'.format(dotfile)) @@ -443,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'] From 7b5ea5917d0566e4015ce0d956360466cf79c638 Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Tue, 29 Jan 2019 17:19:43 -0400 Subject: [PATCH 18/19] Forbid setting both `link` and `link_children` --- dotdrop/config.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/dotdrop/config.py b/dotdrop/config.py index f75fd53..1c50106 100644 --- a/dotdrop/config.py +++ b/dotdrop/config.py @@ -53,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' @@ -217,11 +218,22 @@ class Cfg: for k, v in self.content[self.key_dotfiles].items(): src = os.path.normpath(v[self.key_dotfiles_src]) dst = os.path.normpath(v[self.key_dotfiles_dst]) - link = LinkTypes.PARENTS \ - if self.key_dotfiles_link in v and v[self.key_dotfiles_link] \ - else LinkTypes.CHILDREN \ - if 'link_children' in v and v['link_children'] \ - else LinkTypes.NOLINK + + # 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] From b750a9f887ab1c11a3116caac4e68d61080a8ece Mon Sep 17 00:00:00 2001 From: Marcel Robitaille Date: Tue, 29 Jan 2019 19:20:57 -0400 Subject: [PATCH 19/19] Fix whitespace string concatenation issue --- dotdrop/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotdrop/config.py b/dotdrop/config.py index 1c50106..dbd59e2 100644 --- a/dotdrop/config.py +++ b/dotdrop/config.py @@ -223,7 +223,7 @@ class Cfg: 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 "{}".' + msg += ' dotfile, error on dotfile "{}".' self.log.err(msg.format(k)) # Otherwise, get link type