punit 1.7.2__tar.gz → 1.8.1__tar.gz
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.
- {punit-1.7.2/src/pUnit.egg-info → punit-1.8.1}/PKG-INFO +1 -1
- {punit-1.7.2 → punit-1.8.1}/pyproject.toml +1 -1
- {punit-1.7.2 → punit-1.8.1/src/pUnit.egg-info}/PKG-INFO +1 -1
- {punit-1.7.2 → punit-1.8.1}/src/pUnit.egg-info/SOURCES.txt +2 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/TestResult.py +40 -4
- {punit-1.7.2 → punit-1.8.1}/src/punit/__init__.py +7 -4
- {punit-1.7.2 → punit-1.8.1}/src/punit/__main__.py +14 -14
- punit-1.8.1/src/punit/assertions/__init__.py +28 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/assertions/collections.py +70 -11
- punit-1.8.1/src/punit/assertions/exceptions.py +87 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/assertions/numeric.py +103 -19
- {punit-1.7.2 → punit-1.8.1}/src/punit/assertions/strings.py +54 -6
- {punit-1.7.2 → punit-1.8.1}/src/punit/cli.py +26 -7
- {punit-1.7.2 → punit-1.8.1}/src/punit/facts/Fact.py +49 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/facts/FactManager.py +8 -8
- punit-1.8.1/src/punit/facts/__init__.py +35 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/filters/Filter.py +13 -2
- {punit-1.7.2 → punit-1.8.1}/src/punit/filters/FilterManager.py +6 -6
- {punit-1.7.2 → punit-1.8.1}/src/punit/reports/HtmlReportGenerator.py +8 -1
- {punit-1.7.2 → punit-1.8.1}/src/punit/reports/JUnitReportGenerator.py +9 -4
- {punit-1.7.2 → punit-1.8.1}/src/punit/reports/JsonReportGenerator.py +5 -1
- punit-1.8.1/src/punit/results/__init__.py +22 -0
- punit-1.8.1/src/punit/results/fails.py +87 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/runner.py +37 -8
- {punit-1.7.2 → punit-1.8.1}/src/punit/setups/Setup.py +66 -2
- {punit-1.7.2 → punit-1.8.1}/src/punit/setups/SetupManager.py +2 -2
- punit-1.8.1/src/punit/setups/__init__.py +43 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/teardowns/Teardown.py +58 -2
- {punit-1.7.2 → punit-1.8.1}/src/punit/teardowns/TeardownManager.py +2 -2
- punit-1.8.1/src/punit/teardowns/__init__.py +42 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/theories/Theory.py +79 -1
- {punit-1.7.2 → punit-1.8.1}/src/punit/theories/TheoryManager.py +3 -3
- punit-1.8.1/src/punit/theories/__init__.py +33 -0
- punit-1.8.1/src/punit/traits/Trait.py +88 -0
- punit-1.8.1/src/punit/traits/__init__.py +51 -0
- punit-1.7.2/src/punit/assertions/__init__.py +0 -12
- punit-1.7.2/src/punit/assertions/exceptions.py +0 -37
- punit-1.7.2/src/punit/facts/__init__.py +0 -11
- punit-1.7.2/src/punit/setups/__init__.py +0 -11
- punit-1.7.2/src/punit/teardowns/__init__.py +0 -11
- punit-1.7.2/src/punit/theories/__init__.py +0 -11
- punit-1.7.2/src/punit/traits/Trait.py +0 -32
- punit-1.7.2/src/punit/traits/__init__.py +0 -11
- {punit-1.7.2 → punit-1.8.1}/LICENSE +0 -0
- {punit-1.7.2 → punit-1.8.1}/README.md +0 -0
- {punit-1.7.2 → punit-1.8.1}/setup.cfg +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/pUnit.egg-info/dependency_links.txt +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/pUnit.egg-info/entry_points.txt +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/pUnit.egg-info/requires.txt +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/pUnit.egg-info/top_level.txt +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/discovery/TestModuleDiscovery.py +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/discovery/__init__.py +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/filters/__init__.py +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/metadata/CallableMetadata.py +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/metadata/__init__.py +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/py.typed +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/reports/__init__.py +0 -0
- {punit-1.7.2 → punit-1.8.1}/src/punit/traits/TraitManager.py +0 -0
|
@@ -32,6 +32,8 @@ src/punit/reports/HtmlReportGenerator.py
|
|
|
32
32
|
src/punit/reports/JUnitReportGenerator.py
|
|
33
33
|
src/punit/reports/JsonReportGenerator.py
|
|
34
34
|
src/punit/reports/__init__.py
|
|
35
|
+
src/punit/results/__init__.py
|
|
36
|
+
src/punit/results/fails.py
|
|
35
37
|
src/punit/setups/Setup.py
|
|
36
38
|
src/punit/setups/SetupManager.py
|
|
37
39
|
src/punit/setups/__init__.py
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import sys
|
|
5
|
-
from typing import Any,
|
|
5
|
+
from typing import Any, Optional, TextIO
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class TextIOCapture:
|
|
@@ -27,11 +27,28 @@ class TextIOCapture:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class TestResult:
|
|
30
|
+
"""
|
|
31
|
+
Accumulates the outcome of a single test execution.
|
|
32
|
+
|
|
33
|
+
Example
|
|
34
|
+
-------
|
|
35
|
+
|
|
36
|
+
Create and populate a result manually:
|
|
37
|
+
|
|
38
|
+
.. code-block:: python
|
|
39
|
+
|
|
40
|
+
result = TestResult()
|
|
41
|
+
result.module_name = 'tests.example'
|
|
42
|
+
result.test_name = 'test_something'
|
|
43
|
+
result.is_success = True
|
|
44
|
+
|
|
45
|
+
"""
|
|
30
46
|
|
|
31
47
|
__class_name: Optional[str]
|
|
32
48
|
__exception: Exception | None
|
|
33
49
|
__file_name: str | None
|
|
34
50
|
__host_name: str | None
|
|
51
|
+
__expected_failure_reason: str | None
|
|
35
52
|
__is_success: bool | None
|
|
36
53
|
__module_name: str | None
|
|
37
54
|
__package_name: str | None
|
|
@@ -47,6 +64,7 @@ class TestResult:
|
|
|
47
64
|
self.__exception = None
|
|
48
65
|
self.__file_name = None
|
|
49
66
|
self.__host_name = None
|
|
67
|
+
self.__expected_failure_reason = None
|
|
50
68
|
self.__is_success = None
|
|
51
69
|
self.__module_name = None
|
|
52
70
|
self.__package_name = None
|
|
@@ -69,6 +87,14 @@ class TestResult:
|
|
|
69
87
|
def class_name(self, value: Optional[str]) -> None:
|
|
70
88
|
self.__class_name = value
|
|
71
89
|
|
|
90
|
+
@property
|
|
91
|
+
def expected_failure_reason(self) -> str | None:
|
|
92
|
+
return self.__expected_failure_reason
|
|
93
|
+
|
|
94
|
+
@expected_failure_reason.setter
|
|
95
|
+
def expected_failure_reason(self, value: str | None) -> None:
|
|
96
|
+
self.__expected_failure_reason = value
|
|
97
|
+
|
|
72
98
|
@property
|
|
73
99
|
def exception(self) -> Exception | None:
|
|
74
100
|
return self.__exception
|
|
@@ -93,6 +119,16 @@ class TestResult:
|
|
|
93
119
|
def host_name(self, value: str) -> None:
|
|
94
120
|
self.__host_name = value
|
|
95
121
|
|
|
122
|
+
@property
|
|
123
|
+
def is_expected_failure(self) -> bool:
|
|
124
|
+
"""Deprecated: use ``expected_failure_reason is not None`` instead."""
|
|
125
|
+
return self.expected_failure_reason is not None
|
|
126
|
+
|
|
127
|
+
@is_expected_failure.setter
|
|
128
|
+
def is_expected_failure(self, value: bool) -> None:
|
|
129
|
+
# Deprecated no-op; consumers should set expected_failure_reason directly.
|
|
130
|
+
pass
|
|
131
|
+
|
|
96
132
|
@property
|
|
97
133
|
def is_success(self) -> bool:
|
|
98
134
|
return False if self.__is_success is None else self.__is_success
|
|
@@ -102,8 +138,8 @@ class TestResult:
|
|
|
102
138
|
self.__is_success = value
|
|
103
139
|
|
|
104
140
|
@property
|
|
105
|
-
def module_name(self) -> str:
|
|
106
|
-
return
|
|
141
|
+
def module_name(self) -> str | None:
|
|
142
|
+
return self.__module_name
|
|
107
143
|
|
|
108
144
|
@module_name.setter
|
|
109
145
|
def module_name(self, value: str) -> None:
|
|
@@ -175,7 +211,7 @@ class TestResult:
|
|
|
175
211
|
elif took >= 0.000000001:
|
|
176
212
|
return f'{(took*1000000):.3f}'.rstrip('0').rstrip('.') + 'μs'
|
|
177
213
|
else:
|
|
178
|
-
return f'{(took*
|
|
214
|
+
return f'{(took*1000000):.3f}'.rstrip('0').rstrip('.') + 'ns'
|
|
179
215
|
|
|
180
216
|
def capture_output(self, quiet: bool = False) -> None:
|
|
181
217
|
self.__stdout_capture = TextIOCapture(sys.stdout, quiet)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
"""pUnit -- a modernized unit-testing framework for Python."""
|
|
5
|
+
|
|
4
6
|
from .assertions import collections, exceptions, numeric, strings
|
|
5
7
|
from .facts import fact
|
|
8
|
+
from .results import fails
|
|
6
9
|
from .setups import Setup, setup
|
|
7
10
|
from .teardowns import Teardown, teardown
|
|
8
11
|
from .theories import theory, inlinedata
|
|
@@ -12,8 +15,8 @@ from .assertions.exceptions import raises
|
|
|
12
15
|
from .assertions.numeric import approx
|
|
13
16
|
|
|
14
17
|
|
|
15
|
-
__version__ = '1.
|
|
16
|
-
__commit__ = '
|
|
18
|
+
__version__ = '1.8.1'
|
|
19
|
+
__commit__ = 'faca395'
|
|
17
20
|
__all__ = [
|
|
18
21
|
'__version__', '__commit__',
|
|
19
22
|
'assertions',
|
|
@@ -22,9 +25,9 @@ __all__ = [
|
|
|
22
25
|
'numeric', 'approx',
|
|
23
26
|
'strings',
|
|
24
27
|
'fact',
|
|
25
|
-
'metadata',
|
|
26
28
|
'setup', 'Setup',
|
|
27
29
|
'teardown', 'Teardown',
|
|
28
30
|
'theory', 'inlinedata',
|
|
29
|
-
'trait'
|
|
31
|
+
'trait',
|
|
32
|
+
'fails',
|
|
30
33
|
]
|
|
@@ -16,7 +16,7 @@ from .teardowns.TeardownManager import TeardownManager
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
async def async_main() -> None:
|
|
19
|
-
|
|
19
|
+
start_time = time.time()
|
|
20
20
|
cli = CommandLineInterface.parse()
|
|
21
21
|
if cli.help: # pragma: no cover
|
|
22
22
|
cli.print_help()
|
|
@@ -26,27 +26,27 @@ async def async_main() -> None:
|
|
|
26
26
|
cli.print_version()
|
|
27
27
|
os.chdir(cli.workdir)
|
|
28
28
|
if cli.no_pathfix is not True:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if os.path.exists(
|
|
32
|
-
sys.path.append(
|
|
33
|
-
if
|
|
34
|
-
sys.path.append(
|
|
29
|
+
cwd_path = str(Path.cwd())
|
|
30
|
+
src_path = os.path.join(cwd_path, 'src')
|
|
31
|
+
if os.path.exists(src_path) and src_path not in sys.path:
|
|
32
|
+
sys.path.append(src_path)
|
|
33
|
+
if cwd_path not in sys.path:
|
|
34
|
+
sys.path.append(cwd_path)
|
|
35
35
|
test_module_discovery = TestModuleDiscovery(
|
|
36
36
|
os.path.join(cli.workdir, cli.test_package_name),
|
|
37
37
|
cli.includePatterns,
|
|
38
38
|
cli.excludePatterns,
|
|
39
39
|
cli)
|
|
40
40
|
test_module_discovery.discover()
|
|
41
|
-
|
|
42
|
-
results = await
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
test_runner = TestRunner(cli.test_package_name, test_module_discovery.filenames, cli)
|
|
42
|
+
results = await test_runner.run()
|
|
43
|
+
total_time = time.time() - start_time
|
|
44
|
+
failure_count = 0
|
|
45
45
|
for result in results:
|
|
46
46
|
if not result.is_success:
|
|
47
|
-
|
|
47
|
+
failure_count += 1 # pragma: no cover
|
|
48
48
|
if not cli.quiet:
|
|
49
|
-
print(f'Total: {len(results)}, Failures: {
|
|
49
|
+
print(f'Total: {len(results)}, Failures: {failure_count}, Took: {total_time:.3f}s')
|
|
50
50
|
if cli.reportFormat is not None: # pragma: no cover
|
|
51
51
|
report: str = ''
|
|
52
52
|
match cli.reportFormat:
|
|
@@ -74,7 +74,7 @@ async def async_main() -> None:
|
|
|
74
74
|
pass
|
|
75
75
|
|
|
76
76
|
if cli.no_exitcode is not True:
|
|
77
|
-
if
|
|
77
|
+
if failure_count:
|
|
78
78
|
# test failures trigger exit code 119 (for automation gating)
|
|
79
79
|
sys.exit(119)
|
|
80
80
|
if TeardownManager.instance().teardown_error_count > 0:
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Assertion helpers for pUnit tests.
|
|
5
|
+
|
|
6
|
+
With pUnit, assertions are typically performed using Python's ``assert`` statement.
|
|
7
|
+
This module provides several helper submodules that offer additional assertion
|
|
8
|
+
capabilities beyond the built-in ``assert`` keyword.
|
|
9
|
+
|
|
10
|
+
Sub-modules
|
|
11
|
+
-----------
|
|
12
|
+
|
|
13
|
+
* :py:mod:`punit.assertions.collections`; helpers for comparing sequences and collections.
|
|
14
|
+
* :py:mod:`punit.assertions.exceptions`; helpers for asserting raised exceptions.
|
|
15
|
+
* :py:mod:`punit.assertions.numeric`; helpers for approximate numeric comparisons.
|
|
16
|
+
* :py:mod:`punit.assertions.strings`; helpers for string comparisons and checks.
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from . import collections, exceptions, numeric, strings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
'collections',
|
|
25
|
+
'exceptions',
|
|
26
|
+
'numeric',
|
|
27
|
+
'strings'
|
|
28
|
+
]
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
"""Collection assertion helpers for comparing sequences and containers.
|
|
5
|
+
|
|
6
|
+
Provides utilities to check sequence equality, length bounds, and emptiness
|
|
7
|
+
beyond Python's built-in ``assert`` statement.
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
4
11
|
from typing import Any, Callable, Optional, Sequence, cast
|
|
5
12
|
|
|
6
13
|
|
|
@@ -10,11 +17,30 @@ def are_same(actual: Sequence[Any] | set[Any] | dict[Any, Any] | None, expected:
|
|
|
10
17
|
|
|
11
18
|
Can optionally specify *sort* so that differences in order do not cause failure.
|
|
12
19
|
|
|
13
|
-
:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
Args:
|
|
21
|
+
actual: The sequence to check.
|
|
22
|
+
expected: The sequence to compare against.
|
|
23
|
+
sort: Sort sequences before performing comparisons.
|
|
24
|
+
sort_function: Custom function to use when sorting.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
True if the sequences contain the same elements in the same order, False otherwise
|
|
28
|
+
|
|
29
|
+
Example
|
|
30
|
+
-------
|
|
31
|
+
|
|
32
|
+
.. code-block:: python
|
|
33
|
+
|
|
34
|
+
from punit import collections
|
|
35
|
+
|
|
36
|
+
a = [1, 2, 3]
|
|
37
|
+
b = [1, 2, 3]
|
|
38
|
+
c = [3, 2, 1]
|
|
39
|
+
|
|
40
|
+
assert collections.are_same(a, b)
|
|
41
|
+
assert not collections.are_same(b, c)
|
|
42
|
+
assert collections.are_same(None, None)
|
|
43
|
+
assert not collections.are_same(a, None)
|
|
18
44
|
"""
|
|
19
45
|
if actual is expected:
|
|
20
46
|
return True
|
|
@@ -52,12 +78,31 @@ def are_same(actual: Sequence[Any] | set[Any] | dict[Any, Any] | None, expected:
|
|
|
52
78
|
|
|
53
79
|
def has_length(actual: Sequence[Any] | set[Any] | dict[Any, Any] | None, min: Optional[int] = None, max: Optional[int] = None) -> bool:
|
|
54
80
|
"""
|
|
55
|
-
Check if actual value
|
|
81
|
+
Check if actual value's length falls within the inclusive range [min, max].
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
actual: The sequence (or collection) to check.
|
|
85
|
+
min: Inclusive lower bound on length (``len(actual) >= min``).
|
|
86
|
+
max: Inclusive upper bound on length (``len(actual) <= max``).
|
|
56
87
|
|
|
57
|
-
:
|
|
58
|
-
|
|
88
|
+
Returns:
|
|
89
|
+
True if the length satisfies the bounds, False otherwise.
|
|
90
|
+
When actual is None, returns True only when both bounds are effectively zero.
|
|
59
91
|
|
|
60
|
-
|
|
92
|
+
Example
|
|
93
|
+
-------
|
|
94
|
+
|
|
95
|
+
.. code-block:: python
|
|
96
|
+
|
|
97
|
+
from punit import collections
|
|
98
|
+
|
|
99
|
+
a = [1]
|
|
100
|
+
b = [1, 2]
|
|
101
|
+
|
|
102
|
+
assert collections.has_length(a, min=1)
|
|
103
|
+
assert collections.has_length(b, min=2)
|
|
104
|
+
assert collections.has_length([1, 2, 3], max=3)
|
|
105
|
+
assert not collections.has_length(a, min=2)
|
|
61
106
|
"""
|
|
62
107
|
if actual is None:
|
|
63
108
|
if ((min is None or min == 0) and (max is None or max == 0)):
|
|
@@ -74,8 +119,22 @@ def is_none_or_empty(actual: Sequence[Any] | set[Any] | dict[Any, Any] | None) -
|
|
|
74
119
|
"""
|
|
75
120
|
Check if actual value is None or empty.
|
|
76
121
|
|
|
77
|
-
:
|
|
78
|
-
|
|
122
|
+
Args:
|
|
123
|
+
actual: The sequence to check.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if the value is None or empty, False otherwise
|
|
127
|
+
|
|
128
|
+
Example
|
|
129
|
+
-------
|
|
130
|
+
|
|
131
|
+
.. code-block:: python
|
|
132
|
+
|
|
133
|
+
from punit import collections
|
|
134
|
+
|
|
135
|
+
assert not collections.is_none_or_empty([1, 2, 3])
|
|
136
|
+
assert collections.is_none_or_empty([])
|
|
137
|
+
assert collections.is_none_or_empty(None)
|
|
79
138
|
"""
|
|
80
139
|
if actual is None:
|
|
81
140
|
return True
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Exception assertion helpers for verifying raised exceptions.
|
|
5
|
+
|
|
6
|
+
Provides the ``raises`` class which can verify that a callable raises an
|
|
7
|
+
exception of the expected type using either Python 3.11+ generic syntax or
|
|
8
|
+
an explicit ``expect`` keyword argument.
|
|
9
|
+
|
|
10
|
+
Example
|
|
11
|
+
-------
|
|
12
|
+
|
|
13
|
+
.. code-block:: python
|
|
14
|
+
|
|
15
|
+
from punit import exceptions
|
|
16
|
+
|
|
17
|
+
class MyException(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
def raises_exception():
|
|
21
|
+
raise MyException()
|
|
22
|
+
|
|
23
|
+
assert exceptions.raises[MyException](raises_exception)
|
|
24
|
+
assert exceptions.raises[MyException](raises_exception, exact=True)
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from typing import Any, Callable, Generic, Optional, TypeVar, cast, get_args
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
TError = TypeVar('TError', bound=Exception)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class raises(Generic[TError]):
|
|
35
|
+
"""Assert that a callable raises an expected exception.
|
|
36
|
+
|
|
37
|
+
Use ``raises`` to verify that a function, method, or lambda expression
|
|
38
|
+
raises an exception of the expected type. Two syntaxes are supported:
|
|
39
|
+
|
|
40
|
+
* Python >=3.11 generic syntax: ``raises[MyException](action)``
|
|
41
|
+
* Explicit expect argument (for <=3.11): ``raises(action, expect=MyException)``
|
|
42
|
+
|
|
43
|
+
The ``exact`` parameter controls whether the raised exception must be an exact
|
|
44
|
+
type match (not a subclass) of the expected type.
|
|
45
|
+
|
|
46
|
+
Example
|
|
47
|
+
-------
|
|
48
|
+
|
|
49
|
+
.. code-block:: python
|
|
50
|
+
|
|
51
|
+
class MyException(Exception):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def raises_exception():
|
|
55
|
+
raise MyException()
|
|
56
|
+
|
|
57
|
+
assert raises[MyException](raises_exception)
|
|
58
|
+
assert not raises[ValueError](raises_exception)
|
|
59
|
+
assert raises[MyException](raises_exception, exact=True)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__action: Callable[[], Any]
|
|
63
|
+
__exact: bool
|
|
64
|
+
__expect: Optional[TError | type]
|
|
65
|
+
|
|
66
|
+
def __init__(self, action: Callable[[], Any], *, exact: bool = False, expect: Optional[TError | type] = None) -> None:
|
|
67
|
+
self.__action = action
|
|
68
|
+
self.__exact = exact
|
|
69
|
+
self.__expect = expect
|
|
70
|
+
|
|
71
|
+
def __bool__(self) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
self.__action()
|
|
74
|
+
except BaseException as ex:
|
|
75
|
+
expected = self.__expect
|
|
76
|
+
if expected is None and hasattr(self, '__orig_class__') is not None:
|
|
77
|
+
expected = get_args(getattr(self, '__orig_class__'))[0]
|
|
78
|
+
if self.__exact:
|
|
79
|
+
return type(ex) is expected
|
|
80
|
+
elif expected is not None:
|
|
81
|
+
return issubclass(type(ex), cast(Any, expected))
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
'raises'
|
|
87
|
+
]
|
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
"""Numeric assertion helpers for approximate comparisons and checks.
|
|
5
|
+
|
|
6
|
+
Provides the ``approx`` class (inspired by pytest.approx) for directional
|
|
7
|
+
tolerance-based numeric comparisons, plus standalone helpers like
|
|
8
|
+
``isclose``, ``isnan``, ``isinfinite``, and ``percentage``.
|
|
9
|
+
|
|
10
|
+
The ``approx`` class supports natural Python comparison syntax with
|
|
11
|
+
directional (one-sided) tolerance:
|
|
12
|
+
|
|
13
|
+
* ``x == approx(expected)``; approximately equal (bidirectional tolerance)
|
|
14
|
+
* ``x > approx(threshold)``; strictly greater than (tolerance extends below)
|
|
15
|
+
* ``x >= approx(threshold)``; at least the threshold (tolerance extends below)
|
|
16
|
+
* ``x < approx(threshold)``; strictly less than (tolerance extends above)
|
|
17
|
+
* ``x <= approx(threshold)``; at most the threshold (tolerance extends above)
|
|
18
|
+
|
|
19
|
+
Example
|
|
20
|
+
-------
|
|
21
|
+
|
|
22
|
+
.. code-block:: python
|
|
23
|
+
|
|
24
|
+
from punit.assertions.numeric import approx, isclose, isnan
|
|
25
|
+
|
|
26
|
+
assert 0.1 + 0.2 == approx(0.3)
|
|
27
|
+
assert isclose(1 + 2j, 1.0 + 2.0j)
|
|
28
|
+
assert not isclose(3, 3.000000001)
|
|
29
|
+
assert isnan(float('nan'))
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
|
|
4
33
|
from __future__ import annotations
|
|
5
34
|
|
|
6
35
|
from decimal import Decimal
|
|
@@ -38,7 +67,7 @@ def _real_expected(base: '_ApproxComparator') -> float:
|
|
|
38
67
|
"""
|
|
39
68
|
Return *base._expected* as float for real-only operations.
|
|
40
69
|
|
|
41
|
-
Raises ``TypeError`` when the expected value is complex
|
|
70
|
+
Raises ``TypeError`` when the expected value is complex; those
|
|
42
71
|
comparisons belong to ``isclose`` / ``_approximately_equal``.
|
|
43
72
|
"""
|
|
44
73
|
if isinstance(base._expected, complex):
|
|
@@ -61,21 +90,26 @@ def isclose(
|
|
|
61
90
|
complex inputs the real and imaginary parts are compared independently using the
|
|
62
91
|
same tolerances.
|
|
63
92
|
|
|
64
|
-
:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
Args:
|
|
94
|
+
a: First numeric value.
|
|
95
|
+
b: Second numeric value.
|
|
96
|
+
rel_tol: Relative tolerance (default 1e-9).
|
|
97
|
+
abs_tol: Absolute tolerance (default 0.0).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if ``|a - b| <= max(rel_tol * |b|, abs_tol)`` for real values, or
|
|
69
101
|
equivalent per-component comparison for complex.
|
|
70
102
|
|
|
71
|
-
|
|
103
|
+
Example
|
|
104
|
+
-------
|
|
105
|
+
|
|
106
|
+
.. code-block:: python
|
|
72
107
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
True
|
|
108
|
+
from punit.assertions.numeric import isclose
|
|
109
|
+
|
|
110
|
+
assert isclose(1 + 2j, 1.0 + 2.0j)
|
|
111
|
+
assert not isclose(3, 3.000000001)
|
|
112
|
+
assert isclose(3, 3.000000001, rel_tol=1e-6)
|
|
79
113
|
"""
|
|
80
114
|
if isinstance(a, complex) or isinstance(b, complex):
|
|
81
115
|
a_c = a if isinstance(a, complex) else complex(a, 0)
|
|
@@ -511,6 +545,22 @@ def isnan(value: Numeric) -> bool:
|
|
|
511
545
|
Check if value is NaN (Not a Number).
|
|
512
546
|
|
|
513
547
|
For complex inputs, returns True only if both the real and imaginary parts are NaN.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
value: The numeric value to check.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
True if value is NaN, False otherwise.
|
|
554
|
+
|
|
555
|
+
Example
|
|
556
|
+
-------
|
|
557
|
+
|
|
558
|
+
.. code-block:: python
|
|
559
|
+
|
|
560
|
+
from punit.assertions.numeric import isnan
|
|
561
|
+
|
|
562
|
+
assert isnan(float('nan'))
|
|
563
|
+
assert not isnan(0.0)
|
|
514
564
|
"""
|
|
515
565
|
if isinstance(value, complex):
|
|
516
566
|
return math.isnan(value.real) and math.isnan(value.imag)
|
|
@@ -522,6 +572,22 @@ def isinfinite(value: Numeric) -> bool:
|
|
|
522
572
|
Check if value is infinite (positive or negative).
|
|
523
573
|
|
|
524
574
|
For complex inputs, returns True only if either the real or imaginary part is infinite.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
value: The numeric value to check.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
True if value is infinite, False otherwise.
|
|
581
|
+
|
|
582
|
+
Example
|
|
583
|
+
-------
|
|
584
|
+
|
|
585
|
+
.. code-block:: python
|
|
586
|
+
|
|
587
|
+
from punit.assertions.numeric import isinfinite
|
|
588
|
+
|
|
589
|
+
assert isinfinite(float('inf'))
|
|
590
|
+
assert not isinfinite(0.0)
|
|
525
591
|
"""
|
|
526
592
|
if isinstance(value, complex):
|
|
527
593
|
return math.isinf(value.real) or math.isinf(value.imag)
|
|
@@ -536,16 +602,34 @@ def percentage(
|
|
|
536
602
|
"""
|
|
537
603
|
Calculate the percentage difference between two numeric values.
|
|
538
604
|
|
|
539
|
-
When relative_to_expected=True, uses value_b as reference
|
|
540
|
-
|
|
605
|
+
When relative_to_expected=True, uses value_b as reference::
|
|
606
|
+
|
|
607
|
+
abs(a - b) / |b| * 100
|
|
608
|
+
|
|
609
|
+
When relative_to_expected=False, uses symmetric form::
|
|
610
|
+
|
|
611
|
+
abs(a - b) / ((|a| + |b|) / 2) * 100
|
|
541
612
|
|
|
542
613
|
For complex inputs, magnitudes (``abs()``) are used consistently: the numerator is
|
|
543
614
|
the magnitude of the difference, and the denominator is the magnitude of the reference.
|
|
544
615
|
|
|
545
|
-
:
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
616
|
+
Args:
|
|
617
|
+
value_a: The first numeric value.
|
|
618
|
+
value_b: The second numeric value (reference when relative_to_expected=True).
|
|
619
|
+
relative_to_expected: If True, use value_b as reference (default).
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
The percentage difference as a float. Returns ``inf`` if both values are zero but differ, otherwise ``0.0``.
|
|
623
|
+
|
|
624
|
+
Example
|
|
625
|
+
-------
|
|
626
|
+
|
|
627
|
+
.. code-block:: python
|
|
628
|
+
|
|
629
|
+
from punit.assertions.numeric import percentage
|
|
630
|
+
|
|
631
|
+
assert percentage(10, 100) == 90.0
|
|
632
|
+
assert percentage(0, 0) == 0.0
|
|
549
633
|
"""
|
|
550
634
|
diff_mag = abs(value_a - value_b)
|
|
551
635
|
if relative_to_expected:
|