punit 1.5.3__tar.gz → 1.7.2__tar.gz

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