Skip to content

Commit 8e5aa81

Browse files
committed
Merge pull request #328 from bcipolli/deprecate_binopener
MRG: More graceful handling of compressed file Opening This PR deprecates BinOpener, instead exposing a decorator for creating arbitrary file extension / opener methods (similar to that proposed in #317 and created in #319).
2 parents 6dc6bae + f4c20f3 commit 8e5aa81

15 files changed

+134
-61
lines changed

bin/nib-nifti-dx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ def main():
2525
(opts, files) = parser.parse_args()
2626

2727
for fname in files:
28-
with nib.volumeutils.BinOpener(fname) as fobj:
29-
hdr = fobj.read(nib.nifti1.header_dtype.itemsize)
28+
with nib.openers.ImageOpener(fname) as fobj:
29+
hdr = fobj.read(nib.nifti1.header_dtype.itemsize)
3030
result = nib.Nifti1Header.diagnose_binaryblock(hdr)
3131
if len(result):
3232
print('Picky header check output for "%s"\n' % fname)

nibabel/arrayproxy.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
"""
2828
import warnings
2929

30-
from .volumeutils import BinOpener, array_from_file, apply_read_scaling
30+
from .volumeutils import array_from_file, apply_read_scaling
3131
from .fileslice import fileslice
3232
from .keywordonly import kw_only_meth
33+
from .openers import ImageOpener
3334

3435

3536
class ArrayProxy(object):
@@ -130,7 +131,7 @@ def get_unscaled(self):
130131
131132
This is an optional part of the proxy API
132133
'''
133-
with BinOpener(self.file_like) as fileobj:
134+
with ImageOpener(self.file_like) as fileobj:
134135
raw_data = array_from_file(self._shape,
135136
self._dtype,
136137
fileobj,
@@ -145,7 +146,7 @@ def __array__(self):
145146
return apply_read_scaling(raw_data, self._slope, self._inter)
146147

147148
def __getitem__(self, slicer):
148-
with BinOpener(self.file_like) as fileobj:
149+
with ImageOpener(self.file_like) as fileobj:
149150
raw_data = fileslice(fileobj,
150151
slicer,
151152
self._shape,

nibabel/benchmarks/bench_fileslice.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import numpy as np
2020

2121
from io import BytesIO
22-
from ..openers import Opener
22+
from ..openers import ImageOpener
2323
from ..fileslice import fileslice
2424
from ..rstutils import rst_table
2525
from ..tmpdirs import InTemporaryDirectory
@@ -47,10 +47,10 @@ def run_slices(file_like, repeat=3, offset=0, order='F'):
4747
n_dim = len(SHAPE)
4848
n_slicers = len(_slices_for_len(1))
4949
times_arr = np.zeros((n_dim, n_slicers))
50-
with Opener(file_like, 'wb') as fobj:
50+
with ImageOpener(file_like, 'wb') as fobj:
5151
fobj.write(b'\0' * offset)
5252
fobj.write(arr.tostring(order=order))
53-
with Opener(file_like, 'rb') as fobj:
53+
with ImageOpener(file_like, 'rb') as fobj:
5454
for i, L in enumerate(SHAPE):
5555
for j, slicer in enumerate(_slices_for_len(L)):
5656
sliceobj = [slice(None)] * n_dim

nibabel/fileholders.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from copy import copy
1212

13-
from .volumeutils import BinOpener
13+
from .openers import ImageOpener
1414

1515

1616
class FileHolderError(Exception):
@@ -63,10 +63,10 @@ def get_prepare_fileobj(self, *args, **kwargs):
6363
``self.pos``
6464
'''
6565
if self.fileobj is not None:
66-
obj = BinOpener(self.fileobj) # for context manager
66+
obj = ImageOpener(self.fileobj) # for context manager
6767
obj.seek(self.pos)
6868
elif self.filename is not None:
69-
obj = BinOpener(self.filename, *args, **kwargs)
69+
obj = ImageOpener(self.filename, *args, **kwargs)
7070
if self.pos != 0:
7171
obj.seek(self.pos)
7272
else:

nibabel/freesurfer/mghformat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ..fileholders import FileHolder, copy_file_map
1919
from ..arrayproxy import ArrayProxy
2020
from ..keywordonly import kw_only_meth
21+
from ..openers import ImageOpener
2122

2223
# mgh header
2324
# See https://surfer.nmr.mgh.harvard.edu/fswiki/FsTutorial/MghFormat
@@ -453,6 +454,7 @@ def writeftr_to(self, fileobj):
453454
fileobj.write(ftr_nd.tostring())
454455

455456

457+
@ImageOpener.register_ext_from_image('.mgz', ImageOpener.gz_def)
456458
class MGHImage(SpatialImage):
457459
""" Class for MGH format image
458460
"""

nibabel/loadsave.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import numpy as np
1313

1414
from .filename_parser import types_filenames, splitext_addext
15-
from .volumeutils import BinOpener, Opener
15+
from .openers import ImageOpener
1616
from .analyze import AnalyzeImage
1717
from .spm2analyze import Spm2AnalyzeImage
1818
from .nifti1 import Nifti1Image, Nifti1Pair, header_dtype as ni1_hdr_dtype
@@ -68,18 +68,18 @@ def guessed_image_type(filename):
6868
elif lext == '.mnc':
6969
# Look for HDF5 signature for MINC2
7070
# https://www.hdfgroup.org/HDF5/doc/H5.format.html
71-
with Opener(filename) as fobj:
71+
with ImageOpener(filename) as fobj:
7272
signature = fobj.read(4)
7373
klass = Minc2Image if signature == b'\211HDF' else Minc1Image
7474
elif lext == '.nii':
75-
with BinOpener(filename) as fobj:
75+
with ImageOpener(filename) as fobj:
7676
binaryblock = fobj.read(348)
7777
ft = which_analyze_type(binaryblock)
7878
klass = Nifti2Image if ft == 'nifti2' else Nifti1Image
7979
else: # might be nifti 1 or 2 pair or analyze of some sort
8080
files_types = (('image', '.img'), ('header', '.hdr'))
8181
filenames = types_filenames(filename, files_types)
82-
with BinOpener(filenames['header']) as fobj:
82+
with ImageOpener(filenames['header']) as fobj:
8383
binaryblock = fobj.read(348)
8484
ft = which_analyze_type(binaryblock)
8585
if ft == 'nifti2':
@@ -208,7 +208,7 @@ def read_img_data(img, prefer='scaled'):
208208
hdr.set_data_offset(dao.offset)
209209
if default_scaling and (dao.slope, dao.inter) != (1, 0):
210210
hdr.set_slope_inter(dao.slope, dao.inter)
211-
with BinOpener(img_file_like) as fileobj:
211+
with ImageOpener(img_file_like) as fileobj:
212212
if prefer == 'scaled':
213213
return hdr.data_from_fileobj(fileobj)
214214
return hdr.raw_data_from_fileobj(fileobj)

nibabel/nicom/dicomwrappers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from . import csareader as csar
2020
from .dwiparams import B2q, nearest_pos_semi_def, q2bg
21-
from ..volumeutils import BinOpener
21+
from ..openers import ImageOpener
2222
from ..onetime import setattr_on_read as one_time
2323

2424

@@ -51,7 +51,7 @@ def wrapper_from_file(file_like, *args, **kwargs):
5151
"""
5252
import dicom
5353

54-
with BinOpener(file_like) as fobj:
54+
with ImageOpener(file_like) as fobj:
5555
dcm_data = dicom.read_file(fobj, *args, **kwargs)
5656
return wrapper_from_data(dcm_data)
5757

nibabel/openers.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,42 @@ def __enter__(self):
146146

147147
def __exit__(self, exc_type, exc_val, exc_tb):
148148
self.close_if_mine()
149+
150+
151+
class ImageOpener(Opener):
152+
""" Opener-type class passed to image classes to collect compressed extensions
153+
154+
This class allows itself to have image extensions added to its class
155+
attributes, via the `register_ex_from_images`. The class can therefore
156+
change state when image classes are defined.
157+
"""
158+
159+
@classmethod
160+
def register_ext_from_image(opener_klass, ext, func_def):
161+
"""Decorator for adding extension / opener_function associations.
162+
163+
Should be used to decorate classes.
164+
165+
Parameters
166+
----------
167+
opener_klass : decorated class
168+
ext : file extension to associate `func_def` with.
169+
should start with '.'
170+
func_def : opener function/parameter tuple
171+
Should be a `(function, (args,))` tuple, where `function` accepts
172+
a filename as the first parameter, and `args` defines the
173+
other arguments that `function` accepts. These arguments must
174+
be any (unordered) subset of `mode`, `compresslevel`,
175+
and `buffering`.
176+
177+
Returns
178+
-------
179+
opener_klass, with a side-effect of updating the ImageOpener class
180+
with the desired extension / opener association.
181+
"""
182+
def decorate(klass):
183+
assert ext not in opener_klass.compress_ext_map, \
184+
"Cannot redefine extension-function mappings."
185+
opener_klass.compress_ext_map[ext] = func_def
186+
return klass
187+
return decorate

nibabel/parrec.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,11 @@
9999
from .keywordonly import kw_only_meth
100100
from .spatialimages import SpatialImage, Header
101101
from .eulerangles import euler2mat
102-
from .volumeutils import Recoder, array_from_file, BinOpener
102+
from .volumeutils import Recoder, array_from_file
103103
from .affines import from_matvec, dot_reduce, apply_affine
104104
from .nifti1 import unit_codes
105105
from .fileslice import fileslice, strided_scalar
106+
from .openers import ImageOpener
106107

107108
# PSL to RAS affine
108109
PSL_TO_RAS = np.array([[0, 0, -1, 0], # L -> R
@@ -581,13 +582,13 @@ def is_proxy(self):
581582
return True
582583

583584
def get_unscaled(self):
584-
with BinOpener(self.file_like) as fileobj:
585+
with ImageOpener(self.file_like) as fileobj:
585586
return _data_from_rec(fileobj, self._rec_shape, self._dtype,
586587
self._slice_indices, self._shape,
587588
mmap=self._mmap)
588589

589590
def __array__(self):
590-
with BinOpener(self.file_like) as fileobj:
591+
with ImageOpener(self.file_like) as fileobj:
591592
return _data_from_rec(fileobj,
592593
self._rec_shape,
593594
self._dtype,
@@ -603,7 +604,7 @@ def __getitem__(self, slicer):
603604
return np.asanyarray(self)[slicer]
604605
# Slices all sequential from zero, can use fileslice
605606
# This gives more efficient volume by volume loading, for example
606-
with BinOpener(self.file_like) as fileobj:
607+
with ImageOpener(self.file_like) as fileobj:
607608
raw_data = fileslice(fileobj, slicer, self._shape, self._dtype, 0,
608609
'F')
609610
# Broadcast scaling to shape of original data

nibabel/tests/test_helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import numpy as np
66

7-
from ..openers import Opener
7+
from ..openers import ImageOpener
88
from ..tmpdirs import InTemporaryDirectory
99
from ..optpkg import optional_package
1010
_, have_scipy, _ = optional_package('scipy.io')
@@ -49,7 +49,7 @@ def bz2_mio_error():
4949
import scipy.io
5050

5151
with InTemporaryDirectory():
52-
with Opener('test.mat.bz2', 'wb') as fobj:
52+
with ImageOpener('test.mat.bz2', 'wb') as fobj:
5353
try:
5454
scipy.io.savemat(fobj, {'a': 1}, format='4')
5555
except ValueError:

nibabel/tests/test_openers.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@
1010
import os
1111
from gzip import GzipFile
1212
from bz2 import BZ2File
13-
1413
from io import BytesIO, UnsupportedOperation
15-
from ..externals.six import PY3
16-
from ..py3k import asstr, asbytes
1714

15+
from ..checkwarns import ErrorWarnings
16+
from ..py3k import asstr, asbytes
17+
from ..openers import Opener, ImageOpener
1818
from ..tmpdirs import InTemporaryDirectory
19-
20-
from ..openers import Opener
19+
from ..volumeutils import BinOpener
2120

2221
from nose.tools import (assert_true, assert_false, assert_equal,
2322
assert_not_equal, assert_raises)
@@ -57,7 +56,6 @@ def test_Opener():
5756
# mode is gently ignored
5857
fobj = Opener(obj, mode='r')
5958

60-
6159
def test_Opener_various():
6260
# Check we can do all sorts of files here
6361
message = b"Oh what a giveaway"
@@ -85,6 +83,43 @@ def test_Opener_various():
8583
# Just check there is a fileno
8684
assert_not_equal(fobj.fileno(), 0)
8785

86+
def test_BinOpener():
87+
with ErrorWarnings():
88+
assert_raises(DeprecationWarning,
89+
BinOpener, 'test.txt', 'r')
90+
91+
class TestImageOpener:
92+
def setUp(self):
93+
self.compress_ext_map = ImageOpener.compress_ext_map.copy()
94+
95+
def teardown(self):
96+
ImageOpener.compress_ext_map = self.compress_ext_map
97+
98+
def test_vanilla(self):
99+
# Test that ImageOpener does add '.mgz' as gzipped file type
100+
with InTemporaryDirectory():
101+
with ImageOpener('test.gz', 'w') as fobj:
102+
assert_true(hasattr(fobj.fobj, 'compress'))
103+
with ImageOpener('test.mgz', 'w') as fobj:
104+
assert_true(hasattr(fobj.fobj, 'compress'))
105+
106+
def test_new_association(self):
107+
def file_opener(fileish, mode):
108+
return open(fileish, mode)
109+
110+
# Add the association
111+
n_associations = len(ImageOpener.compress_ext_map)
112+
dec = ImageOpener.register_ext_from_image('.foo',
113+
(file_opener, ('mode',)))
114+
dec(self.__class__)
115+
assert_equal(n_associations + 1, len(ImageOpener.compress_ext_map))
116+
assert_true('.foo' in ImageOpener.compress_ext_map)
117+
118+
with InTemporaryDirectory():
119+
with ImageOpener('test.foo', 'w'):
120+
pass
121+
assert_true(os.path.exists('test.foo'))
122+
88123

89124
def test_file_like_wrapper():
90125
# Test wrapper using BytesIO (full API)

nibabel/tests/test_parrec.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .. import parrec
1414
from ..parrec import (parse_PAR_header, PARRECHeader, PARRECError, vol_numbers,
1515
vol_is_full, PARRECImage, PARRECArrayProxy, exts2pars)
16-
from ..openers import Opener
16+
from ..openers import ImageOpener
1717
from ..fileholders import FileHolder
1818
from ..volumeutils import array_from_file
1919

@@ -32,7 +32,7 @@
3232
DATA_PATH = pjoin(dirname(__file__), 'data')
3333
EG_PAR = pjoin(DATA_PATH, 'phantom_EPI_asc_CLEAR_2_1.PAR')
3434
EG_REC = pjoin(DATA_PATH, 'phantom_EPI_asc_CLEAR_2_1.REC')
35-
with Opener(EG_PAR, 'rt') as _fobj:
35+
with ImageOpener(EG_PAR, 'rt') as _fobj:
3636
HDR_INFO, HDR_DEFS = parse_PAR_header(_fobj)
3737
# Fake truncated
3838
TRUNC_PAR = pjoin(DATA_PATH, 'phantom_truncated.PAR')

0 commit comments

Comments
 (0)