2525
2626See :mod:`nibabel.tests.test_proxy_api` for proxy API conformance checks.
2727"""
28+ from contextlib import contextmanager
29+ from threading import RLock
30+
2831import numpy as np
2932
3033from .deprecated import deprecate_with_version
3134from .volumeutils import array_from_file , apply_read_scaling
3235from .fileslice import fileslice
3336from .keywordonly import kw_only_meth
34- from .openers import ImageOpener
37+ from .openers import ImageOpener , HAVE_INDEXED_GZIP
38+
39+
40+ """This flag controls whether a new file handle is created every time an image
41+ is accessed through an ``ArrayProxy``, or a single file handle is created and
42+ used for the lifetime of the ``ArrayProxy``. It should be set to one of
43+ ``True``, ``False``, or ``'auto'``.
44+
45+ If ``True``, a single file handle is created and used. If ``False``, a new
46+ file handle is created every time the image is accessed. If ``'auto'``, and
47+ the optional ``indexed_gzip`` dependency is present, a single file handle is
48+ created and persisted. If ``indexed_gzip`` is not available, behaviour is the
49+ same as if ``keep_file_open is False``.
50+
51+ If this is set to any other value, attempts to create an ``ArrayProxy`` without
52+ specifying the ``keep_file_open`` flag will result in a ``ValueError`` being
53+ raised.
54+ """
55+ KEEP_FILE_OPEN_DEFAULT = False
3556
3657
3758class ArrayProxy (object ):
@@ -69,8 +90,8 @@ class ArrayProxy(object):
6990 _header = None
7091
7192 @kw_only_meth (2 )
72- def __init__ (self , file_like , spec , mmap = True ):
73- """ Initialize array proxy instance
93+ def __init__ (self , file_like , spec , mmap = True , keep_file_open = None ):
94+ """Initialize array proxy instance
7495
7596 Parameters
7697 ----------
@@ -99,8 +120,18 @@ def __init__(self, file_like, spec, mmap=True):
99120 True gives the same behavior as ``mmap='c'``. If `file_like`
100121 cannot be memory-mapped, ignore `mmap` value and read array from
101122 file.
102- scaling : {'fp', 'dv'}, optional, keyword only
103- Type of scaling to use - see header ``get_data_scaling`` method.
123+ keep_file_open : { None, 'auto', True, False }, optional, keyword only
124+ `keep_file_open` controls whether a new file handle is created
125+ every time the image is accessed, or a single file handle is
126+ created and used for the lifetime of this ``ArrayProxy``. If
127+ ``True``, a single file handle is created and used. If ``False``,
128+ a new file handle is created every time the image is accessed. If
129+ ``'auto'``, and the optional ``indexed_gzip`` dependency is
130+ present, a single file handle is created and persisted. If
131+ ``indexed_gzip`` is not available, behaviour is the same as if
132+ ``keep_file_open is False``. If ``file_like`` is an open file
133+ handle, this setting has no effect. The default value (``None``)
134+ will result in the value of ``KEEP_FILE_OPEN_DEFAULT`` being used.
104135 """
105136 if mmap not in (True , False , 'c' , 'r' ):
106137 raise ValueError ("mmap should be one of {True, False, 'c', 'r'}" )
@@ -125,6 +156,70 @@ def __init__(self, file_like, spec, mmap=True):
125156 # Permit any specifier that can be interpreted as a numpy dtype
126157 self ._dtype = np .dtype (self ._dtype )
127158 self ._mmap = mmap
159+ self ._keep_file_open = self ._should_keep_file_open (file_like ,
160+ keep_file_open )
161+ self ._lock = RLock ()
162+
163+ def __del__ (self ):
164+ """If this ``ArrayProxy`` was created with ``keep_file_open=True``,
165+ the open file object is closed if necessary.
166+ """
167+ if hasattr (self , '_opener' ) and not self ._opener .closed :
168+ self ._opener .close_if_mine ()
169+ self ._opener = None
170+
171+ def __getstate__ (self ):
172+ """Returns the state of this ``ArrayProxy`` during pickling. """
173+ state = self .__dict__ .copy ()
174+ state .pop ('_lock' , None )
175+ return state
176+
177+ def __setstate__ (self , state ):
178+ """Sets the state of this ``ArrayProxy`` during unpickling. """
179+ self .__dict__ .update (state )
180+ self ._lock = RLock ()
181+
182+ def _should_keep_file_open (self , file_like , keep_file_open ):
183+ """Called by ``__init__``, and used to determine the final value of
184+ ``keep_file_open``.
185+
186+ The return value is derived from these rules:
187+
188+ - If ``file_like`` is a file(-like) object, ``False`` is returned.
189+ Otherwise, ``file_like`` is assumed to be a file name.
190+ - if ``file_like`` ends with ``'gz'``, and the ``indexed_gzip``
191+ library is available, ``True`` is returned.
192+ - Otherwise, ``False`` is returned.
193+
194+ Parameters
195+ ----------
196+
197+ file_like : object
198+ File-like object or filename, as passed to ``__init__``.
199+ keep_file_open : { 'auto', True, False }
200+ Flag as passed to ``__init__``.
201+
202+ Returns
203+ -------
204+
205+ The value of ``keep_file_open`` that will be used by this
206+ ``ArrayProxy``.
207+ """
208+ if keep_file_open is None :
209+ keep_file_open = KEEP_FILE_OPEN_DEFAULT
210+ # if keep_file_open is True/False, we do what the user wants us to do
211+ if isinstance (keep_file_open , bool ):
212+ return keep_file_open
213+ if keep_file_open != 'auto' :
214+ raise ValueError ('keep_file_open should be one of {None, '
215+ '\' auto\' , True, False}' )
216+
217+ # file_like is a handle - keep_file_open is irrelevant
218+ if hasattr (file_like , 'read' ) and hasattr (file_like , 'seek' ):
219+ return False
220+ # Otherwise, if file_like is gzipped, and we have_indexed_gzip, we set
221+ # keep_file_open to True, else we set it to False
222+ return HAVE_INDEXED_GZIP and file_like .endswith ('gz' )
128223
129224 @property
130225 @deprecate_with_version ('ArrayProxy.header deprecated' , '2.2' , '3.0' )
@@ -155,12 +250,33 @@ def inter(self):
155250 def is_proxy (self ):
156251 return True
157252
253+ @contextmanager
254+ def _get_fileobj (self ):
255+ """Create and return a new ``ImageOpener``, or return an existing one.
256+
257+ The specific behaviour depends on the value of the ``keep_file_open``
258+ flag that was passed to ``__init__``.
259+
260+ Yields
261+ ------
262+ ImageOpener
263+ A newly created ``ImageOpener`` instance, or an existing one,
264+ which provides access to the file.
265+ """
266+ if self ._keep_file_open :
267+ if not hasattr (self , '_opener' ):
268+ self ._opener = ImageOpener (self .file_like )
269+ yield self ._opener
270+ else :
271+ with ImageOpener (self .file_like ) as opener :
272+ yield opener
273+
158274 def get_unscaled (self ):
159- ''' Read of data from file
275+ """ Read of data from file
160276
161277 This is an optional part of the proxy API
162- '''
163- with ImageOpener ( self .file_like ) as fileobj :
278+ """
279+ with self ._get_fileobj ( ) as fileobj , self . _lock :
164280 raw_data = array_from_file (self ._shape ,
165281 self ._dtype ,
166282 fileobj ,
@@ -175,18 +291,19 @@ def __array__(self):
175291 return apply_read_scaling (raw_data , self ._slope , self ._inter )
176292
177293 def __getitem__ (self , slicer ):
178- with ImageOpener ( self .file_like ) as fileobj :
294+ with self ._get_fileobj ( ) as fileobj :
179295 raw_data = fileslice (fileobj ,
180296 slicer ,
181297 self ._shape ,
182298 self ._dtype ,
183299 self ._offset ,
184- order = self .order )
300+ order = self .order ,
301+ lock = self ._lock )
185302 # Upcast as necessary for big slopes, intercepts
186303 return apply_read_scaling (raw_data , self ._slope , self ._inter )
187304
188305 def reshape (self , shape ):
189- ''' Return an ArrayProxy with a new shape, without modifying data '''
306+ """ Return an ArrayProxy with a new shape, without modifying data """
190307 size = np .prod (self ._shape )
191308
192309 # Calculate new shape if not fully specified
0 commit comments