pUnit 1.2.0__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 +186 -0
- punit/__init__.py +21 -0
- punit/__main__.py +59 -0
- punit/assertions/__init__.py +11 -0
- punit/assertions/collections.py +76 -0
- punit/assertions/exceptions.py +37 -0
- punit/assertions/strings.py +55 -0
- punit/cli.py +294 -0
- punit/discovery/TestModuleDiscovery.py +113 -0
- punit/discovery/__init__.py +9 -0
- punit/facts/Fact.py +82 -0
- punit/facts/FactManager.py +94 -0
- punit/facts/__init__.py +11 -0
- punit/reports/HtmlReportGenerator.py +93 -0
- punit/reports/JUnitReportGenerator.py +196 -0
- punit/reports/__init__.py +5 -0
- punit/runner.py +118 -0
- punit/theories/Theory.py +100 -0
- punit/theories/TheoryManager.py +110 -0
- punit/theories/__init__.py +11 -0
- punit/traits/Trait.py +34 -0
- punit/traits/__init__.py +9 -0
- punit-1.2.0.data/data/bin/punit +7 -0
- punit-1.2.0.dist-info/METADATA +207 -0
- punit-1.2.0.dist-info/RECORD +28 -0
- punit-1.2.0.dist-info/WHEEL +5 -0
- punit-1.2.0.dist-info/licenses/LICENSE +21 -0
- punit-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 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) if totalCount > 0 else 1)*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 testResult.exception is not None:
|
|
81
|
+
if len(f'{testResult.exception}') > 0:
|
|
82
|
+
lines.append(f'Error:<br/> {testResult.exception}<br/>')
|
|
83
|
+
lines.append(f'Traceback:<br/>{"".join(traceback.format_tb(testResult.exception.__traceback__)).replace('\n', '<br/>').replace(' ', ' ')}')
|
|
84
|
+
lines.append('</pre></div>')
|
|
85
|
+
if testResult.stdout is not None:
|
|
86
|
+
lines.append(f'<div class="testresult-stdout"><pre>{testResult.stdout}</pre></div>')
|
|
87
|
+
if testResult.stderr is not None:
|
|
88
|
+
lines.append(f'<div class="testresult-stderr"><pre>{testResult.stderr}</pre></div>')
|
|
89
|
+
lines.append('</div>')
|
|
90
|
+
lines.append('</div>')
|
|
91
|
+
lines.append('</body>')
|
|
92
|
+
lines.append('</html>')
|
|
93
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import datetime
|
|
6
|
+
import xml.etree.ElementTree as et
|
|
7
|
+
from xml.sax.saxutils import escape
|
|
8
|
+
from ..TestResult import TestResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JUnitError:
|
|
12
|
+
message:Optional[str] = None
|
|
13
|
+
type:Optional[str] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JUnitTestCase:
|
|
17
|
+
# attrs
|
|
18
|
+
classname:Optional[str] = None
|
|
19
|
+
disabled:Optional[bool] = None
|
|
20
|
+
name:Optional[str] = None
|
|
21
|
+
time:Optional[float] = None
|
|
22
|
+
# eles
|
|
23
|
+
error:Optional[JUnitError] = None
|
|
24
|
+
failure:Optional[JUnitError] = None
|
|
25
|
+
stdout:Optional[str] = None
|
|
26
|
+
stderr:Optional[str] = None
|
|
27
|
+
skipped:Optional[bool] = None
|
|
28
|
+
def __init__(self, testResult:TestResult) -> None:
|
|
29
|
+
self.disabled = False
|
|
30
|
+
self.classname = testResult.moduleName if testResult.className is None else f'{testResult.moduleName}.{testResult.className}'
|
|
31
|
+
self.time = testResult.took
|
|
32
|
+
data = testResult.properties.get('data')
|
|
33
|
+
if data is not None and len(data) > 0:
|
|
34
|
+
data = f'({",".join([str(e) for e in data])})'
|
|
35
|
+
else:
|
|
36
|
+
data = ''
|
|
37
|
+
self.name = f'{testResult.testName}{data}'
|
|
38
|
+
if not testResult.isSuccess:
|
|
39
|
+
exc_type = type(testResult.exception)
|
|
40
|
+
error = JUnitError()
|
|
41
|
+
error.message = f'{testResult.exception}'
|
|
42
|
+
error.type = getattr(exc_type, '__name__') if not hasattr(exc_type, '__qualname__') else getattr(exc_type, '__qualname__')
|
|
43
|
+
if issubclass(exc_type, AssertionError):
|
|
44
|
+
self.failure = error
|
|
45
|
+
else:
|
|
46
|
+
self.error = error
|
|
47
|
+
self.stdout = None if testResult.stdout is None else escape(testResult.stdout)
|
|
48
|
+
self.stderr = None if testResult.stderr is None else escape(testResult.stderr)
|
|
49
|
+
self.skipped = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class JUnitTestSuite:
|
|
53
|
+
# attrs
|
|
54
|
+
name:Optional[str] = None
|
|
55
|
+
hostname:Optional[str] = None
|
|
56
|
+
id:Optional[int] = None
|
|
57
|
+
name:Optional[str] = None
|
|
58
|
+
package:Optional[str] = None
|
|
59
|
+
timestamp:Optional[datetime.datetime] = None
|
|
60
|
+
# eles
|
|
61
|
+
testCases:Optional[list[JUnitTestCase]] = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def disabled(self) -> int:
|
|
65
|
+
result:int = 0
|
|
66
|
+
if self.testCases is not None:
|
|
67
|
+
for testCase in self.testCases:
|
|
68
|
+
if testCase.disabled == True:
|
|
69
|
+
result += 1
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def errors(self) -> int:
|
|
74
|
+
result:int = 0
|
|
75
|
+
if self.testCases is not None:
|
|
76
|
+
for testCase in self.testCases:
|
|
77
|
+
if testCase.error is not None:
|
|
78
|
+
result += 1
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def failures(self) -> int:
|
|
83
|
+
result:int = 0
|
|
84
|
+
if self.testCases is not None:
|
|
85
|
+
for testCase in self.testCases:
|
|
86
|
+
if testCase.failure is not None:
|
|
87
|
+
result += 1
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def skipped(self) -> int:
|
|
92
|
+
result:int = 0
|
|
93
|
+
if self.testCases is not None:
|
|
94
|
+
for testCase in self.testCases:
|
|
95
|
+
if testCase.skipped:
|
|
96
|
+
result += 1
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def tests(self) -> float:
|
|
101
|
+
return 0 if self.testCases is None else len(self.testCases)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def time(self) -> float:
|
|
105
|
+
result:float = 0.0
|
|
106
|
+
if self.testCases is not None:
|
|
107
|
+
for testCase in self.testCases:
|
|
108
|
+
result += testCase.time if testCase.time is not None else 0
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class JUnitReportGenerator:
|
|
113
|
+
|
|
114
|
+
def __init__(self):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def generate(self, testResults:list[TestResult]) -> str:
|
|
118
|
+
# transform to intermediary model
|
|
119
|
+
testSuites:dict[str, JUnitTestSuite] = {}
|
|
120
|
+
ts = 0
|
|
121
|
+
for testResult in testResults:
|
|
122
|
+
if testResult.stopTime is not None and ts < testResult.stopTime:
|
|
123
|
+
ts = testResult.stopTime
|
|
124
|
+
testSuite:JUnitTestSuite|None = testSuites.get(testResult.moduleName)
|
|
125
|
+
if testSuite is None:
|
|
126
|
+
testSuite = JUnitTestSuite()
|
|
127
|
+
testSuite.name = testResult.moduleName
|
|
128
|
+
testSuite.id = len(testSuites)
|
|
129
|
+
testSuite.hostname = testResult.hostName
|
|
130
|
+
testSuite.package = testResult.packageName
|
|
131
|
+
testSuite.testCases = []
|
|
132
|
+
testSuites[testResult.moduleName] = testSuite
|
|
133
|
+
if testSuite.testCases is None:
|
|
134
|
+
testSuite.testCases = []
|
|
135
|
+
testSuite.testCases.append(JUnitTestCase(testResult))
|
|
136
|
+
testSuite.timestamp = datetime.datetime.fromtimestamp(ts)
|
|
137
|
+
# materialize as xml
|
|
138
|
+
totalDisabledCount = 0
|
|
139
|
+
totalErrorCount = 0
|
|
140
|
+
totalFailureCount = 0
|
|
141
|
+
totalTestCount = 0
|
|
142
|
+
totalTime = 0
|
|
143
|
+
testSuitesEle:et.Element = et.Element('testsuites')
|
|
144
|
+
for testSuiteName in testSuites:
|
|
145
|
+
testSuite:JUnitTestSuite|None = testSuites[testSuiteName]
|
|
146
|
+
if testSuite is not None:
|
|
147
|
+
totalTime += testSuite.time
|
|
148
|
+
totalDisabledCount += testSuite.disabled
|
|
149
|
+
totalErrorCount += testSuite.errors
|
|
150
|
+
totalFailureCount += testSuite.failures
|
|
151
|
+
totalTestCount += testSuite.tests
|
|
152
|
+
testSuiteEle = et.SubElement(testSuitesEle, 'testsuite')
|
|
153
|
+
testSuiteEle.attrib['disabled'] = str(testSuite.disabled)
|
|
154
|
+
testSuiteEle.attrib['errors'] = str(testSuite.errors)
|
|
155
|
+
testSuiteEle.attrib['failures'] = str(testSuite.failures)
|
|
156
|
+
testSuiteEle.attrib['id'] = str(testSuite.id)
|
|
157
|
+
testSuiteEle.attrib['name'] = str(testSuite.name)
|
|
158
|
+
testSuiteEle.attrib['package'] = str(testSuite.package)
|
|
159
|
+
testSuiteEle.attrib['timestamp'] = str(testSuite.timestamp)
|
|
160
|
+
testSuiteEle.attrib['hostname'] = str(testSuite.hostname)
|
|
161
|
+
testSuiteEle.attrib['tests'] = str(testSuite.tests)
|
|
162
|
+
testSuiteEle.attrib['time'] = f'{testSuite.time:.6f}'.rstrip('0').rstrip('.')
|
|
163
|
+
if testSuite.testCases is not None:
|
|
164
|
+
for testCase in testSuite.testCases:
|
|
165
|
+
testCaseEle = et.SubElement(testSuiteEle, 'testcase')
|
|
166
|
+
testCaseEle.attrib['name'] = testCase.name if testCase.name is not None else 'UNKNOWN'
|
|
167
|
+
if testCase.classname is not None:
|
|
168
|
+
testCaseEle.attrib['classname'] = testCase.classname
|
|
169
|
+
testCaseEle.attrib['time'] = f'{testCase.time:.6f}'.rstrip('0').rstrip('.')
|
|
170
|
+
if testCase.skipped:
|
|
171
|
+
et.SubElement(testCaseEle, 'skipped')
|
|
172
|
+
if testCase.error is not None:
|
|
173
|
+
ele = et.SubElement(testCaseEle, 'error')
|
|
174
|
+
if testCase.error.type is not None:
|
|
175
|
+
ele.attrib['type'] = testCase.error.type
|
|
176
|
+
if testCase.error.message is not None:
|
|
177
|
+
ele.attrib['message'] = testCase.error.message
|
|
178
|
+
if testCase.failure is not None:
|
|
179
|
+
ele = et.SubElement(testCaseEle, 'failure')
|
|
180
|
+
if testCase.failure.type is not None:
|
|
181
|
+
ele.attrib['type'] = testCase.failure.type
|
|
182
|
+
if testCase.failure.message is not None:
|
|
183
|
+
ele.attrib['message'] = testCase.failure.message
|
|
184
|
+
if testCase.stdout is not None:
|
|
185
|
+
ele = et.SubElement(testCaseEle, 'system-out')
|
|
186
|
+
ele.text = testCase.stdout
|
|
187
|
+
if testCase.stderr is not None:
|
|
188
|
+
ele = et.SubElement(testCaseEle, 'system-err')
|
|
189
|
+
ele.text = testCase.stderr
|
|
190
|
+
testSuitesEle.attrib['disabled'] = str(totalDisabledCount)
|
|
191
|
+
testSuitesEle.attrib['errors'] = str(totalErrorCount)
|
|
192
|
+
testSuitesEle.attrib['failures'] = str(totalErrorCount)
|
|
193
|
+
testSuitesEle.attrib['tests'] = str(totalTestCount)
|
|
194
|
+
testSuitesEle.attrib['time'] = f'{totalTime:.6f}'.rstrip('0').rstrip('.')
|
|
195
|
+
et.indent(testSuitesEle, space=" ")
|
|
196
|
+
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: © 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,100 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
##
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from types import FunctionType, MethodType, ModuleType
|
|
7
|
+
from typing import Callable, Coroutine, Optional, cast
|
|
8
|
+
|
|
9
|
+
from ..traits.Trait import Trait
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Theory:
|
|
13
|
+
|
|
14
|
+
__className:Optional[str]
|
|
15
|
+
__datas:list[tuple]
|
|
16
|
+
__moduleName:str
|
|
17
|
+
__target:FunctionType|MethodType
|
|
18
|
+
__testName:Optional[str]
|
|
19
|
+
__traits:list[Trait]
|
|
20
|
+
|
|
21
|
+
def __init__(self, moduleName:str, target:FunctionType|MethodType|Callable, className:Optional[str] = None, testName:Optional[str] = None):
|
|
22
|
+
self.__className = className
|
|
23
|
+
self.__datas = []
|
|
24
|
+
self.__moduleName = moduleName
|
|
25
|
+
self.__target = target
|
|
26
|
+
self.__testName = testName
|
|
27
|
+
self.__traits = []
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def className(self) -> Optional[str]:
|
|
31
|
+
return self.__className
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def datas(self) -> list[tuple]:
|
|
35
|
+
return self.__datas
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def moduleName(self) -> str:
|
|
39
|
+
return self.__moduleName
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def target(self) -> FunctionType|MethodType:
|
|
43
|
+
return self.__target
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def testName(self) -> str:
|
|
47
|
+
return self.__testName if self.__testName is not None else self.__target.__qualname__.split('.')[-1]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def traits(self) -> list[Trait]:
|
|
51
|
+
return self.__traits
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def filterName(self) -> str:
|
|
55
|
+
return f'{self.moduleName}/{"" if self.className is None or len(self.className) == 0 else f"{self.className}/"}{self.testName}'
|
|
56
|
+
|
|
57
|
+
async def execute(self, module:ModuleType, data:tuple) -> None:
|
|
58
|
+
coro:Coroutine|None = None
|
|
59
|
+
if hasattr(self.__target, '__qualname__') and self.__target.__qualname__.find('.') > -1:
|
|
60
|
+
qnparts = self.__target.__qualname__.split('.')
|
|
61
|
+
if isinstance(self.__target, staticmethod):
|
|
62
|
+
coro = self.__target(*data)
|
|
63
|
+
self.__className = '.'.join(qnparts[0:-1])
|
|
64
|
+
self.__testName = qnparts[-1]
|
|
65
|
+
else:
|
|
66
|
+
qntarget = module
|
|
67
|
+
self.__testName = qnparts[-1]
|
|
68
|
+
self.__className = '.'.join(qnparts[0:-1])
|
|
69
|
+
for qnpart in qnparts[0:-1]:
|
|
70
|
+
qntarget = getattr(qntarget, qnpart)
|
|
71
|
+
if isinstance(self.__target, classmethod):
|
|
72
|
+
args = (qntarget,) + data
|
|
73
|
+
coro = self.__target.__func__(*args)
|
|
74
|
+
else:
|
|
75
|
+
args = (cast(Callable,qntarget)(),) + data
|
|
76
|
+
coro = self.__target(*args)
|
|
77
|
+
else:
|
|
78
|
+
self.__className = None
|
|
79
|
+
self.__testName = self.__target.__name__
|
|
80
|
+
coro = self.__target(*data)
|
|
81
|
+
if inspect.iscoroutine(coro):
|
|
82
|
+
await coro
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def theory(target:Callable) -> Callable:
|
|
86
|
+
from .TheoryManager import TheoryManager
|
|
87
|
+
if (not inspect.isfunction(target)) and (not isinstance(target, classmethod)) and (not isinstance(target, staticmethod)):
|
|
88
|
+
raise Exception('@theory can only be applied to functions and methods.')
|
|
89
|
+
theory:Theory = Theory(target.__module__, target)
|
|
90
|
+
TheoryManager.instance().put(theory)
|
|
91
|
+
return target
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def inlinedata(*args) -> Callable:
|
|
95
|
+
def wrapper(target:Callable) -> Callable:
|
|
96
|
+
if args is not None and len(args) > 0:
|
|
97
|
+
from .TheoryManager import TheoryManager
|
|
98
|
+
TheoryManager.instance().withData(target, args)
|
|
99
|
+
return target
|
|
100
|
+
return wrapper
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
##
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from ..traits.Trait import Trait
|
|
9
|
+
from .Theory import Theory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TheoryManager:
|
|
13
|
+
|
|
14
|
+
__excludeTraits:list[Trait]
|
|
15
|
+
__filterPattern:Optional[re.Pattern]
|
|
16
|
+
__includeTraits:list[Trait]
|
|
17
|
+
__instance:'TheoryManager'|None = None
|
|
18
|
+
__modules:dict[str, list[Theory]]
|
|
19
|
+
__datas:dict[Callable, list[tuple]]
|
|
20
|
+
__traits:dict[Callable, list[Trait]]
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
if TheoryManager.__instance is not None:
|
|
24
|
+
raise Exception('Cannot create more than one instance of TheoryManager')
|
|
25
|
+
self.__filterPattern = None
|
|
26
|
+
self.__modules = {}
|
|
27
|
+
self.__datas = {}
|
|
28
|
+
self.__traits = {}
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def instance() -> TheoryManager:
|
|
32
|
+
if TheoryManager.__instance is None:
|
|
33
|
+
TheoryManager.__instance = TheoryManager()
|
|
34
|
+
return TheoryManager.__instance
|
|
35
|
+
@property
|
|
36
|
+
def excludeTraits(self) -> list[Trait]:
|
|
37
|
+
return [] if self.__excludeTraits is None else self.excludeTraits
|
|
38
|
+
|
|
39
|
+
@excludeTraits.setter
|
|
40
|
+
def excludeTraits(self, value:list[Trait]) -> None:
|
|
41
|
+
self.__excludeTraits = value
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def filterPattern(self) -> Optional[re.Pattern]:
|
|
45
|
+
return self.__filterPattern
|
|
46
|
+
|
|
47
|
+
@filterPattern.setter
|
|
48
|
+
def filterPattern(self, value:re.Pattern) -> None:
|
|
49
|
+
self.__filterPattern = value
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def includeTraits(self) -> list[Trait]:
|
|
53
|
+
return [] if self.__includeTraits is None else self.includeTraits
|
|
54
|
+
|
|
55
|
+
@includeTraits.setter
|
|
56
|
+
def includeTraits(self, value:list[Trait]) -> None:
|
|
57
|
+
self.__includeTraits = value
|
|
58
|
+
|
|
59
|
+
def __excludeByTraits(self, theory:Theory) -> bool:
|
|
60
|
+
if self.__excludeTraits is not None and len(self.__excludeTraits) > 0:
|
|
61
|
+
for trait in self.__excludeTraits:
|
|
62
|
+
for L_trait in theory.traits:
|
|
63
|
+
if trait.name == L_trait.name and (trait.value is None or (trait.value == L_trait.value)):
|
|
64
|
+
return True
|
|
65
|
+
if self.__includeTraits is not None and len(self.__includeTraits) > 0:
|
|
66
|
+
for trait in self.__includeTraits:
|
|
67
|
+
for L_trait in theory.traits:
|
|
68
|
+
if trait.name == L_trait.name and (trait.value is None or (trait.value == L_trait.value)):
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def get(self, moduleName:str) -> list[Theory]:
|
|
74
|
+
l = self.__modules.get(moduleName)
|
|
75
|
+
if l is None:
|
|
76
|
+
l = []
|
|
77
|
+
self.__modules[moduleName] = l
|
|
78
|
+
return l
|
|
79
|
+
|
|
80
|
+
def put(self, theory:Theory) -> None:
|
|
81
|
+
if self.__filterPattern is None or len(self.__filterPattern.findall(theory.filterName)) > 0:
|
|
82
|
+
l = self.get(theory.moduleName)
|
|
83
|
+
d = self.__datas.get(theory.target)
|
|
84
|
+
if d is not None:
|
|
85
|
+
d.reverse()
|
|
86
|
+
for data in d:
|
|
87
|
+
theory.datas.append(data)
|
|
88
|
+
t = self.__traits.get(theory.target)
|
|
89
|
+
if t is not None:
|
|
90
|
+
for trait in t:
|
|
91
|
+
theory.traits.append(trait)
|
|
92
|
+
if not self.__excludeByTraits(theory):
|
|
93
|
+
l.append(theory)
|
|
94
|
+
|
|
95
|
+
def withData(self, target:Callable, data:tuple) -> None:
|
|
96
|
+
# TODO: data acquisition should be deferred until put() since that is where filterPattern
|
|
97
|
+
# is applied, but for current implementation `@inlinedata()` is not affected. more advanced
|
|
98
|
+
# data decorators may benefit from deferral (for example, data coming from an API or DB.)
|
|
99
|
+
d = self.__datas.get(target)
|
|
100
|
+
if d is None:
|
|
101
|
+
d = []
|
|
102
|
+
self.__datas[target] = d
|
|
103
|
+
d.append(data)
|
|
104
|
+
|
|
105
|
+
def withTrait(self, target:Callable, trait:Trait) -> None:
|
|
106
|
+
t = self.__traits.get(target)
|
|
107
|
+
if t is None:
|
|
108
|
+
t = []
|
|
109
|
+
self.__traits[target] = t
|
|
110
|
+
t.append(trait)
|
punit/traits/Trait.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
##
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Trait:
|
|
9
|
+
|
|
10
|
+
__name:str
|
|
11
|
+
__value:str|None
|
|
12
|
+
|
|
13
|
+
def __init__(self, name:str, value:Optional[str] = None):
|
|
14
|
+
self.__name = name
|
|
15
|
+
self.__value = value
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def name(self) -> str:
|
|
19
|
+
return self.__name
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def value(self) -> str|None:
|
|
23
|
+
return self.__value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def trait(name:str, value:Optional[str]=None) -> Callable:
|
|
27
|
+
def wrapper(target:Callable) -> Callable:
|
|
28
|
+
from ..theories.TheoryManager import TheoryManager
|
|
29
|
+
from ..facts.FactManager import FactManager
|
|
30
|
+
trait = Trait(name, value)
|
|
31
|
+
TheoryManager.instance().withTrait(target, trait)
|
|
32
|
+
FactManager.instance().withTrait(target, trait)
|
|
33
|
+
return target
|
|
34
|
+
return wrapper
|