Coverage for python/lsst/utils/tests.py: 78%
361 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:54 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:54 +0000
1# This file is part of utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12"""Support code for running unit tests."""
14from __future__ import annotations
16__all__ = [
17 "ExecutablesTestCase",
18 "ImportTestCase",
19 "MemoryTestCase",
20 "TestCase",
21 "assertFloatsAlmostEqual",
22 "assertFloatsEqual",
23 "assertFloatsNotEqual",
24 "classParameters",
25 "debugger",
26 "getTempFilePath",
27 "init",
28 "methodParameters",
29 "temporaryDirectory",
30]
32import contextlib
33import functools
34import gc
35import inspect
36import itertools
37import os
38import re
39import shutil
40import subprocess
41import sys
42import tempfile
43import unittest
44import warnings
45from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
46from importlib import resources
47from typing import Any, ClassVar
49import numpy
50import psutil
52from .doImport import doImport
54# Initialize the list of open files to an empty set
55open_files = set()
58def _get_open_files() -> set[str]:
59 """Return a set containing the list of files currently open in this
60 process.
62 Returns
63 -------
64 open_files : `set`
65 Set containing the list of open files.
66 """
67 return {p.path for p in psutil.Process().open_files()}
70def init() -> None:
71 """Initialize the memory tester and file descriptor leak tester."""
72 global open_files
73 # Reset the list of open files
74 open_files = _get_open_files()
77def sort_tests(tests) -> unittest.TestSuite:
78 """Sort supplied test suites such that MemoryTestCases are at the end.
80 `lsst.utils.tests.MemoryTestCase` tests should always run after any other
81 tests in the module.
83 Parameters
84 ----------
85 tests : sequence
86 Sequence of test suites.
88 Returns
89 -------
90 suite : `unittest.TestSuite`
91 A combined `~unittest.TestSuite` with
92 `~lsst.utils.tests.MemoryTestCase` at the end.
93 """
94 suite = unittest.TestSuite()
95 memtests = []
96 for test_suite in tests:
97 try:
98 # Just test the first test method in the suite for MemoryTestCase
99 # Use loop rather than next as it is possible for a test class
100 # to not have any test methods and the Python community prefers
101 # for loops over catching a StopIteration exception.
102 bases = None
103 for method in test_suite: 103 ↛ anywhereline 103 didn't jump anywhere: it always raised an exception.
104 bases = inspect.getmro(method.__class__)
105 break
106 if bases is not None and MemoryTestCase in bases:
107 memtests.append(test_suite)
108 else:
109 suite.addTests(test_suite)
110 except TypeError:
111 if isinstance(test_suite, MemoryTestCase):
112 memtests.append(test_suite)
113 else:
114 suite.addTest(test_suite)
115 suite.addTests(memtests)
116 return suite
119def _suiteClassWrapper(tests):
120 return unittest.TestSuite(sort_tests(tests))
123# Replace the suiteClass callable in the defaultTestLoader
124# so that we can reorder the test ordering. This will have
125# no effect if no memory test cases are found.
126unittest.defaultTestLoader.suiteClass = _suiteClassWrapper
129class MemoryTestCase(unittest.TestCase):
130 """Check for resource leaks."""
132 ignore_regexps: ClassVar[list[str]] = []
133 """List of regexps to ignore when checking for open files."""
135 @classmethod
136 def tearDownClass(cls) -> None:
137 """Reset the leak counter when the tests have been completed."""
138 init()
140 def testFileDescriptorLeaks(self) -> None:
141 """Check if any file descriptors are open since init() called.
143 Ignores files with certain known path components and any files
144 that match regexp patterns in class property ``ignore_regexps``.
145 """
146 gc.collect()
147 global open_files
148 now_open = _get_open_files()
150 # Some files are opened out of the control of the stack.
151 now_open = {
152 f
153 for f in now_open
154 if not f.endswith(".car")
155 and not f.startswith("/proc/")
156 and not f.startswith("/sys/")
157 and not f.endswith(".ttf")
158 and not (f.startswith("/var/lib/") and f.endswith("/passwd"))
159 and not f.endswith("astropy.log")
160 and not f.endswith("mime/mime.cache")
161 and not f.endswith(".sqlite3")
162 and not re.search(r"/perf-\d+\.map$", f)
163 and not any(re.search(r, f) for r in self.ignore_regexps)
164 }
166 diff = now_open.difference(open_files)
167 if diff: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 for f in diff:
169 print(f"File open: {f}")
170 self.fail(f"Failed to close {len(diff)} file{'s' if len(diff) != 1 else ''}")
173class ExecutablesTestCase(unittest.TestCase):
174 """Test that executables can be run and return good status.
176 The test methods are dynamically created. Callers
177 must subclass this class in their own test file and invoke
178 the create_executable_tests() class method to register the tests.
179 """
181 TESTS_DISCOVERED = -1
183 @classmethod
184 def setUpClass(cls) -> None:
185 """Abort testing if automated test creation was enabled and
186 no tests were found.
187 """
188 if cls.TESTS_DISCOVERED == 0: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 raise RuntimeError("No executables discovered.")
191 def testSanity(self) -> None:
192 """Ensure that there is at least one test to be
193 executed. This allows the test runner to trigger the class set up
194 machinery to test whether there are some executables to test.
195 """
197 def assertExecutable(
198 self,
199 executable: str,
200 root_dir: str | None = None,
201 args: Sequence[str] | None = None,
202 msg: str | None = None,
203 ) -> None:
204 """Check an executable runs and returns good status.
206 Prints output to standard out. On bad exit status the test
207 fails. If the executable can not be located the test is skipped.
209 Parameters
210 ----------
211 executable : `str`
212 Path to an executable. ``root_dir`` is not used if this is an
213 absolute path.
214 root_dir : `str`, optional
215 Directory containing executable. Ignored if `None`.
216 args : `list` or `tuple`, optional
217 Arguments to be provided to the executable.
218 msg : `str`, optional
219 Message to use when the test fails. Can be `None` for default
220 message.
222 Raises
223 ------
224 AssertionError
225 The executable did not return 0 exit status.
226 """
227 if root_dir is not None and not os.path.isabs(executable):
228 executable = os.path.join(root_dir, executable)
230 # Form the argument list for subprocess
231 sp_args = [executable]
232 argstr = "no arguments"
233 if args is not None:
234 sp_args.extend(args)
235 argstr = 'arguments "' + " ".join(args) + '"'
237 print(f"Running executable '{executable}' with {argstr}...")
238 if not os.path.exists(executable):
239 self.skipTest(f"Executable {executable} is unexpectedly missing")
240 failmsg = None
241 try:
242 output = subprocess.check_output(sp_args)
243 except subprocess.CalledProcessError as e:
244 output = e.output
245 failmsg = f"Bad exit status from '{executable}': {e.returncode}"
246 print(output.decode("utf-8"))
247 if failmsg:
248 if msg is None:
249 msg = failmsg
250 self.fail(msg)
252 @classmethod
253 def _build_test_method(cls, executable: str, root_dir: str) -> None:
254 """Build a test method and attach to class.
256 A test method is created for the supplied excutable located
257 in the supplied root directory. This method is attached to the class
258 so that the test runner will discover the test and run it.
260 Parameters
261 ----------
262 cls : `object`
263 The class in which to create the tests.
264 executable : `str`
265 Name of executable. Can be absolute path.
266 root_dir : `str`
267 Path to executable. Not used if executable path is absolute.
268 """
269 if not os.path.isabs(executable): 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 executable = os.path.abspath(os.path.join(root_dir, executable))
272 # Create the test name from the executable path.
273 test_name = "test_exe_" + executable.replace("/", "_")
275 # This is the function that will become the test method
276 def test_executable_runs(*args: Any) -> None:
277 self = args[0]
278 self.assertExecutable(executable)
280 # Give it a name and attach it to the class
281 test_executable_runs.__name__ = test_name
282 setattr(cls, test_name, test_executable_runs)
284 @classmethod
285 def create_executable_tests(cls, ref_file: str, executables: Sequence[str] | None = None) -> None:
286 """Discover executables to test and create corresponding test methods.
288 Scans the directory containing the supplied reference file
289 (usually ``__file__`` supplied from the test class) to look for
290 executables. If executables are found a test method is created
291 for each one. That test method will run the executable and
292 check the returned value.
294 Executable scripts with a ``.py`` extension and shared libraries
295 are ignored by the scanner.
297 This class method must be called before test discovery.
299 Parameters
300 ----------
301 ref_file : `str`
302 Path to a file within the directory to be searched.
303 If the files are in the same location as the test file, then
304 ``__file__`` can be used.
305 executables : `list` or `tuple`, optional
306 Sequence of executables that can override the automated
307 detection. If an executable mentioned here is not found, a
308 skipped test will be created for it, rather than a failed
309 test.
311 Examples
312 --------
313 >>> cls.create_executable_tests(__file__)
314 """
315 # Get the search directory from the reference file
316 ref_dir = os.path.abspath(os.path.dirname(ref_file))
318 if executables is None: 318 ↛ 333line 318 didn't jump to line 333 because the condition on line 318 was always true
319 # Look for executables to test by walking the tree
320 executables = []
321 for root, _, files in os.walk(ref_dir):
322 for f in files:
323 # Skip Python files. Shared libraries are executable.
324 if not f.endswith(".py") and not f.endswith(".so"):
325 full_path = os.path.join(root, f)
326 if os.access(full_path, os.X_OK):
327 executables.append(full_path)
329 # Store the number of tests found for later assessment.
330 # Do not raise an exception if we have no executables as this would
331 # cause the testing to abort before the test runner could properly
332 # integrate it into the failure report.
333 cls.TESTS_DISCOVERED = len(executables)
335 # Create the test functions and attach them to the class
336 for e in executables:
337 cls._build_test_method(e, ref_dir)
340class ImportTestCase(unittest.TestCase):
341 """Test that the named packages can be imported and all files within
342 that package.
344 The test methods are created dynamically. Callers must subclass this
345 method and define the ``PACKAGES`` property.
346 """
348 PACKAGES: ClassVar[Iterable[str]] = ()
349 """Packages to be imported."""
351 SKIP_FILES: ClassVar[Mapping[str, Container[str]]] = {}
352 """Files to be skipped importing; specified as key-value pairs.
354 The key is the package name and the value is a set of files names in that
355 package to skip.
357 Note: Files with names not ending in .py or beginning with leading double
358 underscores are always skipped.
359 """
361 _n_registered = 0
362 """Number of packages registered for testing by this class."""
364 def _test_no_packages_registered_for_import_testing(self) -> None:
365 """Test when no packages have been registered.
367 Without this, if no packages have been listed no tests will be
368 registered and the test system will not report on anything. This
369 test fails and reports why.
370 """
371 raise AssertionError("No packages registered with import test. Was the PACKAGES property set?")
373 def __init_subclass__(cls, **kwargs: Any) -> None:
374 """Create the test methods based on the content of the ``PACKAGES``
375 class property.
376 """
377 super().__init_subclass__(**kwargs)
379 for mod in cls.PACKAGES:
380 test_name = "test_import_" + mod.replace(".", "_")
382 def test_import(*args: Any, mod=mod) -> None:
383 self = args[0]
384 self.assertImport(mod)
386 test_import.__name__ = test_name
387 setattr(cls, test_name, test_import)
388 cls._n_registered += 1
390 # If there are no packages listed that is likely a mistake and
391 # so register a failing test.
392 if cls._n_registered == 0: 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true
393 cls.test_no_packages_registered = cls._test_no_packages_registered_for_import_testing
395 def assertImport(self, root_pkg):
396 for file in resources.files(root_pkg).iterdir():
397 file = file.name
398 # When support for python 3.9 is dropped, this could be updated to
399 # use match case construct.
400 if not file.endswith(".py"):
401 continue
402 if file.startswith("__"):
403 continue
404 if file in self.SKIP_FILES.get(root_pkg, ()):
405 continue
406 root, _ = os.path.splitext(file)
407 module_name = f"{root_pkg}.{root}"
408 with self.subTest(module=module_name):
409 try:
410 doImport(module_name)
411 except ImportError as e:
412 raise AssertionError(f"Error importing module {module_name}: {e}") from e
415@contextlib.contextmanager
416def getTempFilePath(ext: str, expectOutput: bool = True) -> Iterator[str]:
417 """Return a path suitable for a temporary file and try to delete the
418 file on success.
420 If the with block completes successfully then the file is deleted,
421 if possible; failure results in a printed warning.
422 If a file is remains when it should not, a RuntimeError exception is
423 raised. This exception is also raised if a file is not present on context
424 manager exit when one is expected to exist.
425 If the block exits with an exception the file if left on disk so it can be
426 examined. The file name has a random component such that nested context
427 managers can be used with the same file suffix.
429 Parameters
430 ----------
431 ext : `str`
432 File name extension, e.g. ``.fits``.
433 expectOutput : `bool`, optional
434 If `True`, a file should be created within the context manager.
435 If `False`, a file should not be present when the context manager
436 exits.
438 Yields
439 ------
440 path : `str`
441 Path for a temporary file. The path is a combination of the caller's
442 file path and the name of the top-level function.
444 Examples
445 --------
446 .. code-block:: python
448 # file tests/testFoo.py
449 import unittest
450 import lsst.utils.tests
453 class FooTestCase(unittest.TestCase):
454 def testBasics(self):
455 self.runTest()
457 def runTest(self):
458 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
459 # if tests/.tests exists then
460 # tmpFile = "tests/.tests/testFoo_testBasics.fits"
461 # otherwise tmpFile = "testFoo_testBasics.fits"
462 ...
463 # at the end of this "with" block the path tmpFile will be
464 # deleted, but only if the file exists and the "with"
465 # block terminated normally (rather than with an exception)
468 ...
469 """
470 stack = inspect.stack()
471 # get name of first function in the file
472 for i in range(2, len(stack)): 472 ↛ 483line 472 didn't jump to line 483 because the loop on line 472 didn't complete
473 frameInfo = inspect.getframeinfo(stack[i][0])
474 if i == 2:
475 callerFilePath = frameInfo.filename
476 callerFuncName = frameInfo.function
477 elif callerFilePath == frameInfo.filename:
478 # this function called the previous function
479 callerFuncName = frameInfo.function
480 else:
481 break
483 callerDir, callerFileNameWithExt = os.path.split(callerFilePath)
484 callerFileName = os.path.splitext(callerFileNameWithExt)[0]
485 outDir = os.path.join(callerDir, ".tests")
486 if not os.path.isdir(outDir): 486 ↛ 491line 486 didn't jump to line 491 because the condition on line 486 was never true
487 # No .tests directory implies we are not running with sconsUtils.
488 # Need to use the current working directory, the callerDir, or
489 # /tmp equivalent. If cwd is used if must be as an absolute path
490 # in case the test code changes cwd.
491 outDir = os.path.abspath(os.path.curdir)
492 prefix = f"{callerFileName}_{callerFuncName}-"
493 outPath = tempfile.mktemp(dir=outDir, suffix=ext, prefix=prefix)
494 if os.path.exists(outPath): 494 ↛ 499line 494 didn't jump to line 499 because the condition on line 494 was never true
495 # There should not be a file there given the randomizer. Warn and
496 # remove.
497 # Use stacklevel 3 so that the warning is reported from the end of the
498 # with block
499 warnings.warn(f"Unexpectedly found pre-existing tempfile named {outPath!r}", stacklevel=3)
500 with contextlib.suppress(OSError):
501 os.remove(outPath)
503 yield outPath
505 fileExists = os.path.exists(outPath)
506 if expectOutput:
507 if not fileExists:
508 raise RuntimeError(f"Temp file expected named {outPath} but none found")
509 else:
510 if fileExists:
511 raise RuntimeError(f"Unexpectedly discovered temp file named {outPath}")
512 # Try to clean up the file regardless
513 if fileExists:
514 try:
515 os.remove(outPath)
516 except OSError as e:
517 # Use stacklevel 3 so that the warning is reported from the end of
518 # the with block.
519 warnings.warn(f"Warning: could not remove file {outPath!r}: {e}", stacklevel=3)
522class TestCase(unittest.TestCase):
523 """Subclass of unittest.TestCase that adds some custom assertions for
524 convenience.
525 """
528def inTestCase(func: Callable) -> Callable:
529 """Add a free function to our custom TestCase class, while
530 also making it available as a free function.
532 Parameters
533 ----------
534 func : `~collections.abc.Callable`
535 Function to be added to `unittest.TestCase` class.
537 Returns
538 -------
539 func : `~collections.abc.Callable`
540 The given function.
541 """
542 setattr(TestCase, func.__name__, func)
543 return func
546def debugger(*exceptions):
547 """Enter the debugger when there's an uncaught exception.
549 To use, just slap a ``@debugger()`` on your function.
551 You may provide specific exception classes to catch as arguments to
552 the decorator function, e.g.,
553 ``@debugger(RuntimeError, NotImplementedError)``.
554 This defaults to just `AssertionError`, for use on `unittest.TestCase`
555 methods.
557 Code provided by "Rosh Oxymoron" on StackOverflow:
558 http://stackoverflow.com/questions/4398967/python-unit-testing-automatically-running-the-debugger-when-a-test-fails
560 Parameters
561 ----------
562 *exceptions : `Exception`
563 Specific exception classes to catch. Default is to catch
564 `AssertionError`.
566 Notes
567 -----
568 Consider using ``pytest --pdb`` instead of this decorator.
569 """
570 if not exceptions:
571 exceptions = (Exception,)
573 def decorator(f):
574 @functools.wraps(f)
575 def wrapper(*args, **kwargs):
576 try:
577 return f(*args, **kwargs)
578 except exceptions:
579 import pdb
580 import sys
582 pdb.post_mortem(sys.exc_info()[2])
584 return wrapper
586 return decorator
589def plotImageDiff(
590 lhs: numpy.ndarray,
591 rhs: numpy.ndarray,
592 bad: numpy.ndarray | None = None,
593 diff: numpy.ndarray | None = None,
594 plotFileName: str | None = None,
595) -> None:
596 """Plot the comparison of two 2-d NumPy arrays.
598 Parameters
599 ----------
600 lhs : `numpy.ndarray`
601 LHS values to compare; a 2-d NumPy array.
602 rhs : `numpy.ndarray`
603 RHS values to compare; a 2-d NumPy array.
604 bad : `numpy.ndarray`
605 A 2-d boolean NumPy array of values to emphasize in the plots.
606 diff : `numpy.ndarray`
607 Difference array; a 2-d NumPy array, or None to show lhs-rhs.
608 plotFileName : `str`
609 Filename to save the plot to. If None, the plot will be displayed in
610 a window.
612 Notes
613 -----
614 This method uses `matplotlib` and imports it internally; it should be
615 wrapped in a try/except block within packages that do not depend on
616 `matplotlib` (including `~lsst.utils`).
617 """
618 if plotFileName is None:
619 # We need to create an interactive plot with pyplot.
620 from matplotlib import pyplot
622 fig = pyplot.figure()
623 else:
624 # We can create a non-interactive figure.
625 from .plotting import make_figure
627 fig = make_figure()
629 if diff is None:
630 diff = lhs - rhs
632 if bad is not None:
633 # make an rgba image that's red and transparent where not bad
634 badImage = numpy.zeros(bad.shape + (4,), dtype=numpy.uint8)
635 badImage[:, :, 0] = 255
636 badImage[:, :, 1] = 0
637 badImage[:, :, 2] = 0
638 badImage[:, :, 3] = 255 * bad
639 vmin1 = numpy.minimum(numpy.min(lhs), numpy.min(rhs))
640 vmax1 = numpy.maximum(numpy.max(lhs), numpy.max(rhs))
641 vmin2 = numpy.min(diff)
642 vmax2 = numpy.max(diff)
643 for n, (image, title) in enumerate([(lhs, "lhs"), (rhs, "rhs"), (diff, "diff")]):
644 ax = fig.add_subplot(2, 3, n + 1)
645 im1 = ax.imshow(
646 image, cmap=pyplot.cm.gray, interpolation="nearest", origin="lower", vmin=vmin1, vmax=vmax1
647 )
648 if bad is not None:
649 ax.imshow(badImage, alpha=0.2, interpolation="nearest", origin="lower")
650 ax.axis("off")
651 ax.set_title(title)
652 ax = fig.add_subplot(2, 3, n + 4)
653 im2 = ax.imshow(
654 image, cmap=pyplot.cm.gray, interpolation="nearest", origin="lower", vmin=vmin2, vmax=vmax2
655 )
656 if bad is not None:
657 ax.imshow(badImage, alpha=0.2, interpolation="nearest", origin="lower")
658 ax.axis("off")
659 ax.set_title(title)
660 fig.subplots_adjust(left=0.05, bottom=0.05, top=0.92, right=0.75, wspace=0.05, hspace=0.05)
661 cax1 = fig.add_subplot([0.8, 0.55, 0.05, 0.4])
662 fig.colorbar(im1, cax=cax1)
663 cax2 = fig.add_subplot([0.8, 0.05, 0.05, 0.4])
664 fig.colorbar(im2, cax=cax2)
665 if plotFileName:
666 fig.savefig(plotFileName)
667 else:
668 pyplot.show()
671@inTestCase
672def assertFloatsAlmostEqual(
673 testCase: unittest.TestCase,
674 lhs: float | numpy.ndarray,
675 rhs: float | numpy.ndarray,
676 rtol: float | None = sys.float_info.epsilon,
677 atol: float | None = sys.float_info.epsilon,
678 relTo: float | None = None,
679 printFailures: bool = True,
680 plotOnFailure: bool = False,
681 plotFileName: str | None = None,
682 invert: bool = False,
683 msg: str | None = None,
684 ignoreNaNs: bool = False,
685) -> None:
686 """Highly-configurable floating point comparisons for scalars and arrays.
688 The test assertion will fail if all elements ``lhs`` and ``rhs`` are not
689 equal to within the tolerances specified by ``rtol`` and ``atol``.
690 More precisely, the comparison is:
692 ``abs(lhs - rhs) <= relTo*rtol OR abs(lhs - rhs) <= atol``
694 If ``rtol`` or ``atol`` is `None`, that term in the comparison is not
695 performed at all.
697 When not specified, ``relTo`` is the elementwise maximum of the absolute
698 values of ``lhs`` and ``rhs``. If set manually, it should usually be set
699 to either ``lhs`` or ``rhs``, or a scalar value typical of what is
700 expected.
702 Parameters
703 ----------
704 testCase : `unittest.TestCase`
705 Instance the test is part of.
706 lhs : scalar or array-like
707 LHS value(s) to compare; may be a scalar or array-like of any
708 dimension.
709 rhs : scalar or array-like
710 RHS value(s) to compare; may be a scalar or array-like of any
711 dimension.
712 rtol : `float`, optional
713 Relative tolerance for comparison; defaults to double-precision
714 epsilon.
715 atol : `float`, optional
716 Absolute tolerance for comparison; defaults to double-precision
717 epsilon.
718 relTo : `float`, optional
719 Value to which comparison with rtol is relative.
720 printFailures : `bool`, optional
721 Upon failure, print all inequal elements as part of the message.
722 plotOnFailure : `bool`, optional
723 Upon failure, plot the originals and their residual with matplotlib.
724 Only 2-d arrays are supported.
725 plotFileName : `str`, optional
726 Filename to save the plot to. If `None`, the plot will be displayed in
727 a window.
728 invert : `bool`, optional
729 If `True`, invert the comparison and fail only if any elements *are*
730 equal. Used to implement `~lsst.utils.tests.assertFloatsNotEqual`,
731 which should generally be used instead for clarity.
732 will return `True`).
733 msg : `str`, optional
734 String to append to the error message when assert fails.
735 ignoreNaNs : `bool`, optional
736 If `True` (`False` is default) mask out any NaNs from operand arrays
737 before performing comparisons if they are in the same locations; NaNs
738 in different locations are trigger test assertion failures, even when
739 ``invert=True``. Scalar NaNs are treated like arrays containing only
740 NaNs of the same shape as the other operand, and no comparisons are
741 performed if both sides are scalar NaNs.
743 Raises
744 ------
745 AssertionError
746 The values are not almost equal.
747 """
748 if ignoreNaNs:
749 lhsMask = numpy.isnan(lhs)
750 rhsMask = numpy.isnan(rhs)
751 if not numpy.all(lhsMask == rhsMask):
752 testCase.fail(
753 f"lhs has {lhsMask.sum()} NaN values and rhs has {rhsMask.sum()} NaN values, "
754 "in different locations."
755 )
756 if numpy.all(lhsMask):
757 assert numpy.all(rhsMask), "Should be guaranteed by previous if."
758 # All operands are fully NaN (either scalar NaNs or arrays of only
759 # NaNs).
760 return
761 assert not numpy.all(rhsMask), "Should be guaranteed by prevoius two ifs."
762 # If either operand is an array select just its not-NaN values. Note
763 # that these expressions are never True for scalar operands, because if
764 # they are NaN then the numpy.all checks above will catch them.
765 if numpy.any(lhsMask):
766 lhs = lhs[numpy.logical_not(lhsMask)]
767 if numpy.any(rhsMask):
768 rhs = rhs[numpy.logical_not(rhsMask)]
769 if not numpy.isfinite(lhs).all():
770 testCase.fail("Non-finite values in lhs")
771 if not numpy.isfinite(rhs).all():
772 testCase.fail("Non-finite values in rhs")
773 diff = lhs - rhs
774 absDiff = numpy.abs(lhs - rhs)
775 if rtol is not None:
776 if relTo is None:
777 relTo = numpy.maximum(numpy.abs(lhs), numpy.abs(rhs))
778 else:
779 relTo = numpy.abs(relTo)
780 bad = absDiff > rtol * relTo
781 if atol is not None:
782 bad = numpy.logical_and(bad, absDiff > atol)
783 else:
784 if atol is None:
785 raise ValueError("rtol and atol cannot both be None")
786 bad = absDiff > atol
787 failed = numpy.any(bad)
788 if invert:
789 failed = not failed
790 bad = numpy.logical_not(bad)
791 cmpStr = "=="
792 failStr = "are the same"
793 else:
794 cmpStr = "!="
795 failStr = "differ"
796 errMsg = []
797 if failed:
798 if numpy.isscalar(bad):
799 if rtol is None:
800 errMsg = [f"{lhs} {cmpStr} {rhs}; diff={absDiff} with atol={atol}"]
801 elif atol is None:
802 errMsg = [f"{lhs} {cmpStr} {rhs}; diff={absDiff}/{relTo}={absDiff / relTo} with rtol={rtol}"]
803 else:
804 errMsg = [
805 f"{lhs} {cmpStr} {rhs}; diff={absDiff}/{relTo}={absDiff / relTo} "
806 f"with rtol={rtol}, atol={atol}"
807 ]
808 else:
809 errMsg = [f"{bad.sum()}/{bad.size} elements {failStr} with rtol={rtol}, atol={atol}"]
810 if plotOnFailure: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true
811 if len(lhs.shape) != 2 or len(rhs.shape) != 2:
812 raise ValueError("plotOnFailure is only valid for 2-d arrays")
813 try:
814 plotImageDiff(lhs, rhs, bad, diff=diff, plotFileName=plotFileName)
815 except ImportError:
816 errMsg.append("Failure plot requested but matplotlib could not be imported.")
817 if printFailures: 817 ↛ 834line 817 didn't jump to line 834 because the condition on line 817 was always true
818 # Make sure everything is an array if any of them are, so we
819 # can treat them the same (diff and absDiff are arrays if
820 # either rhs or lhs is), and we don't get here if neither is.
821 if numpy.isscalar(relTo):
822 relTo = numpy.ones(bad.shape, dtype=float) * relTo
823 if numpy.isscalar(lhs):
824 lhs = numpy.ones(bad.shape, dtype=float) * lhs
825 if numpy.isscalar(rhs):
826 rhs = numpy.ones(bad.shape, dtype=float) * rhs
827 if rtol is None:
828 for a, b, diff in zip(lhs[bad], rhs[bad], absDiff[bad]):
829 errMsg.append(f"{a} {cmpStr} {b} (diff={diff})")
830 else:
831 for a, b, diff, rel in zip(lhs[bad], rhs[bad], absDiff[bad], relTo[bad]):
832 errMsg.append(f"{a} {cmpStr} {b} (diff={diff}/{rel}={diff / rel})")
834 if msg is not None:
835 errMsg.append(msg)
836 testCase.assertFalse(failed, msg="\n".join(errMsg))
839@inTestCase
840def assertFloatsNotEqual(
841 testCase: unittest.TestCase,
842 lhs: float | numpy.ndarray,
843 rhs: float | numpy.ndarray,
844 **kwds: Any,
845) -> None:
846 """Fail a test if the given floating point values are equal to within the
847 given tolerances.
849 See `~lsst.utils.tests.assertFloatsAlmostEqual` (called with
850 ``rtol=atol=0``) for more information.
852 Parameters
853 ----------
854 testCase : `unittest.TestCase`
855 Instance the test is part of.
856 lhs : scalar or array-like
857 LHS value(s) to compare; may be a scalar or array-like of any
858 dimension.
859 rhs : scalar or array-like
860 RHS value(s) to compare; may be a scalar or array-like of any
861 dimension.
862 **kwds : `~typing.Any`
863 Keyword parameters forwarded to `assertFloatsAlmostEqual`.
865 Raises
866 ------
867 AssertionError
868 The values are almost equal.
869 """
870 return assertFloatsAlmostEqual(testCase, lhs, rhs, invert=True, **kwds)
873@inTestCase
874def assertFloatsEqual(
875 testCase: unittest.TestCase,
876 lhs: float | numpy.ndarray,
877 rhs: float | numpy.ndarray,
878 **kwargs: Any,
879) -> None:
880 """
881 Assert that lhs == rhs (both numeric types, whether scalar or array).
883 See `~lsst.utils.tests.assertFloatsAlmostEqual` (called with
884 ``rtol=atol=0``) for more information.
886 Parameters
887 ----------
888 testCase : `unittest.TestCase`
889 Instance the test is part of.
890 lhs : scalar or array-like
891 LHS value(s) to compare; may be a scalar or array-like of any
892 dimension.
893 rhs : scalar or array-like
894 RHS value(s) to compare; may be a scalar or array-like of any
895 dimension.
896 **kwargs : `~typing.Any`
897 Keyword parameters forwarded to `assertFloatsAlmostEqual`.
899 Raises
900 ------
901 AssertionError
902 The values are not equal.
903 """
904 return assertFloatsAlmostEqual(testCase, lhs, rhs, rtol=0, atol=0, **kwargs)
907def _settingsIterator(settings: dict[str, Sequence[Any]]) -> Iterator[dict[str, Any]]:
908 """Return an iterator for the provided test settings
910 Parameters
911 ----------
912 settings : `dict` (`str`: iterable)
913 Lists of test parameters. Each should be an iterable of the same
914 length. If a string is provided as an iterable, it will be converted
915 to a list of a single string.
917 Raises
918 ------
919 AssertionError
920 If the ``settings`` are not of the same length.
922 Yields
923 ------
924 parameters : `dict` (`str`: anything)
925 Set of parameters.
926 """
927 for name, values in settings.items():
928 if isinstance(values, str): 928 ↛ 931line 928 didn't jump to line 931 because the condition on line 928 was never true
929 # Probably meant as a single-element string, rather than an
930 # iterable of chars.
931 settings[name] = [values]
932 num = len(next(iter(settings.values()))) # Number of settings
933 for name, values in settings.items():
934 assert len(values) == num, f"Length mismatch for setting {name}: {len(values)} vs {num}"
935 for ii in range(num):
936 values = [settings[kk][ii] for kk in settings]
937 yield dict(zip(settings, values))
940def classParameters(**settings: Sequence[Any]) -> Callable:
941 """Class decorator for generating unit tests.
943 This decorator generates classes with class variables according to the
944 supplied ``settings``.
946 Parameters
947 ----------
948 **settings : `dict` (`str`: iterable)
949 The lists of test parameters to set as class variables in turn. Each
950 should be an iterable of the same length.
952 Examples
953 --------
954 ::
956 @classParameters(foo=[1, 2], bar=[3, 4])
957 class MyTestCase(unittest.TestCase): ...
959 will generate two classes, as if you wrote::
961 class MyTestCase_1_3(unittest.TestCase):
962 foo = 1
963 bar = 3
964 ...
967 class MyTestCase_2_4(unittest.TestCase):
968 foo = 2
969 bar = 4
970 ...
972 Note that the values are embedded in the class name.
973 """
975 def decorator(cls: type) -> None:
976 module = sys.modules[cls.__module__].__dict__
977 for params in _settingsIterator(settings):
978 name = f"{cls.__name__}_{'_'.join(str(vv) for vv in params.values())}"
979 bindings = dict(cls.__dict__)
980 bindings.update(params)
981 module[name] = type(name, (cls,), bindings)
983 return decorator
986def methodParameters(**settings: Sequence[Any]) -> Callable:
987 """Iterate over supplied settings to create subtests automatically.
989 This decorator iterates over the supplied settings, using
990 ``TestCase.subTest`` to communicate the values in the event of a failure.
992 Parameters
993 ----------
994 **settings : `dict` (`str`: iterable)
995 The lists of test parameters. Each should be an iterable of the same
996 length.
998 Examples
999 --------
1000 .. code-block:: python
1002 @methodParameters(foo=[1, 2], bar=[3, 4])
1003 def testSomething(self, foo, bar): ...
1005 will run:
1007 .. code-block:: python
1009 testSomething(foo=1, bar=3)
1010 testSomething(foo=2, bar=4)
1011 """
1013 def decorator(func: Callable) -> Callable:
1014 @functools.wraps(func)
1015 def wrapper(self: unittest.TestCase, *args: Any, **kwargs: Any) -> None:
1016 for params in _settingsIterator(settings):
1017 kwargs.update(params)
1018 with self.subTest(**{k: repr(v) for k, v in params.items()}):
1019 func(self, *args, **kwargs)
1021 return wrapper
1023 return decorator
1026def _cartesianProduct(settings: Mapping[str, Sequence[Any]]) -> Mapping[str, Sequence[Any]]:
1027 """Return the cartesian product of the settings.
1029 Parameters
1030 ----------
1031 settings : `dict` mapping `str` to `iterable`
1032 Parameter combinations.
1034 Returns
1035 -------
1036 product : `dict` mapping `str` to `iterable`
1037 Parameter combinations covering the cartesian product (all possible
1038 combinations) of the input parameters.
1040 Examples
1041 --------
1042 .. code-block:: python
1044 cartesianProduct({"foo": [1, 2], "bar": ["black", "white"]})
1046 will return:
1048 .. code-block:: python
1050 {"foo": [1, 1, 2, 2], "bar": ["black", "white", "black", "white"]}
1051 """
1052 product: dict[str, list[Any]] = {kk: [] for kk in settings}
1053 for values in itertools.product(*settings.values()):
1054 for kk, vv in zip(settings.keys(), values):
1055 product[kk].append(vv)
1056 return product
1059def classParametersProduct(**settings: Sequence[Any]) -> Callable:
1060 """Class decorator for generating unit tests.
1062 This decorator generates classes with class variables according to the
1063 cartesian product of the supplied ``settings``.
1065 Parameters
1066 ----------
1067 **settings : `dict` (`str`: iterable)
1068 The lists of test parameters to set as class variables in turn. Each
1069 should be an iterable.
1071 Examples
1072 --------
1073 .. code-block:: python
1075 @classParametersProduct(foo=[1, 2], bar=[3, 4])
1076 class MyTestCase(unittest.TestCase): ...
1078 will generate four classes, as if you wrote::
1080 .. code-block:: python
1082 class MyTestCase_1_3(unittest.TestCase):
1083 foo = 1
1084 bar = 3
1085 ...
1088 class MyTestCase_1_4(unittest.TestCase):
1089 foo = 1
1090 bar = 4
1091 ...
1094 class MyTestCase_2_3(unittest.TestCase):
1095 foo = 2
1096 bar = 3
1097 ...
1100 class MyTestCase_2_4(unittest.TestCase):
1101 foo = 2
1102 bar = 4
1103 ...
1105 Note that the values are embedded in the class name.
1106 """
1107 return classParameters(**_cartesianProduct(settings))
1110def methodParametersProduct(**settings: Sequence[Any]) -> Callable:
1111 """Iterate over cartesian product creating sub tests.
1113 This decorator iterates over the cartesian product of the supplied
1114 settings, using `~unittest.TestCase.subTest` to communicate the values in
1115 the event of a failure.
1117 Parameters
1118 ----------
1119 **settings : `dict` (`str`: iterable)
1120 The parameter combinations to test. Each should be an iterable.
1122 Examples
1123 --------
1124 @methodParametersProduct(foo=[1, 2], bar=["black", "white"])
1125 def testSomething(self, foo, bar):
1126 ...
1128 will run:
1130 testSomething(foo=1, bar="black")
1131 testSomething(foo=1, bar="white")
1132 testSomething(foo=2, bar="black")
1133 testSomething(foo=2, bar="white")
1134 """
1135 return methodParameters(**_cartesianProduct(settings))
1138@contextlib.contextmanager
1139def temporaryDirectory() -> Iterator[str]:
1140 """Context manager that creates and destroys a temporary directory.
1142 The difference from `tempfile.TemporaryDirectory` is that this ignores
1143 errors when deleting a directory, which may happen with some filesystems.
1145 Yields
1146 ------
1147 `str`
1148 Name of the temporary directory.
1149 """
1150 tmpdir = tempfile.mkdtemp()
1151 yield tmpdir
1152 shutil.rmtree(tmpdir, ignore_errors=True)