lsst-utils 25.2023.600__py3-none-any.whl → 29.2025.4800__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. lsst/utils/__init__.py +0 -3
  2. lsst/utils/_packaging.py +2 -0
  3. lsst/utils/argparsing.py +79 -0
  4. lsst/utils/classes.py +27 -9
  5. lsst/utils/db_auth.py +339 -0
  6. lsst/utils/deprecated.py +10 -7
  7. lsst/utils/doImport.py +8 -9
  8. lsst/utils/inheritDoc.py +34 -6
  9. lsst/utils/introspection.py +285 -19
  10. lsst/utils/iteration.py +193 -7
  11. lsst/utils/logging.py +155 -105
  12. lsst/utils/packages.py +324 -82
  13. lsst/utils/plotting/__init__.py +15 -0
  14. lsst/utils/plotting/figures.py +159 -0
  15. lsst/utils/plotting/limits.py +155 -0
  16. lsst/utils/plotting/publication_plots.py +184 -0
  17. lsst/utils/plotting/rubin.mplstyle +46 -0
  18. lsst/utils/tests.py +231 -102
  19. lsst/utils/threads.py +9 -3
  20. lsst/utils/timer.py +207 -110
  21. lsst/utils/usage.py +6 -6
  22. lsst/utils/version.py +1 -1
  23. lsst/utils/wrappers.py +74 -29
  24. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/METADATA +19 -15
  25. lsst_utils-29.2025.4800.dist-info/RECORD +32 -0
  26. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/WHEEL +1 -1
  27. lsst/utils/_forwarded.py +0 -28
  28. lsst/utils/backtrace/__init__.py +0 -33
  29. lsst/utils/ellipsis.py +0 -54
  30. lsst/utils/get_caller_name.py +0 -45
  31. lsst_utils-25.2023.600.dist-info/RECORD +0 -29
  32. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/COPYRIGHT +0 -0
  33. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/LICENSE +0 -0
  34. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/top_level.txt +0 -0
  35. {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/zip-safe +0 -0
lsst/utils/tests.py CHANGED
@@ -9,19 +9,22 @@
9
9
  # Use of this source code is governed by a 3-clause BSD-style
10
10
  # license that can be found in the LICENSE file.
11
11
 
12
- """Support code for running unit tests"""
12
+ """Support code for running unit tests."""
13
+
14
+ from __future__ import annotations
13
15
 
14
16
  __all__ = [
15
- "init",
16
- "MemoryTestCase",
17
17
  "ExecutablesTestCase",
18
- "getTempFilePath",
18
+ "ImportTestCase",
19
+ "MemoryTestCase",
19
20
  "TestCase",
20
21
  "assertFloatsAlmostEqual",
21
- "assertFloatsNotEqual",
22
22
  "assertFloatsEqual",
23
- "debugger",
23
+ "assertFloatsNotEqual",
24
24
  "classParameters",
25
+ "debugger",
26
+ "getTempFilePath",
27
+ "init",
25
28
  "methodParameters",
26
29
  "temporaryDirectory",
27
30
  ]
@@ -32,22 +35,27 @@ import gc
32
35
  import inspect
33
36
  import itertools
34
37
  import os
38
+ import re
35
39
  import shutil
36
40
  import subprocess
37
41
  import sys
38
42
  import tempfile
39
43
  import unittest
40
44
  import warnings
41
- from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Set, Type, Union
45
+ from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
46
+ from importlib import resources
47
+ from typing import Any, ClassVar
42
48
 
43
49
  import numpy
44
50
  import psutil
45
51
 
52
+ from .doImport import doImport
53
+
46
54
  # Initialize the list of open files to an empty set
47
55
  open_files = set()
48
56
 
49
57
 
50
- def _get_open_files() -> Set[str]:
58
+ def _get_open_files() -> set[str]:
51
59
  """Return a set containing the list of files currently open in this
52
60
  process.
53
61
 
@@ -56,7 +64,7 @@ def _get_open_files() -> Set[str]:
56
64
  open_files : `set`
57
65
  Set containing the list of open files.
58
66
  """
59
- return set(p.path for p in psutil.Process().open_files())
67
+ return {p.path for p in psutil.Process().open_files()}
60
68
 
61
69
 
62
70
  def init() -> None:
@@ -108,47 +116,57 @@ def sort_tests(tests) -> unittest.TestSuite:
108
116
  return suite
109
117
 
110
118
 
111
- def suiteClassWrapper(tests):
119
+ def _suiteClassWrapper(tests):
112
120
  return unittest.TestSuite(sort_tests(tests))
113
121
 
114
122
 
115
123
  # Replace the suiteClass callable in the defaultTestLoader
116
124
  # so that we can reorder the test ordering. This will have
117
125
  # no effect if no memory test cases are found.
118
- unittest.defaultTestLoader.suiteClass = suiteClassWrapper
126
+ unittest.defaultTestLoader.suiteClass = _suiteClassWrapper
119
127
 
120
128
 
121
129
  class MemoryTestCase(unittest.TestCase):
122
130
  """Check for resource leaks."""
123
131
 
132
+ ignore_regexps: ClassVar[list[str]] = []
133
+ """List of regexps to ignore when checking for open files."""
134
+
124
135
  @classmethod
125
136
  def tearDownClass(cls) -> None:
126
- """Reset the leak counter when the tests have been completed"""
137
+ """Reset the leak counter when the tests have been completed."""
127
138
  init()
128
139
 
129
140
  def testFileDescriptorLeaks(self) -> None:
130
- """Check if any file descriptors are open since init() called."""
141
+ """Check if any file descriptors are open since init() called.
142
+
143
+ Ignores files with certain known path components and any files
144
+ that match regexp patterns in class property ``ignore_regexps``.
145
+ """
131
146
  gc.collect()
132
147
  global open_files
133
148
  now_open = _get_open_files()
134
149
 
135
150
  # Some files are opened out of the control of the stack.
136
- now_open = set(
151
+ now_open = {
137
152
  f
138
153
  for f in now_open
139
154
  if not f.endswith(".car")
140
155
  and not f.startswith("/proc/")
156
+ and not f.startswith("/sys/")
141
157
  and not f.endswith(".ttf")
142
158
  and not (f.startswith("/var/lib/") and f.endswith("/passwd"))
143
159
  and not f.endswith("astropy.log")
144
160
  and not f.endswith("mime/mime.cache")
145
- )
161
+ and not f.endswith(".sqlite3")
162
+ and not any(re.search(r, f) for r in self.ignore_regexps)
163
+ }
146
164
 
147
165
  diff = now_open.difference(open_files)
148
166
  if diff:
149
167
  for f in diff:
150
- print("File open: %s" % f)
151
- self.fail("Failed to close %d file%s" % (len(diff), "s" if len(diff) != 1 else ""))
168
+ print(f"File open: {f}")
169
+ self.fail(f"Failed to close {len(diff)} file{'s' if len(diff) != 1 else ''}")
152
170
 
153
171
 
154
172
  class ExecutablesTestCase(unittest.TestCase):
@@ -174,14 +192,13 @@ class ExecutablesTestCase(unittest.TestCase):
174
192
  executed. This allows the test runner to trigger the class set up
175
193
  machinery to test whether there are some executables to test.
176
194
  """
177
- pass
178
195
 
179
196
  def assertExecutable(
180
197
  self,
181
198
  executable: str,
182
- root_dir: Optional[str] = None,
183
- args: Optional[Sequence[str]] = None,
184
- msg: Optional[str] = None,
199
+ root_dir: str | None = None,
200
+ args: Sequence[str] | None = None,
201
+ msg: str | None = None,
185
202
  ) -> None:
186
203
  """Check an executable runs and returns good status.
187
204
 
@@ -216,15 +233,15 @@ class ExecutablesTestCase(unittest.TestCase):
216
233
  sp_args.extend(args)
217
234
  argstr = 'arguments "' + " ".join(args) + '"'
218
235
 
219
- print("Running executable '{}' with {}...".format(executable, argstr))
236
+ print(f"Running executable '{executable}' with {argstr}...")
220
237
  if not os.path.exists(executable):
221
- self.skipTest("Executable {} is unexpectedly missing".format(executable))
238
+ self.skipTest(f"Executable {executable} is unexpectedly missing")
222
239
  failmsg = None
223
240
  try:
224
241
  output = subprocess.check_output(sp_args)
225
242
  except subprocess.CalledProcessError as e:
226
243
  output = e.output
227
- failmsg = "Bad exit status from '{}': {}".format(executable, e.returncode)
244
+ failmsg = f"Bad exit status from '{executable}': {e.returncode}"
228
245
  print(output.decode("utf-8"))
229
246
  if failmsg:
230
247
  if msg is None:
@@ -264,7 +281,7 @@ class ExecutablesTestCase(unittest.TestCase):
264
281
  setattr(cls, test_name, test_executable_runs)
265
282
 
266
283
  @classmethod
267
- def create_executable_tests(cls, ref_file: str, executables: Optional[Sequence[str]] = None) -> None:
284
+ def create_executable_tests(cls, ref_file: str, executables: Sequence[str] | None = None) -> None:
268
285
  """Discover executables to test and create corresponding test methods.
269
286
 
270
287
  Scans the directory containing the supplied reference file
@@ -300,7 +317,7 @@ class ExecutablesTestCase(unittest.TestCase):
300
317
  if executables is None:
301
318
  # Look for executables to test by walking the tree
302
319
  executables = []
303
- for root, dirs, files in os.walk(ref_dir):
320
+ for root, _, files in os.walk(ref_dir):
304
321
  for f in files:
305
322
  # Skip Python files. Shared libraries are executable.
306
323
  if not f.endswith(".py") and not f.endswith(".so"):
@@ -319,10 +336,85 @@ class ExecutablesTestCase(unittest.TestCase):
319
336
  cls._build_test_method(e, ref_dir)
320
337
 
321
338
 
339
+ class ImportTestCase(unittest.TestCase):
340
+ """Test that the named packages can be imported and all files within
341
+ that package.
342
+
343
+ The test methods are created dynamically. Callers must subclass this
344
+ method and define the ``PACKAGES`` property.
345
+ """
346
+
347
+ PACKAGES: ClassVar[Iterable[str]] = ()
348
+ """Packages to be imported."""
349
+
350
+ SKIP_FILES: ClassVar[Mapping[str, Container[str]]] = {}
351
+ """Files to be skipped importing; specified as key-value pairs.
352
+
353
+ The key is the package name and the value is a set of files names in that
354
+ package to skip.
355
+
356
+ Note: Files with names not ending in .py or beginning with leading double
357
+ underscores are always skipped.
358
+ """
359
+
360
+ _n_registered = 0
361
+ """Number of packages registered for testing by this class."""
362
+
363
+ def _test_no_packages_registered_for_import_testing(self) -> None:
364
+ """Test when no packages have been registered.
365
+
366
+ Without this, if no packages have been listed no tests will be
367
+ registered and the test system will not report on anything. This
368
+ test fails and reports why.
369
+ """
370
+ raise AssertionError("No packages registered with import test. Was the PACKAGES property set?")
371
+
372
+ def __init_subclass__(cls, **kwargs: Any) -> None:
373
+ """Create the test methods based on the content of the ``PACKAGES``
374
+ class property.
375
+ """
376
+ super().__init_subclass__(**kwargs)
377
+
378
+ for mod in cls.PACKAGES:
379
+ test_name = "test_import_" + mod.replace(".", "_")
380
+
381
+ def test_import(*args: Any, mod=mod) -> None:
382
+ self = args[0]
383
+ self.assertImport(mod)
384
+
385
+ test_import.__name__ = test_name
386
+ setattr(cls, test_name, test_import)
387
+ cls._n_registered += 1
388
+
389
+ # If there are no packages listed that is likely a mistake and
390
+ # so register a failing test.
391
+ if cls._n_registered == 0:
392
+ cls.test_no_packages_registered = cls._test_no_packages_registered_for_import_testing
393
+
394
+ def assertImport(self, root_pkg):
395
+ for file in resources.files(root_pkg).iterdir():
396
+ file = file.name
397
+ # When support for python 3.9 is dropped, this could be updated to
398
+ # use match case construct.
399
+ if not file.endswith(".py"):
400
+ continue
401
+ if file.startswith("__"):
402
+ continue
403
+ if file in self.SKIP_FILES.get(root_pkg, ()):
404
+ continue
405
+ root, _ = os.path.splitext(file)
406
+ module_name = f"{root_pkg}.{root}"
407
+ with self.subTest(module=module_name):
408
+ try:
409
+ doImport(module_name)
410
+ except ImportError as e:
411
+ raise AssertionError(f"Error importing module {module_name}: {e}") from e
412
+
413
+
322
414
  @contextlib.contextmanager
323
415
  def getTempFilePath(ext: str, expectOutput: bool = True) -> Iterator[str]:
324
416
  """Return a path suitable for a temporary file and try to delete the
325
- file on success
417
+ file on success.
326
418
 
327
419
  If the with block completes successfully then the file is deleted,
328
420
  if possible; failure results in a printed warning.
@@ -342,11 +434,11 @@ def getTempFilePath(ext: str, expectOutput: bool = True) -> Iterator[str]:
342
434
  If `False`, a file should not be present when the context manager
343
435
  exits.
344
436
 
345
- Returns
346
- -------
437
+ Yields
438
+ ------
347
439
  path : `str`
348
440
  Path for a temporary file. The path is a combination of the caller's
349
- file path and the name of the top-level function
441
+ file path and the name of the top-level function.
350
442
 
351
443
  Examples
352
444
  --------
@@ -355,6 +447,8 @@ def getTempFilePath(ext: str, expectOutput: bool = True) -> Iterator[str]:
355
447
  # file tests/testFoo.py
356
448
  import unittest
357
449
  import lsst.utils.tests
450
+
451
+
358
452
  class FooTestCase(unittest.TestCase):
359
453
  def testBasics(self):
360
454
  self.runTest()
@@ -368,6 +462,8 @@ def getTempFilePath(ext: str, expectOutput: bool = True) -> Iterator[str]:
368
462
  # at the end of this "with" block the path tmpFile will be
369
463
  # deleted, but only if the file exists and the "with"
370
464
  # block terminated normally (rather than with an exception)
465
+
466
+
371
467
  ...
372
468
  """
373
469
  stack = inspect.stack()
@@ -387,29 +483,31 @@ def getTempFilePath(ext: str, expectOutput: bool = True) -> Iterator[str]:
387
483
  callerFileName = os.path.splitext(callerFileNameWithExt)[0]
388
484
  outDir = os.path.join(callerDir, ".tests")
389
485
  if not os.path.isdir(outDir):
390
- outDir = ""
391
- prefix = "%s_%s-" % (callerFileName, callerFuncName)
486
+ # No .tests directory implies we are not running with sconsUtils.
487
+ # Need to use the current working directory, the callerDir, or
488
+ # /tmp equivalent. If cwd is used if must be as an absolute path
489
+ # in case the test code changes cwd.
490
+ outDir = os.path.abspath(os.path.curdir)
491
+ prefix = f"{callerFileName}_{callerFuncName}-"
392
492
  outPath = tempfile.mktemp(dir=outDir, suffix=ext, prefix=prefix)
393
493
  if os.path.exists(outPath):
394
494
  # There should not be a file there given the randomizer. Warn and
395
495
  # remove.
396
496
  # Use stacklevel 3 so that the warning is reported from the end of the
397
497
  # with block
398
- warnings.warn("Unexpectedly found pre-existing tempfile named %r" % (outPath,), stacklevel=3)
399
- try:
498
+ warnings.warn(f"Unexpectedly found pre-existing tempfile named {outPath!r}", stacklevel=3)
499
+ with contextlib.suppress(OSError):
400
500
  os.remove(outPath)
401
- except OSError:
402
- pass
403
501
 
404
502
  yield outPath
405
503
 
406
504
  fileExists = os.path.exists(outPath)
407
505
  if expectOutput:
408
506
  if not fileExists:
409
- raise RuntimeError("Temp file expected named {} but none found".format(outPath))
507
+ raise RuntimeError(f"Temp file expected named {outPath} but none found")
410
508
  else:
411
509
  if fileExists:
412
- raise RuntimeError("Unexpectedly discovered temp file named {}".format(outPath))
510
+ raise RuntimeError(f"Unexpectedly discovered temp file named {outPath}")
413
511
  # Try to clean up the file regardless
414
512
  if fileExists:
415
513
  try:
@@ -417,7 +515,7 @@ def getTempFilePath(ext: str, expectOutput: bool = True) -> Iterator[str]:
417
515
  except OSError as e:
418
516
  # Use stacklevel 3 so that the warning is reported from the end of
419
517
  # the with block.
420
- warnings.warn("Warning: could not remove file %r: %s" % (outPath, e), stacklevel=3)
518
+ warnings.warn(f"Warning: could not remove file {outPath!r}: {e}", stacklevel=3)
421
519
 
422
520
 
423
521
  class TestCase(unittest.TestCase):
@@ -429,13 +527,23 @@ class TestCase(unittest.TestCase):
429
527
  def inTestCase(func: Callable) -> Callable:
430
528
  """Add a free function to our custom TestCase class, while
431
529
  also making it available as a free function.
530
+
531
+ Parameters
532
+ ----------
533
+ func : `~collections.abc.Callable`
534
+ Function to be added to `unittest.TestCase` class.
535
+
536
+ Returns
537
+ -------
538
+ func : `~collections.abc.Callable`
539
+ The given function.
432
540
  """
433
541
  setattr(TestCase, func.__name__, func)
434
542
  return func
435
543
 
436
544
 
437
545
  def debugger(*exceptions):
438
- """Enter the debugger when there's an uncaught exception
546
+ """Enter the debugger when there's an uncaught exception.
439
547
 
440
548
  To use, just slap a ``@debugger()`` on your function.
441
549
 
@@ -448,6 +556,12 @@ def debugger(*exceptions):
448
556
  Code provided by "Rosh Oxymoron" on StackOverflow:
449
557
  http://stackoverflow.com/questions/4398967/python-unit-testing-automatically-running-the-debugger-when-a-test-fails
450
558
 
559
+ Parameters
560
+ ----------
561
+ *exceptions : `Exception`
562
+ Specific exception classes to catch. Default is to catch
563
+ `AssertionError`.
564
+
451
565
  Notes
452
566
  -----
453
567
  Consider using ``pytest --pdb`` instead of this decorator.
@@ -474,22 +588,22 @@ def debugger(*exceptions):
474
588
  def plotImageDiff(
475
589
  lhs: numpy.ndarray,
476
590
  rhs: numpy.ndarray,
477
- bad: Optional[numpy.ndarray] = None,
478
- diff: Optional[numpy.ndarray] = None,
479
- plotFileName: Optional[str] = None,
591
+ bad: numpy.ndarray | None = None,
592
+ diff: numpy.ndarray | None = None,
593
+ plotFileName: str | None = None,
480
594
  ) -> None:
481
595
  """Plot the comparison of two 2-d NumPy arrays.
482
596
 
483
597
  Parameters
484
598
  ----------
485
599
  lhs : `numpy.ndarray`
486
- LHS values to compare; a 2-d NumPy array
600
+ LHS values to compare; a 2-d NumPy array.
487
601
  rhs : `numpy.ndarray`
488
- RHS values to compare; a 2-d NumPy array
602
+ RHS values to compare; a 2-d NumPy array.
489
603
  bad : `numpy.ndarray`
490
- A 2-d boolean NumPy array of values to emphasize in the plots
604
+ A 2-d boolean NumPy array of values to emphasize in the plots.
491
605
  diff : `numpy.ndarray`
492
- difference array; a 2-d NumPy array, or None to show lhs-rhs
606
+ Difference array; a 2-d NumPy array, or None to show lhs-rhs.
493
607
  plotFileName : `str`
494
608
  Filename to save the plot to. If None, the plot will be displayed in
495
609
  a window.
@@ -500,11 +614,20 @@ def plotImageDiff(
500
614
  wrapped in a try/except block within packages that do not depend on
501
615
  `matplotlib` (including `~lsst.utils`).
502
616
  """
503
- from matplotlib import pyplot
617
+ if plotFileName is None:
618
+ # We need to create an interactive plot with pyplot.
619
+ from matplotlib import pyplot
620
+
621
+ fig = pyplot.figure()
622
+ else:
623
+ # We can create a non-interactive figure.
624
+ from .plotting import make_figure
625
+
626
+ fig = make_figure()
504
627
 
505
628
  if diff is None:
506
629
  diff = lhs - rhs
507
- pyplot.figure()
630
+
508
631
  if bad is not None:
509
632
  # make an rgba image that's red and transparent where not bad
510
633
  badImage = numpy.zeros(bad.shape + (4,), dtype=numpy.uint8)
@@ -517,29 +640,29 @@ def plotImageDiff(
517
640
  vmin2 = numpy.min(diff)
518
641
  vmax2 = numpy.max(diff)
519
642
  for n, (image, title) in enumerate([(lhs, "lhs"), (rhs, "rhs"), (diff, "diff")]):
520
- pyplot.subplot(2, 3, n + 1)
521
- im1 = pyplot.imshow(
643
+ ax = fig.add_subplot(2, 3, n + 1)
644
+ im1 = ax.imshow(
522
645
  image, cmap=pyplot.cm.gray, interpolation="nearest", origin="lower", vmin=vmin1, vmax=vmax1
523
646
  )
524
647
  if bad is not None:
525
- pyplot.imshow(badImage, alpha=0.2, interpolation="nearest", origin="lower")
526
- pyplot.axis("off")
527
- pyplot.title(title)
528
- pyplot.subplot(2, 3, n + 4)
529
- im2 = pyplot.imshow(
648
+ ax.imshow(badImage, alpha=0.2, interpolation="nearest", origin="lower")
649
+ ax.axis("off")
650
+ ax.set_title(title)
651
+ ax = fig.add_subplot(2, 3, n + 4)
652
+ im2 = ax.imshow(
530
653
  image, cmap=pyplot.cm.gray, interpolation="nearest", origin="lower", vmin=vmin2, vmax=vmax2
531
654
  )
532
655
  if bad is not None:
533
- pyplot.imshow(badImage, alpha=0.2, interpolation="nearest", origin="lower")
534
- pyplot.axis("off")
535
- pyplot.title(title)
536
- pyplot.subplots_adjust(left=0.05, bottom=0.05, top=0.92, right=0.75, wspace=0.05, hspace=0.05)
537
- cax1 = pyplot.axes([0.8, 0.55, 0.05, 0.4])
538
- pyplot.colorbar(im1, cax=cax1)
539
- cax2 = pyplot.axes([0.8, 0.05, 0.05, 0.4])
540
- pyplot.colorbar(im2, cax=cax2)
656
+ ax.imshow(badImage, alpha=0.2, interpolation="nearest", origin="lower")
657
+ ax.axis("off")
658
+ ax.set_title(title)
659
+ fig.subplots_adjust(left=0.05, bottom=0.05, top=0.92, right=0.75, wspace=0.05, hspace=0.05)
660
+ cax1 = fig.add_subplot([0.8, 0.55, 0.05, 0.4])
661
+ fig.colorbar(im1, cax=cax1)
662
+ cax2 = fig.add_subplot([0.8, 0.05, 0.05, 0.4])
663
+ fig.colorbar(im2, cax=cax2)
541
664
  if plotFileName:
542
- pyplot.savefig(plotFileName)
665
+ fig.savefig(plotFileName)
543
666
  else:
544
667
  pyplot.show()
545
668
 
@@ -547,16 +670,16 @@ def plotImageDiff(
547
670
  @inTestCase
548
671
  def assertFloatsAlmostEqual(
549
672
  testCase: unittest.TestCase,
550
- lhs: Union[float, numpy.ndarray],
551
- rhs: Union[float, numpy.ndarray],
552
- rtol: Optional[float] = sys.float_info.epsilon,
553
- atol: Optional[float] = sys.float_info.epsilon,
554
- relTo: Optional[float] = None,
673
+ lhs: float | numpy.ndarray,
674
+ rhs: float | numpy.ndarray,
675
+ rtol: float | None = sys.float_info.epsilon,
676
+ atol: float | None = sys.float_info.epsilon,
677
+ relTo: float | None = None,
555
678
  printFailures: bool = True,
556
679
  plotOnFailure: bool = False,
557
- plotFileName: Optional[str] = None,
680
+ plotFileName: str | None = None,
558
681
  invert: bool = False,
559
- msg: Optional[str] = None,
682
+ msg: str | None = None,
560
683
  ignoreNaNs: bool = False,
561
684
  ) -> None:
562
685
  """Highly-configurable floating point comparisons for scalars and arrays.
@@ -673,19 +796,16 @@ def assertFloatsAlmostEqual(
673
796
  if failed:
674
797
  if numpy.isscalar(bad):
675
798
  if rtol is None:
676
- errMsg = ["%s %s %s; diff=%s with atol=%s" % (lhs, cmpStr, rhs, absDiff, atol)]
799
+ errMsg = [f"{lhs} {cmpStr} {rhs}; diff={absDiff} with atol={atol}"]
677
800
  elif atol is None:
678
- errMsg = [
679
- "%s %s %s; diff=%s/%s=%s with rtol=%s"
680
- % (lhs, cmpStr, rhs, absDiff, relTo, absDiff / relTo, rtol)
681
- ]
801
+ errMsg = [f"{lhs} {cmpStr} {rhs}; diff={absDiff}/{relTo}={absDiff / relTo} with rtol={rtol}"]
682
802
  else:
683
803
  errMsg = [
684
- "%s %s %s; diff=%s/%s=%s with rtol=%s, atol=%s"
685
- % (lhs, cmpStr, rhs, absDiff, relTo, absDiff / relTo, rtol, atol)
804
+ f"{lhs} {cmpStr} {rhs}; diff={absDiff}/{relTo}={absDiff / relTo} "
805
+ f"with rtol={rtol}, atol={atol}"
686
806
  ]
687
807
  else:
688
- errMsg = ["%d/%d elements %s with rtol=%s, atol=%s" % (bad.sum(), bad.size, failStr, rtol, atol)]
808
+ errMsg = [f"{bad.sum()}/{bad.size} elements {failStr} with rtol={rtol}, atol={atol}"]
689
809
  if plotOnFailure:
690
810
  if len(lhs.shape) != 2 or len(rhs.shape) != 2:
691
811
  raise ValueError("plotOnFailure is only valid for 2-d arrays")
@@ -705,10 +825,10 @@ def assertFloatsAlmostEqual(
705
825
  rhs = numpy.ones(bad.shape, dtype=float) * rhs
706
826
  if rtol is None:
707
827
  for a, b, diff in zip(lhs[bad], rhs[bad], absDiff[bad]):
708
- errMsg.append("%s %s %s (diff=%s)" % (a, cmpStr, b, diff))
828
+ errMsg.append(f"{a} {cmpStr} {b} (diff={diff})")
709
829
  else:
710
830
  for a, b, diff, rel in zip(lhs[bad], rhs[bad], absDiff[bad], relTo[bad]):
711
- errMsg.append("%s %s %s (diff=%s/%s=%s)" % (a, cmpStr, b, diff, rel, diff / rel))
831
+ errMsg.append(f"{a} {cmpStr} {b} (diff={diff}/{rel}={diff / rel})")
712
832
 
713
833
  if msg is not None:
714
834
  errMsg.append(msg)
@@ -718,8 +838,8 @@ def assertFloatsAlmostEqual(
718
838
  @inTestCase
719
839
  def assertFloatsNotEqual(
720
840
  testCase: unittest.TestCase,
721
- lhs: Union[float, numpy.ndarray],
722
- rhs: Union[float, numpy.ndarray],
841
+ lhs: float | numpy.ndarray,
842
+ rhs: float | numpy.ndarray,
723
843
  **kwds: Any,
724
844
  ) -> None:
725
845
  """Fail a test if the given floating point values are equal to within the
@@ -738,6 +858,8 @@ def assertFloatsNotEqual(
738
858
  rhs : scalar or array-like
739
859
  RHS value(s) to compare; may be a scalar or array-like of any
740
860
  dimension.
861
+ **kwds : `~typing.Any`
862
+ Keyword parameters forwarded to `assertFloatsAlmostEqual`.
741
863
 
742
864
  Raises
743
865
  ------
@@ -750,8 +872,8 @@ def assertFloatsNotEqual(
750
872
  @inTestCase
751
873
  def assertFloatsEqual(
752
874
  testCase: unittest.TestCase,
753
- lhs: Union[float, numpy.ndarray],
754
- rhs: Union[float, numpy.ndarray],
875
+ lhs: float | numpy.ndarray,
876
+ rhs: float | numpy.ndarray,
755
877
  **kwargs: Any,
756
878
  ) -> None:
757
879
  """
@@ -770,6 +892,8 @@ def assertFloatsEqual(
770
892
  rhs : scalar or array-like
771
893
  RHS value(s) to compare; may be a scalar or array-like of any
772
894
  dimension.
895
+ **kwargs : `~typing.Any`
896
+ Keyword parameters forwarded to `assertFloatsAlmostEqual`.
773
897
 
774
898
  Raises
775
899
  ------
@@ -779,7 +903,7 @@ def assertFloatsEqual(
779
903
  return assertFloatsAlmostEqual(testCase, lhs, rhs, rtol=0, atol=0, **kwargs)
780
904
 
781
905
 
782
- def _settingsIterator(settings: Dict[str, Sequence[Any]]) -> Iterator[Dict[str, Any]]:
906
+ def _settingsIterator(settings: dict[str, Sequence[Any]]) -> Iterator[dict[str, Any]]:
783
907
  """Return an iterator for the provided test settings
784
908
 
785
909
  Parameters
@@ -813,7 +937,7 @@ def _settingsIterator(settings: Dict[str, Sequence[Any]]) -> Iterator[Dict[str,
813
937
 
814
938
 
815
939
  def classParameters(**settings: Sequence[Any]) -> Callable:
816
- """Class decorator for generating unit tests
940
+ """Class decorator for generating unit tests.
817
941
 
818
942
  This decorator generates classes with class variables according to the
819
943
  supplied ``settings``.
@@ -829,8 +953,7 @@ def classParameters(**settings: Sequence[Any]) -> Callable:
829
953
  ::
830
954
 
831
955
  @classParameters(foo=[1, 2], bar=[3, 4])
832
- class MyTestCase(unittest.TestCase):
833
- ...
956
+ class MyTestCase(unittest.TestCase): ...
834
957
 
835
958
  will generate two classes, as if you wrote::
836
959
 
@@ -839,6 +962,7 @@ def classParameters(**settings: Sequence[Any]) -> Callable:
839
962
  bar = 3
840
963
  ...
841
964
 
965
+
842
966
  class MyTestCase_2_4(unittest.TestCase):
843
967
  foo = 2
844
968
  bar = 4
@@ -847,7 +971,7 @@ def classParameters(**settings: Sequence[Any]) -> Callable:
847
971
  Note that the values are embedded in the class name.
848
972
  """
849
973
 
850
- def decorator(cls: Type) -> None:
974
+ def decorator(cls: type) -> None:
851
975
  module = sys.modules[cls.__module__].__dict__
852
976
  for params in _settingsIterator(settings):
853
977
  name = f"{cls.__name__}_{'_'.join(str(vv) for vv in params.values())}"
@@ -875,8 +999,7 @@ def methodParameters(**settings: Sequence[Any]) -> Callable:
875
999
  .. code-block:: python
876
1000
 
877
1001
  @methodParameters(foo=[1, 2], bar=[3, 4])
878
- def testSomething(self, foo, bar):
879
- ...
1002
+ def testSomething(self, foo, bar): ...
880
1003
 
881
1004
  will run:
882
1005
 
@@ -891,7 +1014,7 @@ def methodParameters(**settings: Sequence[Any]) -> Callable:
891
1014
  def wrapper(self: unittest.TestCase, *args: Any, **kwargs: Any) -> None:
892
1015
  for params in _settingsIterator(settings):
893
1016
  kwargs.update(params)
894
- with self.subTest(**params):
1017
+ with self.subTest(**{k: repr(v) for k, v in params.items()}):
895
1018
  func(self, *args, **kwargs)
896
1019
 
897
1020
  return wrapper
@@ -900,7 +1023,7 @@ def methodParameters(**settings: Sequence[Any]) -> Callable:
900
1023
 
901
1024
 
902
1025
  def _cartesianProduct(settings: Mapping[str, Sequence[Any]]) -> Mapping[str, Sequence[Any]]:
903
- """Return the cartesian product of the settings
1026
+ """Return the cartesian product of the settings.
904
1027
 
905
1028
  Parameters
906
1029
  ----------
@@ -925,7 +1048,7 @@ def _cartesianProduct(settings: Mapping[str, Sequence[Any]]) -> Mapping[str, Seq
925
1048
 
926
1049
  {"foo": [1, 1, 2, 2], "bar": ["black", "white", "black", "white"]}
927
1050
  """
928
- product: Dict[str, List[Any]] = {kk: [] for kk in settings}
1051
+ product: dict[str, list[Any]] = {kk: [] for kk in settings}
929
1052
  for values in itertools.product(*settings.values()):
930
1053
  for kk, vv in zip(settings.keys(), values):
931
1054
  product[kk].append(vv)
@@ -933,7 +1056,7 @@ def _cartesianProduct(settings: Mapping[str, Sequence[Any]]) -> Mapping[str, Seq
933
1056
 
934
1057
 
935
1058
  def classParametersProduct(**settings: Sequence[Any]) -> Callable:
936
- """Class decorator for generating unit tests
1059
+ """Class decorator for generating unit tests.
937
1060
 
938
1061
  This decorator generates classes with class variables according to the
939
1062
  cartesian product of the supplied ``settings``.
@@ -949,8 +1072,7 @@ def classParametersProduct(**settings: Sequence[Any]) -> Callable:
949
1072
  .. code-block:: python
950
1073
 
951
1074
  @classParametersProduct(foo=[1, 2], bar=[3, 4])
952
- class MyTestCase(unittest.TestCase):
953
- ...
1075
+ class MyTestCase(unittest.TestCase): ...
954
1076
 
955
1077
  will generate four classes, as if you wrote::
956
1078
 
@@ -961,16 +1083,19 @@ def classParametersProduct(**settings: Sequence[Any]) -> Callable:
961
1083
  bar = 3
962
1084
  ...
963
1085
 
1086
+
964
1087
  class MyTestCase_1_4(unittest.TestCase):
965
1088
  foo = 1
966
1089
  bar = 4
967
1090
  ...
968
1091
 
1092
+
969
1093
  class MyTestCase_2_3(unittest.TestCase):
970
1094
  foo = 2
971
1095
  bar = 3
972
1096
  ...
973
1097
 
1098
+
974
1099
  class MyTestCase_2_4(unittest.TestCase):
975
1100
  foo = 2
976
1101
  bar = 4
@@ -993,9 +1118,8 @@ def methodParametersProduct(**settings: Sequence[Any]) -> Callable:
993
1118
  **settings : `dict` (`str`: iterable)
994
1119
  The parameter combinations to test. Each should be an iterable.
995
1120
 
996
- Example
997
- -------
998
-
1121
+ Examples
1122
+ --------
999
1123
  @methodParametersProduct(foo=[1, 2], bar=["black", "white"])
1000
1124
  def testSomething(self, foo, bar):
1001
1125
  ...
@@ -1016,6 +1140,11 @@ def temporaryDirectory() -> Iterator[str]:
1016
1140
 
1017
1141
  The difference from `tempfile.TemporaryDirectory` is that this ignores
1018
1142
  errors when deleting a directory, which may happen with some filesystems.
1143
+
1144
+ Yields
1145
+ ------
1146
+ `str`
1147
+ Name of the temporary directory.
1019
1148
  """
1020
1149
  tmpdir = tempfile.mkdtemp()
1021
1150
  yield tmpdir