diff --git a/.gitignore b/.gitignore index 004a8d7..0db4888 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.pyc .coverage +.idea/ +.vscode/ +.atom/ diff --git a/README.md b/README.md index a00f1c5..30b4bc6 100644 --- a/README.md +++ b/README.md @@ -177,11 +177,12 @@ the following entries: * `dotpath`: path to the folder containing the dotfiles to be managed by dotdrop (absolute path or relative to the config file location) * **dotfiles** entry: a list of dotfiles in the form - + * When `link` is true, dotdrop will create a link instead of copying. Template generation (as in [template](#template)) is not supported when `link` is true. ``` : dst: src: + link: # Optional ``` * **profiles** entry: a list of profiles with a sublist diff --git a/dotdrop/config.py b/dotdrop/config.py index 5cd02b2..213463c 100644 --- a/dotdrop/config.py +++ b/dotdrop/config.py @@ -18,6 +18,7 @@ class Cfg: key_dotpath = 'dotpath' key_dotfiles_src = 'src' key_dotfiles_dst = 'dst' + key_dotfiles_link = 'link' def __init__(self, cfgpath): if not os.path.exists(cfgpath): @@ -61,7 +62,9 @@ class Cfg: for k, v in self.content[self.key_dotfiles].items(): src = v[self.key_dotfiles_src] dst = v[self.key_dotfiles_dst] - self.dotfiles[k] = Dotfile(k, dst, src) + link = v[self.key_dotfiles_link] if self.key_dotfiles_link \ + in v else False + self.dotfiles[k] = Dotfile(k, dst, src, link) # contains a list of dotfiles defined for each profile for k, v in self.profiles.items(): self.prodots[k] = [] @@ -84,7 +87,7 @@ class Cfg: return absconf return dotpath - def new(self, dotfile, profile): + def new(self, dotfile, profile, link=False): """ import new dotfile """ dots = self.content[self.key_dotfiles] if dots is None: @@ -100,6 +103,8 @@ class Cfg: self.key_dotfiles_dst: dotfile.dst, self.key_dotfiles_src: dotfile.src } + if link: + dots[dotfile.key][self.key_dotfiles_link] = True profiles = self.profiles if profile in profiles and profiles[profile] != [self.key_all]: if self.content[self.key_profiles][profile] is None: @@ -115,8 +120,7 @@ class Cfg: """ returns a list of dotfiles for a specific profile """ if profile not in self.prodots: return [] - tmp = sorted(self.prodots[profile], key=lambda x: x.key, reverse=True) - return tmp + return sorted(self.prodots[profile], key=lambda x: x.key, reverse=True) def get_profiles(self): """ returns all defined profiles """ diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index dad1ca1..176a6f0 100755 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -34,7 +34,8 @@ Usage: [(-f | --force)] [--nodiff] [--dry] dotdrop.py compare [--profile=] [--cfg=] dotdrop.py list [--cfg=] - dotdrop.py import [--cfg=] [--profile=] [--dry] ... + dotdrop.py import [--profile=] [--cfg=] + [(-l | --link)] [--dry] ... dotdrop.py (-h | --help) dotdrop.py (-v | --version) @@ -43,6 +44,7 @@ Options: --cfg= Path to the config [default: %s/config.yaml]. --dry Dry run. --nodiff Do not diff when installing [default: False]. + -l --link Import and link [default: False]. -f --force Do not warn if exists [default: False]. -v --version Show version. -h --help Show this screen. @@ -66,7 +68,10 @@ def install(opts, conf): diff=opts['installdiff']) installed = [] for dotfile in dotfiles: - r = inst.install(t, opts['profile'], dotfile.src, dotfile.dst) + if hasattr(dotfile, "link") and dotfile.link: + r = inst.link(dotfile.src, dotfile.dst) + else: + r = inst.install(t, opts['profile'], dotfile.src, dotfile.dst) installed.extend(r) LOG.log('\n%u dotfile(s) installed.' % (len(installed))) return True @@ -98,8 +103,7 @@ def importer(opts, conf, paths): key = dst.split(os.sep)[-1] if key == 'config': key = '_'.join(dst.split(os.sep)[-2:]) - key = key.lstrip('.') - key = key.lower() + key = key.lstrip('.').lower() if os.path.isdir(dst): key = 'd_%s' % (key) else: @@ -113,17 +117,22 @@ def importer(opts, conf, paths): if os.path.exists(srcf): LOG.err('\"%s\" already exists, ignored !' % (srcf)) continue - conf.new(dotfile, opts['profile']) + conf.new(dotfile, opts['profile'], opts['link']) cmd = ['mkdir', '-p', '%s' % (os.path.dirname(srcf))] if opts['dry']: LOG.dry('would run: %s' % (' '.join(cmd))) else: utils.run(cmd, raw=False, log=False) - cmd = ['cp', '-r', '%s' % (dst), '%s' % (srcf)] + if opts['link']: + cmd = ['mv', '%s' % (dst), '%s' % (srcf)] + else: + cmd = ['cp', '-r', '%s' % (dst), '%s' % (srcf)] if opts['dry']: LOG.dry('would run: %s' % (' '.join(cmd))) else: utils.run(cmd, raw=False, log=False) + if opts['link']: + os.symlink(srcf, dst) LOG.sub('\"%s\" imported' % (path)) cnt += 1 if opts['dry']: @@ -156,6 +165,7 @@ if __name__ == '__main__': opts['profile'] = args['--profile'] opts['safe'] = not args['--force'] opts['installdiff'] = not args['--nodiff'] + opts['link'] = args['--link'] header() diff --git a/dotdrop/dotfile.py b/dotdrop/dotfile.py index c696631..a7e53b0 100644 --- a/dotdrop/dotfile.py +++ b/dotdrop/dotfile.py @@ -7,15 +7,16 @@ represents a dotfile in dotdrop class Dotfile: - def __init__(self, key, dst, src): + def __init__(self, key, dst, src, link=False): # key of dotfile in the config self.key = key # where to install this dotfile self.dst = dst # stored dotfile in dotdrop self.src = src + # should be a link + self.link = link def __str__(self): - string = 'key:%s, src: %s, dst: %s' % (self.key, - self.src, self.dst) - return string + return 'key:%s, src: %s, dst: %s, link: %s' % (self.key, self.src, + self.dst, self.link) diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 658cb53..7581c3a 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -32,6 +32,35 @@ class Installer: return self._handle_dir(templater, profile, src, dst) return self._handle_file(templater, profile, src, dst) + def link(self, src, dst): + '''Sets src as the link target of dst''' + src = os.path.join(self.base, os.path.expanduser(src)) + dst = os.path.join(self.base, os.path.expanduser(dst)) + if os.path.exists(dst): + if os.path.realpath(dst) == os.path.realpath(src): + self.log.sub('ignoring "%s", link exists' % dst) + return [] + if self.dry: + self.log.dry('would remove %s and link it to %s' + % (dst, src)) + return [] + if self.safe and \ + not self.log.ask('Remove "%s" for link creation?' % dst): + self.log.warn('ignoring "%s", link was not created' % dst) + return [] + try: + utils.remove(dst) + except OSError: + self.log.err('something went wrong with %s' % src) + return [] + if self.dry: + self.log.dry('would link %s to %s' % (dst, src)) + return [] + os.symlink(src, dst) + self.log.sub('linked %s to %s' % (dst, src)) + # Follows original developer's behavior + return [(src, dst)] + def _handle_file(self, templater, profile, src, dst): '''Install a file using templater for "profile"''' content = templater.generate(src, profile) @@ -47,7 +76,7 @@ class Installer: self.log.sub('ignoring \"%s\", same content' % (dst)) return [] if ret == 0: - if not self.quiet: + if not self.quiet and not self.dry: self.log.sub('copied %s to %s' % (src, dst)) return [(src, dst)] return [] diff --git a/dotdrop/utils.py b/dotdrop/utils.py index e48e7bc..8c2f450 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -6,7 +6,10 @@ utilities import subprocess import tempfile +import os from logger import Logger +from shutil import rmtree + LOG = Logger() @@ -29,3 +32,15 @@ def diff(src, dst, log=False, raw=True): def get_tmpdir(): return tempfile.mkdtemp(prefix='dotdrop-') + + +def remove(path): + ''' Remove a file / directory / symlink ''' + if not os.path.exists(path): + raise OSError("File not found: %s" % path) + if os.path.islink(path) or os.path.isfile(path): + os.unlink(path) + elif os.path.isdir(path): + rmtree(path) + else: + raise OSError("Unsupported file type for deletion: %s" % path) diff --git a/tests/helpers.py b/tests/helpers.py index 35c7c1b..c6cc804 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -68,6 +68,7 @@ def load_config(confpath, profile): opts['profile'] = profile opts['safe'] = True opts['installdiff'] = True + opts['link'] = False return conf, opts diff --git a/tests/test_install.py b/tests/test_install.py index 5c17be0..f0a0bee 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -42,6 +42,7 @@ exec bspwm f.write(' %s:\n' % (d.key)) f.write(' dst: %s\n' % (d.dst)) f.write(' src: %s\n' % (d.src)) + f.write(' link: %s\n' % str(d.link).lower()) f.write('profiles:\n') f.write(' %s:\n' % (profile)) for d in dotfiles: @@ -78,10 +79,15 @@ exec bspwm with open(dst4, 'w') as f: f.write(get_string(16)) + # to test link + f5, c5 = create_random_file(tmp) + dst5 = os.path.join(dst, get_string(6)) + d5 = Dotfile(get_string(6), dst5, os.path.basename(f5), link=True) + # generate the config and stuff profile = get_string(5) confpath = os.path.join(tmp, self.CONFIG_NAME) - self.fake_config(confpath, [d1, d2, d3, d4], profile, tmp) + self.fake_config(confpath, [d1, d2, d3, d4, d5], profile, tmp) conf = Cfg(confpath) self.assertTrue(conf is not None) @@ -94,6 +100,11 @@ exec bspwm self.assertTrue(os.path.exists(dst1)) self.assertTrue(os.path.exists(dst2)) self.assertTrue(os.path.exists(dst3)) + self.assertTrue(os.path.exists(dst5)) + + # check if 'dst5' is a link whose target is 'f5' + self.assertTrue(os.path.islink(dst5)) + self.assertTrue(os.path.realpath(dst5) == os.path.realpath(f5)) # make sure backup is there b = dst4 + Installer.BACKUP_SUFFIX