Coverage for nilearn/_utils/testing.py: 28%

75 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-20 10:58 +0200

1"""Utilities for testing nilearn.""" 

2 

3import gc 

4import os 

5import sys 

6import tempfile 

7import warnings 

8from pathlib import Path 

9 

10import pytest 

11from numpy import __version__ as np_version 

12 

13from nilearn._utils import compare_version 

14from nilearn._utils.helpers import OPTIONAL_MATPLOTLIB_MIN_VERSION 

15 

16try: 

17 from matplotlib import __version__ as mpl_version 

18except ImportError: 

19 mpl_version = OPTIONAL_MATPLOTLIB_MIN_VERSION 

20 

21 

22# we use memory_profiler library for memory consumption checks 

23try: 

24 from memory_profiler import memory_usage 

25 

26 def with_memory_profiler(func): 

27 """Use as a decorator to skip tests requiring memory_profiler.""" 

28 return func 

29 

30 def memory_used(func, *args, **kwargs): 

31 """Compute memory usage when executing func.""" 

32 

33 def func_3_times(*args, **kwargs): 

34 for _ in range(3): 

35 func(*args, **kwargs) 

36 

37 gc.collect() 

38 mem_use = memory_usage((func_3_times, args, kwargs), interval=0.001) 

39 return max(mem_use) - min(mem_use) 

40 

41except ImportError: 

42 

43 def with_memory_profiler(func): # noqa: ARG001 

44 """Use as a decorator to skip tests requiring memory_profiler.""" 

45 

46 def dummy_func(): 

47 pytest.skip("Test requires memory_profiler.") 

48 

49 return dummy_func 

50 

51 memory_usage = memory_used = None # type: ignore[assignment] 

52 

53 

54def is_64bit() -> bool: 

55 """Return True if python is run on 64bits.""" 

56 return sys.maxsize > 2**32 

57 

58 

59def assert_memory_less_than( 

60 memory_limit, tolerance, callable_obj, *args, **kwargs 

61): 

62 """Check memory consumption of a callable stays below a given limit. 

63 

64 Parameters 

65 ---------- 

66 memory_limit : int 

67 The expected memory limit in MiB. 

68 

69 tolerance : float 

70 As memory_profiler results have some variability, this adds some 

71 tolerance around memory_limit. Accepted values are in range [0.0, 1.0]. 

72 

73 callable_obj : callable 

74 The function to be called to check memory consumption. 

75 

76 """ 

77 mem_used = memory_used(callable_obj, *args, **kwargs) 

78 

79 if mem_used > memory_limit * (1 + tolerance): 

80 raise ValueError( 

81 f"Memory consumption measured ({mem_used:.2f} MiB) is " 

82 f"greater than required memory limit ({memory_limit} MiB) within " 

83 f"accepted tolerance ({tolerance * 100:.2f}%)." 

84 ) 

85 

86 # We are confident in memory_profiler measures above 100MiB. 

87 # We raise an error if the measure is below the limit of 50MiB to avoid 

88 # false positive. 

89 if mem_used < 50: 

90 raise ValueError( 

91 "Memory profiler measured an untrustable memory " 

92 f"consumption ({mem_used:.2f} MiB). The expected memory " 

93 f"limit was {memory_limit:.2f} MiB. Try to bench with larger " 

94 "objects (at least 100MiB in memory)." 

95 ) 

96 

97 

98def serialize_niimg(img, gzipped=True): 

99 """Serialize a Nifti1Image to nifti. 

100 

101 Serialize to .nii.gz if gzipped, else to .nii Returns a `bytes` object. 

102 

103 """ 

104 with tempfile.TemporaryDirectory() as tmp_dir: 

105 tmp_dir = Path(tmp_dir) 

106 file_path = tmp_dir / f"img.nii{'.gz' if gzipped else ''}" 

107 img.to_filename(file_path) 

108 with file_path.open("rb") as f: 

109 return f.read() 

110 

111 

112def write_imgs_to_path( 

113 *imgs, file_path=None, create_files=True, use_wildcards=False 

114): 

115 """Write Nifti images on disk. 

116 

117 Write nifti images in a specified location. 

118 

119 Parameters 

120 ---------- 

121 imgs : Nifti1Image 

122 Several Nifti images. Every format understood by nibabel.save is 

123 accepted. 

124 

125 file_path: pathlib.Path 

126 Output directory 

127 

128 create_files : bool 

129 If True, imgs are written on disk and filenames are returned. If 

130 False, nothing is written, and imgs is returned as output. This is 

131 useful to test the two cases (filename / Nifti1Image) in the same 

132 loop. 

133 

134 use_wildcards : bool 

135 If True, and create_files is True, imgs are written on disk and a 

136 matching glob is returned. 

137 

138 Returns 

139 ------- 

140 filenames : string or list of strings 

141 Filename(s) where input images have been written. If a single image 

142 has been given as input, a single string is returned. Otherwise, a 

143 list of string is returned. 

144 

145 """ 

146 if file_path is None: 

147 file_path = Path.cwd() 

148 

149 if create_files: 

150 filenames = [] 

151 prefix = "nilearn_" 

152 suffix = ".nii" 

153 

154 with warnings.catch_warnings(): 

155 warnings.simplefilter("ignore", RuntimeWarning) 

156 for i, img in enumerate(imgs): 

157 filename = file_path / (prefix + str(i) + suffix) 

158 filenames.append(str(filename)) 

159 img.to_filename(filename) 

160 del img 

161 

162 if use_wildcards: 

163 return str(file_path / f"{prefix}*{suffix}") 

164 if len(filenames) == 1: 

165 return filenames[0] 

166 return filenames 

167 

168 elif len(imgs) == 1: 

169 return imgs[0] 

170 else: 

171 return imgs 

172 

173 

174def are_tests_running(): 

175 """Return whether we are running the pytest test loader.""" 

176 # https://docs.pytest.org/en/stable/example/simple.html#detect-if-running-from-within-a-pytest-run 

177 return os.environ.get("PYTEST_VERSION") is not None 

178 

179 

180def skip_if_running_tests(msg=""): 

181 """Raise a SkipTest if we appear to be running the pytest test loader. 

182 

183 Parameters 

184 ---------- 

185 msg : string, optional 

186 The message issued when a test is skipped. 

187 

188 """ 

189 if are_tests_running(): 

190 pytest.skip(msg, allow_module_level=True) 

191 

192 

193def on_windows_with_old_mpl_and_new_numpy(): 

194 return ( 

195 compare_version(np_version, ">", "1.26.4") 

196 and compare_version(mpl_version, "<", "3.8.0") 

197 and os.name == "nt" 

198 )