Skip to content

Commit 60c3afb

Browse files
authored
Merge pull request #1255 from effigies/enh/copyarrayproxy
ENH: Add copy() method to ArrayProxy
2 parents 86b2e53 + 1c1845f commit 60c3afb

File tree

2 files changed

+73
-18
lines changed

2 files changed

+73
-18
lines changed

nibabel/arrayproxy.py

+31-13
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858

5959
if ty.TYPE_CHECKING: # pragma: no cover
6060
import numpy.typing as npt
61+
from typing_extensions import Self # PY310
6162

6263
# Taken from numpy/__init__.pyi
6364
_DType = ty.TypeVar('_DType', bound=np.dtype[ty.Any])
@@ -212,11 +213,30 @@ def __init__(self, file_like, spec, *, mmap=True, order=None, keep_file_open=Non
212213
self.order = order
213214
# Flags to keep track of whether a single ImageOpener is created, and
214215
# whether a single underlying file handle is created.
215-
self._keep_file_open, self._persist_opener = self._should_keep_file_open(
216-
file_like, keep_file_open
217-
)
216+
self._keep_file_open, self._persist_opener = self._should_keep_file_open(keep_file_open)
218217
self._lock = RLock()
219218

219+
def _has_fh(self) -> bool:
220+
"""Determine if our file-like is a filehandle or path"""
221+
return hasattr(self.file_like, 'read') and hasattr(self.file_like, 'seek')
222+
223+
def copy(self) -> Self:
224+
"""Create a new ArrayProxy for the same file and parameters
225+
226+
If the proxied file is an open file handle, the new ArrayProxy
227+
will share a lock with the old one.
228+
"""
229+
spec = self._shape, self._dtype, self._offset, self._slope, self._inter
230+
new = self.__class__(
231+
self.file_like,
232+
spec,
233+
mmap=self._mmap,
234+
keep_file_open=self._keep_file_open,
235+
)
236+
if self._has_fh():
237+
new._lock = self._lock
238+
return new
239+
220240
def __del__(self):
221241
"""If this ``ArrayProxy`` was created with ``keep_file_open=True``,
222242
the open file object is closed if necessary.
@@ -236,13 +256,13 @@ def __setstate__(self, state):
236256
self.__dict__.update(state)
237257
self._lock = RLock()
238258

239-
def _should_keep_file_open(self, file_like, keep_file_open):
259+
def _should_keep_file_open(self, keep_file_open):
240260
"""Called by ``__init__``.
241261
242262
This method determines how to manage ``ImageOpener`` instances,
243263
and the underlying file handles - the behaviour depends on:
244264
245-
- whether ``file_like`` is an an open file handle, or a path to a
265+
- whether ``self.file_like`` is an an open file handle, or a path to a
246266
``'.gz'`` file, or a path to a non-gzip file.
247267
- whether ``indexed_gzip`` is present (see
248268
:attr:`.openers.HAVE_INDEXED_GZIP`).
@@ -261,24 +281,24 @@ def _should_keep_file_open(self, file_like, keep_file_open):
261281
and closed on each file access.
262282
263283
The internal ``_keep_file_open`` flag is only relevant if
264-
``file_like`` is a ``'.gz'`` file, and the ``indexed_gzip`` library is
284+
``self.file_like`` is a ``'.gz'`` file, and the ``indexed_gzip`` library is
265285
present.
266286
267287
This method returns the values to be used for the internal
268288
``_persist_opener`` and ``_keep_file_open`` flags; these values are
269289
derived according to the following rules:
270290
271-
1. If ``file_like`` is a file(-like) object, both flags are set to
291+
1. If ``self.file_like`` is a file(-like) object, both flags are set to
272292
``False``.
273293
274294
2. If ``keep_file_open`` (as passed to :meth:``__init__``) is
275295
``True``, both internal flags are set to ``True``.
276296
277-
3. If ``keep_file_open`` is ``False``, but ``file_like`` is not a path
297+
3. If ``keep_file_open`` is ``False``, but ``self.file_like`` is not a path
278298
to a ``.gz`` file or ``indexed_gzip`` is not present, both flags
279299
are set to ``False``.
280300
281-
4. If ``keep_file_open`` is ``False``, ``file_like`` is a path to a
301+
4. If ``keep_file_open`` is ``False``, ``self.file_like`` is a path to a
282302
``.gz`` file, and ``indexed_gzip`` is present, ``_persist_opener``
283303
is set to ``True``, and ``_keep_file_open`` is set to ``False``.
284304
In this case, file handle management is delegated to the
@@ -287,8 +307,6 @@ def _should_keep_file_open(self, file_like, keep_file_open):
287307
Parameters
288308
----------
289309
290-
file_like : object
291-
File-like object or filename, as passed to ``__init__``.
292310
keep_file_open : { True, False }
293311
Flag as passed to ``__init__``.
294312
@@ -311,10 +329,10 @@ def _should_keep_file_open(self, file_like, keep_file_open):
311329
raise ValueError('keep_file_open must be one of {None, True, False}')
312330

313331
# file_like is a handle - keep_file_open is irrelevant
314-
if hasattr(file_like, 'read') and hasattr(file_like, 'seek'):
332+
if self._has_fh():
315333
return False, False
316334
# if the file is a gzip file, and we have_indexed_gzip,
317-
have_igzip = openers.HAVE_INDEXED_GZIP and file_like.endswith('.gz')
335+
have_igzip = openers.HAVE_INDEXED_GZIP and self.file_like.endswith('.gz')
318336

319337
persist_opener = keep_file_open or have_igzip
320338
return keep_file_open, persist_opener

nibabel/tests/test_arrayproxy.py

+42-5
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from .. import __version__
2424
from ..arrayproxy import ArrayProxy, get_obj_dtype, is_proxy, reshape_dataobj
2525
from ..deprecator import ExpiredDeprecationError
26-
from ..nifti1 import Nifti1Header
26+
from ..nifti1 import Nifti1Header, Nifti1Image
2727
from ..openers import ImageOpener
2828
from ..testing import memmap_after_ufunc
2929
from ..tmpdirs import InTemporaryDirectory
@@ -553,16 +553,53 @@ def test_keep_file_open_true_false_invalid():
553553
ArrayProxy(fname, ((10, 10, 10), dtype))
554554

555555

556+
def islock(l):
557+
# isinstance doesn't work on threading.Lock?
558+
return hasattr(l, 'acquire') and hasattr(l, 'release')
559+
560+
556561
def test_pickle_lock():
557562
# Test that ArrayProxy can be pickled, and that thread lock is created
558563

559-
def islock(l):
560-
# isinstance doesn't work on threading.Lock?
561-
return hasattr(l, 'acquire') and hasattr(l, 'release')
562-
563564
proxy = ArrayProxy('dummyfile', ((10, 10, 10), np.float32))
564565
assert islock(proxy._lock)
565566
pickled = pickle.dumps(proxy)
566567
unpickled = pickle.loads(pickled)
567568
assert islock(unpickled._lock)
568569
assert proxy._lock is not unpickled._lock
570+
571+
572+
def test_copy():
573+
# Test copying array proxies
574+
575+
# If the file-like is a file name, get a new lock
576+
proxy = ArrayProxy('dummyfile', ((10, 10, 10), np.float32))
577+
assert islock(proxy._lock)
578+
copied = proxy.copy()
579+
assert islock(copied._lock)
580+
assert proxy._lock is not copied._lock
581+
582+
# If an open filehandle, the lock should be shared to
583+
# avoid changing filehandle state in critical sections
584+
proxy = ArrayProxy(BytesIO(), ((10, 10, 10), np.float32))
585+
assert islock(proxy._lock)
586+
copied = proxy.copy()
587+
assert islock(copied._lock)
588+
assert proxy._lock is copied._lock
589+
590+
591+
def test_copy_with_indexed_gzip_handle(tmp_path):
592+
indexed_gzip = pytest.importorskip('indexed_gzip')
593+
594+
spec = ((50, 50, 50, 50), np.float32, 352, 1, 0)
595+
data = np.arange(np.prod(spec[0]), dtype=spec[1]).reshape(spec[0])
596+
fname = str(tmp_path / 'test.nii.gz')
597+
Nifti1Image(data, np.eye(4)).to_filename(fname)
598+
599+
with indexed_gzip.IndexedGzipFile(fname) as fobj:
600+
proxy = ArrayProxy(fobj, spec)
601+
copied = proxy.copy()
602+
603+
assert proxy.file_like is copied.file_like
604+
assert np.array_equal(proxy[0, 0, 0], copied[0, 0, 0])
605+
assert np.array_equal(proxy[-1, -1, -1], copied[-1, -1, -1])

0 commit comments

Comments
 (0)