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/cli.py ADDED
@@ -0,0 +1,294 @@
1
+ # SPDX-FileCopyrightText: © Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import os
5
+ import sys
6
+ from typing import Optional
7
+ from . import __version__
8
+ from .traits import Trait
9
+
10
+
11
+ class CommandLineInterface:
12
+
13
+ __aliases:dict[str,str]
14
+ __excludePatterns:list[str]
15
+ __excludeTraits:list[Trait]
16
+ __failfast:bool
17
+ __filterPattern:str
18
+ __help:bool
19
+ __includePatterns:list[str]
20
+ __includeTraits:list[Trait]
21
+ __no_default_patterns:bool
22
+ __outputFilename:Optional[str]
23
+ __quiet:bool
24
+ __reportFormat:Optional[str]
25
+ __testPackageName:str|None
26
+ __verbose:bool
27
+ __workdir:str|None
28
+
29
+ def __init__(self):
30
+ self.__aliases = dict[str,str]()
31
+ self.__excludePatterns = []
32
+ self.__excludeTraits = []
33
+ self.__failfast = False
34
+ self.__filterPattern = '*'
35
+ self.__help = False
36
+ self.__includePatterns = []
37
+ self.__includeTraits = []
38
+ self.__no_default_patterns = False
39
+ self.__outputFilename = None
40
+ self.__quiet = False
41
+ self.__reportFormat = None
42
+ self.__testPackageName = 'tests'
43
+ self.__workdir = os.path.curdir
44
+ self.__verbose = False
45
+
46
+ def __parse(self, argv:list[str]) -> 'CommandLineInterface':
47
+ aliasName:str|None = None
48
+ extractFilterPattern:bool = False
49
+ extractExcludePattern:bool = False
50
+ extractTrait:bool = False
51
+ extractIncludePattern:bool = False
52
+ extractAliasName:bool = False
53
+ extractAliasPath:bool = False
54
+ extractReportFormat:bool = False
55
+ extractOutputFilename:bool = False
56
+ for arg in argv:
57
+ if extractFilterPattern:
58
+ self.__filterPattern = arg
59
+ extractFilterPattern = False
60
+ continue
61
+ if extractIncludePattern:
62
+ self.__includePatterns.append(arg)
63
+ extractIncludePattern = False
64
+ continue
65
+ elif extractTrait:
66
+ isExclude = arg.startswith('!')
67
+ arg = arg.lstrip('!')
68
+ parts = arg.split('=')
69
+ trait = Trait(parts[0], parts[1]) if len(parts) == 2 else Trait(arg)
70
+ if isExclude:
71
+ self.__excludeTraits.append(trait)
72
+ else:
73
+ self.__includeTraits.append(trait)
74
+ extractTrait = False
75
+ continue
76
+ elif extractExcludePattern:
77
+ self.__excludePatterns.append(arg)
78
+ extractExcludePattern = False
79
+ continue
80
+ elif extractAliasName:
81
+ aliasName = arg
82
+ extractAliasName = False
83
+ extractAliasPath = True
84
+ continue
85
+ elif extractAliasPath and aliasName is not None:
86
+ extractAliasPath = False
87
+ self.__aliases[aliasName] = arg
88
+ continue
89
+ elif self.__workdir is None:
90
+ self.__workdir = arg
91
+ continue
92
+ elif self.__testPackageName is None:
93
+ self.__testPackageName = arg
94
+ continue
95
+ elif extractReportFormat:
96
+ extractReportFormat = False
97
+ self.__reportFormat = arg.lower()
98
+ match self.__reportFormat:
99
+ case 'html' | 'junit':
100
+ pass
101
+ case _:
102
+ print(f'Unsupported value "{arg}" for --report argument, aborting.')
103
+ print(f'(valid values are "json" and "junit")')
104
+ exit(4)
105
+ continue
106
+ elif extractOutputFilename:
107
+ extractOutputFilename = False
108
+ self.__outputFilename = arg
109
+ continue
110
+ match arg:
111
+ case '-h' | '--help':
112
+ self.__help = True
113
+ case '-a':
114
+ extractAliasName = True
115
+ case '-f' | '--filter':
116
+ extractFilterPattern = True
117
+ case '-e' | '--exclude':
118
+ extractExcludePattern = True
119
+ case '-z' | '--failfast':
120
+ self.__failfast = True
121
+ case '-p' | '--test-package':
122
+ self.__testPackageName = None
123
+ case '-i' | '--include':
124
+ extractIncludePattern = True
125
+ case '-q' | '--quiet':
126
+ self.__quiet = True
127
+ case '-w' | '--working-directory':
128
+ self.__workdir = None
129
+ case '-v' | '--verbose':
130
+ self.__verbose = True
131
+ case '-n' | '--no-default-patterns':
132
+ self.__no_default_patterns = True
133
+ case '-r' | '--report':
134
+ extractReportFormat = True
135
+ case '-o' | '--output':
136
+ extractOutputFilename = True
137
+ case '-t' | '--trait':
138
+ extractTrait = True
139
+ case _:
140
+ continue
141
+ return self
142
+
143
+ @property
144
+ def aliases(self) -> dict:
145
+ return self.__aliases
146
+
147
+ @property
148
+ def failfast(self) -> bool:
149
+ return self.__failfast
150
+
151
+ @property
152
+ def filterPattern(self) -> str:
153
+ return self.__filterPattern
154
+
155
+ @property
156
+ def excludePatterns(self) -> list[str]:
157
+ return self.__excludePatterns
158
+
159
+ @property
160
+ def excludeTraits(self) -> list[Trait]:
161
+ return self.__excludeTraits
162
+
163
+ @property
164
+ def help(self) -> bool:
165
+ return self.__help
166
+
167
+ @property
168
+ def includePatterns(self) -> list[str]:
169
+ return self.__includePatterns
170
+
171
+ @property
172
+ def includeTraits(self) -> list[Trait]:
173
+ return self.__includeTraits
174
+
175
+ @property
176
+ def outputFilename(self) -> str|None:
177
+ return self.__outputFilename
178
+
179
+ @property
180
+ def quiet(self) -> bool:
181
+ return self.__quiet or (self.__reportFormat is not None and self.__outputFilename is None)
182
+
183
+ @property
184
+ def reportFormat(self) -> str|None:
185
+ return self.__reportFormat
186
+
187
+ @property
188
+ def testPackageName(self) -> str:
189
+ return 'tests' if self.__testPackageName is None else self.__testPackageName
190
+
191
+ @property
192
+ def verbose(self) -> bool:
193
+ return self.__verbose and self.__reportFormat is None
194
+
195
+ @property
196
+ def workdir(self) -> str:
197
+ return os.path.curdir if self.__workdir is None else self.__workdir
198
+
199
+ def printHelp(self) -> None:
200
+ self.printVersion()
201
+ print(
202
+ """
203
+ Usage: python3 -m punit [-h|--help]
204
+ [-q|--quiet] [-v|--verbose]
205
+ [-z|--failfast]
206
+ [-p|--test-package NAME]
207
+ [-i|--include PATTERN]
208
+ [-e|--exclude PATTERN]
209
+ [-f|--filter PATTERN]
210
+ [-t|--trait [!]NAME[=VALUE]]
211
+ [-w|--workdir DIRECTORY]
212
+ [-n|--no-default-patterns]
213
+ [-r|--report {junit|json}]
214
+ [-o|--output FILENAME]
215
+
216
+ Options:
217
+ -h, --help Show this help text and exit
218
+ -q, --quiet Quiet output
219
+ -v, --verbose Verbose output
220
+ -z, --failfast Stop on first failure or error
221
+ -p, --test-package NAME
222
+ Use NAME as the test package, all tests should
223
+ be locatable as modules in the named package.
224
+ Default: 'tests'
225
+ -i, --include PATTERN
226
+ Include any tests matching PATTERN
227
+ Default: '*.py'
228
+ -e, --exclude PATTERN
229
+ Exclude any tests matching PATTERN, overriding --include
230
+ Default: '__*__' (dunder files), '/.*/' (dot-directories)
231
+ -f, --filter
232
+ Only execute tests matching PATTERN
233
+ Default: '*'
234
+ -t, --trait [!]NAME[=VALUE]
235
+ Execute tests with the specified trait, negated by prefixing with '!'.
236
+ If VALUE is specified, matches tests with the trait having specified value.
237
+ If VALUE is not specified, matches any test with the trait having any value.
238
+ Default: No filtering based on traits.
239
+ -w, --working-directory DIRECTORY
240
+ Working directory (defaults to start directory)
241
+ -n, --no-default-patterns
242
+ Do not apply any default include/exclude patterns.
243
+ -r, --report {html|junit}
244
+ Generate a report to stdout using either an "html"
245
+ or "junit" format. When generating a report to stdout
246
+ all other output is suppressed, unless `--output`
247
+ is also specified.
248
+ -o, --output FILENAME
249
+ If `--report` is used, instead of writing to stdout
250
+ write to FILENAME. In this case `--report` does not
251
+ suppress any program output.
252
+ """
253
+ )
254
+ exit(0)
255
+
256
+ def printSummary(self) -> None:
257
+ self.printVersion()
258
+ print(f'Working Directory:\n\t{self.__workdir}')
259
+ print(f'Fail Fast: \n\t{"Yes" if self.__failfast else "No"}')
260
+ if len(self.__includePatterns) > 0:
261
+ print('Include Patterns:')
262
+ for pattern in self.__includePatterns:
263
+ print(f'\t{pattern}')
264
+ if len(self.__excludePatterns) > 0:
265
+ print('Exclude Patterns:')
266
+ for pattern in self.__excludePatterns:
267
+ print(f'\t{pattern}')
268
+ print('Filter Pattern:')
269
+ print(f'\t{self.__filterPattern}')
270
+ def printVersion(self) -> None:
271
+ print(f'pUnit {__version__}')
272
+
273
+ def validate(self) -> None:
274
+ if self.__workdir is None or len(self.__workdir.lstrip()) == 0 or self.__workdir.startswith('-'):
275
+ print(f'Invalid working directory specified: {self.__workdir}')
276
+ exit(1)
277
+ elif not os.path.isdir(self.__workdir):
278
+ print(f'Working directory does not exist: {self.__workdir}')
279
+ exit(2)
280
+ self.__workdir = os.path.abspath(self.__workdir)
281
+ if not self.__no_default_patterns:
282
+ # if no other patterns specified, default to including all files found in the directory matching `testPackageName`
283
+ if len(self.__includePatterns) == 0:
284
+ self.__includePatterns.append(f'/{self.testPackageName}/*.py')
285
+ # always exclude dot-folders (.git, .venv, etc)
286
+ self.__excludePatterns.append('/.*')
287
+ # always exclude dunder files
288
+ self.__excludePatterns.append('/__*__')
289
+
290
+ @staticmethod
291
+ def parse(argv:list[str] = sys.argv) -> CommandLineInterface:
292
+ result = CommandLineInterface().__parse(argv)
293
+ result.validate()
294
+ return result
@@ -0,0 +1,113 @@
1
+ # SPDX-FileCopyrightText: © Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import os
5
+ import re
6
+
7
+ from ..facts import FactManager
8
+ from ..theories import TheoryManager
9
+ from ..cli import CommandLineInterface
10
+ from ..traits import Trait
11
+
12
+ class TestModuleDiscovery:
13
+
14
+ __cli:CommandLineInterface
15
+ __excludePatterns:list[re.Pattern]
16
+ __excludeTraits:list[Trait]
17
+ __filenames:list[str]
18
+ __filterPattern:re.Pattern
19
+ __includePatterns:list[re.Pattern]
20
+ __includeTraits:list[Trait]
21
+ __workdir:str
22
+
23
+ def __init__(self, workdir:str, includePatterns:list[str], excludePatterns:list[str], cli:CommandLineInterface):
24
+ self.__cli = cli
25
+ self.__excludePatterns = []
26
+ if excludePatterns is not None:
27
+ for pattern in excludePatterns:
28
+ self.__excludePatterns.append(
29
+ re.compile(
30
+ self.__convertPatternToRegex(pattern),
31
+ re.IGNORECASE))
32
+ self.__filenames = []
33
+ self.__filterPattern = re.compile(self.__convertPatternToRegex(cli.filterPattern))
34
+ self.__includePatterns = []
35
+ self.__excludeTraits = cli.excludeTraits
36
+ self.__includeTraits = cli.includeTraits
37
+ if includePatterns is not None:
38
+ for pattern in includePatterns:
39
+ self.__includePatterns.append(
40
+ re.compile(
41
+ self.__convertPatternToRegex(pattern),
42
+ re.IGNORECASE))
43
+ self.__workdir = workdir
44
+
45
+ def __convertPatternToRegex(self, pattern:str):
46
+ result = re.escape(pattern)\
47
+ .replace('\\\\', '/')\
48
+ .replace('\\*', r'.+')\
49
+ .replace('?', '.')
50
+ return result
51
+
52
+ def __testAnyInclude(self, input:str) -> bool:
53
+ for pat in self.__includePatterns:
54
+ if len(pat.findall(input)) > 0:
55
+ return True
56
+ return False
57
+
58
+ def __testAnyExclude(self, input:str) -> bool:
59
+ for pat in self.__excludePatterns:
60
+ if len(pat.findall(input)) > 0:
61
+ return True
62
+ return False
63
+
64
+ def __walkDirectory(self, path:str) -> list[str]:
65
+ filenames = []
66
+ if os.path.isdir(path):
67
+ for dname, dlist, flist in os.walk(path, topdown=True):
68
+ if self.__cli.verbose:
69
+ print(f'.. discovery for: {dname}')
70
+ dname2 = dname.replace('\\', '/')
71
+ if self.__testAnyExclude(dname2):
72
+ # directory is excluded, prune all children
73
+ if self.__cli.verbose:
74
+ print(f'Excluded: {dname}')
75
+ while len(dlist) > 0:
76
+ del dlist[0]
77
+ while len(flist) > 0:
78
+ del flist[0]
79
+ # determine if any individual subdirs should be pruned
80
+ dlnames = dlist.copy()
81
+ for dlname in dlnames:
82
+ dlname2 = dlname.replace('\\', '/')
83
+ if self.__testAnyExclude(dlname2):
84
+ if self.__cli.verbose:
85
+ print(f'Excluded: {dlname}')
86
+ dlist.remove(dlname)
87
+ # determine if any individual files should be pruned
88
+ for fname in flist:
89
+ fname2 = os.path.join(dname, fname).replace('\\', '/')
90
+ if self.__testAnyInclude(fname2) and not self.__testAnyExclude(fname2):
91
+ if fname.endswith('.py'):
92
+ if self.__cli.verbose:
93
+ print(f'Included: {fname}')
94
+ filenames.append(fname2)
95
+ return filenames
96
+
97
+ @property
98
+ def filenames(self) -> list[str]:
99
+ return self.__filenames
100
+
101
+ def discover(self) -> list[str]:
102
+ FactManager.instance().excludeTraits = self.__excludeTraits
103
+ FactManager.instance().filterPattern = self.__filterPattern
104
+ FactManager.instance().includeTraits = self.__includeTraits
105
+ TheoryManager.instance().excludeTraits = self.__excludeTraits
106
+ TheoryManager.instance().filterPattern = self.__filterPattern
107
+ TheoryManager.instance().includeTraits = self.__includeTraits
108
+ if self.__cli.verbose:
109
+ print(f'.. starting test discovery')
110
+ self.__filenames = self.__walkDirectory(self.__workdir)
111
+ if self.__cli.verbose:
112
+ print('.. finished test discovery.')
113
+ return self.__filenames
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: © Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .TestModuleDiscovery import TestModuleDiscovery
5
+
6
+
7
+ __all__ = [
8
+ 'TestModuleDiscovery'
9
+ ]
punit/facts/Fact.py ADDED
@@ -0,0 +1,82 @@
1
+ # SPDX-FileCopyrightText: © Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import inspect
5
+ from types import FunctionType, MethodType, ModuleType
6
+ from typing import Callable, Coroutine, Optional, cast
7
+
8
+ from ..traits.Trait import Trait
9
+
10
+
11
+ class Fact:
12
+
13
+ __className:Optional[str]
14
+ __moduleName:str
15
+ __target:FunctionType|MethodType
16
+ __testName:Optional[str]
17
+ __traits:list[Trait]
18
+
19
+ def __init__(self, moduleName:str, target:FunctionType|MethodType|Callable, className:Optional[str] = None, testName:Optional[str] = None):
20
+ self.__className = className
21
+ self.__moduleName = moduleName
22
+ self.__target = target
23
+ self.__testName = testName
24
+ self.__traits = []
25
+
26
+ @property
27
+ def className(self) -> Optional[str]:
28
+ return self.__className
29
+
30
+ @property
31
+ def moduleName(self) -> str:
32
+ return self.__moduleName
33
+
34
+ @property
35
+ def target(self) -> FunctionType|MethodType:
36
+ return self.__target
37
+
38
+ @property
39
+ def testName(self) -> str:
40
+ return self.__testName if self.__testName is not None else self.__target.__qualname__.split('.')[-1]
41
+
42
+ @property
43
+ def filterName(self) -> str:
44
+ return f'{self.moduleName}/{"" if self.className is None or len(self.className) == 0 else f"{self.className}/"}{self.testName}'
45
+
46
+ @property
47
+ def traits(self) -> list[Trait]:
48
+ return self.__traits
49
+
50
+ async def execute(self, module:ModuleType) -> None:
51
+ coro:Coroutine|None = None
52
+ if hasattr(self.__target, '__qualname__') and self.__target.__qualname__.find('.') > -1:
53
+ qnparts = self.__target.__qualname__.split('.')
54
+ if isinstance(self.__target, staticmethod):
55
+ coro = self.__target()
56
+ self.__className = '.'.join(qnparts[0:-1])
57
+ self.__testName = qnparts[-1]
58
+ else:
59
+ qntarget = module
60
+ self.__className = '.'.join(qnparts[0:-1])
61
+ self.__testName = qnparts[-1]
62
+ for qnpart in qnparts[0:-1]:
63
+ qntarget = getattr(qntarget, qnpart)
64
+ if isinstance(self.__target, classmethod):
65
+ coro = self.__target.__func__(qntarget)
66
+ else:
67
+ coro = self.__target(cast(Callable,qntarget)())
68
+ else:
69
+ self.__className = None
70
+ self.__testName = self.__target.__name__
71
+ coro = self.__target()
72
+ if inspect.iscoroutine(coro):
73
+ await coro
74
+
75
+
76
+ def fact(target:Callable) -> Callable:
77
+ from .FactManager import FactManager
78
+ if (not inspect.isfunction(target)) and (not isinstance(target, classmethod)) and (not isinstance(target, staticmethod)):
79
+ raise Exception('@fact can only be applied to functions and methods.')
80
+ fact:Fact = Fact(target.__module__, target)
81
+ FactManager.instance().put(fact)
82
+ return target
@@ -0,0 +1,94 @@
1
+ # SPDX-FileCopyrightText: © Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+ ##
4
+
5
+ import re
6
+ from typing import Callable, Optional
7
+
8
+ from .Fact import Fact
9
+ from ..traits.Trait import Trait
10
+
11
+
12
+ class FactManager:
13
+
14
+ __excludeTraits:list[Trait]
15
+ __filterPattern:Optional[re.Pattern]
16
+ __instance:'FactManager'|None = None
17
+ __includeTraits:list[Trait]
18
+ __modules:dict[str, list[Fact]]
19
+ __traits:dict[Callable, list[Trait]]
20
+
21
+ def __init__(self):
22
+ if FactManager.__instance is not None:
23
+ raise Exception('Cannot create more than one instance of FactManager')
24
+ self.__filterPattern = None
25
+ self.__modules = {}
26
+ self.__traits = {}
27
+
28
+ @staticmethod
29
+ def instance() -> FactManager:
30
+ if FactManager.__instance is None:
31
+ FactManager.__instance = FactManager()
32
+ return FactManager.__instance
33
+
34
+ @property
35
+ def excludeTraits(self) -> list[Trait]:
36
+ return [] if self.__excludeTraits is None else self.__excludeTraits
37
+
38
+ @excludeTraits.setter
39
+ def excludeTraits(self, value:list[Trait]) -> None:
40
+ self.__excludeTraits = value
41
+
42
+ @property
43
+ def filterPattern(self) -> Optional[re.Pattern]:
44
+ return self.__filterPattern
45
+
46
+ @filterPattern.setter
47
+ def filterPattern(self, value:re.Pattern) -> None:
48
+ self.__filterPattern = value
49
+
50
+ @property
51
+ def includeTraits(self) -> list[Trait]:
52
+ return [] if self.__includeTraits is None else self.__includeTraits
53
+
54
+ @includeTraits.setter
55
+ def includeTraits(self, value:list[Trait]) -> None:
56
+ self.__includeTraits = value
57
+
58
+ def __excludeByTraits(self, fact:Fact) -> bool:
59
+ if self.__excludeTraits is not None and len(self.__excludeTraits) > 0:
60
+ for trait in self.__excludeTraits:
61
+ for L_trait in fact.traits:
62
+ if trait.name == L_trait.name and (trait.value is None or (trait.value == L_trait.value)):
63
+ return True
64
+ if self.__includeTraits is not None and len(self.__includeTraits) > 0:
65
+ for trait in self.__includeTraits:
66
+ for L_trait in fact.traits:
67
+ if trait.name == L_trait.name and (trait.value is None or (trait.value == L_trait.value)):
68
+ return False
69
+ return True
70
+ return False
71
+
72
+ def get(self, moduleName:str) -> list[Fact]:
73
+ l = self.__modules.get(moduleName)
74
+ if l is None:
75
+ l = []
76
+ self.__modules[moduleName] = l
77
+ return l
78
+
79
+ def put(self, fact:Fact) -> None:
80
+ if self.__filterPattern is None or len(self.__filterPattern.findall(fact.filterName)) > 0:
81
+ l = self.get(fact.moduleName)
82
+ t = self.__traits.get(fact.target)
83
+ if t is not None:
84
+ for trait in t:
85
+ fact.traits.append(trait)
86
+ if not self.__excludeByTraits(fact):
87
+ l.append(fact)
88
+
89
+ def withTrait(self, target:Callable, trait:Trait) -> None:
90
+ t = self.__traits.get(target)
91
+ if t is None:
92
+ t = []
93
+ self.__traits[target] = t
94
+ t.append(trait)
@@ -0,0 +1,11 @@
1
+ # SPDX-FileCopyrightText: © Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .Fact import Fact, fact
5
+ from .FactManager import FactManager
6
+
7
+
8
+ __all__ = [
9
+ 'Fact', 'fact',
10
+ 'FactManager'
11
+ ]