Windows: Symlink support

To provide Windows-compatible functionality, spack code should use
llnl.util.symlink instead of os.symlink. On non-Windows platforms
and on Windows where supported, os.symlink will still be used.

Use junctions when symlinks aren't supported on Windows (#22583)

Support islink for junctions (#24182)

Windows: Update llnl/util/filesystem

* Use '/' as path separator on Windows.
* Recognizing that Windows paths start with '<Letter>:/' instead of '/'

Co-authored-by: lou.lawrence@kitware.com <lou.lawrence@kitware.com>
Co-authored-by: John Parent <john.parent@kitware.com>
This commit is contained in:
Betsy McPhail 2021-10-22 12:16:11 -04:00 committed by Peter Scheibel
parent a7de2fa380
commit fb0e91c534
20 changed files with 224 additions and 44 deletions

View file

@ -6,6 +6,8 @@
from macholib import mach_o
from llnl.util.symlink import symlink
MAGIC = [
struct.pack("!L", getattr(mach_o, "MH_" + _))
for _ in ["MAGIC", "CIGAM", "MAGIC_64", "CIGAM_64"]
@ -140,7 +142,7 @@ def mergetree(src, dst, condition=None, copyfn=mergecopy, srcbase=None):
try:
if os.path.islink(srcname):
realsrc = os.readlink(srcname)
os.symlink(realsrc, dstname)
symlink(realsrc, dstname)
elif os.path.isdir(srcname):
mergetree(
srcname,

View file

@ -12,6 +12,8 @@
from os.path import abspath, normpath, isabs, exists, isdir, isfile, islink, dirname
from llnl.util.symlink import symlink
if sys.version_info > (3,0):
def map_as_list(func, iter):
return list(map(func, iter))
@ -79,7 +81,7 @@ def mklinkto(self, oldname):
def mksymlinkto(self, value, absolute=1):
""" create a symbolic link with the given value (pointing to another name). """
if absolute:
py.error.checked_call(os.symlink, str(value), self.strpath)
py.error.checked_call(symlink, str(value), self.strpath)
else:
base = self.common(value)
# with posix local paths '/' is always a common base
@ -87,7 +89,7 @@ def mksymlinkto(self, value, absolute=1):
reldest = self.relto(base)
n = reldest.count(self.sep)
target = self.sep.join(('..', )*n + (relsource, ))
py.error.checked_call(os.symlink, target, self.strpath)
py.error.checked_call(symlink, target, self.strpath)
def getuserid(user):
import pwd
@ -892,7 +894,7 @@ def try_remove_lockfile():
except OSError:
pass
try:
os.symlink(src, dest)
symlink(src, dest)
except (OSError, AttributeError, NotImplementedError):
pass

View file

@ -24,6 +24,7 @@
from llnl.util import tty
from llnl.util.compat import Sequence
from llnl.util.lang import dedupe, memoized
from llnl.util.symlink import symlink
from spack.util.executable import Executable
@ -508,7 +509,7 @@ def copy_tree(src, dest, symlinks=True, ignore=None, _permissions=False):
.format(target, new_target))
target = new_target
os.symlink(target, d)
symlink(target, d)
elif os.path.isdir(link_target):
mkdirp(d)
else:
@ -806,10 +807,10 @@ def touchp(path):
def force_symlink(src, dest):
try:
os.symlink(src, dest)
symlink(src, dest)
except OSError:
os.remove(dest)
os.symlink(src, dest)
symlink(src, dest)
def join_path(prefix, *args):

View file

@ -13,6 +13,7 @@
import llnl.util.tty as tty
from llnl.util.filesystem import mkdirp, touch, traverse_tree
from llnl.util.symlink import islink, symlink
__all__ = ['LinkTree']
@ -20,7 +21,7 @@
def remove_link(src, dest):
if not os.path.islink(dest):
if not 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
@ -113,7 +114,7 @@ def unmerge_directories(self, dest_root, ignore):
os.remove(marker)
def merge(self, dest_root, ignore_conflicts=False, ignore=None,
link=os.symlink, relative=False):
link=symlink, relative=False):
"""Link all files in src into dest, creating directories
if necessary.
@ -125,7 +126,7 @@ def merge(self, dest_root, ignore_conflicts=False, ignore=None,
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)
link (callable): function to create links with (defaults to llnl.util.symlink)
relative (bool): create all symlinks relative to the target
(default False)

View file

@ -0,0 +1,139 @@
# Copyright 2013-2021 Lawrence Livermore National Security, LLC and other
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import errno
import os
import shutil
import tempfile
from os.path import exists, join
from sys import platform as _platform
is_windows = _platform == 'win32'
__win32_can_symlink__ = None
def symlink(real_path, link_path):
"""
Create a symbolic link.
On Windows, use junctions if os.symlink fails.
"""
if not is_windows or _win32_can_symlink():
os.symlink(real_path, link_path)
else:
try:
# Try to use junctions
_win32_junction(real_path, link_path)
except OSError:
# If all else fails, fall back to copying files
shutil.copyfile(real_path, link_path)
def islink(path):
return os.path.islink(path) or _win32_is_junction(path)
# '_win32' functions based on
# https://github.com/Erotemic/ubelt/blob/master/ubelt/util_links.py
def _win32_junction(path, link):
# junctions require absolute paths
if not os.path.isabs(link):
link = os.path.abspath(link)
# os.symlink will fail if link exists, emulate the behavior here
if exists(link):
raise OSError(errno.EEXIST, 'File exists: %s -> %s' % (link, path))
if not os.path.isabs(path):
parent = os.path.join(link, os.pardir)
path = os.path.join(parent, path)
path = os.path.abspath(path)
if os.path.isdir(path):
# try using a junction
command = 'mklink /J "%s" "%s"' % (link, path)
else:
# try using a hard link
command = 'mklink /H "%s" "%s"' % (link, path)
_cmd(command)
def _win32_can_symlink():
global __win32_can_symlink__
if __win32_can_symlink__ is not None:
return __win32_can_symlink__
tempdir = tempfile.mkdtemp()
dpath = join(tempdir, 'dpath')
fpath = join(tempdir, 'fpath.txt')
dlink = join(tempdir, 'dlink')
flink = join(tempdir, 'flink.txt')
import llnl.util.filesystem as fs
fs.touchp(fpath)
try:
os.symlink(dpath, dlink)
can_symlink_directories = os.path.islink(dlink)
except OSError:
can_symlink_directories = False
try:
os.symlink(fpath, flink)
can_symlink_files = os.path.islink(flink)
except OSError:
can_symlink_files = False
# Cleanup the test directory
shutil.rmtree(tempdir)
__win32_can_symlink__ = can_symlink_directories and can_symlink_files
return __win32_can_symlink__
def _win32_is_junction(path):
"""
Determines if a path is a win32 junction
"""
if os.path.islink(path):
return False
if is_windows:
import ctypes.wintypes
GetFileAttributes = ctypes.windll.kernel32.GetFileAttributesW
GetFileAttributes.argtypes = (ctypes.wintypes.LPWSTR,)
GetFileAttributes.restype = ctypes.wintypes.DWORD
INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
res = GetFileAttributes(path)
return res != INVALID_FILE_ATTRIBUTES and \
bool(res & FILE_ATTRIBUTE_REPARSE_POINT)
return False
# Based on https://github.com/Erotemic/ubelt/blob/master/ubelt/util_cmd.py
def _cmd(command):
import subprocess
# Create a new process to execute the command
def make_proc():
# delay the creation of the process until we validate all args
proc = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, shell=True,
universal_newlines=True, cwd=None, env=None)
return proc
proc = make_proc()
(out, err) = proc.communicate()
if proc.wait() != 0:
raise OSError(str(err))

View file

@ -46,6 +46,7 @@
import llnl.util.tty as tty
from llnl.util.filesystem import install, install_tree, mkdirp
from llnl.util.lang import dedupe
from llnl.util.symlink import symlink
from llnl.util.tty.color import cescape, colorize
from llnl.util.tty.log import MultiProcessFd
@ -545,7 +546,7 @@ def _set_variables_for_single_module(pkg, module):
m.makedirs = os.makedirs
m.remove = os.remove
m.removedirs = os.removedirs
m.symlink = os.symlink
m.symlink = symlink
m.mkdirp = mkdirp
m.install = install
@ -668,11 +669,11 @@ def _static_to_shared_library(arch, compiler, static_lib, shared_lib=None,
shared_lib_link = os.path.basename(shared_lib)
if version or compat_version:
os.symlink(shared_lib_link, shared_lib_base)
symlink(shared_lib_link, shared_lib_base)
if compat_version and compat_version != version:
os.symlink(shared_lib_link, '{0}.{1}'.format(shared_lib_base,
compat_version))
symlink(shared_lib_link, '{0}.{1}'.format(shared_lib_base,
compat_version))
return compiler(*compiler_args, output=compiler_output)

View file

@ -8,6 +8,7 @@
import llnl.util.lang
from llnl.util.filesystem import mkdirp
from llnl.util.symlink import symlink
import spack.config
import spack.error
@ -85,7 +86,7 @@ def symlink(self, mirror_ref):
# to https://github.com/spack/spack/pull/13908)
os.unlink(cosmetic_path)
mkdirp(os.path.dirname(cosmetic_path))
os.symlink(relative_dst, cosmetic_path)
symlink(relative_dst, cosmetic_path)
#: Spack's local cache for downloaded source archives

View file

@ -19,6 +19,7 @@
import os
import llnl.util.tty as tty
from llnl.util.symlink import symlink
import spack.cmd
import spack.cmd.common.arguments as arguments
@ -123,7 +124,7 @@ def deprecate(parser, args):
if not answer:
tty.die('Will not deprecate any packages.')
link_fn = os.link if args.link_type == 'hard' else os.symlink
link_fn = os.link if args.link_type == 'hard' else symlink
for dcate, dcator in zip(all_deprecate, all_deprecators):
dcate.package.do_deprecate(dcator, link_fn)

View file

@ -4,6 +4,10 @@
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import functools
import os
import llnl.util.filesystem
from llnl.util.symlink import symlink
import spack.cmd.common.arguments
import spack.cmd.modules
@ -56,3 +60,11 @@ def setdefault(module_type, specs, args):
with spack.config.override(scope):
writer = spack.modules.module_types['lmod'](spec, args.module_set_name)
writer.update_module_defaults()
module_folder = os.path.dirname(writer.layout.filename)
module_basename = os.path.basename(writer.layout.filename)
with llnl.util.filesystem.working_dir(module_folder):
if os.path.exists('default') and os.path.islink('default'):
os.remove('default')
symlink(module_basename, 'default')

View file

@ -8,6 +8,7 @@
import llnl.util.lang
import llnl.util.tty as tty
from llnl.util.symlink import symlink
import spack.compiler
import spack.compilers.clang
@ -162,10 +163,10 @@ def setup_custom_environment(self, pkg, env):
for fname in os.listdir(dev_dir):
if fname in bins:
os.unlink(os.path.join(dev_dir, fname))
os.symlink(
symlink(
os.path.join(spack.paths.build_env_path, 'cc'),
os.path.join(dev_dir, fname))
os.symlink(developer_root, xcode_link)
symlink(developer_root, xcode_link)
env.set('DEVELOPER_DIR', xcode_link)

View file

@ -293,6 +293,7 @@ def _write_section(self, section):
syaml.dump_config(data_to_write, stream=f,
default_flow_style=False)
rename(tmp, self.path)
except (yaml.YAMLError, IOError) as e:
raise ConfigFileError(
"Error writing to config file: '%s'" % str(e))

View file

@ -17,6 +17,7 @@
import llnl.util.filesystem as fs
import llnl.util.tty as tty
from llnl.util.lang import dedupe
from llnl.util.symlink import islink, symlink
import spack.bootstrap
import spack.compilers
@ -1461,7 +1462,7 @@ def _install_log_links(self, spec):
log_path, '%s-%s.log' % (spec.name, spec.dag_hash(7)))
if os.path.lexists(build_log_link):
os.remove(build_log_link)
os.symlink(spec.package.build_log_path, build_log_link)
symlink(spec.package.build_log_path, build_log_link)
def uninstalled_specs(self):
"""Return a list of all uninstalled (and non-dev) specs."""

View file

@ -43,6 +43,7 @@
temp_rename,
working_dir,
)
from llnl.util.symlink import symlink
import spack.config
import spack.error
@ -640,7 +641,7 @@ def fetch(self):
os.remove(filename)
# Symlink to local cached archive.
os.symlink(path, filename)
symlink(path, filename)
# Remove link if checksum fails, or subsequent fetchers
# will assume they don't need to download.

View file

@ -15,6 +15,7 @@
from llnl.util.filesystem import mkdirp, remove_dead_links, remove_empty_directories
from llnl.util.lang import index_by, match_predicate
from llnl.util.link_tree import LinkTree, MergeConflictError
from llnl.util.symlink import symlink
from llnl.util.tty.color import colorize
import spack.config
@ -39,7 +40,7 @@
def view_symlink(src, dst, **kwargs):
# keyword arguments are irrelevant
# here to fit required call signature
os.symlink(src, dst)
symlink(src, dst)
def view_hardlink(src, dst, **kwargs):
@ -141,7 +142,7 @@ def __init__(self, root, layout, **kwargs):
Initialize a filesystem view under the given `root` directory with
corresponding directory `layout`.
Files are linked by method `link` (os.symlink by default).
Files are linked by method `link` (llnl.util.symlink by default).
"""
self._root = root
self.layout = layout

View file

@ -7,6 +7,7 @@
import llnl.util.tty as tty
from llnl.util.filesystem import mkdirp
from llnl.util.symlink import symlink
from spack.util.editor import editor
from spack.util.executable import Executable, which
@ -179,6 +180,6 @@ def symlink_license(pkg):
os.remove(link_name)
if os.path.exists(target):
os.symlink(target, link_name)
symlink(target, link_name)
tty.msg("Added local symlink %s to global license file" %
link_name)

View file

@ -13,6 +13,7 @@
import llnl.util.lang
import llnl.util.tty as tty
from llnl.util.symlink import symlink
import spack.bootstrap
import spack.platforms
@ -683,7 +684,7 @@ def make_link_relative(new_links, orig_links):
target = os.readlink(orig_link)
relative_target = os.path.relpath(target, os.path.dirname(orig_link))
os.unlink(new_link)
os.symlink(relative_target, new_link)
symlink(relative_target, new_link)
def make_macho_binaries_relative(cur_path_names, orig_path_names,
@ -764,7 +765,7 @@ def relocate_links(links, orig_layout_root,
orig_install_prefix, new_install_prefix, link_target
)
os.unlink(abs_link)
os.symlink(link_target, abs_link)
symlink(link_target, abs_link)
# If the link is absolute and has not been relocated then
# warn the user about that

View file

@ -12,6 +12,7 @@
import pytest
import llnl.util.filesystem as fs
from llnl.util.symlink import symlink
import spack.paths
@ -36,9 +37,9 @@ def stage(tmpdir_factory):
fs.touchp('source/g/i/j/10')
# Create symlinks
os.symlink(os.path.abspath('source/1'), 'source/2')
os.symlink('b/2', 'source/a/b2')
os.symlink('a/b', 'source/f')
symlink(os.path.abspath('source/1'), 'source/2')
symlink('b/2', 'source/a/b2')
symlink('a/b', 'source/f')
# Create destination directory
fs.mkdirp('dest')
@ -174,15 +175,21 @@ def test_symlinks_true(self, stage):
fs.copy_tree('source', 'dest', symlinks=True)
assert os.path.exists('dest/2')
assert os.path.islink('dest/2')
if sys.platform != "win32":
# TODO: islink will return false for junctions
assert os.path.islink('dest/2')
assert os.path.exists('dest/a/b2')
with fs.working_dir('dest/a'):
assert os.path.exists(os.readlink('b2'))
if sys.platform != "win32":
# TODO: Not supported for junctions ?
with fs.working_dir('dest/a'):
assert os.path.exists(os.readlink('b2'))
assert (os.path.realpath('dest/f/2') ==
os.path.abspath('dest/a/b/2'))
assert os.path.realpath('dest/2') == os.path.abspath('dest/1')
if sys.platform != "win32":
# TODO: Not supported on Windows ?
assert (os.path.realpath('dest/f/2') ==
os.path.abspath('dest/a/b/2'))
assert os.path.realpath('dest/2') == os.path.abspath('dest/1')
def test_symlinks_true_ignore(self, stage):
"""Test copying when specifying relative paths that should be ignored
@ -201,7 +208,8 @@ def test_symlinks_false(self, stage):
fs.copy_tree('source', 'dest', symlinks=False)
assert os.path.exists('dest/2')
assert not os.path.islink('dest/2')
if sys.platform != "win32":
assert not os.path.islink('dest/2')
def test_glob_src(self, stage):
"""Test using a glob as the source."""
@ -258,7 +266,8 @@ def test_symlinks_true(self, stage):
fs.install_tree('source', 'dest', symlinks=True)
assert os.path.exists('dest/2')
assert os.path.islink('dest/2')
if sys.platform != "win32":
assert os.path.islink('dest/2')
check_added_exe_permissions('source/2', 'dest/2')
def test_symlinks_false(self, stage):
@ -268,7 +277,8 @@ def test_symlinks_false(self, stage):
fs.install_tree('source', 'dest', symlinks=False)
assert os.path.exists('dest/2')
assert not os.path.islink('dest/2')
if sys.platform != "win32":
assert not os.path.islink('dest/2')
check_added_exe_permissions('source/2', 'dest/2')
def test_glob_src(self, stage):

View file

@ -9,6 +9,7 @@
from llnl.util.filesystem import mkdirp, touchp, working_dir
from llnl.util.link_tree import LinkTree
from llnl.util.symlink import islink
from spack.stage import Stage
@ -42,7 +43,7 @@ def link_tree(stage):
def check_file_link(filename, expected_target):
assert os.path.isfile(filename)
assert os.path.islink(filename)
assert islink(filename)
assert (os.path.abspath(os.path.realpath(filename)) ==
os.path.abspath(expected_target))

View file

@ -16,6 +16,7 @@
import pytest
from llnl.util.filesystem import mkdirp
from llnl.util.symlink import symlink
import spack.binary_distribution as bindist
import spack.cmd.buildcache as buildcache
@ -79,7 +80,7 @@ def test_buildcache(mock_archive, tmpdir):
# Create an absolute symlink
linkname = os.path.join(spec.prefix, "link_to_dummy.txt")
os.symlink(filename, linkname)
symlink(filename, linkname)
# Create the build cache and
# put it directly into the mirror
@ -232,8 +233,8 @@ def test_relocate_links(tmpdir):
with open(new_binname, 'w') as f:
f.write('\n')
os.utime(new_binname, None)
os.symlink(old_binname, new_linkname)
os.symlink('/usr/lib/libc.so', new_linkname2)
symlink(old_binname, new_linkname)
symlink('/usr/lib/libc.so', new_linkname2)
relocate_links(filenames, old_layout_root,
old_install_prefix, new_install_prefix)
assert os.readlink(new_linkname) == new_binname

View file

@ -8,6 +8,7 @@
import shutil
import llnl.util.filesystem as fs
from llnl.util.symlink import symlink
import spack.spec
import spack.store
@ -21,7 +22,7 @@ def test_link_manifest_entry(tmpdir):
file = str(tmpdir.join('file'))
open(file, 'a').close()
link = str(tmpdir.join('link'))
os.symlink(file, link)
symlink(file, link)
data = spack.verify.create_manifest_entry(link)
assert data['type'] == 'link'
@ -43,7 +44,7 @@ def test_link_manifest_entry(tmpdir):
file2 = str(tmpdir.join('file2'))
open(file2, 'a').close()
os.remove(link)
os.symlink(file2, link)
symlink(file2, link)
results = spack.verify.check_entry(link, data)
assert results.has_errors()
@ -157,7 +158,7 @@ def test_check_prefix_manifest(tmpdir):
f.write("I'm a little file short and stout")
link = os.path.join(bin_dir, 'run')
os.symlink(file, link)
symlink(file, link)
spack.verify.write_manifest(spec)
results = spack.verify.check_spec_manifest(spec)