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/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)
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .Fact import Fact, fact
5
+ from .FactManager import FactManager
@@ -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>&nbsp;</div>')
50
+ lines.append(f'<div>Total Executed: <span class="pass">{totalCount-failureCount}</span></div>')
51
+ if failureCount > 0:
52
+ lines.append('<div>&nbsp;</div>')
53
+ lines.append(f'<div>Total Failed: <span class="fail">{failureCount}</span></div>')
54
+ lines.append('<div>&nbsp;</div>')
55
+ percentstyle = 'pass' if failureCount == 0 else 'fail'
56
+ lines.append(f'<div class="passfail-percent">Pass/Fail&nbsp;<span class="percent-{percentstyle}">{100-((failureCount/totalCount)*100):.1f}%</span></div>')
57
+ lines.append('<div>&nbsp;</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/>&nbsp;&nbsp;{testResult.exception}<br/>')
82
+ lines.append(f'Traceback:<br/>{"".join(traceback.format_tb(testResult.exception.__traceback__)).replace('\n', '<br/>').replace(' ', '&nbsp;')}')
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'
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .HtmlReportGenerator import HtmlReportGenerator
5
+ from .JUnitReportGenerator import JUnitReportGenerator
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
@@ -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)