punit 0.0.27__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.
- punit/TestResult.py +180 -0
- punit/__init__.py +8 -0
- punit/__main__.py +55 -0
- punit/assertions/__init__.py +7 -0
- punit/assertions/collections.py +18 -0
- punit/assertions/exceptions.py +33 -0
- punit/cli.py +261 -0
- punit/discovery/TestModuleDiscovery.py +104 -0
- punit/discovery/__init__.py +8 -0
- punit/facts/Fact.py +68 -0
- punit/facts/FactManager.py +50 -0
- punit/facts/__init__.py +5 -0
- punit/reports/HtmlReportGenerator.py +92 -0
- punit/reports/JUnitReportGenerator.py +185 -0
- punit/reports/__init__.py +5 -0
- punit/runner.py +118 -0
- punit/theories/Theory.py +84 -0
- punit/theories/TheoryManager.py +67 -0
- punit/theories/__init__.py +5 -0
- punit-0.0.27.dist-info/LICENSE +21 -0
- punit-0.0.27.dist-info/METADATA +174 -0
- punit-0.0.27.dist-info/RECORD +24 -0
- punit-0.0.27.dist-info/WHEEL +5 -0
- punit-0.0.27.dist-info/top_level.txt +1 -0
punit/facts/Fact.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
from types import FunctionType, MethodType, ModuleType
|
|
6
|
+
from typing import Any, Callable, Coroutine
|
|
7
|
+
from .FactManager import FactManager
|
|
8
|
+
|
|
9
|
+
class Fact:
|
|
10
|
+
|
|
11
|
+
__className:str
|
|
12
|
+
__moduleName:str
|
|
13
|
+
__target:FunctionType|MethodType
|
|
14
|
+
__testName:str
|
|
15
|
+
|
|
16
|
+
def __init__(self, moduleName:str, target:FunctionType|MethodType, className:str = None, testName:str = None):
|
|
17
|
+
self.__className = className
|
|
18
|
+
self.__moduleName = moduleName
|
|
19
|
+
self.__target = target
|
|
20
|
+
self.__testName = testName
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def className(self) -> str:
|
|
24
|
+
return self.__className
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def moduleName(self) -> str:
|
|
28
|
+
return self.__moduleName
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def target(self) -> FunctionType|MethodType:
|
|
32
|
+
return self.__target
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def testName(self) -> str:
|
|
36
|
+
return self.__testName
|
|
37
|
+
|
|
38
|
+
async def execute(self, module:ModuleType) -> None:
|
|
39
|
+
coro:Coroutine = None
|
|
40
|
+
if hasattr(self.__target, '__qualname__') and self.__target.__qualname__.find('.') > -1:
|
|
41
|
+
qnparts = self.__target.__qualname__.split('.')
|
|
42
|
+
if isinstance(self.__target, staticmethod):
|
|
43
|
+
coro = self.__target()
|
|
44
|
+
self.__className = '.'.join(qnparts[0:-1])
|
|
45
|
+
self.__testName = qnparts[-1]
|
|
46
|
+
else:
|
|
47
|
+
qntarget = module
|
|
48
|
+
self.__className = '.'.join(qnparts[0:-1])
|
|
49
|
+
self.__testName = qnparts[-1]
|
|
50
|
+
for qnpart in qnparts[0:-1]:
|
|
51
|
+
qntarget = getattr(qntarget, qnpart)
|
|
52
|
+
if isinstance(self.__target, classmethod):
|
|
53
|
+
coro = self.__target.__func__(qntarget)
|
|
54
|
+
else:
|
|
55
|
+
coro = self.__target(qntarget())
|
|
56
|
+
else:
|
|
57
|
+
self.__className = None
|
|
58
|
+
self.__testName = self.__target.__name__
|
|
59
|
+
coro = self.__target()
|
|
60
|
+
if inspect.iscoroutine(coro):
|
|
61
|
+
await coro
|
|
62
|
+
|
|
63
|
+
def fact(target:Callable) -> Callable:
|
|
64
|
+
if (not inspect.isfunction(target)) and (not isinstance(target, classmethod)) and (not isinstance(target, staticmethod)):
|
|
65
|
+
raise Exception('@fact can only be applied to functions and methods.')
|
|
66
|
+
fact:Fact = Fact(target.__module__, target)
|
|
67
|
+
FactManager.instance().put(fact)
|
|
68
|
+
return target
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
##
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import ForwardRef
|
|
7
|
+
|
|
8
|
+
Fact = ForwardRef('Fact')
|
|
9
|
+
FactManager = ForwardRef('FactManager')
|
|
10
|
+
|
|
11
|
+
class FactManager:
|
|
12
|
+
|
|
13
|
+
__filterPattern:re.Pattern
|
|
14
|
+
__instance:'FactManager' = None
|
|
15
|
+
__modules:dict[str, list[Fact]]
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
if FactManager.__instance is not None:
|
|
19
|
+
raise Exception('Cannot create more than one instance of FactManager')
|
|
20
|
+
self.__filterPattern = None
|
|
21
|
+
self.__modules = {}
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def instance() -> FactManager:
|
|
25
|
+
if FactManager.__instance is None:
|
|
26
|
+
FactManager.__instance = FactManager()
|
|
27
|
+
return FactManager.__instance
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def filterPattern(self) -> re.Pattern:
|
|
31
|
+
return self.__filterPattern
|
|
32
|
+
|
|
33
|
+
@filterPattern.setter
|
|
34
|
+
def filterPattern(self, value:re.Pattern) -> None:
|
|
35
|
+
self.__filterPattern = value
|
|
36
|
+
|
|
37
|
+
def get(self, moduleName:str) -> list[Fact]:
|
|
38
|
+
l = self.__modules.get(moduleName)
|
|
39
|
+
if l is None:
|
|
40
|
+
l = []
|
|
41
|
+
self.__modules[moduleName] = l
|
|
42
|
+
return l
|
|
43
|
+
|
|
44
|
+
def put(self, fact:Fact) -> None:
|
|
45
|
+
filterName:str = fact.target.__name__
|
|
46
|
+
if hasattr(fact.target, '__qualname__'):
|
|
47
|
+
filterName = fact.target.__qualname__
|
|
48
|
+
if len(self.__filterPattern.findall(filterName)) > 0:
|
|
49
|
+
l = self.get(fact.moduleName)
|
|
50
|
+
l.append(fact)
|
punit/facts/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import traceback
|
|
5
|
+
from ..TestResult import TestResult
|
|
6
|
+
|
|
7
|
+
class HtmlReportGenerator:
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
def generate(self, testResults:list[TestResult]) -> str:
|
|
13
|
+
failureCount = 0
|
|
14
|
+
totalCount = 0
|
|
15
|
+
for testResult in testResults:
|
|
16
|
+
totalCount += 1
|
|
17
|
+
if not testResult.isSuccess:
|
|
18
|
+
failureCount += 1
|
|
19
|
+
testResults = testResults.copy()
|
|
20
|
+
testResults.sort(key=lambda e : e.moduleName)
|
|
21
|
+
lines = []
|
|
22
|
+
lines.append('<html>')
|
|
23
|
+
lines.append('<head><title>Test Results</title></head>')
|
|
24
|
+
lines.append('<body>')
|
|
25
|
+
lines.append("""
|
|
26
|
+
<style>
|
|
27
|
+
body { font-family: sans-serif }
|
|
28
|
+
.testresults-summary { border-bottom:solid 3px steelblue; font-size: 2em; display: flex; flex-direction: row; justify-content: center }
|
|
29
|
+
.testresults-summary div { flex-grow: 1 }
|
|
30
|
+
.testresults-summary .pass { color: green; }
|
|
31
|
+
.testresults-summary .fail { color: red; }
|
|
32
|
+
.module-name { font-style: italic }
|
|
33
|
+
.testresult { background-color: #EEE; margin-bottom: 2em; padding: 1em }
|
|
34
|
+
.testresult-pass { border: solid 1px green }
|
|
35
|
+
.testresult-fail { border: solid 1px red }
|
|
36
|
+
.testresult .test-time { border: solid 1px black; padding: 0.3em }
|
|
37
|
+
.percent-pass { color: green }
|
|
38
|
+
.percent-fail { color: red }
|
|
39
|
+
.testresult-body { margin: 1em; border: solid 1px silver; background-color: #FFF }
|
|
40
|
+
.testresult-error { border-left: solid 3px red; font-family: monospace; padding: 0.8em }
|
|
41
|
+
.testresult-stdout { font-family: monospace; max-height: 10em; overflow: auto; padding: 0.8em }
|
|
42
|
+
.testresult-stderr { font-family: monospace; max-height: 10em; overflow: auto; padding: 0.8em }
|
|
43
|
+
.test-time-pass { background-color: #EFE }
|
|
44
|
+
.test-time-fail { background-color: #FEE }
|
|
45
|
+
</style>
|
|
46
|
+
""")
|
|
47
|
+
|
|
48
|
+
lines.append('<div class="testresults-summary">')
|
|
49
|
+
lines.append('<div> </div>')
|
|
50
|
+
lines.append(f'<div>Total Executed: <span class="pass">{totalCount-failureCount}</span></div>')
|
|
51
|
+
if failureCount > 0:
|
|
52
|
+
lines.append('<div> </div>')
|
|
53
|
+
lines.append(f'<div>Total Failed: <span class="fail">{failureCount}</span></div>')
|
|
54
|
+
lines.append('<div> </div>')
|
|
55
|
+
percentstyle = 'pass' if failureCount == 0 else 'fail'
|
|
56
|
+
lines.append(f'<div class="passfail-percent">Pass/Fail <span class="percent-{percentstyle}">{100-((failureCount/totalCount)*100):.1f}%</span></div>')
|
|
57
|
+
lines.append('<div> </div>')
|
|
58
|
+
lines.append('</div>')
|
|
59
|
+
currentModuleName = None
|
|
60
|
+
for testResult in testResults:
|
|
61
|
+
if currentModuleName != testResult.moduleName:
|
|
62
|
+
if currentModuleName is not None:
|
|
63
|
+
lines.append('</div>')
|
|
64
|
+
lines.append('<div class="testresults-module">')
|
|
65
|
+
lines.append(f'<h2 class="module-name">{testResult.packageName}/{testResult.moduleName}</h2>')
|
|
66
|
+
currentModuleName = testResult.moduleName
|
|
67
|
+
passfailstyle = '-pass' if testResult.isSuccess else '-fail'
|
|
68
|
+
passfailglyph = '🟩' if testResult.isSuccess else '🟥'
|
|
69
|
+
lines.append(f'<div class="testresult testresult{passfailstyle}">')
|
|
70
|
+
lines.append('<div class="testresult-heading">')
|
|
71
|
+
lines.append(f'<span class="glyph glyph{passfailstyle}">{passfailglyph}</span>')
|
|
72
|
+
lines.append(f'<span class="test-class">{"" if testResult.className is None else testResult.className}</span>')
|
|
73
|
+
lines.append(f'<span class="test-name">{testResult.testName}</span>')
|
|
74
|
+
lines.append(f'<span class="test-time test-time{passfailstyle}">{testResult.tookPretty}</span>')
|
|
75
|
+
lines.append('</div>')
|
|
76
|
+
if not testResult.isSuccess or testResult.stdout is not None or testResult.stderr is not None:
|
|
77
|
+
lines.append('<div class="testresult-body">')
|
|
78
|
+
if not testResult.isSuccess:
|
|
79
|
+
lines.append('<div class="testresult-error">')
|
|
80
|
+
if len(f'{testResult.exception}') > 0:
|
|
81
|
+
lines.append(f'Error:<br/> {testResult.exception}<br/>')
|
|
82
|
+
lines.append(f'Traceback:<br/>{"".join(traceback.format_tb(testResult.exception.__traceback__)).replace('\n', '<br/>').replace(' ', ' ')}')
|
|
83
|
+
lines.append('</pre></div>')
|
|
84
|
+
if testResult.stdout is not None:
|
|
85
|
+
lines.append(f'<div class="testresult-stdout"><pre>{testResult.stdout}</pre></div>')
|
|
86
|
+
if testResult.stderr is not None:
|
|
87
|
+
lines.append(f'<div class="testresult-stderr"><pre>{testResult.stderr}</pre></div>')
|
|
88
|
+
lines.append('</div>')
|
|
89
|
+
lines.append('</div>')
|
|
90
|
+
lines.append('</body>')
|
|
91
|
+
lines.append('</html>')
|
|
92
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import xml.etree.ElementTree as et
|
|
6
|
+
from xml.sax.saxutils import escape
|
|
7
|
+
from ..TestResult import TestResult
|
|
8
|
+
|
|
9
|
+
class JUnitError:
|
|
10
|
+
message:str = None
|
|
11
|
+
type:str = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JUnitTestCase:
|
|
15
|
+
# attrs
|
|
16
|
+
classname:str = None
|
|
17
|
+
disabled:bool = None
|
|
18
|
+
name:str = None
|
|
19
|
+
time:float = None
|
|
20
|
+
# eles
|
|
21
|
+
error:JUnitError = None
|
|
22
|
+
failure:JUnitError = None
|
|
23
|
+
stdout:str = None
|
|
24
|
+
stderr:str = None
|
|
25
|
+
skipped:bool = None
|
|
26
|
+
def __init__(self, testResult:TestResult) -> None:
|
|
27
|
+
self.disabled = False
|
|
28
|
+
self.classname = testResult.moduleName if testResult.className is None else f'{testResult.moduleName}.{testResult.className}'
|
|
29
|
+
self.time = testResult.took
|
|
30
|
+
data = testResult.properties.get('data')
|
|
31
|
+
if data is not None and len(data) > 0:
|
|
32
|
+
data = f'({",".join([str(e) for e in data])})'
|
|
33
|
+
else:
|
|
34
|
+
data = ''
|
|
35
|
+
self.name = f'{testResult.testName}{data}'
|
|
36
|
+
if not testResult.isSuccess:
|
|
37
|
+
exc_type = type(testResult.exception)
|
|
38
|
+
error = JUnitError()
|
|
39
|
+
error.message = f'{testResult.exception}'
|
|
40
|
+
error.type = getattr(exc_type, '__name__') if not hasattr(exc_type, '__qualname__') else getattr(exc_type, '__qualname__')
|
|
41
|
+
if issubclass(exc_type, AssertionError):
|
|
42
|
+
self.failure = error
|
|
43
|
+
else:
|
|
44
|
+
self.error = error
|
|
45
|
+
self.stdout = None if testResult.stdout is None else escape(testResult.stdout)
|
|
46
|
+
self.stderr = None if testResult.stderr is None else escape(testResult.stderr)
|
|
47
|
+
self.skipped = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class JUnitTestSuite:
|
|
51
|
+
# attrs
|
|
52
|
+
name:str = None
|
|
53
|
+
hostname:str = None
|
|
54
|
+
id:int = None
|
|
55
|
+
name:str = None
|
|
56
|
+
package:str = None
|
|
57
|
+
timestamp:datetime.datetime = None
|
|
58
|
+
# eles
|
|
59
|
+
testCases:list[JUnitTestCase] = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def disabled(self) -> int:
|
|
63
|
+
result:int = 0
|
|
64
|
+
for testCase in self.testCases:
|
|
65
|
+
if testCase.disabled == True:
|
|
66
|
+
result += 1
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def errors(self) -> int:
|
|
71
|
+
result:int = 0
|
|
72
|
+
for testCase in self.testCases:
|
|
73
|
+
if testCase.error is not None:
|
|
74
|
+
result += 1
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def failures(self) -> int:
|
|
79
|
+
result:int = 0
|
|
80
|
+
for testCase in self.testCases:
|
|
81
|
+
if testCase.failure is not None:
|
|
82
|
+
result += 1
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def skipped(self) -> int:
|
|
87
|
+
result:int = 0
|
|
88
|
+
for testCase in self.testCases:
|
|
89
|
+
if testCase.skipped:
|
|
90
|
+
result += 1
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def tests(self) -> float:
|
|
95
|
+
return len(self.testCases)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def time(self) -> float:
|
|
99
|
+
result:float = 0.0
|
|
100
|
+
for testCase in self.testCases:
|
|
101
|
+
result += testCase.time
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class JUnitReportGenerator:
|
|
106
|
+
|
|
107
|
+
def __init__(self):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def generate(self, testResults:list[TestResult]) -> str:
|
|
111
|
+
# transform to intermediary model
|
|
112
|
+
testSuites:dict[str, JUnitTestSuite] = {}
|
|
113
|
+
ts = 0
|
|
114
|
+
for testResult in testResults:
|
|
115
|
+
if ts < testResult.stopTime:
|
|
116
|
+
ts = testResult.stopTime
|
|
117
|
+
testSuite = testSuites.get(testResult.moduleName)
|
|
118
|
+
if testSuite is None:
|
|
119
|
+
testSuite = JUnitTestSuite()
|
|
120
|
+
testSuite.name = testResult.moduleName
|
|
121
|
+
testSuite.id = len(testSuites)
|
|
122
|
+
testSuite.hostname = testResult.hostName
|
|
123
|
+
testSuite.package = testResult.packageName
|
|
124
|
+
testSuite.testCases = []
|
|
125
|
+
testSuites[testResult.moduleName] = testSuite
|
|
126
|
+
testSuite.testCases.append(JUnitTestCase(testResult))
|
|
127
|
+
testSuite.timestamp = datetime.datetime.fromtimestamp(ts)
|
|
128
|
+
# materialize as xml
|
|
129
|
+
totalDisabledCount = 0
|
|
130
|
+
totalErrorCount = 0
|
|
131
|
+
totalFailureCount = 0
|
|
132
|
+
totalTestCount = 0
|
|
133
|
+
totalTime = 0
|
|
134
|
+
testSuitesEle:et.Element = et.Element('testsuites')
|
|
135
|
+
for testSuiteName in testSuites:
|
|
136
|
+
testSuite:JUnitTestSuite = testSuites[testSuiteName]
|
|
137
|
+
totalTime += testSuite.time
|
|
138
|
+
totalDisabledCount += testSuite.disabled
|
|
139
|
+
totalErrorCount += testSuite.errors
|
|
140
|
+
totalFailureCount += testSuite.failures
|
|
141
|
+
totalTestCount += testSuite.tests
|
|
142
|
+
testSuiteEle = et.SubElement(testSuitesEle, 'testsuite')
|
|
143
|
+
testSuiteEle.attrib['disabled'] = str(testSuite.disabled)
|
|
144
|
+
testSuiteEle.attrib['errors'] = str(testSuite.errors)
|
|
145
|
+
testSuiteEle.attrib['failures'] = str(testSuite.failures)
|
|
146
|
+
testSuiteEle.attrib['id'] = str(testSuite.id)
|
|
147
|
+
testSuiteEle.attrib['name'] = str(testSuite.name)
|
|
148
|
+
testSuiteEle.attrib['package'] = str(testSuite.package)
|
|
149
|
+
testSuiteEle.attrib['timestamp'] = str(testSuite.timestamp)
|
|
150
|
+
testSuiteEle.attrib['hostname'] = str(testSuite.hostname)
|
|
151
|
+
testSuiteEle.attrib['tests'] = str(testSuite.tests)
|
|
152
|
+
testSuiteEle.attrib['time'] = f'{testSuite.time:.6f}'.rstrip('0').rstrip('.')
|
|
153
|
+
for testCase in testSuite.testCases:
|
|
154
|
+
testCaseEle = et.SubElement(testSuiteEle, 'testcase')
|
|
155
|
+
testCaseEle.attrib['name'] = testCase.name
|
|
156
|
+
if testCase.classname is not None:
|
|
157
|
+
testCaseEle.attrib['classname'] = testCase.classname
|
|
158
|
+
testCaseEle.attrib['time'] = f'{testCase.time:.6f}'.rstrip('0').rstrip('.')
|
|
159
|
+
if testCase.skipped:
|
|
160
|
+
et.SubElement(testCaseEle, 'skipped')
|
|
161
|
+
if testCase.error is not None:
|
|
162
|
+
ele = et.SubElement(testCaseEle, 'error')
|
|
163
|
+
if testCase.error.type is not None:
|
|
164
|
+
ele.attrib['type'] = testCase.error.type
|
|
165
|
+
if testCase.error.message is not None:
|
|
166
|
+
ele.attrib['message'] = testCase.error.message
|
|
167
|
+
if testCase.failure is not None:
|
|
168
|
+
ele = et.SubElement(testCaseEle, 'failure')
|
|
169
|
+
if testCase.failure.type is not None:
|
|
170
|
+
ele.attrib['type'] = testCase.failure.type
|
|
171
|
+
if testCase.failure.message is not None:
|
|
172
|
+
ele.attrib['message'] = testCase.failure.message
|
|
173
|
+
if testCase.stdout is not None:
|
|
174
|
+
ele = et.SubElement(testCaseEle, 'system-out')
|
|
175
|
+
ele.text = testCase.stdout
|
|
176
|
+
if testCase.stderr is not None:
|
|
177
|
+
ele = et.SubElement(testCaseEle, 'system-err')
|
|
178
|
+
ele.text = testCase.stderr
|
|
179
|
+
testSuitesEle.attrib['disabled'] = str(totalDisabledCount)
|
|
180
|
+
testSuitesEle.attrib['errors'] = str(totalErrorCount)
|
|
181
|
+
testSuitesEle.attrib['failures'] = str(totalErrorCount)
|
|
182
|
+
testSuitesEle.attrib['tests'] = str(totalTestCount)
|
|
183
|
+
testSuitesEle.attrib['time'] = f'{totalTime:.6f}'.rstrip('0').rstrip('.')
|
|
184
|
+
et.indent(testSuitesEle, space=" ")
|
|
185
|
+
return f'<?xml version="1.0" encoding="UTF-8"?>\n{et.tostring(testSuitesEle).decode()}\n'
|
punit/runner.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
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 .cli import CommandLineInterface
|
|
10
|
+
from .facts.FactManager import FactManager
|
|
11
|
+
from .theories.TheoryManager import TheoryManager
|
|
12
|
+
from .TestResult import TestResult
|
|
13
|
+
|
|
14
|
+
class TestRunner:
|
|
15
|
+
|
|
16
|
+
__cli:CommandLineInterface
|
|
17
|
+
__filenames:list[str]
|
|
18
|
+
__testPackageName:str
|
|
19
|
+
|
|
20
|
+
def __init__(self, testPackageName:str, filenames:list[str], cli:CommandLineInterface):
|
|
21
|
+
self.__cli = cli
|
|
22
|
+
self.__filenames = filenames
|
|
23
|
+
self.__testPackageName = testPackageName
|
|
24
|
+
|
|
25
|
+
def printTestResult(self, testResult:TestResult):
|
|
26
|
+
if self.__cli.quiet:
|
|
27
|
+
return
|
|
28
|
+
glyph = '🟩' if testResult.isSuccess else '🟥'
|
|
29
|
+
data = testResult.properties.get('data')
|
|
30
|
+
if data is None:
|
|
31
|
+
data = ''
|
|
32
|
+
else:
|
|
33
|
+
try:
|
|
34
|
+
data = f'({",".join([str(e) for e in data])})'
|
|
35
|
+
except:
|
|
36
|
+
data = '(???)'
|
|
37
|
+
print(f'{glyph} {testResult.moduleName}/{"" if testResult.className is None or len(testResult.className) == 0 else f"{testResult.className}/"}{testResult.testName}{data} [{testResult.tookPretty}]')
|
|
38
|
+
if self.__cli.verbose and (not testResult.isSuccess) and testResult.exception is not None:
|
|
39
|
+
print(f'Test File:\n {testResult.fileName}\nError:\n {testResult.exception}\n Traceback:\n{"".join(traceback.format_tb(testResult.exception.__traceback__))}')
|
|
40
|
+
|
|
41
|
+
async def run(self) -> list[TestResult]:
|
|
42
|
+
results:list[TestResult] = []
|
|
43
|
+
# TODO: aliasing
|
|
44
|
+
hostName:str = socket.gethostname()
|
|
45
|
+
testPackagePath = os.path.join(os.path.abspath(os.curdir), self.__testPackageName).replace('\\', '/')
|
|
46
|
+
for filename in self.__filenames:
|
|
47
|
+
ts = time.time()
|
|
48
|
+
moduleImportName = filename.replace(testPackagePath, '').replace('/', '.').replace('.py', '')
|
|
49
|
+
moduleReportName = moduleImportName.lstrip('.')
|
|
50
|
+
try:
|
|
51
|
+
testModule = importlib.import_module(moduleImportName, self.__testPackageName)
|
|
52
|
+
# execute all facts
|
|
53
|
+
facts = FactManager.instance().get(testModule.__name__)
|
|
54
|
+
for fact in facts:
|
|
55
|
+
result:TestResult = TestResult()
|
|
56
|
+
result.hostName = hostName
|
|
57
|
+
result.packageName = self.__testPackageName
|
|
58
|
+
result.fileName = filename
|
|
59
|
+
result.moduleName = moduleReportName
|
|
60
|
+
result.startTime = time.time()
|
|
61
|
+
result.captureOutput(not self.__cli.verbose)
|
|
62
|
+
try:
|
|
63
|
+
await fact.execute(testModule)
|
|
64
|
+
result.isSuccess = True
|
|
65
|
+
except Exception as ex:
|
|
66
|
+
result.isSuccess = False
|
|
67
|
+
result.exception = ex
|
|
68
|
+
result.releaseOutput()
|
|
69
|
+
result.stopTime = time.time()
|
|
70
|
+
result.className = fact.className
|
|
71
|
+
result.testName = fact.testName
|
|
72
|
+
results.append(result)
|
|
73
|
+
self.printTestResult(result)
|
|
74
|
+
if self.__cli.failfast and not result.isSuccess:
|
|
75
|
+
return results
|
|
76
|
+
# execute all theories
|
|
77
|
+
theories = TheoryManager.instance().get(testModule.__name__)
|
|
78
|
+
for theory in theories:
|
|
79
|
+
for data in theory.datas:
|
|
80
|
+
result:TestResult = TestResult()
|
|
81
|
+
result.hostName = hostName
|
|
82
|
+
result.packageName = self.__testPackageName
|
|
83
|
+
result.properties['data'] = data
|
|
84
|
+
result.fileName = filename
|
|
85
|
+
result.moduleName = moduleReportName
|
|
86
|
+
result.startTime = time.time()
|
|
87
|
+
result.captureOutput(not self.__cli.verbose)
|
|
88
|
+
try:
|
|
89
|
+
await theory.execute(testModule, data)
|
|
90
|
+
result.isSuccess = True
|
|
91
|
+
except Exception as ex:
|
|
92
|
+
result.isSuccess = False
|
|
93
|
+
result.exception = ex
|
|
94
|
+
result.releaseOutput()
|
|
95
|
+
result.stopTime = time.time()
|
|
96
|
+
result.className = theory.className
|
|
97
|
+
result.testName = theory.testName
|
|
98
|
+
results.append(result)
|
|
99
|
+
self.printTestResult(result)
|
|
100
|
+
if self.__cli.failfast and not result.isSuccess:
|
|
101
|
+
return results
|
|
102
|
+
except Exception as ex:
|
|
103
|
+
# module-level failure, report test failure against the module
|
|
104
|
+
# this is a best-attempt to create output that report readers
|
|
105
|
+
# can consume to show there was a broad failure in a module.
|
|
106
|
+
result = TestResult()
|
|
107
|
+
result.className = '*'
|
|
108
|
+
result.moduleName = moduleReportName
|
|
109
|
+
result.testName = '*'
|
|
110
|
+
result.startTime = ts
|
|
111
|
+
result.stopTime = time.time()
|
|
112
|
+
result.isSuccess = False
|
|
113
|
+
result.exception = ex
|
|
114
|
+
results.append(result)
|
|
115
|
+
self.printTestResult(result)
|
|
116
|
+
if self.__cli.failfast:
|
|
117
|
+
return results
|
|
118
|
+
return results
|
punit/theories/Theory.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
##
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from types import FunctionType, MethodType, ModuleType
|
|
7
|
+
from typing import Any, Callable, Coroutine
|
|
8
|
+
from .TheoryManager import TheoryManager
|
|
9
|
+
|
|
10
|
+
class Theory:
|
|
11
|
+
|
|
12
|
+
__className:str
|
|
13
|
+
__datas:list[tuple]
|
|
14
|
+
__moduleName:str
|
|
15
|
+
__target:FunctionType|MethodType
|
|
16
|
+
__testName:str
|
|
17
|
+
|
|
18
|
+
def __init__(self, moduleName:str, target:FunctionType|MethodType, className:str = None, testName:str = None):
|
|
19
|
+
self.__className = className
|
|
20
|
+
self.__datas = []
|
|
21
|
+
self.__moduleName = moduleName
|
|
22
|
+
self.__target = target
|
|
23
|
+
self.__testName = testName
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def className(self) -> str:
|
|
27
|
+
return self.__className
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def datas(self) -> list[tuple]:
|
|
31
|
+
return self.__datas
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def moduleName(self) -> str:
|
|
35
|
+
return self.__moduleName
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def target(self) -> FunctionType|MethodType:
|
|
39
|
+
return self.__target
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def testName(self) -> str:
|
|
43
|
+
return self.__testName
|
|
44
|
+
|
|
45
|
+
async def execute(self, module:ModuleType, data:tuple) -> None:
|
|
46
|
+
coro:Coroutine = None
|
|
47
|
+
if hasattr(self.__target, '__qualname__') and self.__target.__qualname__.find('.') > -1:
|
|
48
|
+
qnparts = self.__target.__qualname__.split('.')
|
|
49
|
+
if isinstance(self.__target, staticmethod):
|
|
50
|
+
coro = self.__target(*data)
|
|
51
|
+
self.__className = '.'.join(qnparts[0:-1])
|
|
52
|
+
self.__testName = qnparts[-1]
|
|
53
|
+
else:
|
|
54
|
+
qntarget = module
|
|
55
|
+
self.__testName = qnparts[-1]
|
|
56
|
+
self.__className = '.'.join(qnparts[0:-1])
|
|
57
|
+
for qnpart in qnparts[0:-1]:
|
|
58
|
+
qntarget = getattr(qntarget, qnpart)
|
|
59
|
+
if isinstance(self.__target, classmethod):
|
|
60
|
+
args = (qntarget,) + data
|
|
61
|
+
coro = self.__target.__func__(*args)
|
|
62
|
+
else:
|
|
63
|
+
args = (qntarget(),) + data
|
|
64
|
+
coro = self.__target(*args)
|
|
65
|
+
else:
|
|
66
|
+
self.__className = None
|
|
67
|
+
self.__testName = self.__target.__name__
|
|
68
|
+
coro = self.__target(*data)
|
|
69
|
+
if inspect.iscoroutine(coro):
|
|
70
|
+
await coro
|
|
71
|
+
|
|
72
|
+
def theory(target:Callable) -> Callable:
|
|
73
|
+
if (not inspect.isfunction(target)) and (not isinstance(target, classmethod)) and (not isinstance(target, staticmethod)):
|
|
74
|
+
raise Exception('@theory can only be applied to functions and methods.')
|
|
75
|
+
theory:Theory = Theory(target.__module__, target)
|
|
76
|
+
TheoryManager.instance().put(theory)
|
|
77
|
+
return target
|
|
78
|
+
|
|
79
|
+
def inlinedata(*args) -> Callable:
|
|
80
|
+
def wrapper(target:Callable) -> Callable:
|
|
81
|
+
if args is not None and len(args) > 0:
|
|
82
|
+
TheoryManager.instance().withData(target, args)
|
|
83
|
+
return target
|
|
84
|
+
return wrapper
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
##
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Callable, ForwardRef
|
|
7
|
+
|
|
8
|
+
Theory = ForwardRef('Theory')
|
|
9
|
+
TheoryManager = ForwardRef('TheoryManager')
|
|
10
|
+
|
|
11
|
+
class TheoryManager:
|
|
12
|
+
|
|
13
|
+
__filterPattern:re.Pattern
|
|
14
|
+
__instance:'TheoryManager' = None
|
|
15
|
+
__modules:dict[str, list[Theory]]
|
|
16
|
+
__datas:dict[Callable, list[tuple]]
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
if TheoryManager.__instance is not None:
|
|
20
|
+
raise Exception('Cannot create more than one instance of TheoryManager')
|
|
21
|
+
self.__filterPattern = None
|
|
22
|
+
self.__modules = {}
|
|
23
|
+
self.__datas = {}
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def instance() -> TheoryManager:
|
|
27
|
+
if TheoryManager.__instance is None:
|
|
28
|
+
TheoryManager.__instance = TheoryManager()
|
|
29
|
+
return TheoryManager.__instance
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def filterPattern(self) -> re.Pattern:
|
|
33
|
+
return self.__filterPattern
|
|
34
|
+
|
|
35
|
+
@filterPattern.setter
|
|
36
|
+
def filterPattern(self, value:re.Pattern) -> None:
|
|
37
|
+
self.__filterPattern = value
|
|
38
|
+
|
|
39
|
+
def get(self, moduleName:str) -> list[Theory]:
|
|
40
|
+
l = self.__modules.get(moduleName)
|
|
41
|
+
if l is None:
|
|
42
|
+
l = []
|
|
43
|
+
self.__modules[moduleName] = l
|
|
44
|
+
return l
|
|
45
|
+
|
|
46
|
+
def put(self, theory:Theory) -> None:
|
|
47
|
+
filterName:str = theory.target.__name__
|
|
48
|
+
if hasattr(theory.target, '__qualname__'):
|
|
49
|
+
filterName = theory.target.__qualname__
|
|
50
|
+
if len(self.__filterPattern.findall(filterName)) > 0:
|
|
51
|
+
l = self.get(theory.moduleName)
|
|
52
|
+
d = self.__datas.get(theory.target)
|
|
53
|
+
if d is not None:
|
|
54
|
+
d.reverse()
|
|
55
|
+
for data in d:
|
|
56
|
+
theory.datas.append(data)
|
|
57
|
+
l.append(theory)
|
|
58
|
+
|
|
59
|
+
def withData(self, target:Callable, data:tuple) -> None:
|
|
60
|
+
# TODO: data acquisition should be deferred until put() since that is where filterPattern
|
|
61
|
+
# is applied, but for current implementation `@inlinedata()` is not affected. more advanced
|
|
62
|
+
# data decorators may benefit from deferral (for example, data coming from an API or DB.)
|
|
63
|
+
d = self.__datas.get(target)
|
|
64
|
+
if d is None:
|
|
65
|
+
d = []
|
|
66
|
+
self.__datas[target] = d
|
|
67
|
+
d.append(data)
|