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.
- lsst/utils/__init__.py +0 -3
- lsst/utils/_packaging.py +2 -0
- lsst/utils/argparsing.py +79 -0
- lsst/utils/classes.py +27 -9
- lsst/utils/db_auth.py +339 -0
- lsst/utils/deprecated.py +10 -7
- lsst/utils/doImport.py +8 -9
- lsst/utils/inheritDoc.py +34 -6
- lsst/utils/introspection.py +285 -19
- lsst/utils/iteration.py +193 -7
- lsst/utils/logging.py +155 -105
- lsst/utils/packages.py +324 -82
- lsst/utils/plotting/__init__.py +15 -0
- lsst/utils/plotting/figures.py +159 -0
- lsst/utils/plotting/limits.py +155 -0
- lsst/utils/plotting/publication_plots.py +184 -0
- lsst/utils/plotting/rubin.mplstyle +46 -0
- lsst/utils/tests.py +231 -102
- lsst/utils/threads.py +9 -3
- lsst/utils/timer.py +207 -110
- lsst/utils/usage.py +6 -6
- lsst/utils/version.py +1 -1
- lsst/utils/wrappers.py +74 -29
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/METADATA +19 -15
- lsst_utils-29.2025.4800.dist-info/RECORD +32 -0
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/WHEEL +1 -1
- lsst/utils/_forwarded.py +0 -28
- lsst/utils/backtrace/__init__.py +0 -33
- lsst/utils/ellipsis.py +0 -54
- lsst/utils/get_caller_name.py +0 -45
- lsst_utils-25.2023.600.dist-info/RECORD +0 -29
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/COPYRIGHT +0 -0
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info/licenses}/LICENSE +0 -0
- {lsst_utils-25.2023.600.dist-info → lsst_utils-29.2025.4800.dist-info}/top_level.txt +0 -0
- {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
|
-
"
|
|
18
|
+
"ImportTestCase",
|
|
19
|
+
"MemoryTestCase",
|
|
19
20
|
"TestCase",
|
|
20
21
|
"assertFloatsAlmostEqual",
|
|
21
|
-
"assertFloatsNotEqual",
|
|
22
22
|
"assertFloatsEqual",
|
|
23
|
-
"
|
|
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
|
|
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() ->
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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:
|
|
151
|
-
self.fail("Failed to close
|
|
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:
|
|
183
|
-
args:
|
|
184
|
-
msg:
|
|
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 {}..."
|
|
236
|
+
print(f"Running executable '{executable}' with {argstr}...")
|
|
220
237
|
if not os.path.exists(executable):
|
|
221
|
-
self.skipTest("Executable {} is unexpectedly missing"
|
|
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 '{}': {
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
|
399
|
-
|
|
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"
|
|
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 {}"
|
|
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
|
|
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:
|
|
478
|
-
diff:
|
|
479
|
-
plotFileName:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
im1 =
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
im2 =
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
cax1 =
|
|
538
|
-
|
|
539
|
-
cax2 =
|
|
540
|
-
|
|
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
|
-
|
|
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:
|
|
551
|
-
rhs:
|
|
552
|
-
rtol:
|
|
553
|
-
atol:
|
|
554
|
-
relTo:
|
|
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:
|
|
680
|
+
plotFileName: str | None = None,
|
|
558
681
|
invert: bool = False,
|
|
559
|
-
msg:
|
|
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 = ["
|
|
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
|
-
"
|
|
685
|
-
|
|
804
|
+
f"{lhs} {cmpStr} {rhs}; diff={absDiff}/{relTo}={absDiff / relTo} "
|
|
805
|
+
f"with rtol={rtol}, atol={atol}"
|
|
686
806
|
]
|
|
687
807
|
else:
|
|
688
|
-
errMsg = ["
|
|
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("
|
|
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("
|
|
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:
|
|
722
|
-
rhs:
|
|
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:
|
|
754
|
-
rhs:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|