Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Accept pathlib.Path objects where filenames are accepted #610

Merged
merged 18 commits into from
Oct 23, 2019
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions nibabel/filebasedimages.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from copy import deepcopy
from .fileholders import FileHolder
from .filename_parser import (types_filenames, TypesFilenamesError,
splitext_addext)
splitext_addext, _stringify_path)
effigies marked this conversation as resolved.
Show resolved Hide resolved
from .openers import ImageOpener
from .deprecated import deprecate_with_version

Expand Down Expand Up @@ -246,7 +246,7 @@ def set_filename(self, filename):

Parameters
----------
filename : str
filename : str or os.PathLike
If the image format only has one file associated with it,
this will be the only filename set into the image
``.file_map`` attribute. Otherwise, the image instance will
Expand Down Expand Up @@ -279,7 +279,7 @@ def filespec_to_file_map(klass, filespec):

Parameters
----------
filespec : str
filespec : str or os.PathLike
Filename that might be for this image file type.

Returns
Expand Down Expand Up @@ -321,7 +321,7 @@ def to_filename(self, filename):

Parameters
----------
filename : str
filename : str or os.PathLike
filename to which to save image. We will parse `filename`
with ``filespec_to_file_map`` to work out names for image,
header etc.
Expand Down Expand Up @@ -419,7 +419,7 @@ def _sniff_meta_for(klass, filename, sniff_nbytes, sniff=None):

Parameters
----------
filename : str
filename : str or os.PathLike
Filename for an image, or an image header (metadata) file.
If `filename` points to an image data file, and the image type has
a separate "header" file, we work out the name of the header file,
Expand Down Expand Up @@ -466,7 +466,7 @@ def path_maybe_image(klass, filename, sniff=None, sniff_max=1024):

Parameters
----------
filename : str
filename : str or os.PathLike
Filename for an image, or an image header (metadata) file.
If `filename` points to an image data file, and the image type has
a separate "header" file, we work out the name of the header file,
Expand Down
47 changes: 39 additions & 8 deletions nibabel/filename_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,42 @@
''' Create filename pairs, triplets etc, with expected extensions '''

import os
try:
basestring
except NameError:
basestring = str
import pathlib


class TypesFilenamesError(Exception):
pass


def _stringify_path(filepath_or_buffer):
"""Attempt to convert a path-like object to a string.

Parameters
----------
filepath_or_buffer : str or os.PathLike
effigies marked this conversation as resolved.
Show resolved Hide resolved

Returns
-------
str_filepath_or_buffer : maybe a string version of the object
effigies marked this conversation as resolved.
Show resolved Hide resolved
Notes
-----
Objects supporting the fspath protocol (python 3.6+) are coerced
according to its __fspath__ method.
For backwards compatibility with older pythons, pathlib.Path and
py.path objects are specially coerced.
effigies marked this conversation as resolved.
Show resolved Hide resolved
Any other object is passed through unchanged, which includes bytes,
strings, buffers, or anything else that's not even path-like.

Copied from:
https://github.com/pandas-dev/pandas/blob/325dd686de1589c17731cf93b649ed5ccb5a99b4/pandas/io/common.py#L131-L160
"""
if hasattr(filepath_or_buffer, "__fspath__"):
return filepath_or_buffer.__fspath__()
elif isinstance(filepath_or_buffer, pathlib.Path):
return str(filepath_or_buffer)
return filepath_or_buffer


def types_filenames(template_fname, types_exts,
effigies marked this conversation as resolved.
Show resolved Hide resolved
trailing_suffixes=('.gz', '.bz2'),
enforce_extensions=True,
Expand All @@ -31,7 +57,7 @@ def types_filenames(template_fname, types_exts,

Parameters
----------
template_fname : str
template_fname : str or os.PathLike
template filename from which to construct output dict of
filenames, with given `types_exts` type to extension mapping. If
``self.enforce_extensions`` is True, then filename must have one
Expand Down Expand Up @@ -82,7 +108,8 @@ def types_filenames(template_fname, types_exts,
>>> tfns == {'t1': '/path/test.funny', 't2': '/path/test.ext2'}
True
'''
if not isinstance(template_fname, basestring):
template_fname = _stringify_path(template_fname)
if not isinstance(template_fname, str):
raise TypesFilenamesError('Need file name as input '
'to set_filenames')
if template_fname.endswith('.'):
Expand Down Expand Up @@ -151,7 +178,7 @@ def parse_filename(filename,

Parameters
----------
filename : str
filename : str or os.PathLike
filename in which to search for type extensions
types_exts : sequence of sequences
sequence of (name, extension) str sequences defining type to
Expand Down Expand Up @@ -190,6 +217,8 @@ def parse_filename(filename,
>>> parse_filename('/path/fnameext2.gz', types_exts, ('.gz',))
('/path/fname', 'ext2', '.gz', 't2')
'''
filename = _stringify_path(filename)

ignored = None
if match_case:
endswith = _endswith
Expand Down Expand Up @@ -232,7 +261,7 @@ def splitext_addext(filename,

Parameters
----------
filename : str
filename : str or os.PathLike
filename that may end in any or none of `addexts`
match_case : bool, optional
If True, match case of `addexts` and `filename`, otherwise do
Expand All @@ -257,6 +286,8 @@ def splitext_addext(filename,
>>> splitext_addext('fname.ext.foo', ('.foo', '.bar'))
('fname', '.ext', '.foo')
'''
filename = _stringify_path(filename)

if match_case:
endswith = _endswith
else:
Expand Down
2 changes: 2 additions & 0 deletions nibabel/freesurfer/mghformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ..volumeutils import (array_to_file, array_from_file, endian_codes,
Recoder)
from ..filebasedimages import SerializableImage
from ..filename_parser import _stringify_path
from ..spatialimages import HeaderDataError, SpatialImage
from ..fileholders import FileHolder
from ..arrayproxy import ArrayProxy, reshape_dataobj
Expand Down Expand Up @@ -529,6 +530,7 @@ def __init__(self, dataobj, affine, header=None,

@classmethod
def filespec_to_file_map(klass, filespec):
filespec = _stringify_path(filespec)
""" Check for compressed .mgz format, then .mgh format """
if splitext(filespec)[1].lower() == '.mgz':
return dict(image=FileHolder(filename=filespec))
Expand Down
11 changes: 8 additions & 3 deletions nibabel/loadsave.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import os
import numpy as np

from .filename_parser import splitext_addext
from .filename_parser import splitext_addext, _stringify_path
from .openers import ImageOpener
from .filebasedimages import ImageFileError
from .imageclasses import all_image_classes
Expand All @@ -25,7 +25,7 @@ def load(filename, **kwargs):

Parameters
----------
filename : string
filename : str or os.PathLike
specification of file to load
\*\*kwargs : keyword arguments
Keyword arguments to format-specific load
Expand All @@ -35,12 +35,16 @@ def load(filename, **kwargs):
img : ``SpatialImage``
Image of guessed type
'''
filename = _stringify_path(filename)
effigies marked this conversation as resolved.
Show resolved Hide resolved

# Check file exists and is not empty
try:
stat_result = os.stat(filename)
except OSError:
raise FileNotFoundError("No such file or no access: '%s'" % filename)
if stat_result.st_size <= 0:
raise ImageFileError("Empty file: '%s'" % filename)

sniff = None
for image_klass in all_image_classes:
is_valid, sniff = image_klass.path_maybe_image(filename, sniff)
Expand Down Expand Up @@ -85,13 +89,14 @@ def save(img, filename):
----------
img : ``SpatialImage``
image to save
filename : str
filename : str or os.PathLike
filename (often implying filenames) to which to save `img`.

Returns
-------
None
'''
filename = _stringify_path(filename)
effigies marked this conversation as resolved.
Show resolved Hide resolved

# Save the type as expected
try:
Expand Down
29 changes: 16 additions & 13 deletions nibabel/tests/test_image_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import warnings
from functools import partial
from itertools import product
import pathlib

import numpy as np

Expand Down Expand Up @@ -141,21 +142,23 @@ def validate_filenames(self, imaker, params):
assert_almost_equal(np.asanyarray(img.dataobj), np.asanyarray(rt_img.dataobj))
# get_ / set_ filename
fname = 'an_image' + self.standard_extension
img.set_filename(fname)
assert_equal(img.get_filename(), fname)
assert_equal(img.file_map['image'].filename, fname)
for path in (fname, pathlib.Path(fname)):
img.set_filename(path)
assert_equal(img.get_filename(), str(path))
assert_equal(img.file_map['image'].filename, str(path))
# to_ / from_ filename
fname = 'another_image' + self.standard_extension
with InTemporaryDirectory():
# Validate that saving or loading a file doesn't use deprecated methods internally
with clear_and_catch_warnings() as w:
warnings.simplefilter('error', DeprecationWarning)
img.to_filename(fname)
rt_img = img.__class__.from_filename(fname)
assert_array_equal(img.shape, rt_img.shape)
assert_almost_equal(img.get_fdata(), rt_img.get_fdata())
assert_almost_equal(np.asanyarray(img.dataobj), np.asanyarray(rt_img.dataobj))
del rt_img # to allow windows to delete the directory
for path in (fname, pathlib.Path(fname)):
with InTemporaryDirectory():
# Validate that saving or loading a file doesn't use deprecated methods internally
with clear_and_catch_warnings() as w:
warnings.simplefilter('error', DeprecationWarning)
img.to_filename(path)
rt_img = img.__class__.from_filename(path)
assert_array_equal(img.shape, rt_img.shape)
assert_almost_equal(img.get_fdata(), rt_img.get_fdata())
assert_almost_equal(np.asanyarray(img.dataobj), np.asanyarray(rt_img.dataobj))
del rt_img # to allow windows to delete the directory

def validate_no_slicing(self, imaker, params):
img = imaker()
Expand Down
16 changes: 9 additions & 7 deletions nibabel/tests/test_image_load_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import shutil
from os.path import dirname, join as pjoin
from tempfile import mkdtemp
import pathlib

import numpy as np

Expand Down Expand Up @@ -255,13 +256,14 @@ def test_filename_save():
try:
pth = mkdtemp()
fname = pjoin(pth, 'image' + out_ext)
nils.save(img, fname)
rt_img = nils.load(fname)
assert_array_almost_equal(rt_img.get_fdata(), data)
assert_true(type(rt_img) is loadklass)
# delete image to allow file close. Otherwise windows
# raises an error when trying to delete the directory
del rt_img
for path in (fname, pathlib.Path(fname)):
nils.save(img, path)
rt_img = nils.load(path)
assert_array_almost_equal(rt_img.get_fdata(), data)
assert_true(type(rt_img) is loadklass)
# delete image to allow file close. Otherwise windows
# raises an error when trying to delete the directory
del rt_img
finally:
shutil.rmtree(pth)

Expand Down
30 changes: 20 additions & 10 deletions nibabel/tests/test_loadsave.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from os.path import dirname, join as pjoin
import shutil
import pathlib

import numpy as np

Expand All @@ -26,14 +27,20 @@


def test_read_img_data():
for fname in ('example4d.nii.gz',
'example_nifti2.nii.gz',
'minc1_1_scale.mnc',
'minc1_4d.mnc',
'test.mgz',
'tiny.mnc'
):
fpath = pjoin(data_path, fname)
fnames_test = [
'example4d.nii.gz',
'example_nifti2.nii.gz',
'minc1_1_scale.mnc',
'minc1_4d.mnc',
'test.mgz',
'tiny.mnc'
]
fnames_test += [pathlib.Path(p) for p in fnames_test]
for fname in fnames_test:
# os.path.join doesnt work between str / os.PathLike in py3.5
fpath = pjoin(data_path, str(fname))
if isinstance(fname, pathlib.Path):
fpath = pathlib.Path(fpath)
img = load(fpath)
data = img.get_fdata()
data2 = read_img_data(img)
Expand All @@ -45,8 +52,11 @@ def test_read_img_data():
assert_array_equal(read_img_data(img, prefer='unscaled'), data)
# Assert all caps filename works as well
with TemporaryDirectory() as tmpdir:
up_fpath = pjoin(tmpdir, fname.upper())
shutil.copyfile(fpath, up_fpath)
up_fpath = pjoin(tmpdir, str(fname).upper())
if isinstance(fname, pathlib.Path):
up_fpath = pathlib.Path(up_fpath)
# shutil doesnt work with os.PathLike in py3.5
shutil.copyfile(str(fpath), str(up_fpath))
img = load(up_fpath)
assert_array_equal(img.dataobj, data)
del img
Expand Down