diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c55323973b075f6026b9ceb1904c22be9907bab6..ddf56a5b72b4449b4079c1c63c8ebbb5f00f9d38 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,18 @@ order. ------------------------- +Added +^^^^^ + + +* New :func:`.winpath` and :func:`wslpath` functions for working with paths + when using FSL in a Windows Subsystem for Linux (WSL) environment. +* New :func:`.wslcmd` function for generating a path to a FSL command installed + in a WSL environment. +* New :meth:`.Platform.fslwsl` attribute for detecting whether FSL is installed + in a WSL environment. + + Fixed ^^^^^ diff --git a/fsl/utils/path.py b/fsl/utils/path.py index 8d312bef5f7e427debeb6fde27ce30501220c3f6..0a0859528945a7b944482442b0ffc658d0dc05f9 100644 --- a/fsl/utils/path.py +++ b/fsl/utils/path.py @@ -23,6 +23,8 @@ paths. removeDuplicates uniquePrefix commonBase + wslpath + winpath """ @@ -30,6 +32,9 @@ import os.path as op import os import glob import operator +import re + +from fsl.utils.platform import platform class PathError(Exception): @@ -524,3 +529,58 @@ def commonBase(paths): return base raise PathError('No common base') + + +def wslpath(winpath): + """ + Convert Windows path (or a command line argument containing a Windows path) + to the equivalent WSL path (e.g. ``c:\\Users`` -> ``/mnt/c/Users``). Also supports + paths in the form ``\\wsl$\\(distro)\\users\\...`` + + :param winpath: Command line argument which may (or may not) contain a Windows path. It is assumed to be + either of the form <windows path> or --<arg>=<windows path>. Note that we don't need to + handle --arg <windows path> or -a <windows path> since in these cases the argument + and the path will be parsed as separate entities. + :return: If ``winpath`` matches a Windows path, the converted argument (including the --<arg>= portion). + Otherwise returns ``winpath`` unchanged. + """ + match = re.match(r"^(--[\w-]+=)?\\\\wsl\$[\\\/][^\\^\/]+(.*)$", winpath) + if match: + arg, path = match.group(1, 2) + if arg is None: + arg = "" + return arg + path.replace("\\", "/") + + match = re.match(r"^(--[\w-]+=)?([a-zA-z]):(.+)$", winpath) + if match: + arg, drive, path = match.group(1, 2, 3) + if arg is None: + arg = "" + return arg + "/mnt/" + drive.lower() + path.replace("\\", "/") + + return winpath + + +def winpath(wslpath): + """ + Convert a WSL-local filepath (for example ``/usr/local/fsl/``) into a path that can be used from + Windows. + + If ``self.fslwsl`` is ``False``, simply returns ``wslpath`` unmodified + Otherwise, uses ``FSLDIR`` to deduce the WSL distro in use for FSL. + This requires WSL2 which supports the ``\\wsl$\`` network path. + wslpath is assumed to be an absolute path. + """ + if not platform.fslwsl: + return wslpath + else: + match = re.match(r"^\\\\wsl\$\\([^\\]+).*$", platform.fsldir) + if match: + distro = match.group(1) + else: + distro = None + + if not distro: + raise RuntimeError("Could not identify WSL installation from FSLDIR (%s)" % platform.fsldir) + + return "\\\\wsl$\\" + distro + wslpath.replace("/", "\\") diff --git a/fsl/utils/platform.py b/fsl/utils/platform.py index 4a97325bc1515017ca93bc1883bea66d8f205420..ebd9478cb79f3d11d5cda0d4bf8a3eeb00c534f7 100644 --- a/fsl/utils/platform.py +++ b/fsl/utils/platform.py @@ -292,6 +292,12 @@ class Platform(notifier.Notifier): return os.environ.get('FSLDEVDIR', None) + @property + def fslwsl(self): + """Boolean flag indicating whether FSL is installed in Windows Subsystem for Linux """ + return self.fsldir is not None and self.fsldir.startswith("\\\\wsl$") + + @fsldir.setter def fsldir(self, value): """Changes the value of the :attr:`fsldir` property, and notifies any diff --git a/fsl/utils/run.py b/fsl/utils/run.py index caacbf9761bc7c9da7e198449915ee848ce6c04b..e2adbeb8de7d39006b28eac3497573fd281cd8d9 100644 --- a/fsl/utils/run.py +++ b/fsl/utils/run.py @@ -20,21 +20,22 @@ """ -import sys -import shlex -import logging -import threading -import contextlib -import collections -import subprocess as sp -import os.path as op - -import six +import sys +import shlex +import logging +import threading +import contextlib +import collections.abc as abc +import subprocess as sp +import os.path as op +import os + +import six from fsl.utils.platform import platform as fslplatform import fsl.utils.fslsub as fslsub import fsl.utils.tempdir as tempdir - +import fsl.utils.path as fslpath log = logging.getLogger(__name__) @@ -202,7 +203,7 @@ def run(*args, **kwargs): if submit is True: submit = dict() - if submit is not None and not isinstance(submit, collections.Mapping): + if submit is not None and not isinstance(submit, abc.Mapping): raise ValueError('submit must be a mapping containing ' 'options for fsl.utils.fslsub.submit') @@ -359,7 +360,12 @@ def runfsl(*args, **kwargs): args = prepareArgs(args) for prefix in prefixes: cmdpath = op.join(prefix, args[0]) - if op.isfile(cmdpath): + if fslplatform.fslwsl: + wslargs = wslcmd(cmdpath, *args) + if wslargs is not None: + args = wslargs + break + elif op.isfile(cmdpath): args[0] = cmdpath break @@ -372,6 +378,53 @@ def runfsl(*args, **kwargs): return run(*args, **kwargs) +def wslcmd(cmdpath, *args): + """ + Convert a command + arguments into an equivalent set of arguments that will run the command + under Windows Subsystem for Linux + + :param cmdpath: Fully qualified path to the command. This is essentially a WSL path not a Windows + one since FSLDIR is specified as a WSL path, however it may have backslashes + as path separators due to previous use of ``os.path.join`` + :param args: Sequence of command arguments (the first of which is the unqualified command name) + + :return: If ``cmdpath`` exists and is executable in WSL, return a sequence of command arguments + which when executed will run the command in WSL. Windows paths in the argument list will + be converted to WSL paths. If ``cmdpath`` was not executable in WSL, returns None + """ + # Check if command exists in WSL (remembering that the command path may include FSLDIR which + # is a Windows path) + cmdpath = fslpath.wslpath(cmdpath) + retcode = sp.call(["wsl", "test", "-x", cmdpath]) + if retcode == 0: + # Form a new argument list and convert any Windows paths in it into WSL paths + wslargs = [fslpath.wslpath(arg) for arg in args] + wslargs[0] = cmdpath + local_fsldir = fslpath.wslpath(fslplatform.fsldir) + if fslplatform.fsldevdir: + local_fsldevdir = fslpath.wslpath(fslplatform.fsldevdir) + else: + local_fsldevdir = None + # Prepend important environment variables - note that it seems we cannot + # use WSLENV for this due to its insistance on path mapping. FIXME FSLDEVDIR? + local_path = "$PATH" + if local_fsldevdir: + local_path += ":%s/bin" % local_fsldevdir + local_path += ":%s/bin" % local_fsldir + prepargs = [ + "wsl", + "PATH=%s" % local_path, + "FSLDIR=%s" % local_fsldir, + "FSLOUTPUTTYPE=%s" % os.environ.get("FSLOUTPUTTYPE", "NIFTI_GZ") + ] + if local_fsldevdir: + prepargs.append("FSLDEVDIR=%s" % local_fsldevdir) + return prepargs + wslargs + else: + # Command was not found in WSL with this path + return None + + def wait(job_ids): """Proxy for :func:`.fslsub.wait`. """ return fslsub.wait(job_ids) diff --git a/tests/test_fsl_utils_path.py b/tests/test_fsl_utils_path.py index d4cc1bec5ede307776f8f22d5857b692e67c01e6..0361ced5ee2843f4c6827e4bbcabd705251d6cdf 100644 --- a/tests/test_fsl_utils_path.py +++ b/tests/test_fsl_utils_path.py @@ -13,6 +13,7 @@ import shutil import tempfile import pytest +import mock import fsl.utils.path as fslpath import fsl.data.image as fslimage @@ -1395,3 +1396,19 @@ def test_commonBase(): for ft in failtests: with pytest.raises(fslpath.PathError): fslpath.commonBase(ft) + +def test_wslpath(): + assert fslpath.wslpath('c:\\Users\\Fishcake\\image.nii.gz') == '/mnt/c/Users/Fishcake/image.nii.gz' + assert fslpath.wslpath('--input=x:\\transfers\\scratch\\image_2.nii') == '--input=/mnt/x/transfers/scratch/image_2.nii' + assert fslpath.wslpath('\\\\wsl$\\centos 7\\users\\fsl\\file.nii') == '/users/fsl/file.nii' + assert fslpath.wslpath('--file=\\\\wsl$\\centos 7\\home\\fsl\\img.nii.gz') == '--file=/home/fsl/img.nii.gz' + assert fslpath.wslpath('\\\\wsl$/centos 7/users\\fsl\\file.nii') == '/users/fsl/file.nii' + +def test_winpath(): + """ + See comment for ``test_fslwsl`` for why we are overwriting FSLDIR + """ + with mock.patch.dict('os.environ', **{ 'FSLDIR' : '\\\\wsl$\\my cool linux distro v2.0\\usr\\local\\fsl'}): + assert fslpath.winpath("/home/fsl/myfile.dat") == '\\\\wsl$\\my cool linux distro v2.0\\home\\fsl\\myfile.dat' + with mock.patch.dict('os.environ', **{ 'FSLDIR' : '/opt/fsl'}): + assert fslpath.winpath("/home/fsl/myfile.dat") == '/home/fsl/myfile.dat' diff --git a/tests/test_platform.py b/tests/test_platform.py index 05321fb43bf9578fd1e59714d118f335bb6f1640..9024bec8262658dd419c48ef288f2d4af2a75c8c 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -212,3 +212,17 @@ def test_detect_ssh(): p = fslplatform.Platform() assert not p.inSSHSession assert not p.inVNCSession + +def test_fslwsl(): + """ + Note that ``Platform.fsldir`` requires the directory in ``FSLDIR`` to exist and + sets ``FSLDIR`` to ``None`` if it doesn't. So we create a ``Platform`` first + and then overwrite ``FSLDIR``. This is a bit of a hack but the logic we are testing + here is whether ``Platform.fslwsl`` recognizes a WSL ``FSLDIR`` string + """ + p = fslplatform.Platform() + with mock.patch.dict('os.environ', **{ 'FSLDIR' : '\\\\wsl$\\my cool linux distro v1.0\\usr\\local\\fsl'}): + assert p.fslwsl + + with mock.patch.dict('os.environ', **{ 'FSLDIR' : '/usr/local/fsl'}): + assert not p.fslwsl