diff --git a/fsl/data/image.py b/fsl/data/image.py index 755037cab0a8c1263b81ef0bf58eefe8bd89ab4c..cff15449300e2d84a1e26329df702c2b659d2ff6 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -751,8 +751,9 @@ EXTENSION_DESCRIPTIONS = ['Compressed NIFTI images', """Descriptions for each of the extensions in :data:`ALLOWED_EXTENSIONS`. """ -REPLACEMENTS = {'.hdr' : ['.img'], '.hdr.gz' : ['.img.gz']} -"""Suffix replacements used by :func:`addExt` to resolve file path +FILE_GROUPS = [('.img', '.hdr'), + ('.img.gz', '.hdr.gz')] +"""File suffix groups used by :func:`addExt` to resolve file path ambiguities - see :func:`fsl.utils.path.addExt`. """ @@ -809,7 +810,7 @@ def addExt(prefix, mustExist=True): ALLOWED_EXTENSIONS, mustExist, DEFAULT_EXTENSION, - replace=REPLACEMENTS) + fileGroups=FILE_GROUPS) def loadIndexedImageFile(filename): diff --git a/fsl/utils/path.py b/fsl/utils/path.py index 3732f3fed26ec918771995efe2bd5871238e7ddf..1c15fe96497b3a660b2c5700c4d2f0412580cc51 100644 --- a/fsl/utils/path.py +++ b/fsl/utils/path.py @@ -16,14 +16,21 @@ paths. addExt removeExt getExt + splitExt + getFileGroup + imcp + immv """ import os.path as op +import shutil class PathError(Exception): - """``Exception`` class raised by :func:`addExt` and :func:`getExt`. """ + """``Exception`` class raised by the functions defined in this module + when something goes wrong. + """ pass @@ -74,7 +81,7 @@ def addExt(prefix, allowedExts, mustExist=True, defaultExt=None, - replace=None): + fileGroups=None): """Adds a file extension to the given file ``prefix``. If ``mustExist`` is False, and the file does not already have a @@ -84,11 +91,11 @@ def addExt(prefix, If ``mustExist`` is ``True`` (the default), the function checks to see if any files exist that have the given prefix, and a supported file - extension. A :exc:`ValueError` is raised if: + extension. A :exc:`PathError` is raised if: - No files exist with the given prefix and a supported extension. - - ``replace`` is ``None``, and more than one file exists with the + - ``fileGroups`` is ``None``, and more than one file exists with the given prefix, and a supported extension. Otherwise the full file name is returned. @@ -100,33 +107,12 @@ def addExt(prefix, :arg mustExist: Whether the file must exist or not. :arg defaultExt: Default file extension to use. - - :arg replace: If multiple files exist with the same ``prefix`` and - supported extensions (e.g. ``file.hdr`` and - ``file.img``), this dictionary can be used to resolve - ambiguities. It must have the structure:: - - { - suffix : [replacement, ...], - ... - } - - If files with ``suffix`` and one of the ``replacement`` - suffixes exists, the ``suffix`` file will - be ignored, and replaced with the ``replacement`` file. - If multiple ``replacement`` files exist alongside the - ``suffix`` file, a ``PathError`` is raised. - - .. note:: The primary use-case of the ``replace`` parameter is to resolve - ambiguity with respect to NIFTI and ANALYSE75 image pairs. By - specifying ``replace={'.hdr' : ['.img'. '.img.gz'}``, the - ``addExt`` function is able to figure out what you mean when you - wish to add an extension to ``file``, and ``file.hdr`` and - either ``file.img`` or ``file.img.gz`` (but not both) exist. + + :arg fileGroups: Recognised file groups - see :func:`getFileGroup`. """ - if replace is None: - replace = {} + if fileGroups is None: + fileGroups = {} if not mustExist: @@ -149,14 +135,14 @@ def addExt(prefix, else: allPaths = [prefix + ext for ext in allowedExts] - exists = [op.isfile(e) for e in allPaths] - nexists = sum(exists) + allPaths = [p for p in allPaths if op.isfile(p)] + nexists = len(allPaths) # Could not find any supported file # with the specified prefix if nexists == 0: raise PathError('Could not find a supported file ' - 'with prefix {}'.format(prefix)) + 'with prefix "{}"'.format(prefix)) # Ambiguity! More than one supported # file with the specified prefix. @@ -164,99 +150,260 @@ def addExt(prefix, # Remove non-existent paths from the # extended list, get all their - # suffixes, and potential replacements - allPaths = [allPaths[i] for i in range(len(allPaths)) if exists[i]] - suffixes = [getExt(e, allowedExts) for e in allPaths] - replacements = [replace.get(s) for s in suffixes] - hasReplace = [r is not None for r in replacements] - - # If any replacement has been specified - # for any of the existing suffixes, - # see if we have a unique match for - # exactly one existing suffix, the - # one to be ignored/replaced. - if sum(hasReplace) == 1: - - # Make sure there is exactly one potential - # replacement for this suffix. If there's - # more than one (e.g. file.hdr plus both - # file.img and file.img.gz) we can't resolve - # the ambiguity. In this case the code will - # fall through to the raise statement below. - toReplace = allPaths[hasReplace.index(True)] - replacements = replacements[hasReplace.index(True)] - replacements = [prefix + ext for ext in replacements] - replExists = [r in allPaths for r in replacements] - - if sum(replExists) == 1: - - replacedBy = replacements[replExists.index(True)] - allPaths[allPaths.index(toReplace)] = replacedBy - allPaths = list(set(allPaths)) - - exists = [True] * len(allPaths) - - # There's more than one path match - - # we can't resolve the ambiguity - if len(allPaths) > 1: + # suffixes, and see if they match + # any file groups. + suffixes = [getExt(p, allowedExts) for p in allPaths] + groupMatches = [sorted(suffixes) == sorted(g) for g in fileGroups] + + # Is there a match for a file suffix group? + # If not, multiple files with the specified + # prefix exist, and there is no way to + # resolve the ambiguity. + if sum(groupMatches) != 1: raise PathError('More than one file with ' - 'prefix {}'.format(prefix)) + 'prefix "{}"'.format(prefix)) + + # Otherwise, we return a path + # to the file which matches the + # first suffix in the group. + groupIdx = groupMatches.index(True) + allPaths = [prefix + fileGroups[groupIdx][0]] # Return the full file name of the # supported file that was found - extIdx = exists.index(True) - return allPaths[extIdx] + return allPaths[0] -def removeExt(filename, allowedExts): - """Removes the extension from the given file name. Returns the filename - unmodified if it does not have a supported extension. +def removeExt(filename, allowedExts=None): + """Returns the base name of the given file name. See :func:`splitExt`. """ - :arg filename: The file name to strip. - - :arg allowedExts: A list of strings containing the allowed file - extensions. - """ + return splitExt(filename, allowedExts)[0] - # figure out the extension of the given file - extMatches = [filename.endswith(ext) for ext in allowedExts] - - # the file does not have a supported extension - if not any(extMatches): - return filename - # figure out the length of the matched extension - extIdx = extMatches.index(True) - extLen = len(allowedExts[extIdx]) +def getExt(filename, allowedExts=None): + """Returns the extension of the given file name. See :func:`splitExt`. """ - # and trim it from the file name - return filename[:-extLen] + return splitExt(filename, allowedExts)[1] -def getExt(filename, allowedExts=None): - """Returns the extension from the given file name. +def splitExt(filename, allowedExts=None): + """Returns the base name and the extension from the given file name. If ``allowedExts`` is ``None``, this function is equivalent to using:: - os.path.splitext(filename)[1] + os.path.splitext(filename) If ``allowedExts`` is provided, but the file does not end with an allowed - extension, a :exc:`PathError` is raised. + extension, a tuple containing ``(filename, '')`` is returned. + :arg filename: The file name to split. + :arg allowedExts: Allowed/recognised file extensions. """ # If allowedExts is not specified, # we just use op.splitext if allowedExts is None: - return op.splitext(filename)[1] + return op.splitext(filename) # Otherwise, try and find a suffix match extMatches = [filename.endswith(ext) for ext in allowedExts] + # No match, assume there is no extension if not any(extMatches): - raise PathError('{} does not end in a supported extension ({})'.format( - filename, ', '.join(allowedExts))) + return filename, '' + # Otherwise split the filename + # into its base and its extension extIdx = extMatches.index(True) - return allowedExts[extIdx] + extLen = len(allowedExts[extIdx]) + + return filename[:-extLen], filename[-extLen:] + + +def getFileGroup(path, allowedExts=None, fileGroups=None, fullPaths=True): + """If the given ``path`` is part of a ``fileGroup``, returns a list + containing the paths to all other files in the group (including the + ``path`` itself). + + If the ``path`` does not appear to be part of a file group, a list + containing only the ``path`` is returned. + + File groups can be used to specify a collection of file suffixes which + should always exist alongside each other. This can be used to resolve + ambiguity when multiple files exist with the same ``prefix`` and supported + extensions (e.g. ``file.hdr`` and ``file.img``). The file groups are + specified as a list of sequences, for example:: + + [('.img', '.hdr'), + ('.img.gz', '.hdr.gz')] + + If you specify``fileGroups=[('.img', '.hdr')]`` and ``prefix='file'``, and + both ``file.img`` and ``file.hdr`` exist, the :func:`addExt` function would + return ``file.img`` (i.e. the file which matches the first extension in + the group). + + Similarly, if you call the :func:`imcp` or :func:`immv` functions with the + above parameters, both ``file.img`` and ``file.hdr`` will be moved. + + .. note:: The primary use-case of file groups is to resolve ambiguity with + respect to NIFTI and ANALYSE75 image pairs. By specifying + ``fileGroups=[('.img', '.hdr'), ('.img.gz', '.hdr.gz')]``, the + :func:`addExt`, :func:`immv` and :func:`imcp` functions are able + to figure out what you mean when you specify ``file``, and both + ``file.hdr`` and ``file.img`` (or ``file.hdr.gz`` and + ``file.img.gz``) exist. + + :arg path: Path to the file. Must contain the file extension. + + :arg allowedExts: Allowed/recognised file extensions. + + :arg fileGroups: Recognised file groups. + + :arg fullPaths: If ``True`` (the default), full file paths (relative to + the ``path``) are returned. Otherwise, only the file + extensions in the group are returned. + """ + + if fileGroups is None: + return [path] + + base, ext = splitExt(path, allowedExts) + + matchedGroups = [] + matchedGroupFiles = [] + + for group in fileGroups: + + if ext not in group: + continue + + groupFiles = [base + s for s in group] + + if not all([op.exists(f) for f in groupFiles]): + continue + + matchedGroups .append(group) + matchedGroupFiles.append(groupFiles) + + # If the given path is part of more + # than one existing file group, we + # can't resolve this ambiguity. + if len(matchedGroupFiles) != 1: + if fullPaths: return [path] + else: return [ext] + else: + if fullPaths: return matchedGroupFiles[0] + else: return matchedGroups[ 0] + + +def imcp(src, + dest, + allowedExts=None, + fileGroups=None, + overwrite=False, + move=False): + """Copy the given ``src`` file to destination ``dest``. + + :arg src: Path to copy. If ``allowedExts`` is provided, + the file extension can be omitted. + + :arg dest: Destination path. Can be an incomplete file + specification (i.e. without the extension), or a + directory. + + :arg allowedExts: Allowed/recognised file extensions. + + :arg fileGroups: Recognised file groups - see the :func:`getFileGroup` + documentation. + + :arg overwrite: If ``True`` this function will overwrite files that + already exist. Defaults to ``False``. + + :arg move: If ``True``, the files are moved, instead of being + copied. + """ + + base, ext = splitExt(src, allowedExts) + destIsDir = op.isdir(dest) + + # If dest has been specified + # as a file name, we don't + # care about its extension. + if not destIsDir: + dest = removeExt(dest, allowedExts) + + # src was specified without an + # extension, or the specitifed + # src does not have an allowed + # extension. + if ext == '': + + # Try to resolve the specified src + # path - if src does not exist, or + # does not have an allowed extension, + # addExt will raise an error + src = addExt(src, + allowedExts, + mustExist=True, + fileGroups=fileGroups) + + # We've resolved src to a + # full filename - split it + # again to get its extension + base, ext = splitExt(src, allowedExts) + + # If the source is part of a file group, + # e.g. src.img/src.hdr, we want to copy + # the whole set of files. So here we + # build a list of source files that need + # to be copied/moved. The getFileGroup + # function returns all other files that + # are associated with this file (i.e. + # part of the same group). + # + # We store the sources as separate + # (base, ext) tuples, so we don't + # have to re-split when creating + # destination paths. + copySrcs = getFileGroup(src, allowedExts, fileGroups, fullPaths=False) + copySrcs = [(base, e) for e in copySrcs] + + # Note that these additional files + # do not have to exist, e.g. + # imcp('blah.img', ...) will still + # work if there is no blah.hdr + copySrcs = [(b, e) for (b, e) in copySrcs if op.exists(b + e)] + + # Build a list of destinations for each + # copy source - we build this list in + # advance, so we can fail if any of the + # destinations already exist. + copyDests = [] + for i, (base, ext) in enumerate(copySrcs): + + # We'll also take this opportunity + # to re-combine the source paths + copySrcs[i] = base + ext + + if destIsDir: copyDests.append(dest) + else: copyDests.append(dest + ext) + + # Fail if any of the destination + # paths already exist + if not overwrite: + if not destIsDir and any([op.exists(d) for d in copyDests]): + raise PathError('imcp error - a destination path already ' + 'exists ({})'.format(', '.join(copyDests))) + + # Do the copy/move + for src, dest in zip(copySrcs, copyDests): + + if move: shutil.move(src, dest) + else: shutil.copy(src, dest) + + +def immv(src, dest, allowedExts=None, fileGroups=None, overwrite=False): + """Move the specified ``src`` to the specified ``dest``. See :func:`imcp`. + """ + imcp(src, dest, allowedExts, fileGroups, overwrite, move=True)