punit 1.5.3__tar.gz → 1.7.2__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.5.3/src/pUnit.egg-info → punit-1.7.2}/PKG-INFO +1 -1
- {punit-1.5.3 → punit-1.7.2}/pyproject.toml +1 -1
- {punit-1.5.3 → punit-1.7.2/src/pUnit.egg-info}/PKG-INFO +1 -1
- {punit-1.5.3 → punit-1.7.2}/src/pUnit.egg-info/SOURCES.txt +6 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/TestResult.py +7 -3
- {punit-1.5.3 → punit-1.7.2}/src/punit/__init__.py +6 -2
- {punit-1.5.3 → punit-1.7.2}/src/punit/__main__.py +27 -16
- {punit-1.5.3 → punit-1.7.2}/src/punit/assertions/numeric.py +4 -2
- {punit-1.5.3 → punit-1.7.2}/src/punit/cli.py +9 -7
- {punit-1.5.3 → punit-1.7.2}/src/punit/facts/Fact.py +15 -5
- {punit-1.5.3 → punit-1.7.2}/src/punit/facts/FactManager.py +3 -1
- {punit-1.5.3 → punit-1.7.2}/src/punit/filters/FilterManager.py +3 -1
- {punit-1.5.3 → punit-1.7.2}/src/punit/metadata/CallableMetadata.py +2 -1
- punit-1.7.2/src/punit/runner.py +241 -0
- punit-1.7.2/src/punit/setups/Setup.py +100 -0
- punit-1.7.2/src/punit/setups/SetupManager.py +69 -0
- punit-1.7.2/src/punit/setups/__init__.py +11 -0
- punit-1.7.2/src/punit/teardowns/Teardown.py +100 -0
- punit-1.7.2/src/punit/teardowns/TeardownManager.py +69 -0
- punit-1.7.2/src/punit/teardowns/__init__.py +11 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/theories/Theory.py +15 -4
- {punit-1.5.3 → punit-1.7.2}/src/punit/theories/TheoryManager.py +3 -1
- {punit-1.5.3 → punit-1.7.2}/src/punit/traits/TraitManager.py +3 -2
- punit-1.5.3/src/punit/runner.py +0 -124
- {punit-1.5.3 → punit-1.7.2}/LICENSE +0 -0
- {punit-1.5.3 → punit-1.7.2}/README.md +0 -0
- {punit-1.5.3 → punit-1.7.2}/setup.cfg +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/pUnit.egg-info/dependency_links.txt +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/pUnit.egg-info/entry_points.txt +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/pUnit.egg-info/requires.txt +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/pUnit.egg-info/top_level.txt +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/assertions/__init__.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/assertions/collections.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/assertions/exceptions.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/assertions/strings.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/discovery/TestModuleDiscovery.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/discovery/__init__.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/facts/__init__.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/filters/Filter.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/filters/__init__.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/metadata/__init__.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/py.typed +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/reports/HtmlReportGenerator.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/reports/JUnitReportGenerator.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/reports/JsonReportGenerator.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/reports/__init__.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/theories/__init__.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/traits/Trait.py +0 -0
- {punit-1.5.3 → punit-1.7.2}/src/punit/traits/__init__.py +0 -0
|
@@ -32,6 +32,12 @@ 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/setups/Setup.py
|
|
36
|
+
src/punit/setups/SetupManager.py
|
|
37
|
+
src/punit/setups/__init__.py
|
|
38
|
+
src/punit/teardowns/Teardown.py
|
|
39
|
+
src/punit/teardowns/TeardownManager.py
|
|
40
|
+
src/punit/teardowns/__init__.py
|
|
35
41
|
src/punit/theories/Theory.py
|
|
36
42
|
src/punit/theories/TheoryManager.py
|
|
37
43
|
src/punit/theories/__init__.py
|
|
@@ -59,7 +59,11 @@ class TestResult:
|
|
|
59
59
|
|
|
60
60
|
@property
|
|
61
61
|
def class_name(self) -> Optional[str]:
|
|
62
|
-
return
|
|
62
|
+
return (
|
|
63
|
+
self.__class_name
|
|
64
|
+
if self.__class_name is not None and len(self.__class_name) > 0
|
|
65
|
+
else None
|
|
66
|
+
)
|
|
63
67
|
|
|
64
68
|
@class_name.setter
|
|
65
69
|
def class_name(self, value: Optional[str]) -> None:
|
|
@@ -173,13 +177,13 @@ class TestResult:
|
|
|
173
177
|
else:
|
|
174
178
|
return f'{(took*1000):.3f}'.rstrip('0').rstrip('.') + 'ms'
|
|
175
179
|
|
|
176
|
-
def
|
|
180
|
+
def capture_output(self, quiet: bool = False) -> None:
|
|
177
181
|
self.__stdout_capture = TextIOCapture(sys.stdout, quiet)
|
|
178
182
|
self.__stderr_capture = TextIOCapture(sys.stderr, quiet)
|
|
179
183
|
sys.stdout = self.__stdout_capture
|
|
180
184
|
sys.stderr = self.__stderr_capture
|
|
181
185
|
|
|
182
|
-
def
|
|
186
|
+
def release_output(self) -> None:
|
|
183
187
|
if self.__stdout_capture is not None and self.__stdout_capture.target is not None:
|
|
184
188
|
sys.stdout = self.__stdout_capture.target
|
|
185
189
|
if self.__stderr_capture is not None and self.__stderr_capture.target is not None:
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
from .assertions import collections, exceptions, numeric, strings
|
|
5
5
|
from .facts import fact
|
|
6
|
+
from .setups import Setup, setup
|
|
7
|
+
from .teardowns import Teardown, teardown
|
|
6
8
|
from .theories import theory, inlinedata
|
|
7
9
|
from .traits import trait
|
|
8
10
|
|
|
@@ -10,8 +12,8 @@ from .assertions.exceptions import raises
|
|
|
10
12
|
from .assertions.numeric import approx
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
__version__ = '1.
|
|
14
|
-
__commit__ = '
|
|
15
|
+
__version__ = '1.7.2'
|
|
16
|
+
__commit__ = 'b60afab'
|
|
15
17
|
__all__ = [
|
|
16
18
|
'__version__', '__commit__',
|
|
17
19
|
'assertions',
|
|
@@ -21,6 +23,8 @@ __all__ = [
|
|
|
21
23
|
'strings',
|
|
22
24
|
'fact',
|
|
23
25
|
'metadata',
|
|
26
|
+
'setup', 'Setup',
|
|
27
|
+
'teardown', 'Teardown',
|
|
24
28
|
'theory', 'inlinedata',
|
|
25
29
|
'trait'
|
|
26
30
|
]
|
|
@@ -11,17 +11,19 @@ from .cli import CommandLineInterface
|
|
|
11
11
|
from .discovery import TestModuleDiscovery
|
|
12
12
|
from .reports import HtmlReportGenerator, JUnitReportGenerator, JsonReportGenerator
|
|
13
13
|
from .runner import TestRunner
|
|
14
|
+
from .setups.SetupManager import SetupManager
|
|
15
|
+
from .teardowns.TeardownManager import TeardownManager
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
async def async_main() -> None:
|
|
17
19
|
ts = time.time()
|
|
18
20
|
cli = CommandLineInterface.parse()
|
|
19
21
|
if cli.help: # pragma: no cover
|
|
20
|
-
cli.
|
|
22
|
+
cli.print_help()
|
|
21
23
|
elif cli.verbose and not cli.quiet: # pragma: no cover
|
|
22
|
-
cli.
|
|
24
|
+
cli.print_summary()
|
|
23
25
|
elif not cli.quiet: # pragma: no cover
|
|
24
|
-
cli.
|
|
26
|
+
cli.print_version()
|
|
25
27
|
os.chdir(cli.workdir)
|
|
26
28
|
if cli.no_pathfix is not True:
|
|
27
29
|
pathbase = str(Path.cwd())
|
|
@@ -30,13 +32,13 @@ async def async_main() -> None:
|
|
|
30
32
|
sys.path.append(srcbase)
|
|
31
33
|
if pathbase not in sys.path:
|
|
32
34
|
sys.path.append(pathbase)
|
|
33
|
-
|
|
35
|
+
test_module_discovery = TestModuleDiscovery(
|
|
34
36
|
os.path.join(cli.workdir, cli.test_package_name),
|
|
35
37
|
cli.includePatterns,
|
|
36
38
|
cli.excludePatterns,
|
|
37
39
|
cli)
|
|
38
|
-
|
|
39
|
-
testRunner = TestRunner(cli.test_package_name,
|
|
40
|
+
test_module_discovery.discover()
|
|
41
|
+
testRunner = TestRunner(cli.test_package_name, test_module_discovery.filenames, cli)
|
|
40
42
|
results = await testRunner.run()
|
|
41
43
|
totalTime = time.time() - ts
|
|
42
44
|
failureCount = 0
|
|
@@ -61,16 +63,25 @@ async def async_main() -> None:
|
|
|
61
63
|
with open(cli.outputFilename, 'wb') as file:
|
|
62
64
|
file.write(report.encode())
|
|
63
65
|
print(f'\n("{cli.reportFormat}" report written to: {cli.outputFilename})')
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
|
|
67
|
+
# not everyone runs python unbuffered as they should, so force a flush
|
|
68
|
+
sys.stdout.flush()
|
|
69
|
+
sys.stderr.flush()
|
|
70
|
+
try:
|
|
71
|
+
os.fsync(sys.stdout.fileno())
|
|
72
|
+
os.fsync(sys.stderr.fileno())
|
|
73
|
+
except (AttributeError, ValueError, OSError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
if cli.no_exitcode is not True:
|
|
77
|
+
if failureCount:
|
|
78
|
+
# test failures trigger exit code 119 (for automation gating)
|
|
79
|
+
sys.exit(119)
|
|
80
|
+
if TeardownManager.instance().teardown_error_count > 0:
|
|
81
|
+
# teardown errors also trigger exit code 119 (for automation gating)
|
|
82
|
+
sys.exit(119)
|
|
83
|
+
if SetupManager.instance().setup_error_count > 0:
|
|
84
|
+
# setup errors also trigger exit code 119 (for automation gating)
|
|
74
85
|
sys.exit(119)
|
|
75
86
|
|
|
76
87
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
4
6
|
from decimal import Decimal
|
|
5
7
|
import math
|
|
6
8
|
from typing import cast
|
|
@@ -397,7 +399,7 @@ class ApproxRangeComparator:
|
|
|
397
399
|
self._abs_tol = abs_tol
|
|
398
400
|
self._exclusive = False
|
|
399
401
|
|
|
400
|
-
def inclusive(self, flag: bool = True) ->
|
|
402
|
+
def inclusive(self, flag: bool = True) -> ApproxRangeComparator:
|
|
401
403
|
"""
|
|
402
404
|
Return a new ``ApproxRangeComparator`` with toggled inclusivity.
|
|
403
405
|
|
|
@@ -617,7 +619,7 @@ class approx:
|
|
|
617
619
|
"""Return a ``StrictLessThanComparator`` for strictly ``< expected`` (one-sided tolerance below)."""
|
|
618
620
|
return StrictLessThanComparator(self._expected, self._rel_tol, self._abs_tol)
|
|
619
621
|
|
|
620
|
-
def in_range(self, min_val: float, max_val: float) ->
|
|
622
|
+
def in_range(self, min_val: float, max_val: float) -> ApproxRangeComparator:
|
|
621
623
|
"""Return a comparator for ``[min_val, max_val]`` range checks with directional tolerance."""
|
|
622
624
|
return ApproxRangeComparator(min_val, max_val, self._rel_tol, self._abs_tol)
|
|
623
625
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
4
6
|
import os
|
|
5
7
|
import sys
|
|
6
8
|
from typing import Optional
|
|
@@ -46,7 +48,7 @@ class CommandLineInterface:
|
|
|
46
48
|
self.__workdir = os.path.curdir
|
|
47
49
|
self.__verbose = False
|
|
48
50
|
|
|
49
|
-
def __parse(self, argv: list[str]) ->
|
|
51
|
+
def __parse(self, argv: list[str]) -> CommandLineInterface:
|
|
50
52
|
aliasName: str | None = None
|
|
51
53
|
extractFilter: bool = False
|
|
52
54
|
extractExcludePattern: bool = False
|
|
@@ -217,9 +219,9 @@ class CommandLineInterface:
|
|
|
217
219
|
def no_pathfix(self) -> bool:
|
|
218
220
|
return self.__no_pathfix is True
|
|
219
221
|
|
|
220
|
-
def
|
|
222
|
+
def print_help(self) -> None:
|
|
221
223
|
if True: # pragma: no cover
|
|
222
|
-
self.
|
|
224
|
+
self.print_version()
|
|
223
225
|
print("""
|
|
224
226
|
Usage: python3 -m punit [-h|--help]
|
|
225
227
|
[-q|--quiet] [-v|--verbose]
|
|
@@ -281,8 +283,8 @@ Options:
|
|
|
281
283
|
""")
|
|
282
284
|
os._exit(0)
|
|
283
285
|
|
|
284
|
-
def
|
|
285
|
-
self.
|
|
286
|
+
def print_summary(self) -> None:
|
|
287
|
+
self.print_version()
|
|
286
288
|
print(f'Working Directory:\n\t{self.__workdir}')
|
|
287
289
|
print(f'Fail Fast: \n\t{"Yes" if self.__failfast else "No"}')
|
|
288
290
|
if len(self.__includePatterns) > 0:
|
|
@@ -295,7 +297,7 @@ Options:
|
|
|
295
297
|
print(f'\t{pattern}')
|
|
296
298
|
FilterManager.instance().print()
|
|
297
299
|
|
|
298
|
-
def
|
|
300
|
+
def print_version(self) -> None:
|
|
299
301
|
print(f'pUnit {__version__} ({__commit__})')
|
|
300
302
|
|
|
301
303
|
def validate(self) -> None:
|
|
@@ -314,7 +316,7 @@ Options:
|
|
|
314
316
|
self.__excludePatterns.append('/__*__')
|
|
315
317
|
|
|
316
318
|
@staticmethod
|
|
317
|
-
def parse(argv: list[str] = sys.argv) ->
|
|
319
|
+
def parse(argv: list[str] = sys.argv) -> CommandLineInterface:
|
|
318
320
|
result = CommandLineInterface().__parse(argv)
|
|
319
321
|
result.validate()
|
|
320
322
|
return result
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import inspect
|
|
5
5
|
from types import BuiltinFunctionType, BuiltinMethodType, FunctionType, MethodType, ModuleType
|
|
6
|
-
from typing import Callable, Coroutine, Union, cast
|
|
6
|
+
from typing import Any, Callable, Coroutine, Union, cast
|
|
7
7
|
|
|
8
8
|
from ..metadata import CallableMetadata
|
|
9
9
|
|
|
@@ -24,13 +24,14 @@ class Fact:
|
|
|
24
24
|
def target(self) -> Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]:
|
|
25
25
|
return self.__target
|
|
26
26
|
|
|
27
|
-
async def execute(self, module: ModuleType) -> None:
|
|
27
|
+
async def execute(self, module: ModuleType) -> Any | None:
|
|
28
|
+
class_instance: Any | None = None
|
|
28
29
|
coro: Coroutine | None = None
|
|
29
30
|
if hasattr(self.__target, '__qualname__') and self.__target.__qualname__.find('.') > -1:
|
|
30
31
|
if isinstance(self.__target, staticmethod):
|
|
31
32
|
coro = self.__target()
|
|
32
33
|
else:
|
|
33
|
-
qnparts = self.__target.__qualname__.split('.')
|
|
34
|
+
qnparts = [p for p in self.__target.__qualname__.split('.') if p != '<locals>']
|
|
34
35
|
qntarget = module
|
|
35
36
|
for qnpart in qnparts[0:-1]:
|
|
36
37
|
qntarget = getattr(qntarget, qnpart)
|
|
@@ -38,17 +39,26 @@ class Fact:
|
|
|
38
39
|
coro = self.__target.__func__(qntarget)
|
|
39
40
|
else:
|
|
40
41
|
# every test execution gets a new instance of class
|
|
41
|
-
|
|
42
|
+
class_instance = cast(Any, cast(Callable, qntarget)())
|
|
43
|
+
coro = self.__target(class_instance)
|
|
42
44
|
else:
|
|
43
45
|
coro = self.__target()
|
|
44
46
|
if inspect.iscoroutine(coro):
|
|
45
47
|
await coro
|
|
48
|
+
return class_instance
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
def fact(target: Callable) -> Callable:
|
|
49
52
|
from .FactManager import FactManager
|
|
50
|
-
|
|
53
|
+
unwrapped = inspect.unwrap(target)
|
|
54
|
+
if not isinstance(unwrapped, (FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType)):
|
|
51
55
|
raise Exception('@fact can only be applied to functions and methods.')
|
|
56
|
+
if hasattr(unwrapped, '__punit_decorator'):
|
|
57
|
+
raise Exception(
|
|
58
|
+
f'@fact and {getattr(unwrapped, "__punit_decorator")} cannot decorate the same function. '
|
|
59
|
+
f'Function "{unwrapped.__name__}" has already been decorated.'
|
|
60
|
+
)
|
|
61
|
+
setattr(unwrapped, '__punit_decorator', '@fact')
|
|
52
62
|
fact: Fact = Fact(target)
|
|
53
63
|
FactManager.instance().put(fact)
|
|
54
64
|
return target
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
##
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
from typing import Optional
|
|
6
8
|
|
|
7
9
|
from ..filters.FilterManager import FilterManager
|
|
@@ -23,7 +25,7 @@ class FactManager:
|
|
|
23
25
|
self.__modules = {}
|
|
24
26
|
|
|
25
27
|
@staticmethod
|
|
26
|
-
def instance() ->
|
|
28
|
+
def instance() -> FactManager:
|
|
27
29
|
if FactManager.__instance is None:
|
|
28
30
|
FactManager.__instance = FactManager()
|
|
29
31
|
return FactManager.__instance
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
4
6
|
import os
|
|
5
7
|
import select
|
|
6
8
|
import sys
|
|
@@ -22,7 +24,7 @@ class FilterManager:
|
|
|
22
24
|
return self.__filters
|
|
23
25
|
|
|
24
26
|
@staticmethod
|
|
25
|
-
def instance() ->
|
|
27
|
+
def instance() -> FilterManager:
|
|
26
28
|
instance = FilterManager.__instance
|
|
27
29
|
if instance is None:
|
|
28
30
|
instance = FilterManager()
|
|
@@ -14,7 +14,8 @@ class CallableMetadata:
|
|
|
14
14
|
|
|
15
15
|
def __init__(self, callable: Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]) -> None:
|
|
16
16
|
self.__callable = callable
|
|
17
|
-
|
|
17
|
+
parts = callable.__qualname__.replace(f'{callable.__module__}.', '').split('.')[0:-1]
|
|
18
|
+
self.__class_name = '.'.join(p for p in parts if p != '<locals>')
|
|
18
19
|
self.__module_name = callable.__module__
|
|
19
20
|
self.__name = callable.__name__
|
|
20
21
|
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
import traceback
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .cli import CommandLineInterface
|
|
13
|
+
from .facts.FactManager import FactManager
|
|
14
|
+
from .setups.SetupManager import SetupManager
|
|
15
|
+
from .teardowns.TeardownManager import TeardownManager
|
|
16
|
+
from .theories.TheoryManager import TheoryManager
|
|
17
|
+
from .TestResult import TestResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestRunner:
|
|
21
|
+
|
|
22
|
+
__cli: CommandLineInterface
|
|
23
|
+
__filenames: list[str]
|
|
24
|
+
__test_package_name: str
|
|
25
|
+
|
|
26
|
+
def __init__(self, test_package_name: str, filenames: list[str], cli: CommandLineInterface):
|
|
27
|
+
self.__cli = cli
|
|
28
|
+
self.__filenames = filenames
|
|
29
|
+
self.__test_package_name = test_package_name
|
|
30
|
+
|
|
31
|
+
async def __setup(self, module: object, module_name: str, class_name: Optional[str] = None, class_instance: Optional[Any] = None) -> bool:
|
|
32
|
+
"""Execute the setup for a test.
|
|
33
|
+
|
|
34
|
+
Returns ``False`` if an error occurred during setup (the test should not run).
|
|
35
|
+
A class-scoped ``@setup`` handles all tests in that specific class.
|
|
36
|
+
A module-scoped ``@setup`` handles all bare-function tests in that module.
|
|
37
|
+
The two scopes are independent -- there is no fallback between them; each scope fires only when it exists and the test belongs to it.
|
|
38
|
+
"""
|
|
39
|
+
setup_manager = SetupManager.instance()
|
|
40
|
+
|
|
41
|
+
if class_name is not None and len(class_name) > 0:
|
|
42
|
+
# Class-scoped path -- this test lives inside a class
|
|
43
|
+
sd = setup_manager.get('class', module_name, class_name)
|
|
44
|
+
if sd is not None:
|
|
45
|
+
try:
|
|
46
|
+
await sd.execute(module, class_instance) # type: ignore[arg-type]
|
|
47
|
+
except Exception as ex:
|
|
48
|
+
setup_manager.record_error()
|
|
49
|
+
if self.__cli.verbose: # pragma: no cover
|
|
50
|
+
target_desc = (
|
|
51
|
+
f"{sd.metadata.class_name}.{sd.metadata.name}"
|
|
52
|
+
if sd.metadata.class_name
|
|
53
|
+
else sd.metadata.name
|
|
54
|
+
)
|
|
55
|
+
print(f'Setup Error ({target_desc}): {ex}')
|
|
56
|
+
return False
|
|
57
|
+
else:
|
|
58
|
+
# Module-scoped path -- this is a bare-function test
|
|
59
|
+
sd = setup_manager.get('module', module_name, '')
|
|
60
|
+
if sd is not None:
|
|
61
|
+
try:
|
|
62
|
+
await sd.execute(module, class_instance) # type: ignore[arg-type]
|
|
63
|
+
except Exception as ex:
|
|
64
|
+
setup_manager.record_error()
|
|
65
|
+
if self.__cli.verbose: # pragma: no cover
|
|
66
|
+
target_desc = (
|
|
67
|
+
f"{sd.metadata.class_name}.{sd.metadata.name}"
|
|
68
|
+
if sd.metadata.class_name
|
|
69
|
+
else sd.metadata.name
|
|
70
|
+
)
|
|
71
|
+
print(f'Setup Error ({target_desc}): {ex}')
|
|
72
|
+
return False
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
async def __teardown(self, module: object, module_name: str, class_name: Optional[str] = None, class_instance: Optional[Any] = None) -> None:
|
|
76
|
+
"""Execute the teardown for a test.
|
|
77
|
+
|
|
78
|
+
A class-scoped ``@teardown`` handles all tests in that specific class.
|
|
79
|
+
A module-scoped ``@teardown`` handles all bare-function tests in that
|
|
80
|
+
module. The two scopes are independent -- there is no fallback between
|
|
81
|
+
them; each scope fires only when it exists and the test belongs to it.
|
|
82
|
+
"""
|
|
83
|
+
teardown_manager = TeardownManager.instance()
|
|
84
|
+
|
|
85
|
+
if class_name is not None:
|
|
86
|
+
# Class-scoped path -- this test lives inside a class
|
|
87
|
+
td = teardown_manager.get('class', module_name, class_name)
|
|
88
|
+
if td is not None:
|
|
89
|
+
try:
|
|
90
|
+
await td.execute(module, class_instance) # type: ignore[arg-type]
|
|
91
|
+
except Exception as ex:
|
|
92
|
+
teardown_manager.record_error()
|
|
93
|
+
if self.__cli.verbose: # pragma: no cover
|
|
94
|
+
target_desc = (
|
|
95
|
+
f"{td.metadata.class_name}.{td.metadata.name}"
|
|
96
|
+
if td.metadata.class_name
|
|
97
|
+
else td.metadata.name
|
|
98
|
+
)
|
|
99
|
+
print(f'Teardown Error ({target_desc}): {ex}')
|
|
100
|
+
else:
|
|
101
|
+
# Module-scoped path -- this is a bare-function test
|
|
102
|
+
td = teardown_manager.get('module', module_name, '')
|
|
103
|
+
if td is not None:
|
|
104
|
+
try:
|
|
105
|
+
await td.execute(module, class_instance) # type: ignore[arg-type]
|
|
106
|
+
except Exception as ex:
|
|
107
|
+
teardown_manager.record_error()
|
|
108
|
+
if self.__cli.verbose: # pragma: no cover
|
|
109
|
+
target_desc = (
|
|
110
|
+
f"{td.metadata.class_name}.{td.metadata.name}"
|
|
111
|
+
if td.metadata.class_name
|
|
112
|
+
else td.metadata.name
|
|
113
|
+
)
|
|
114
|
+
print(f'Teardown Error ({target_desc}): {ex}')
|
|
115
|
+
|
|
116
|
+
def print_test_result(self, test_result: TestResult):
|
|
117
|
+
if self.__cli.quiet:
|
|
118
|
+
return
|
|
119
|
+
glyph = '🟩' if test_result.is_success else '🟥'
|
|
120
|
+
data = test_result.properties.get('data')
|
|
121
|
+
if data is None:
|
|
122
|
+
data = ''
|
|
123
|
+
else:
|
|
124
|
+
try:
|
|
125
|
+
data = f'({",".join([str(e) for e in data])})'
|
|
126
|
+
except Exception:
|
|
127
|
+
data = '(???)'
|
|
128
|
+
print(f'{glyph} {test_result.module_name}/{"" if test_result.class_name is None or len(test_result.class_name) == 0 else f"{test_result.class_name}/"}{test_result.test_name}{data} [{test_result.tookPretty}]')
|
|
129
|
+
if self.__cli.verbose and (not test_result.is_success) and test_result.exception is not None:
|
|
130
|
+
print(f'Test File:\n {test_result.file_name}\nError:\n {test_result.exception}\n Traceback:\n{"".join(traceback.format_tb(test_result.exception.__traceback__))}')
|
|
131
|
+
|
|
132
|
+
async def __run_facts(self, host_name: str, test_module: ModuleType, filename: str, module_report_name: str, results: list[TestResult]) -> None:
|
|
133
|
+
"""Run all facts for a test module and return their results."""
|
|
134
|
+
facts = FactManager.instance().get(test_module.__name__)
|
|
135
|
+
for fact in facts:
|
|
136
|
+
result = TestResult()
|
|
137
|
+
result.host_name = host_name
|
|
138
|
+
result.package_name = self.__test_package_name
|
|
139
|
+
result.file_name = filename
|
|
140
|
+
result.module_name = module_report_name
|
|
141
|
+
result.start_time = time.time()
|
|
142
|
+
result.capture_output(False)
|
|
143
|
+
# Run pre-test setup; if it fails, skip the fact.
|
|
144
|
+
class_instance: Any = None
|
|
145
|
+
if await self.__setup(test_module, test_module.__name__, fact.metadata.class_name, class_instance):
|
|
146
|
+
try:
|
|
147
|
+
class_instance = await fact.execute(test_module)
|
|
148
|
+
result.is_success = True
|
|
149
|
+
except Exception as ex:
|
|
150
|
+
result.is_success = False
|
|
151
|
+
result.exception = ex
|
|
152
|
+
else:
|
|
153
|
+
# Setup failed — record a failure result.
|
|
154
|
+
result.is_success = False
|
|
155
|
+
result.stop_time = time.time()
|
|
156
|
+
result.class_name = fact.metadata.class_name
|
|
157
|
+
result.test_name = fact.metadata.name
|
|
158
|
+
results.append(result)
|
|
159
|
+
await self.__teardown(test_module, test_module.__name__, result.class_name, class_instance)
|
|
160
|
+
result.release_output()
|
|
161
|
+
self.print_test_result(result)
|
|
162
|
+
if self.__cli.failfast and not result.is_success:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
async def __run_theories(self, host_name: str, test_module: ModuleType, filename: str, module_report_name: str, results: list[TestResult]) -> None:
|
|
166
|
+
"""Run all theory data-points for a test module and return their results."""
|
|
167
|
+
theories = TheoryManager.instance().get(test_module.__name__)
|
|
168
|
+
for theory in theories:
|
|
169
|
+
for data in theory.datas:
|
|
170
|
+
result = TestResult()
|
|
171
|
+
result.host_name = host_name
|
|
172
|
+
result.package_name = self.__test_package_name
|
|
173
|
+
result.properties['data'] = data
|
|
174
|
+
result.file_name = filename
|
|
175
|
+
result.module_name = module_report_name
|
|
176
|
+
result.start_time = time.time()
|
|
177
|
+
result.capture_output(False)
|
|
178
|
+
# Run pre-test setup; if it fails, skip the theory.
|
|
179
|
+
class_instance: Any = None
|
|
180
|
+
if await self.__setup(test_module, test_module.__name__, theory.metadata.class_name, class_instance):
|
|
181
|
+
try:
|
|
182
|
+
class_instance = await theory.execute(test_module, data)
|
|
183
|
+
result.is_success = True
|
|
184
|
+
except Exception as ex:
|
|
185
|
+
result.is_success = False
|
|
186
|
+
result.exception = ex
|
|
187
|
+
else:
|
|
188
|
+
# Setup failed — record a failure result.
|
|
189
|
+
result.is_success = False
|
|
190
|
+
result.stop_time = time.time()
|
|
191
|
+
result.class_name = theory.metadata.class_name
|
|
192
|
+
result.test_name = theory.metadata.name
|
|
193
|
+
results.append(result)
|
|
194
|
+
await self.__teardown(test_module, test_module.__name__, result.class_name, class_instance)
|
|
195
|
+
result.release_output()
|
|
196
|
+
self.print_test_result(result)
|
|
197
|
+
if self.__cli.failfast and not result.is_success:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
async def run(self) -> list[TestResult]:
|
|
201
|
+
results: list[TestResult] = []
|
|
202
|
+
result: TestResult
|
|
203
|
+
# TODO: aliasing
|
|
204
|
+
test_package_path = os.path.join(os.path.abspath(os.curdir), self.__test_package_name).replace('\\', '/')
|
|
205
|
+
for filename in self.__filenames:
|
|
206
|
+
ts = time.time()
|
|
207
|
+
moduleImportName = filename.replace(test_package_path, '').replace('/', '.')
|
|
208
|
+
if moduleImportName.endswith('.py'):
|
|
209
|
+
moduleImportName = moduleImportName[:-3]
|
|
210
|
+
module_report_name = moduleImportName.lstrip('.')
|
|
211
|
+
try:
|
|
212
|
+
host_name: str = socket.gethostname()
|
|
213
|
+
test_module = importlib.import_module(moduleImportName, self.__test_package_name)
|
|
214
|
+
|
|
215
|
+
# execute all facts
|
|
216
|
+
await self.__run_facts(host_name, test_module, filename, module_report_name, results)
|
|
217
|
+
if self.__cli.failfast and any(e.is_success is False for e in results):
|
|
218
|
+
return results
|
|
219
|
+
|
|
220
|
+
# execute all theories
|
|
221
|
+
await self.__run_theories(host_name, test_module, filename, module_report_name, results)
|
|
222
|
+
if self.__cli.failfast and any(e.is_success is False for e in results):
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
except Exception as ex:
|
|
226
|
+
# module-level failure, report test failure against the module
|
|
227
|
+
# this is a best-attempt to create output that report readers
|
|
228
|
+
# can consume to show there was a broad failure in a module.
|
|
229
|
+
result = TestResult()
|
|
230
|
+
result.class_name = '*'
|
|
231
|
+
result.module_name = module_report_name
|
|
232
|
+
result.test_name = '*'
|
|
233
|
+
result.start_time = ts
|
|
234
|
+
result.stop_time = time.time()
|
|
235
|
+
result.is_success = False
|
|
236
|
+
result.exception = ex
|
|
237
|
+
results.append(result)
|
|
238
|
+
self.print_test_result(result)
|
|
239
|
+
if self.__cli.failfast:
|
|
240
|
+
return results
|
|
241
|
+
return results
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
from types import BuiltinFunctionType, BuiltinMethodType, FunctionType, MethodType, ModuleType
|
|
6
|
+
from typing import Callable, Coroutine, Union, cast
|
|
7
|
+
|
|
8
|
+
from ..metadata import CallableMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Setup:
|
|
12
|
+
|
|
13
|
+
__metadata: CallableMetadata
|
|
14
|
+
__scope_type: str # "module" or "class"
|
|
15
|
+
__wrapped_target: Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]
|
|
16
|
+
__target: Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]
|
|
17
|
+
|
|
18
|
+
def __init__(self, target: Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]):
|
|
19
|
+
# For descriptors (staticmethod/classmethod), unwrap to get the raw function
|
|
20
|
+
if isinstance(target, (staticmethod, classmethod)):
|
|
21
|
+
self.__wrapped_target = target
|
|
22
|
+
self.__target = cast(FunctionType, target.__func__) # type: ignore[arg-type]
|
|
23
|
+
else:
|
|
24
|
+
self.__wrapped_target = target
|
|
25
|
+
self.__target = target
|
|
26
|
+
self.__metadata = CallableMetadata(self.__target)
|
|
27
|
+
# Determine scope from class_name: non-empty means class-scoped method
|
|
28
|
+
if (hasattr(self.__target, '__qualname__') and '.' in self.__target.__qualname__):
|
|
29
|
+
self.__scope_type = 'class'
|
|
30
|
+
else:
|
|
31
|
+
self.__scope_type = 'module'
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def metadata(self) -> CallableMetadata:
|
|
35
|
+
return self.__metadata
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def scope_type(self) -> str:
|
|
39
|
+
return self.__scope_type
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def target(self) -> Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]:
|
|
43
|
+
return self.__wrapped_target
|
|
44
|
+
|
|
45
|
+
async def execute(self, module: ModuleType, obj: object | None = None) -> None:
|
|
46
|
+
"""Execute the setup function.
|
|
47
|
+
|
|
48
|
+
For module-scoped setups, ``obj`` is ignored.
|
|
49
|
+
For class-scoped setups on methods, ``obj`` should be an instance
|
|
50
|
+
of the decorated class; it is used as ``self`` via method binding.
|
|
51
|
+
"""
|
|
52
|
+
if self.__scope_type == 'module':
|
|
53
|
+
coro: Coroutine | None = None
|
|
54
|
+
coro = self.__target() # type: ignore[call-arg]
|
|
55
|
+
if inspect.iscoroutine(coro):
|
|
56
|
+
await coro
|
|
57
|
+
else:
|
|
58
|
+
# class-scoped setup method — execute on the provided instance
|
|
59
|
+
# or create one if none was supplied (defensive fallback).
|
|
60
|
+
obj_instance = obj
|
|
61
|
+
if obj_instance is None:
|
|
62
|
+
cls_name = self.__metadata.class_name
|
|
63
|
+
if cls_name:
|
|
64
|
+
cls = getattr(module, cls_name) # type: ignore[union-attr]
|
|
65
|
+
obj_instance = cls()
|
|
66
|
+
else:
|
|
67
|
+
obj_instance = cast(Callable, self.__target)()
|
|
68
|
+
if isinstance(self.__wrapped_target, staticmethod):
|
|
69
|
+
coro = self.__wrapped_target()
|
|
70
|
+
elif isinstance(self.__wrapped_target, classmethod):
|
|
71
|
+
qnparts = self.__wrapped_target.__qualname__.split('.')
|
|
72
|
+
cls = getattr(module, qnparts[-2])
|
|
73
|
+
args = (cls,)
|
|
74
|
+
coro = self.__wrapped_target.__func__(*args)
|
|
75
|
+
else:
|
|
76
|
+
# regular instance method — bind to the instance
|
|
77
|
+
bound = getattr(obj_instance, self.__target.__name__)
|
|
78
|
+
if inspect.iscoroutine(bound):
|
|
79
|
+
await bound
|
|
80
|
+
elif callable(bound):
|
|
81
|
+
coro = bound() # type: ignore[bad-assignment]
|
|
82
|
+
if inspect.iscoroutine(coro): # type: ignore[possibly-undefined]
|
|
83
|
+
await coro
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def setup(target: Callable) -> Callable:
|
|
87
|
+
from .SetupManager import SetupManager
|
|
88
|
+
unwrapped = inspect.unwrap(target)
|
|
89
|
+
if not isinstance(unwrapped, (FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType)):
|
|
90
|
+
raise Exception('@setup can only be applied to functions and methods.')
|
|
91
|
+
if hasattr(unwrapped, '__punit_decorator'):
|
|
92
|
+
raise Exception(
|
|
93
|
+
f'@setup and {getattr(unwrapped, "__punit_decorator")} cannot decorate the same function. '
|
|
94
|
+
f'Function "{unwrapped.__name__}" has already been decorated.'
|
|
95
|
+
)
|
|
96
|
+
setattr(unwrapped, '__punit_decorator', '@setup')
|
|
97
|
+
|
|
98
|
+
sd: Setup = Setup(target)
|
|
99
|
+
SetupManager.instance().put(sd)
|
|
100
|
+
return target
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .Setup import Setup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SetupManager:
|
|
12
|
+
|
|
13
|
+
__instance: Optional['SetupManager'] = None
|
|
14
|
+
# NOTE: dict key is (scope_type, module_name, class_or_empty)
|
|
15
|
+
__setups: dict[Tuple[str, str, str], Setup]
|
|
16
|
+
__setup_error_count: int
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
if SetupManager.__instance is not None:
|
|
20
|
+
raise Exception('Cannot create more than one instance of SetupManager') # pragma: no cover
|
|
21
|
+
self.__setups = {}
|
|
22
|
+
self.__setup_error_count = 0
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def instance() -> SetupManager:
|
|
26
|
+
if SetupManager.__instance is None:
|
|
27
|
+
SetupManager.__instance = SetupManager()
|
|
28
|
+
return SetupManager.__instance
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def setup_error_count(self) -> int:
|
|
32
|
+
return self.__setup_error_count
|
|
33
|
+
|
|
34
|
+
def get(self, scope_type: str, module_name: str, class_name: Optional[str] = None) -> Optional[Setup]:
|
|
35
|
+
"""Look up a setup by (scope_type, module_name, class_name).
|
|
36
|
+
|
|
37
|
+
For class-scoped lookups ``class_name`` must match the first segment of
|
|
38
|
+
the decorated method's ``__qualname__``. For module-scoped setups
|
|
39
|
+
pass ``class_name = None`` or empty string — the manager normalises this
|
|
40
|
+
to ``""``.
|
|
41
|
+
"""
|
|
42
|
+
key: Tuple[str, str, str]
|
|
43
|
+
if class_name is None or class_name == '':
|
|
44
|
+
key = (scope_type, module_name, '')
|
|
45
|
+
else:
|
|
46
|
+
key = (scope_type, module_name, class_name)
|
|
47
|
+
|
|
48
|
+
return self.__setups.get(key)
|
|
49
|
+
|
|
50
|
+
def put(self, sd: Setup) -> None:
|
|
51
|
+
"""Store a setup keyed by (scope_type, module_name, class_or_empty)."""
|
|
52
|
+
cn = sd.metadata.class_name
|
|
53
|
+
if cn is not None and len(cn) > 0:
|
|
54
|
+
key: Tuple[str, str, str] = (sd.scope_type, sd.metadata.module_name, cn)
|
|
55
|
+
else:
|
|
56
|
+
key = (sd.scope_type, sd.metadata.module_name, '')
|
|
57
|
+
|
|
58
|
+
# Only one setup per scope — last decorator wins if accidentally
|
|
59
|
+
# applied twice.
|
|
60
|
+
self.__setups[key] = sd
|
|
61
|
+
|
|
62
|
+
def record_error(self) -> None:
|
|
63
|
+
"""Called when a setup function raises an exception."""
|
|
64
|
+
self.__setup_error_count += 1
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def reset() -> None:
|
|
68
|
+
"""Reset the singleton (used between test modules / suites)."""
|
|
69
|
+
SetupManager.__instance = None
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
from types import BuiltinFunctionType, BuiltinMethodType, FunctionType, MethodType, ModuleType
|
|
6
|
+
from typing import Callable, Coroutine, Union, cast
|
|
7
|
+
|
|
8
|
+
from ..metadata import CallableMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Teardown:
|
|
12
|
+
|
|
13
|
+
__metadata: CallableMetadata
|
|
14
|
+
__scope_type: str # "module" or "class"
|
|
15
|
+
__wrapped_target: Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]
|
|
16
|
+
__target: Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]
|
|
17
|
+
|
|
18
|
+
def __init__(self, target: Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]):
|
|
19
|
+
# For descriptors (staticmethod/classmethod), unwrap to get the raw function
|
|
20
|
+
if isinstance(target, (staticmethod, classmethod)):
|
|
21
|
+
self.__wrapped_target = target
|
|
22
|
+
self.__target = cast(FunctionType, target.__func__) # type: ignore[arg-type]
|
|
23
|
+
else:
|
|
24
|
+
self.__wrapped_target = target
|
|
25
|
+
self.__target = target
|
|
26
|
+
self.__metadata = CallableMetadata(self.__target)
|
|
27
|
+
# Determine scope from class_name: non-empty means class-scoped method
|
|
28
|
+
if (hasattr(self.__target, '__qualname__') and '.' in self.__target.__qualname__):
|
|
29
|
+
self.__scope_type = 'class'
|
|
30
|
+
else:
|
|
31
|
+
self.__scope_type = 'module'
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def metadata(self) -> CallableMetadata:
|
|
35
|
+
return self.__metadata
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def scope_type(self) -> str:
|
|
39
|
+
return self.__scope_type
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def target(self) -> Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]:
|
|
43
|
+
return self.__wrapped_target
|
|
44
|
+
|
|
45
|
+
async def execute(self, module: ModuleType, obj: object | None = None) -> None:
|
|
46
|
+
"""Execute the teardown function.
|
|
47
|
+
|
|
48
|
+
For module-scoped teardowns, ``obj`` is ignored.
|
|
49
|
+
For class-scoped teardowns on methods, ``obj`` should be an instance
|
|
50
|
+
of the decorated class; it is used as ``self`` via method binding.
|
|
51
|
+
"""
|
|
52
|
+
if self.__scope_type == 'module':
|
|
53
|
+
coro: Coroutine | None = None
|
|
54
|
+
coro = self.__target() # type: ignore[call-arg]
|
|
55
|
+
if inspect.iscoroutine(coro):
|
|
56
|
+
await coro
|
|
57
|
+
else:
|
|
58
|
+
# class-scoped teardown method — execute on the provided instance
|
|
59
|
+
# or create one if none was supplied (defensive fallback).
|
|
60
|
+
obj_instance = obj
|
|
61
|
+
if obj_instance is None:
|
|
62
|
+
cls_name = self.__metadata.class_name
|
|
63
|
+
if cls_name:
|
|
64
|
+
cls = getattr(module, cls_name) # type: ignore[union-attr]
|
|
65
|
+
obj_instance = cls()
|
|
66
|
+
else:
|
|
67
|
+
obj_instance = cast(Callable, self.__target)()
|
|
68
|
+
if isinstance(self.__wrapped_target, staticmethod):
|
|
69
|
+
coro = self.__wrapped_target()
|
|
70
|
+
elif isinstance(self.__wrapped_target, classmethod):
|
|
71
|
+
qnparts = self.__wrapped_target.__qualname__.split('.')
|
|
72
|
+
cls = getattr(module, qnparts[-2])
|
|
73
|
+
args = (cls,)
|
|
74
|
+
coro = self.__wrapped_target.__func__(*args)
|
|
75
|
+
else:
|
|
76
|
+
# regular instance method — bind to the instance
|
|
77
|
+
bound = getattr(obj_instance, self.__target.__name__)
|
|
78
|
+
if inspect.iscoroutine(bound):
|
|
79
|
+
await bound
|
|
80
|
+
elif callable(bound):
|
|
81
|
+
coro = bound() # type: ignore[bad-assignment]
|
|
82
|
+
if inspect.iscoroutine(coro): # type: ignore[possibly-undefined]
|
|
83
|
+
await coro
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def teardown(target: Callable) -> Callable:
|
|
87
|
+
from .TeardownManager import TeardownManager
|
|
88
|
+
unwrapped = inspect.unwrap(target)
|
|
89
|
+
if not isinstance(unwrapped, (FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType)):
|
|
90
|
+
raise Exception('@teardown can only be applied to functions and methods.')
|
|
91
|
+
if hasattr(unwrapped, '__punit_decorator'):
|
|
92
|
+
raise Exception(
|
|
93
|
+
f'@teardown and {getattr(unwrapped, "__punit_decorator")} cannot decorate the same function. '
|
|
94
|
+
f'Function "{unwrapped.__name__}" has already been decorated.'
|
|
95
|
+
)
|
|
96
|
+
setattr(unwrapped, '__punit_decorator', '@teardown')
|
|
97
|
+
|
|
98
|
+
td: Teardown = Teardown(target)
|
|
99
|
+
TeardownManager.instance().put(td)
|
|
100
|
+
return target
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .Teardown import Teardown
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TeardownManager:
|
|
12
|
+
|
|
13
|
+
__instance: Optional['TeardownManager'] = None
|
|
14
|
+
# NOTE: dict key is (scope_type, module_name, class_or_empty)
|
|
15
|
+
__teardowns: dict[Tuple[str, str, str], Teardown]
|
|
16
|
+
__teardown_error_count: int
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
if TeardownManager.__instance is not None:
|
|
20
|
+
raise Exception('Cannot create more than one instance of TeardownManager') # pragma: no cover
|
|
21
|
+
self.__teardowns = {}
|
|
22
|
+
self.__teardown_error_count = 0
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def instance() -> TeardownManager:
|
|
26
|
+
if TeardownManager.__instance is None:
|
|
27
|
+
TeardownManager.__instance = TeardownManager()
|
|
28
|
+
return TeardownManager.__instance
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def teardown_error_count(self) -> int:
|
|
32
|
+
return self.__teardown_error_count
|
|
33
|
+
|
|
34
|
+
def get(self, scope_type: str, module_name: str, class_name: Optional[str] = None) -> Optional[Teardown]:
|
|
35
|
+
"""Look up a teardown by (scope_type, module_name, class_name).
|
|
36
|
+
|
|
37
|
+
For class-scoped lookups ``class_name`` must match the first segment of
|
|
38
|
+
the decorated method's ``__qualname__``. For module-scoped teardowns
|
|
39
|
+
pass ``class_name = None`` or empty string — the manager normalises this
|
|
40
|
+
to ``""``.
|
|
41
|
+
"""
|
|
42
|
+
key: Tuple[str, str, str]
|
|
43
|
+
if class_name is None or class_name == '':
|
|
44
|
+
key = (scope_type, module_name, '')
|
|
45
|
+
else:
|
|
46
|
+
key = (scope_type, module_name, class_name)
|
|
47
|
+
|
|
48
|
+
return self.__teardowns.get(key)
|
|
49
|
+
|
|
50
|
+
def put(self, td: Teardown) -> None:
|
|
51
|
+
"""Store a teardown keyed by (scope_type, module_name, class_or_empty)."""
|
|
52
|
+
cn = td.metadata.class_name
|
|
53
|
+
if cn is not None and len(cn) > 0:
|
|
54
|
+
key: Tuple[str, str, str] = (td.scope_type, td.metadata.module_name, cn)
|
|
55
|
+
else:
|
|
56
|
+
key = (td.scope_type, td.metadata.module_name, '')
|
|
57
|
+
|
|
58
|
+
# Only one teardown per scope — last decorator wins if accidentally
|
|
59
|
+
# applied twice.
|
|
60
|
+
self.__teardowns[key] = td
|
|
61
|
+
|
|
62
|
+
def record_error(self) -> None:
|
|
63
|
+
"""Called when a teardown function raises an exception."""
|
|
64
|
+
self.__teardown_error_count += 1
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def reset() -> None:
|
|
68
|
+
"""Reset the singleton (used between test modules / suites)."""
|
|
69
|
+
TeardownManager.__instance = None
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import inspect
|
|
6
6
|
from types import BuiltinFunctionType, BuiltinMethodType, FunctionType, MethodType, ModuleType
|
|
7
|
-
from typing import Callable, Coroutine,
|
|
7
|
+
from typing import Any, Callable, Coroutine, Union, cast
|
|
8
8
|
|
|
9
9
|
from ..metadata import CallableMetadata
|
|
10
10
|
|
|
@@ -31,13 +31,15 @@ class Theory:
|
|
|
31
31
|
def target(self) -> Union[FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType, Callable]:
|
|
32
32
|
return self.__target
|
|
33
33
|
|
|
34
|
-
async def execute(self, module: ModuleType, data: tuple) -> None:
|
|
34
|
+
async def execute(self, module: ModuleType, data: tuple) -> Any | None:
|
|
35
|
+
class_instance: Any | None = None
|
|
35
36
|
coro: Coroutine | None = None
|
|
36
37
|
if hasattr(self.__target, '__qualname__') and self.__target.__qualname__.find('.') > -1:
|
|
37
38
|
qnparts = self.__target.__qualname__.split('.')
|
|
38
39
|
if isinstance(self.__target, staticmethod):
|
|
39
40
|
coro = self.__target(*data)
|
|
40
41
|
else:
|
|
42
|
+
qnparts = [p for p in self.__target.__qualname__.split('.') if p != '<locals>']
|
|
41
43
|
qntarget = module
|
|
42
44
|
for qnpart in qnparts[0:-1]:
|
|
43
45
|
qntarget = getattr(qntarget, qnpart)
|
|
@@ -46,19 +48,28 @@ class Theory:
|
|
|
46
48
|
coro = self.__target.__func__(*args)
|
|
47
49
|
else:
|
|
48
50
|
# every test execution gets a new instance of class
|
|
49
|
-
|
|
51
|
+
class_instance = cast(Any, cast(Callable, qntarget)())
|
|
52
|
+
args = (class_instance,) + data
|
|
50
53
|
coro = self.__target(*args)
|
|
51
54
|
else:
|
|
52
55
|
self.__test_name = self.__target.__name__
|
|
53
56
|
coro = self.__target(*data)
|
|
54
57
|
if inspect.iscoroutine(coro):
|
|
55
58
|
await coro
|
|
59
|
+
return class_instance
|
|
56
60
|
|
|
57
61
|
|
|
58
62
|
def theory(target: Callable) -> Callable:
|
|
59
63
|
from .TheoryManager import TheoryManager
|
|
60
|
-
|
|
64
|
+
unwrapped = inspect.unwrap(target)
|
|
65
|
+
if not isinstance(unwrapped, (FunctionType, MethodType, BuiltinFunctionType, BuiltinMethodType)):
|
|
61
66
|
raise Exception('@theory can only be applied to functions and methods.')
|
|
67
|
+
if hasattr(unwrapped, '__punit_decorator'):
|
|
68
|
+
raise Exception(
|
|
69
|
+
f'@theory and {getattr(unwrapped, "__punit_decorator")} cannot decorate the same function. '
|
|
70
|
+
f'Function "{unwrapped.__name__}" has already been decorated.'
|
|
71
|
+
)
|
|
72
|
+
setattr(unwrapped, '__punit_decorator', '@theory')
|
|
62
73
|
theory: Theory = Theory(target)
|
|
63
74
|
TheoryManager.instance().put(theory)
|
|
64
75
|
return target
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
##
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
from typing import Callable, Optional
|
|
6
8
|
|
|
7
9
|
from ..filters.FilterManager import FilterManager
|
|
@@ -25,7 +27,7 @@ class TheoryManager:
|
|
|
25
27
|
self.__datas = {}
|
|
26
28
|
|
|
27
29
|
@staticmethod
|
|
28
|
-
def instance() ->
|
|
30
|
+
def instance() -> TheoryManager:
|
|
29
31
|
if TheoryManager.__instance is None:
|
|
30
32
|
TheoryManager.__instance = TheoryManager()
|
|
31
33
|
return TheoryManager.__instance
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
4
5
|
|
|
5
6
|
from types import FunctionType, MethodType
|
|
6
7
|
from typing import Callable, Optional
|
|
@@ -19,7 +20,7 @@ class TraitManager:
|
|
|
19
20
|
self.__traits = dict[Callable | FunctionType | MethodType, dict[str, Trait]]()
|
|
20
21
|
|
|
21
22
|
@staticmethod
|
|
22
|
-
def instance() ->
|
|
23
|
+
def instance() -> TraitManager:
|
|
23
24
|
if TraitManager.__instance is None:
|
|
24
25
|
TraitManager.__instance = TraitManager()
|
|
25
26
|
return TraitManager.__instance
|
punit-1.5.3/src/punit/runner.py
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
-
# SPDX-License-Identifier: MIT
|
|
3
|
-
|
|
4
|
-
import importlib
|
|
5
|
-
import os
|
|
6
|
-
import socket
|
|
7
|
-
import time
|
|
8
|
-
import traceback
|
|
9
|
-
|
|
10
|
-
from .cli import CommandLineInterface
|
|
11
|
-
from .facts.FactManager import FactManager
|
|
12
|
-
from .metadata.CallableMetadata import CallableMetadata
|
|
13
|
-
from .theories.TheoryManager import TheoryManager
|
|
14
|
-
from .TestResult import TestResult
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class TestRunner:
|
|
18
|
-
|
|
19
|
-
__cli: CommandLineInterface
|
|
20
|
-
__filenames: list[str]
|
|
21
|
-
__test_package_name: str
|
|
22
|
-
|
|
23
|
-
def __init__(self, test_package_name: str, filenames: list[str], cli: CommandLineInterface):
|
|
24
|
-
self.__cli = cli
|
|
25
|
-
self.__filenames = filenames
|
|
26
|
-
self.__test_package_name = test_package_name
|
|
27
|
-
|
|
28
|
-
def print_test_result(self, test_result: TestResult):
|
|
29
|
-
if self.__cli.quiet:
|
|
30
|
-
return
|
|
31
|
-
glyph = '🟩' if test_result.is_success else '🟥'
|
|
32
|
-
data = test_result.properties.get('data')
|
|
33
|
-
if data is None:
|
|
34
|
-
data = ''
|
|
35
|
-
else:
|
|
36
|
-
try:
|
|
37
|
-
data = f'({",".join([str(e) for e in data])})'
|
|
38
|
-
except Exception:
|
|
39
|
-
data = '(???)'
|
|
40
|
-
print(f'{glyph} {test_result.module_name}/{"" if test_result.class_name is None or len(test_result.class_name) == 0 else f"{test_result.class_name}/"}{test_result.test_name}{data} [{test_result.tookPretty}]')
|
|
41
|
-
if self.__cli.verbose and (not test_result.is_success) and test_result.exception is not None:
|
|
42
|
-
print(f'Test File:\n {test_result.file_name}\nError:\n {test_result.exception}\n Traceback:\n{"".join(traceback.format_tb(test_result.exception.__traceback__))}')
|
|
43
|
-
|
|
44
|
-
async def run(self) -> list[TestResult]:
|
|
45
|
-
results: list[TestResult] = []
|
|
46
|
-
result: TestResult
|
|
47
|
-
# TODO: aliasing
|
|
48
|
-
host_name: str = socket.gethostname()
|
|
49
|
-
test_package_path = os.path.join(os.path.abspath(os.curdir), self.__test_package_name).replace('\\', '/')
|
|
50
|
-
for filename in self.__filenames:
|
|
51
|
-
ts = time.time()
|
|
52
|
-
moduleImportName = filename.replace(test_package_path, '').replace('/', '.')
|
|
53
|
-
if moduleImportName.endswith('.py'):
|
|
54
|
-
moduleImportName = moduleImportName[:-3]
|
|
55
|
-
module_report_name = moduleImportName.lstrip('.')
|
|
56
|
-
try:
|
|
57
|
-
testModule = importlib.import_module(moduleImportName, self.__test_package_name)
|
|
58
|
-
# execute all facts
|
|
59
|
-
facts = FactManager.instance().get(testModule.__name__)
|
|
60
|
-
for fact in facts:
|
|
61
|
-
result = TestResult()
|
|
62
|
-
result.host_name = host_name
|
|
63
|
-
result.package_name = self.__test_package_name
|
|
64
|
-
result.file_name = filename
|
|
65
|
-
result.module_name = module_report_name
|
|
66
|
-
result.start_time = time.time()
|
|
67
|
-
result.captureOutput(not self.__cli.verbose)
|
|
68
|
-
try:
|
|
69
|
-
await fact.execute(testModule)
|
|
70
|
-
result.is_success = True
|
|
71
|
-
except Exception as ex:
|
|
72
|
-
result.is_success = False
|
|
73
|
-
result.exception = ex
|
|
74
|
-
result.releaseOutput()
|
|
75
|
-
result.stop_time = time.time()
|
|
76
|
-
result.class_name = fact.metadata.class_name
|
|
77
|
-
result.test_name = fact.metadata.name
|
|
78
|
-
results.append(result)
|
|
79
|
-
self.print_test_result(result)
|
|
80
|
-
if self.__cli.failfast and not result.is_success:
|
|
81
|
-
return results
|
|
82
|
-
# execute all theories
|
|
83
|
-
theories = TheoryManager.instance().get(testModule.__name__)
|
|
84
|
-
for theory in theories:
|
|
85
|
-
for data in theory.datas:
|
|
86
|
-
result = TestResult()
|
|
87
|
-
result.host_name = host_name
|
|
88
|
-
result.package_name = self.__test_package_name
|
|
89
|
-
result.properties['data'] = data
|
|
90
|
-
result.file_name = filename
|
|
91
|
-
result.module_name = module_report_name
|
|
92
|
-
result.start_time = time.time()
|
|
93
|
-
result.captureOutput(not self.__cli.verbose)
|
|
94
|
-
try:
|
|
95
|
-
await theory.execute(testModule, data)
|
|
96
|
-
result.is_success = True
|
|
97
|
-
except Exception as ex:
|
|
98
|
-
result.is_success = False
|
|
99
|
-
result.exception = ex
|
|
100
|
-
result.releaseOutput()
|
|
101
|
-
result.stop_time = time.time()
|
|
102
|
-
result.class_name = theory.metadata.class_name
|
|
103
|
-
result.test_name = theory.metadata.name
|
|
104
|
-
results.append(result)
|
|
105
|
-
self.print_test_result(result)
|
|
106
|
-
if self.__cli.failfast and not result.is_success:
|
|
107
|
-
return results
|
|
108
|
-
except Exception as ex:
|
|
109
|
-
# module-level failure, report test failure against the module
|
|
110
|
-
# this is a best-attempt to create output that report readers
|
|
111
|
-
# can consume to show there was a broad failure in a module.
|
|
112
|
-
result = TestResult()
|
|
113
|
-
result.class_name = '*'
|
|
114
|
-
result.module_name = module_report_name
|
|
115
|
-
result.test_name = '*'
|
|
116
|
-
result.start_time = ts
|
|
117
|
-
result.stop_time = time.time()
|
|
118
|
-
result.is_success = False
|
|
119
|
-
result.exception = ex
|
|
120
|
-
results.append(result)
|
|
121
|
-
self.print_test_result(result)
|
|
122
|
-
if self.__cli.failfast:
|
|
123
|
-
return results
|
|
124
|
-
return results
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|