link_tree: add option to merge link trees with relative targets

- previous version of link trees would only do absolute symlinks

- this version can do relative links using merge(relative=True)
This commit is contained in:
Todd Gamblin 2019-05-23 14:28:28 -07:00
parent f32843528e
commit d6f2ff1426
2 changed files with 91 additions and 41 deletions

View file

@ -5,6 +5,8 @@
"""LinkTree class for setting up trees of symbolic links.""" """LinkTree class for setting up trees of symbolic links."""
from __future__ import print_function
import os import os
import shutil import shutil
import filecmp import filecmp
@ -17,6 +19,16 @@
empty_file_name = '.spack-empty' empty_file_name = '.spack-empty'
def remove_link(src, dest):
if not os.path.islink(dest):
raise ValueError("%s is not a link tree!" % dest)
# remove if dest is a hardlink/symlink to src; this will only
# be false if two packages are merged into a prefix and have a
# conflicting file
if filecmp.cmp(src, dest, shallow=True):
os.remove(dest)
class LinkTree(object): class LinkTree(object):
"""Class to create trees of symbolic links from a source directory. """Class to create trees of symbolic links from a source directory.
@ -100,16 +112,28 @@ def unmerge_directories(self, dest_root, ignore):
if os.path.exists(marker): if os.path.exists(marker):
os.remove(marker) os.remove(marker)
def merge(self, dest_root, **kwargs): def merge(self, dest_root, ignore_conflicts=False, ignore=None,
link=os.symlink, relative=False):
"""Link all files in src into dest, creating directories """Link all files in src into dest, creating directories
if necessary. if necessary.
If ignore_conflicts is True, do not break when the target exists but
rather return a list of files that could not be linked.
Note that files blocking directories will still cause an error.
"""
ignore_conflicts = kwargs.get("ignore_conflicts", False)
ignore = kwargs.get('ignore', lambda x: False) Keyword Args:
ignore_conflicts (bool): if True, do not break when the target exists;
return a list of files that could not be linked
ignore (callable): callable that returns True if a file is to be
ignored in the merge (by default ignore nothing)
link (callable): function to create links with (defaults to os.symlink)
relative (bool): create all symlinks relative to the target
(default False)
"""
if ignore is None:
ignore = lambda x: False
conflict = self.find_conflict( conflict = self.find_conflict(
dest_root, ignore=ignore, ignore_file_conflicts=ignore_conflicts) dest_root, ignore=ignore, ignore_file_conflicts=ignore_conflicts)
if conflict: if conflict:
@ -117,42 +141,33 @@ def merge(self, dest_root, **kwargs):
self.merge_directories(dest_root, ignore) self.merge_directories(dest_root, ignore)
existing = [] existing = []
merge_file = kwargs.get('merge_file', merge_link)
for src, dst in self.get_file_map(dest_root, ignore).items(): for src, dst in self.get_file_map(dest_root, ignore).items():
if os.path.exists(dst): if os.path.exists(dst):
existing.append(dst) existing.append(dst)
elif relative:
abs_src = os.path.abspath(src)
dst_dir = os.path.dirname(os.path.abspath(dst))
rel = os.path.relpath(abs_src, dst_dir)
link(rel, dst)
else: else:
merge_file(src, dst) link(src, dst)
for c in existing: for c in existing:
tty.warn("Could not merge: %s" % c) tty.warn("Could not merge: %s" % c)
def unmerge(self, dest_root, **kwargs): def unmerge(self, dest_root, ignore=None, remove_file=remove_link):
"""Unlink all files in dest that exist in src. """Unlink all files in dest that exist in src.
Unlinks directories in dest if they are empty. Unlinks directories in dest if they are empty.
""" """
remove_file = kwargs.get('remove_file', remove_link) if ignore is None:
ignore = kwargs.get('ignore', lambda x: False) ignore = lambda x: False
for src, dst in self.get_file_map(dest_root, ignore).items(): for src, dst in self.get_file_map(dest_root, ignore).items():
remove_file(src, dst) remove_file(src, dst)
self.unmerge_directories(dest_root, ignore) self.unmerge_directories(dest_root, ignore)
def merge_link(src, dest):
os.symlink(src, dest)
def remove_link(src, dest):
if not os.path.islink(dest):
raise ValueError("%s is not a link tree!" % dest)
# remove if dest is a hardlink/symlink to src; this will only
# be false if two packages are merged into a prefix and have a
# conflicting file
if filecmp.cmp(src, dest, shallow=True):
os.remove(dest)
class MergeConflictError(Exception): class MergeConflictError(Exception):
def __init__(self, path): def __init__(self, path):

View file

@ -38,9 +38,11 @@ def link_tree(stage):
return LinkTree(source_path) return LinkTree(source_path)
def check_file_link(filename): def check_file_link(filename, expected_target):
assert os.path.isfile(filename) assert os.path.isfile(filename)
assert os.path.islink(filename) assert os.path.islink(filename)
assert (os.path.abspath(os.path.realpath(filename)) ==
os.path.abspath(expected_target))
def check_dir(filename): def check_dir(filename):
@ -51,13 +53,46 @@ def test_merge_to_new_directory(stage, link_tree):
with working_dir(stage.path): with working_dir(stage.path):
link_tree.merge('dest') link_tree.merge('dest')
check_file_link('dest/1') check_file_link('dest/1', 'source/1')
check_file_link('dest/a/b/2') check_file_link('dest/a/b/2', 'source/a/b/2')
check_file_link('dest/a/b/3') check_file_link('dest/a/b/3', 'source/a/b/3')
check_file_link('dest/c/4') check_file_link('dest/c/4', 'source/c/4')
check_file_link('dest/c/d/5') check_file_link('dest/c/d/5', 'source/c/d/5')
check_file_link('dest/c/d/6') check_file_link('dest/c/d/6', 'source/c/d/6')
check_file_link('dest/c/d/e/7') check_file_link('dest/c/d/e/7', 'source/c/d/e/7')
assert os.path.isabs(os.readlink('dest/1'))
assert os.path.isabs(os.readlink('dest/a/b/2'))
assert os.path.isabs(os.readlink('dest/a/b/3'))
assert os.path.isabs(os.readlink('dest/c/4'))
assert os.path.isabs(os.readlink('dest/c/d/5'))
assert os.path.isabs(os.readlink('dest/c/d/6'))
assert os.path.isabs(os.readlink('dest/c/d/e/7'))
link_tree.unmerge('dest')
assert not os.path.exists('dest')
def test_merge_to_new_directory_relative(stage, link_tree):
with working_dir(stage.path):
link_tree.merge('dest', relative=True)
check_file_link('dest/1', 'source/1')
check_file_link('dest/a/b/2', 'source/a/b/2')
check_file_link('dest/a/b/3', 'source/a/b/3')
check_file_link('dest/c/4', 'source/c/4')
check_file_link('dest/c/d/5', 'source/c/d/5')
check_file_link('dest/c/d/6', 'source/c/d/6')
check_file_link('dest/c/d/e/7', 'source/c/d/e/7')
assert not os.path.isabs(os.readlink('dest/1'))
assert not os.path.isabs(os.readlink('dest/a/b/2'))
assert not os.path.isabs(os.readlink('dest/a/b/3'))
assert not os.path.isabs(os.readlink('dest/c/4'))
assert not os.path.isabs(os.readlink('dest/c/d/5'))
assert not os.path.isabs(os.readlink('dest/c/d/6'))
assert not os.path.isabs(os.readlink('dest/c/d/e/7'))
link_tree.unmerge('dest') link_tree.unmerge('dest')
@ -72,13 +107,13 @@ def test_merge_to_existing_directory(stage, link_tree):
link_tree.merge('dest') link_tree.merge('dest')
check_file_link('dest/1') check_file_link('dest/1', 'source/1')
check_file_link('dest/a/b/2') check_file_link('dest/a/b/2', 'source/a/b/2')
check_file_link('dest/a/b/3') check_file_link('dest/a/b/3', 'source/a/b/3')
check_file_link('dest/c/4') check_file_link('dest/c/4', 'source/c/4')
check_file_link('dest/c/d/5') check_file_link('dest/c/d/5', 'source/c/d/5')
check_file_link('dest/c/d/6') check_file_link('dest/c/d/6', 'source/c/d/6')
check_file_link('dest/c/d/e/7') check_file_link('dest/c/d/e/7', 'source/c/d/e/7')
assert os.path.isfile('dest/x') assert os.path.isfile('dest/x')
assert os.path.isfile('dest/a/b/y') assert os.path.isfile('dest/a/b/y')