Commit b5e6a9ed authored by Paul McCarthy's avatar Paul McCarthy 🚵
Browse files

Merge branch 'mnt/3.6.0-prep' into 'master'

Mnt/3.6.0 prep

See merge request fsl/fslpy!293
parents f53bb6f8 a18b60cb
......@@ -11,18 +11,21 @@ Changed
^^^^^^^
* The ``fslpy`` API ocumentation is now hosted at
https://open.win.ox.ac.uk/fsl/fslpy
https://open.win.ox.ac.uk/fsl/fslpy (!290).
* The :mod:`fsl` and :mod:`fsl.scripts` packages have been changed from being
`pkgutil-style
<https://packaging.python.org/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages>`_
namespace packages to now being `native
<https://packaging.python.org/guides/packaging-namespace-packages/#native-namespace-packages>`_
namespace packages.
namespace packages (!290).
* The :class:`.TaskThread` now allows an error handler function to be
specified, which is run on the :mod:`.idle` loop.
specified, which is run on the :mod:`.idle` loop (!283).
* The :func:`.bids.loadMetadata` function no long resolves sym-links when
determining whether a file is contained within a BIDS data set.
determining whether a file is contained within a BIDS data set (!287).
* The :class:`.Image` class can now be created from a ``pathlib.Path`` object
(!292).
* Some functions in the :mod:`.path` module can now be used with
``pathlib.Path`` objects (!293).
Deprecated
^^^^^^^^^^
......@@ -32,17 +35,20 @@ Deprecated
:mod:`fsl.utils.platform` module, including ``frozen``, ``haveGui``,
``canHaveGui``, ``inSSHSession``, ``inVNCSession``, ``wxPlatform``,
``wxFlavour``, ``glVersion``, ``glRenderer``, and ``glIsSoftwareRenderer``.
Equivalent functions are being added to the ``fsleyes-widgets`` library.
Equivalent functions are being added to the ``fsleyes-widgets`` library
(!285).
* The :mod:`fsl.utils.filetree` package has been deprecated, and will be
removed in a future version of ``fslpy`` - it is now published as a separate
library on [PyPI](https://pypi.org/project/file-tree/).
library on [PyPI](https://pypi.org/project/file-tree/) (!286).
Fixed
^^^^^
* Fixed an edge-case in the :mod:`.gifti` module, where a surface with a
single triangle was being loaded incorrectly.
single triangle was being loaded incorrectly (!288).
* Fixed an issue in the :func:`.func_to_cmd` function, where it was
unintentionally leaving flie handles open (!291).
......
......@@ -19,7 +19,7 @@ programming library written in Python. It is used by `FSLeyes
<https://git.fmrib.ox.ac.uk/fsl/fsleyes/fsleyes/>`_.
``fslpy`` is tested against Python versions 3.6, 3.7, 3.8 and 3.9.
``fslpy`` is tested against Python versions 3.7, 3.8 and 3.9.
Installation
......
......@@ -9,13 +9,13 @@ files. Pillow is required to use the ``Bitmap`` class.
"""
import os.path as op
import logging
import six
import os.path as op
import pathlib
import logging
import numpy as np
import numpy as np
from . import image as fslimage
import fsl.data.image as fslimage
log = logging.getLogger(__name__)
......@@ -51,7 +51,7 @@ class Bitmap(object):
data.
"""
if isinstance(bmp, six.string_types):
if isinstance(bmp, (pathlib.Path, str)):
try:
# Allow big images
......@@ -61,7 +61,7 @@ class Bitmap(object):
except ImportError:
raise RuntimeError('Install Pillow to use the Bitmap class')
src = bmp
src = str(bmp)
img = Image.open(src)
# If this is a palette/LUT
......
......@@ -32,22 +32,21 @@ and file names:
"""
import os
import os.path as op
import itertools as it
import json
import string
import logging
import tempfile
import six
import numpy as np
import os
import os.path as op
import itertools as it
import json
import string
import logging
import tempfile
from pathlib import Path
from typing import Union
import numpy as np
import nibabel as nib
import nibabel.fileslice as fileslice
from pathlib import Path
import fsl.utils.meta as meta
import fsl.transform.affine as affine
import fsl.utils.notifier as notifier
......@@ -58,6 +57,10 @@ import fsl.data.constants as constants
import fsl.data.imagewrapper as imagewrapper
PathLike = Union[str, Path]
ImageSource = Union[PathLike, nib.Nifti1Image, np.ndarray, 'Image']
log = logging.getLogger(__name__)
......@@ -993,21 +996,21 @@ class Image(Nifti):
def __init__(self,
image,
name=None,
header=None,
xform=None,
loadData=True,
calcRange=True,
threaded=False,
dataSource=None,
loadMeta=False,
image : ImageSource,
name : str = None,
header : nib.Nifti1Header = None,
xform : np.ndarray = None,
loadData : bool = True,
calcRange : bool = True,
threaded : bool = False,
dataSource : PathLike = None,
loadMeta : bool = False,
**kwargs):
"""Create an ``Image`` object with the given image data or file name.
:arg image: A string containing the name of an image file to load,
or a Path object pointing to an image file, or a
:mod:`numpy` array, or a :mod:`nibabel` image object,
or a Path object pointing to an image file, or a
:mod:`numpy` array, or a :mod:`nibabel` image object,
or an ``Image`` object.
:arg name: A name for the image.
......@@ -1086,16 +1089,11 @@ class Image(Nifti):
header.set_qform(xform, code=qform)
# The image parameter may be the name of an image file
if isinstance(image, six.string_types):
if isinstance(image, (str, Path)):
image = op.abspath(addExt(image))
nibImage = nib.load(image, **kwargs)
dataSource = image
saved = True
# The image parameter may be a Path object pointing to an image file
elif isinstance(image, Path):
nibImage = nib.load(image, **kwargs)
dataSource = str(image)
saved = True
# Or a numpy array - we wrap it in a nibabel image,
# with an identity transformation (each voxel maps
......@@ -1141,15 +1139,13 @@ class Image(Nifti):
nibImage = image
# Figure out the name of this image, if
# it has not beenbeen explicitly passed in
# it has not been explicitly passed in
if name is None:
# If this image was loaded
# from disk, use the file name.
if isinstance(image, six.string_types):
if isinstance(image, (str, Path)):
name = removeExt(op.basename(image))
elif isinstance(image, Path):
name = image.name
# Or the image was created from a numpy array
elif isinstance(image, np.ndarray):
......
......@@ -10,8 +10,8 @@ Freesurfer ``mgh``/``mgz`` image files.
import os.path as op
import pathlib
import six
import numpy as np
import nibabel as nib
......@@ -47,7 +47,7 @@ class MGHImage(fslimage.Image):
All other arguments are passed through to :meth:`Image.__init__`
"""
if isinstance(image, six.string_types):
if isinstance(image, (str, pathlib.Path)):
filename = op.abspath(image)
name = op.basename(filename)
image = nib.load(image)
......
......@@ -14,8 +14,6 @@ that some condition is met.
"""
import six
import nibabel as nib
import fsl.data.image as fslimage
......@@ -24,7 +22,7 @@ import fsl.data.image as fslimage
def ensureIsImage(img):
"""Ensures that the given ``img`` is an in-memory ``nibabel`` object.
"""
if isinstance(img, six.string_types):
if isinstance(img, str):
img = fslimage.addExt(img)
img = nib.load(img)
return img
......@@ -37,7 +37,7 @@ Example usage, building a short pipeline::
"""
from six import BytesIO
from io import BytesIO
import os.path as op
import glob
import time
......@@ -439,9 +439,9 @@ _external_job = ("""#!{}
# This is a temporary file designed to run the python function {},
# so that it can be submitted to the cluster
import pickle
from six import BytesIO
from io import BytesIO
from importlib import import_module
{}
{}
pickle_bytes = BytesIO({})
name_type, name, func_name, args, kwargs = pickle.load(pickle_bytes)
......@@ -455,7 +455,7 @@ elif name_type == 'script':
func = local_execute[func_name]
else:
raise ValueError('Unknown name_type: %r' % name_type)
{}
""")
......
......@@ -21,7 +21,6 @@ a function:
import logging
import hashlib
import functools
import six
log = logging.getLogger(__name__)
......@@ -171,7 +170,7 @@ def memoizeMD5(func):
# compatible) bytes , and take
# the hash of those bytes.
for arg in args:
if not isinstance(arg, six.string_types):
if not isinstance(arg, str):
arg = str(arg)
arg = arg.encode('utf-8')
hashobj.update(arg)
......
......@@ -14,9 +14,6 @@ import inspect
import contextlib
import collections
import six
import fsl.utils.idle as idle
import fsl.utils.weakfuncref as weakfuncref
......@@ -297,7 +294,7 @@ class Notifier(object):
:arg topic: Topic or topics that the listener is registered on.
"""
if topic is None or isinstance(topic, six.string_types):
if topic is None or isinstance(topic, str):
topic = [topic]
topics = topic
......
......@@ -32,16 +32,21 @@ import os.path as op
import os
import glob
import operator
import pathlib
import re
from typing import Sequence, Tuple, Union
from fsl.utils.platform import platform
PathLike = Union[str, pathlib.Path]
class PathError(Exception):
"""``Exception`` class raised by the functions defined in this module
when something goes wrong.
"""
pass
def deepest(path, suffixes):
......@@ -52,12 +57,12 @@ def deepest(path, suffixes):
path = path.strip()
if path == op.sep or path == '':
if path in (op.sep, ''):
return None
path = path.rstrip(op.sep)
if any([path.endswith(s) for s in suffixes]):
if any(path.endswith(s) for s in suffixes):
return path
return deepest(op.dirname(path), suffixes)
......@@ -81,7 +86,7 @@ def shallowest(path, suffixes):
if parent is not None:
return parent
if any([path.endswith(s) for s in suffixes]):
if any(path.endswith(s) for s in suffixes):
return path
return None
......@@ -101,19 +106,23 @@ def allFiles(root):
return files
def hasExt(path, allowedExts):
def hasExt(path : PathLike,
allowedExts : Sequence[str]) -> bool:
"""Convenience function which returns ``True`` if the given ``path``
ends with any of the given ``allowedExts``, ``False`` otherwise.
"""
return any([path.endswith(e) for e in allowedExts])
def addExt(prefix,
allowedExts=None,
mustExist=True,
defaultExt=None,
fileGroups=None,
unambiguous=True):
path = str(path)
return any(path.endswith(e) for e in allowedExts)
def addExt(
prefix : PathLike,
allowedExts : Sequence[str] = None,
mustExist : bool = True,
defaultExt : str = None,
fileGroups : Sequence[Sequence[str]] = None,
unambiguous : bool = True
) -> Union[Sequence[str], str]:
"""Adds a file extension to the given file ``prefix``.
If ``mustExist`` is False, and the file does not already have a
......@@ -148,6 +157,8 @@ def addExt(prefix,
containing *all* matching files is returned.
"""
prefix = str(prefix)
if allowedExts is None: allowedExts = []
if fileGroups is None: fileGroups = {}
......@@ -189,7 +200,8 @@ def addExt(prefix,
# If ambiguity is ok, return
# all matching paths
elif not unambiguous:
if not unambiguous:
return allPaths
# Ambiguity is not ok! More than
......@@ -223,19 +235,29 @@ def addExt(prefix,
return allPaths[0]
def removeExt(filename, allowedExts=None, firstDot=False):
def removeExt(
filename : PathLike,
allowedExts : Sequence[str] = None,
firstDot : bool = False
) -> str:
"""Returns the base name of the given file name. See :func:`splitExt`. """
return splitExt(filename, allowedExts, firstDot)[0]
def getExt(filename, allowedExts=None, firstDot=False):
def getExt(
filename : PathLike,
allowedExts : Sequence[str] = None,
firstDot : bool = False
) -> str:
"""Returns the extension of the given file name. See :func:`splitExt`. """
return splitExt(filename, allowedExts, firstDot)[1]
def splitExt(filename, allowedExts=None, firstDot=False):
def splitExt(
filename : PathLike,
allowedExts : Sequence[str] = None,
firstDot : bool = False
) -> Tuple[str, str]:
"""Returns the base name and the extension from the given file name.
If ``allowedExts`` is ``None`` and ``firstDot`` is ``False``, this
......@@ -262,6 +284,8 @@ def splitExt(filename, allowedExts=None, firstDot=False):
last period. Ignored if ``allowedExts`` is specified.
"""
filename = str(filename)
# If allowedExts is not specified
# we split on a period character
if allowedExts is None:
......@@ -465,7 +489,7 @@ def removeDuplicates(paths, allowedExts=None, fileGroups=None):
groupFiles = getFileGroup(path, allowedExts, fileGroups)
if not any([p in unique for p in groupFiles]):
if not any(p in unique for p in groupFiles):
unique.append(groupFiles[0])
return unique
......@@ -492,14 +516,13 @@ def uniquePrefix(path):
break
# Should never happen if path is valid
elif len(hits) == 0 or idx >= len(filename) - 1:
if len(hits) == 0 or idx >= len(filename) - 1:
raise PathError('No unique prefix for {}'.format(filename))
# Not unique - continue looping
else:
idx += 1
prefix = prefix + filename[idx]
hits = [h for h in hits if h.startswith(prefix)]
idx += 1
prefix = prefix + filename[idx]
hits = [h for h in hits if h.startswith(prefix)]
return prefix
......@@ -525,54 +548,56 @@ def commonBase(paths):
last = base
if all([p.startswith(base) for p in paths]):
if all(p.startswith(base) for p in 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.
def wslpath(path):
"""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)
match = re.match(r"^(--[\w-]+=)?\\\\wsl\$[\\\/][^\\^\/]+(.*)$", path)
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)
match = re.match(r"^(--[\w-]+=)?([a-zA-z]):(.+)$", path)
if match:
arg, drive, path = match.group(1, 2, 3)
if arg is None:
arg = ""
return arg + "/mnt/" + drive.lower() + path.replace("\\", "/")
return winpath
return path
def winpath(wslpath):
"""
Convert a WSL-local filepath (for example ``/usr/local/fsl/``) into a path that can be used from
Windows.
def winpath(path):
"""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.
This requires WSL2 which supports the ``\\wsl$\\`` network path.
wslpath is assumed to be an absolute path.
"""
if not platform.fslwsl:
return wslpath
return path
else:
match = re.match(r"^\\\\wsl\$\\([^\\]+).*$", platform.fsldir)
if match:
......@@ -581,6 +606,7 @@ def winpath(wslpath):
distro = None
if not distro:
raise RuntimeError("Could not identify WSL installation from FSLDIR (%s)" % platform.fsldir)
raise RuntimeError('Could not identify WSL installation from '
'FSLDIR (%s)' % platform.fsldir)
return "\\\\wsl$\\" + distro + wslpath.replace("/", "\\")
return "\\\\wsl$\\" + distro + path.replace("/", "\\")
......@@ -30,8 +30,6 @@ 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
......@@ -83,7 +81,7 @@ def prepareArgs(args):
if len(args) == 1:
# Argument was a command string
if isinstance(args[0], six.string_types):
if isinstance(args[0], str):
args = shlex.split(args[0])
# Argument was an unpacked sequence
......
......@@ -7,13 +7,12 @@
"""This module provides the :class:`WeakFunctionRef` class. """
import six
import types
import weakref
import inspect
class WeakFunctionRef(object):
class WeakFunctionRef:
"""Class which encapsulates a :mod:`weakref` to a function or method.
This class is used by :class:`.Notifier` instances to reference
......@@ -28,10 +27,10 @@ class WeakFunctionRef(object):
"""
# Bound method
if self.__isMethod(func):
if inspect.ismethod(func):
boundMeth = six.get_method_function(func)
boundSelf = six.get_method_self( func)
boundMeth = func.__func__
boundSelf = func.__self__
# We can't take a weakref of the method
# object, so we have to weakref the object
......@@ -73,35 +72,6 @@ class WeakFunctionRef(object):
return self.__str__()
def __isMethod(self, func):
"""Returns ``True`` if the given function is a bound method,
``False`` otherwise.
This seems to be one of the few areas where python 2 and 3 are
irreconcilably incompatible (or just where :mod:`six` does not have a
function to help us).
In Python 3 there is no difference between an unbound method and a
function. But in Python 2, an unbound method is still a method (and
inspect.ismethod returns True).
"""
ismethod = False
# Therefore, in python2 we need to test