testing.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """
  2. Testing utilities.
  3. """
  4. import os
  5. import platform
  6. import re
  7. import struct
  8. import sys
  9. import functools
  10. import inspect
  11. from tempfile import NamedTemporaryFile
  12. import numpy as np
  13. from numpy import testing
  14. from numpy.testing import (
  15. TestCase,
  16. assert_,
  17. assert_warns,
  18. assert_no_warnings,
  19. assert_equal,
  20. assert_almost_equal,
  21. assert_array_equal,
  22. assert_allclose,
  23. assert_array_almost_equal,
  24. assert_array_almost_equal_nulp,
  25. assert_array_less,
  26. )
  27. from .. import data, io
  28. from ..data._fetchers import _fetch
  29. from ..util import img_as_uint, img_as_float, img_as_int, img_as_ubyte
  30. from ._warnings import expected_warnings
  31. from ._dependency_checks import is_wasm
  32. import pytest
  33. skipif = pytest.mark.skipif
  34. xfail = pytest.mark.xfail
  35. parametrize = pytest.mark.parametrize
  36. raises = pytest.raises
  37. fixture = pytest.fixture
  38. SKIP_RE = re.compile(r"(\s*>>>.*?)(\s*)#\s*skip\s+if\s+(.*)$")
  39. # true if python is running in 32bit mode
  40. # Calculate the size of a void * pointer in bits
  41. # https://docs.python.org/3/library/struct.html
  42. arch32 = struct.calcsize("P") * 8 == 32
  43. def assert_less(a, b, msg=None):
  44. message = f"{a!r} is not lower than {b!r}"
  45. if msg is not None:
  46. message += ": " + msg
  47. assert a < b, message
  48. def assert_greater(a, b, msg=None):
  49. message = f"{a!r} is not greater than {b!r}"
  50. if msg is not None:
  51. message += ": " + msg
  52. assert a > b, message
  53. def doctest_skip_parser(func):
  54. """Decorator replaces custom skip test markup in doctests
  55. Say a function has a docstring::
  56. >>> something, HAVE_AMODULE, HAVE_BMODULE = 0, False, False
  57. >>> something # skip if not HAVE_AMODULE
  58. 0
  59. >>> something # skip if HAVE_BMODULE
  60. 0
  61. This decorator will evaluate the expression after ``skip if``. If this
  62. evaluates to True, then the comment is replaced by ``# doctest: +SKIP``. If
  63. False, then the comment is just removed. The expression is evaluated in the
  64. ``globals`` scope of `func`.
  65. For example, if the module global ``HAVE_AMODULE`` is False, and module
  66. global ``HAVE_BMODULE`` is False, the returned function will have docstring::
  67. >>> something # doctest: +SKIP
  68. >>> something + else # doctest: +SKIP
  69. >>> something # doctest: +SKIP
  70. """
  71. lines = func.__doc__.split('\n')
  72. new_lines = []
  73. for line in lines:
  74. match = SKIP_RE.match(line)
  75. if match is None:
  76. new_lines.append(line)
  77. continue
  78. code, space, expr = match.groups()
  79. try:
  80. # Works as a function decorator
  81. if eval(expr, func.__globals__):
  82. code = code + space + "# doctest: +SKIP"
  83. except AttributeError:
  84. # Works as a class decorator
  85. if eval(expr, func.__init__.__globals__):
  86. code = code + space + "# doctest: +SKIP"
  87. new_lines.append(code)
  88. func.__doc__ = "\n".join(new_lines)
  89. return func
  90. def roundtrip(image, plugin, suffix):
  91. """Save and read an image using a specified plugin"""
  92. if '.' not in suffix:
  93. suffix = '.' + suffix
  94. with NamedTemporaryFile(suffix=suffix, delete=False) as temp_file:
  95. fname = temp_file.name
  96. io.imsave(fname, image, plugin=plugin)
  97. new = io.imread(fname, plugin=plugin)
  98. try:
  99. os.remove(fname)
  100. except Exception:
  101. pass
  102. return new
  103. def color_check(plugin, fmt='png'):
  104. """Check roundtrip behavior for color images.
  105. All major input types should be handled as ubytes and read
  106. back correctly.
  107. """
  108. img = img_as_ubyte(data.chelsea())
  109. r1 = roundtrip(img, plugin, fmt)
  110. testing.assert_allclose(img, r1)
  111. img2 = img > 128
  112. r2 = roundtrip(img2, plugin, fmt)
  113. testing.assert_allclose(img2, r2.astype(bool))
  114. img3 = img_as_float(img)
  115. r3 = roundtrip(img3, plugin, fmt)
  116. testing.assert_allclose(r3, img)
  117. img4 = img_as_int(img)
  118. if fmt.lower() in (('tif', 'tiff')):
  119. img4 -= 100
  120. r4 = roundtrip(img4, plugin, fmt)
  121. testing.assert_allclose(r4, img4)
  122. else:
  123. r4 = roundtrip(img4, plugin, fmt)
  124. testing.assert_allclose(r4, img_as_ubyte(img4))
  125. img5 = img_as_uint(img)
  126. r5 = roundtrip(img5, plugin, fmt)
  127. testing.assert_allclose(r5, img)
  128. def mono_check(plugin, fmt='png'):
  129. """Check the roundtrip behavior for images that support most types.
  130. All major input types should be handled.
  131. """
  132. img = img_as_ubyte(data.moon())
  133. r1 = roundtrip(img, plugin, fmt)
  134. testing.assert_allclose(img, r1)
  135. img2 = img > 128
  136. r2 = roundtrip(img2, plugin, fmt)
  137. testing.assert_allclose(img2, r2.astype(bool))
  138. img3 = img_as_float(img)
  139. r3 = roundtrip(img3, plugin, fmt)
  140. if r3.dtype.kind == 'f':
  141. testing.assert_allclose(img3, r3)
  142. else:
  143. testing.assert_allclose(r3, img_as_uint(img))
  144. img4 = img_as_int(img)
  145. if fmt.lower() in (('tif', 'tiff')):
  146. img4 -= 100
  147. r4 = roundtrip(img4, plugin, fmt)
  148. testing.assert_allclose(r4, img4)
  149. else:
  150. r4 = roundtrip(img4, plugin, fmt)
  151. testing.assert_allclose(r4, img_as_uint(img4))
  152. img5 = img_as_uint(img)
  153. r5 = roundtrip(img5, plugin, fmt)
  154. testing.assert_allclose(r5, img5)
  155. def fetch(data_filename, prefix=None):
  156. """Attempt to fetch data, but if unavailable, skip the tests.
  157. Parameters
  158. ----------
  159. data_filename : str
  160. File path in the scikit-image repo tree, e.g.,
  161. 'restoration/camera_rl.npy', possibly pointing to a remote location.
  162. prefix : str, optional
  163. If None, `data_filename` is prefixed by 'src/skimage'
  164. If 'tests', `data_filename` is prefixed by 'tests/skimage'.
  165. Returns
  166. -------
  167. file_path : str
  168. Path of the local file, possibly pointing to a remote location.
  169. """
  170. try:
  171. return _fetch(data_filename, prefix=prefix)
  172. except (ConnectionError, ModuleNotFoundError):
  173. pytest.skip(f'Unable to download {data_filename}', allow_module_level=True)
  174. # Ref: about the lack of threading support in WASM, please see
  175. # https://github.com/pyodide/pyodide/issues/237
  176. def run_in_parallel(workers=2, warnings_matching=None):
  177. """Decorator to run the same function multiple times in parallel.
  178. This decorator is useful to ensure that separate threads execute
  179. concurrently and correctly while releasing the GIL.
  180. It is currently skipped when running on WASM-based platforms, as
  181. the threading module is not supported.
  182. Parameters
  183. ----------
  184. workers : int, optional
  185. The number of times the function is run in parallel.
  186. warnings_matching : list or None
  187. This parameter is passed on to `expected_warnings` so as not to have
  188. race conditions with the warnings filters. A single
  189. `expected_warnings` context manager is used for all threads.
  190. If None, then no warnings are checked.
  191. """
  192. assert workers > 0
  193. def wrapper(func):
  194. if is_wasm:
  195. # Threading isn't supported on WASM, return early
  196. return func
  197. import threading
  198. @functools.wraps(func)
  199. def inner(*args, **kwargs):
  200. with expected_warnings(warnings_matching):
  201. threads = []
  202. for i in range(workers - 1):
  203. thread = threading.Thread(target=func, args=args, kwargs=kwargs)
  204. threads.append(thread)
  205. for thread in threads:
  206. thread.start()
  207. func(*args, **kwargs)
  208. for thread in threads:
  209. thread.join()
  210. return inner
  211. return wrapper
  212. def assert_stacklevel(warnings, *, offset=-1):
  213. """Assert correct stacklevel of captured warnings.
  214. When scikit-image raises warnings, the stacklevel should ideally be set
  215. so that the origin of the warnings will point to the public function
  216. that was called by the user and not necessarily the very place where the
  217. warnings were emitted (which may be inside some internal function).
  218. This utility function helps with checking that
  219. the stacklevel was set correctly on warnings captured by `pytest.warns`.
  220. Parameters
  221. ----------
  222. warnings : collections.abc.Iterable[warning.WarningMessage]
  223. Warnings that were captured by `pytest.warns`.
  224. offset : int, optional
  225. Offset from the line this function is called to the line were the
  226. warning is supposed to originate from. For multiline calls, the
  227. first line is relevant. Defaults to -1 which corresponds to the line
  228. right above the one where this function is called.
  229. Raises
  230. ------
  231. AssertionError
  232. If a warning in `warnings` does not match the expected line number or
  233. file name.
  234. Examples
  235. --------
  236. >>> def test_something():
  237. ... with pytest.warns(UserWarning, match="some message") as record:
  238. ... something_raising_a_warning()
  239. ... assert_stacklevel(record)
  240. ...
  241. >>> def test_another_thing():
  242. ... with pytest.warns(UserWarning, match="some message") as record:
  243. ... iam_raising_many_warnings(
  244. ... "A long argument that forces the call to wrap."
  245. ... )
  246. ... assert_stacklevel(record, offset=-3)
  247. """
  248. __tracebackhide__ = True # Hide traceback for py.test
  249. frame = inspect.stack()[1].frame # 0 is current frame, 1 is outer frame
  250. line_number = frame.f_lineno + offset
  251. filename = frame.f_code.co_filename
  252. expected = f"{filename}:{line_number}"
  253. for warning in warnings:
  254. actual = f"{warning.filename}:{warning.lineno}"
  255. msg = (
  256. "Warning with wrong stacklevel:\n"
  257. f" Expected: {expected}\n"
  258. f" Actual: {actual}\n"
  259. f" {warning.category.__name__}: {warning.message}"
  260. )
  261. assert actual == expected, msg