Skip to content

Commit b5a4b8c

Browse files
authored
Merge pull request #1178 from effigies/type/primary_image_api
ENH: Add type annotations for image API (FileBasedImage and DataobjImage)
2 parents 79f968f + 570101b commit b5a4b8c

File tree

3 files changed

+108
-31
lines changed

3 files changed

+108
-31
lines changed

nibabel/arrayproxy.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
2626
See :mod:`nibabel.tests.test_proxy_api` for proxy API conformance checks.
2727
"""
28+
from __future__ import annotations
29+
30+
import typing as ty
2831
import warnings
2932
from contextlib import contextmanager
3033
from threading import RLock
@@ -53,7 +56,28 @@
5356
KEEP_FILE_OPEN_DEFAULT = False
5457

5558

56-
class ArrayProxy:
59+
if ty.TYPE_CHECKING: # pragma: no cover
60+
import numpy.typing as npt
61+
62+
63+
class ArrayLike(ty.Protocol):
64+
"""Protocol for numpy ndarray-like objects
65+
66+
This is more stringent than :class:`numpy.typing.ArrayLike`, but guarantees
67+
access to shape, ndim and slicing.
68+
"""
69+
70+
shape: tuple[int, ...]
71+
ndim: int
72+
73+
def __array__(self, dtype: npt.DTypeLike | None = None, /) -> npt.NDArray:
74+
... # pragma: no cover
75+
76+
def __getitem__(self, key, /) -> npt.NDArray:
77+
... # pragma: no cover
78+
79+
80+
class ArrayProxy(ArrayLike):
5781
"""Class to act as proxy for the array that can be read from a file
5882
5983
The array proxy allows us to freeze the passed fileobj and header such that

nibabel/dataobj_images.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,33 @@
77
* returns an array from ``numpy.asanyarray(obj)``;
88
* has an attribute or property ``shape``.
99
"""
10+
from __future__ import annotations
11+
12+
import typing as ty
1013

1114
import numpy as np
1215

16+
from .arrayproxy import ArrayLike
1317
from .deprecated import deprecate_with_version
14-
from .filebasedimages import FileBasedImage
18+
from .filebasedimages import FileBasedHeader, FileBasedImage, FileMap, FileSpec
19+
20+
if ty.TYPE_CHECKING: # pragma: no cover
21+
import numpy.typing as npt
1522

1623

1724
class DataobjImage(FileBasedImage):
1825
"""Template class for images that have dataobj data stores"""
1926

20-
def __init__(self, dataobj, header=None, extra=None, file_map=None):
27+
_data_cache: np.ndarray | None
28+
_fdata_cache: np.ndarray | None
29+
30+
def __init__(
31+
self,
32+
dataobj: ArrayLike,
33+
header: FileBasedHeader | ty.Mapping | None = None,
34+
extra: ty.Mapping | None = None,
35+
file_map: FileMap | None = None,
36+
):
2137
"""Initialize dataobj image
2238
2339
The datobj image is a combination of (dataobj, header), with optional
@@ -40,11 +56,11 @@ def __init__(self, dataobj, header=None, extra=None, file_map=None):
4056
"""
4157
super().__init__(header=header, extra=extra, file_map=file_map)
4258
self._dataobj = dataobj
43-
self._fdata_cache = None
4459
self._data_cache = None
60+
self._fdata_cache = None
4561

4662
@property
47-
def dataobj(self):
63+
def dataobj(self) -> ArrayLike:
4864
return self._dataobj
4965

5066
@deprecate_with_version(
@@ -202,7 +218,11 @@ def get_data(self, caching='fill'):
202218
self._data_cache = data
203219
return data
204220

205-
def get_fdata(self, caching='fill', dtype=np.float64):
221+
def get_fdata(
222+
self,
223+
caching: ty.Literal['fill', 'unchanged'] = 'fill',
224+
dtype: npt.DTypeLike = np.float64,
225+
) -> np.ndarray:
206226
"""Return floating point image data with necessary scaling applied
207227
208228
The image ``dataobj`` property can be an array proxy or an array. An
@@ -351,7 +371,7 @@ def get_fdata(self, caching='fill', dtype=np.float64):
351371
return data
352372

353373
@property
354-
def in_memory(self):
374+
def in_memory(self) -> bool:
355375
"""True when any array data is in memory cache
356376
357377
There are separate caches for `get_data` reads and `get_fdata` reads.
@@ -363,7 +383,7 @@ def in_memory(self):
363383
or self._data_cache is not None
364384
)
365385

366-
def uncache(self):
386+
def uncache(self) -> None:
367387
"""Delete any cached read of data from proxied data
368388
369389
Remember there are two types of images:
@@ -392,15 +412,21 @@ def uncache(self):
392412
self._data_cache = None
393413

394414
@property
395-
def shape(self):
415+
def shape(self) -> tuple[int, ...]:
396416
return self._dataobj.shape
397417

398418
@property
399-
def ndim(self):
419+
def ndim(self) -> int:
400420
return self._dataobj.ndim
401421

402422
@classmethod
403-
def from_file_map(klass, file_map, *, mmap=True, keep_file_open=None):
423+
def from_file_map(
424+
klass,
425+
file_map: FileMap,
426+
*,
427+
mmap: bool | ty.Literal['c', 'r'] = True,
428+
keep_file_open: bool | None = None,
429+
):
404430
"""Class method to create image from mapping in ``file_map``
405431
406432
Parameters
@@ -433,7 +459,13 @@ def from_file_map(klass, file_map, *, mmap=True, keep_file_open=None):
433459
raise NotImplementedError
434460

435461
@classmethod
436-
def from_filename(klass, filename, *, mmap=True, keep_file_open=None):
462+
def from_filename(
463+
klass,
464+
filename: FileSpec,
465+
*,
466+
mmap: bool | ty.Literal['c', 'r'] = True,
467+
keep_file_open: bool | None = None,
468+
):
437469
"""Class method to create image from filename `filename`
438470
439471
Parameters

nibabel/filebasedimages.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from __future__ import annotations
1111

1212
import io
13+
import os
14+
import typing as ty
1315
from copy import deepcopy
1416
from typing import Type
1517
from urllib import request
@@ -18,6 +20,10 @@
1820
from .filename_parser import TypesFilenamesError, splitext_addext, types_filenames
1921
from .openers import ImageOpener
2022

23+
FileSpec = ty.Union[str, os.PathLike]
24+
FileMap = ty.Mapping[str, FileHolder]
25+
FileSniff = ty.Tuple[bytes, str]
26+
2127

2228
class ImageFileError(Exception):
2329
pass
@@ -41,10 +47,10 @@ def from_header(klass, header=None):
4147
)
4248

4349
@classmethod
44-
def from_fileobj(klass, fileobj):
50+
def from_fileobj(klass, fileobj: io.IOBase):
4551
raise NotImplementedError
4652

47-
def write_to(self, fileobj):
53+
def write_to(self, fileobj: io.IOBase):
4854
raise NotImplementedError
4955

5056
def __eq__(self, other):
@@ -53,7 +59,7 @@ def __eq__(self, other):
5359
def __ne__(self, other):
5460
return not self == other
5561

56-
def copy(self):
62+
def copy(self) -> FileBasedHeader:
5763
"""Copy object to independent representation
5864
5965
The copy should not be affected by any changes to the original
@@ -155,7 +161,12 @@ class FileBasedImage:
155161
makeable: bool = True # Used in test code
156162
rw: bool = True # Used in test code
157163

158-
def __init__(self, header=None, extra=None, file_map=None):
164+
def __init__(
165+
self,
166+
header: FileBasedHeader | ty.Mapping | None = None,
167+
extra: ty.Mapping | None = None,
168+
file_map: FileMap | None = None,
169+
):
159170
"""Initialize image
160171
161172
The image is a combination of (header), with
@@ -182,14 +193,14 @@ def __init__(self, header=None, extra=None, file_map=None):
182193
self.file_map = file_map
183194

184195
@property
185-
def header(self):
196+
def header(self) -> FileBasedHeader:
186197
return self._header
187198

188199
def __getitem__(self, key):
189200
"""No slicing or dictionary interface for images"""
190201
raise TypeError('Cannot slice image objects.')
191202

192-
def get_filename(self):
203+
def get_filename(self) -> str | None:
193204
"""Fetch the image filename
194205
195206
Parameters
@@ -210,7 +221,7 @@ def get_filename(self):
210221
characteristic_type = self.files_types[0][0]
211222
return self.file_map[characteristic_type].filename
212223

213-
def set_filename(self, filename):
224+
def set_filename(self, filename: str):
214225
"""Sets the files in the object from a given filename
215226
216227
The different image formats may check whether the filename has
@@ -228,16 +239,16 @@ def set_filename(self, filename):
228239
self.file_map = self.__class__.filespec_to_file_map(filename)
229240

230241
@classmethod
231-
def from_filename(klass, filename):
242+
def from_filename(klass, filename: FileSpec):
232243
file_map = klass.filespec_to_file_map(filename)
233244
return klass.from_file_map(file_map)
234245

235246
@classmethod
236-
def from_file_map(klass, file_map):
247+
def from_file_map(klass, file_map: FileMap):
237248
raise NotImplementedError
238249

239250
@classmethod
240-
def filespec_to_file_map(klass, filespec):
251+
def filespec_to_file_map(klass, filespec: FileSpec):
241252
"""Make `file_map` for this class from filename `filespec`
242253
243254
Class method
@@ -271,7 +282,7 @@ def filespec_to_file_map(klass, filespec):
271282
file_map[key] = FileHolder(filename=fname)
272283
return file_map
273284

274-
def to_filename(self, filename, **kwargs):
285+
def to_filename(self, filename: FileSpec, **kwargs):
275286
r"""Write image to files implied by filename string
276287
277288
Parameters
@@ -290,11 +301,11 @@ def to_filename(self, filename, **kwargs):
290301
self.file_map = self.filespec_to_file_map(filename)
291302
self.to_file_map(**kwargs)
292303

293-
def to_file_map(self, file_map=None, **kwargs):
304+
def to_file_map(self, file_map: FileMap | None = None, **kwargs):
294305
raise NotImplementedError
295306

296307
@classmethod
297-
def make_file_map(klass, mapping=None):
308+
def make_file_map(klass, mapping: ty.Mapping[str, str | io.IOBase] | None = None):
298309
"""Class method to make files holder for this image type
299310
300311
Parameters
@@ -327,7 +338,7 @@ def make_file_map(klass, mapping=None):
327338
load = from_filename
328339

329340
@classmethod
330-
def instance_to_filename(klass, img, filename):
341+
def instance_to_filename(klass, img: FileBasedImage, filename: FileSpec):
331342
"""Save `img` in our own format, to name implied by `filename`
332343
333344
This is a class method
@@ -343,7 +354,7 @@ def instance_to_filename(klass, img, filename):
343354
img.to_filename(filename)
344355

345356
@classmethod
346-
def from_image(klass, img):
357+
def from_image(klass, img: FileBasedImage):
347358
"""Class method to create new instance of own class from `img`
348359
349360
Parameters
@@ -359,7 +370,12 @@ def from_image(klass, img):
359370
raise NotImplementedError()
360371

361372
@classmethod
362-
def _sniff_meta_for(klass, filename, sniff_nbytes, sniff=None):
373+
def _sniff_meta_for(
374+
klass,
375+
filename: FileSpec,
376+
sniff_nbytes: int,
377+
sniff: FileSniff | None = None,
378+
):
363379
"""Sniff metadata for image represented by `filename`
364380
365381
Parameters
@@ -404,7 +420,12 @@ def _sniff_meta_for(klass, filename, sniff_nbytes, sniff=None):
404420
return (binaryblock, meta_fname)
405421

406422
@classmethod
407-
def path_maybe_image(klass, filename, sniff=None, sniff_max=1024):
423+
def path_maybe_image(
424+
klass,
425+
filename: FileSpec,
426+
sniff: FileSniff | None = None,
427+
sniff_max: int = 1024,
428+
):
408429
"""Return True if `filename` may be image matching this class
409430
410431
Parameters
@@ -547,7 +568,7 @@ def from_bytes(klass, bytestring: bytes):
547568
548569
Parameters
549570
----------
550-
bstring : bytes
571+
bytestring : bytes
551572
Byte string containing the on-disk representation of an image
552573
"""
553574
return klass.from_stream(io.BytesIO(bytestring))
@@ -571,7 +592,7 @@ def to_bytes(self, **kwargs) -> bytes:
571592
return bio.getvalue()
572593

573594
@classmethod
574-
def from_url(klass, url, timeout=5):
595+
def from_url(klass, url: str | request.Request, timeout: float = 5):
575596
"""Retrieve and load an image from a URL
576597
577598
Class method

0 commit comments

Comments
 (0)