cocotest 0.1.0__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.
cocotest-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pierre-Louis Nordmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: cocotest
3
+ Version: 0.1.0
4
+ Summary: Lightweight, opinionated test orchestration framework for cocotb.
5
+ Author: Pierre-Louis Nordmann
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Topic :: Software Development :: Testing
10
+ Requires-Dist: cocotb~=2.0.1
11
+ Requires-Dist: psutil>=7.2.2
12
+ Requires-Python: >=3.11
13
+ Project-URL: Homepage, https://github.com/p-nordmann/cocotest
14
+ Project-URL: Repository, https://github.com/p-nordmann/cocotest
15
+ Project-URL: Issues, https://github.com/p-nordmann/cocotest/issues
16
+ Description-Content-Type: text/markdown
17
+
18
+ # cocotest
19
+
20
+ Lightweight, opinionated test orchestration framework for cocotb.
21
+
22
+ ## Quickstart
23
+
24
+ Cocotest is intended to work in similar fashion to pytest.
25
+ We do not intend to provide as many features as pytest, far from it, but we take a lot of inspiration from it.
26
+ Cocotest should feel familiar for developers with experience in the Python ecosystem.
27
+
28
+ ### Installation
29
+
30
+ You can install cocotest using pip or uv:
31
+
32
+ ```sh
33
+ pip install cocotest
34
+
35
+ # Or with uv:
36
+ uv add cocotest
37
+ ```
38
+
39
+ ### Getting started
40
+
41
+ Once cocotest is installed, you can run your tests with the following command:
42
+
43
+ ```sh
44
+ cocotest /path/to/tests
45
+
46
+ # Or with uv:
47
+ uv run cocotest /path/to/tests
48
+ ```
49
+
50
+ Of course, you need to write your tests in such a way that cocotest knows what to do with them:
51
+
52
+ ```Python
53
+ from cocotb.handle import HierarchyObject
54
+
55
+ from cocotest import DUTSpec
56
+
57
+ dut = DUTSpec(
58
+ simulator="ghdl",
59
+ sources=["testbench/heartbeat/heartbeat.vhd"],
60
+ hdl_toplevel="heartbeat",
61
+ lang="vhdl",
62
+ build_args=["--std=08"],
63
+ test_args=["--std=08"],
64
+ )
65
+
66
+
67
+ async def test_heartbeat_pass(dut: HierarchyObject):
68
+ print("Inside test: test_heartbeat_pass")
69
+ pass
70
+ ```
71
+
72
+ Here there are two important things:
73
+
74
+ - `dut = DUTSpec(...)`: this is where we tell cocotest about the DUT that we will use, so it knows how to launch cocotb;
75
+ - `async def test_heartbeat_pass(dut: HierarchyObject)`: here we declare a test.
76
+
77
+ There are 3 conditions for our test to be detected by cocotest:
78
+
79
+ - `async def`: the test function must be asynchronous, as it will be run inside cocotb and manipulate the DUT;
80
+ - `test_...`: its name must start with "test\_" so cocotest knows how to find it;
81
+ - `dut`: its DUT argument for the cocotb test must have the same name as some `DUTSpec` instance present in the scope. This way, cocotest will know what cocotb test to launch with which DUT.
82
+
83
+ And... that's it! Just use the `cocotest` command and your test will run.
84
+ No need for fancy makefiles, no need for `@cocotb.test`; you can now define various DUTs to use in various test cases which will be automatically run by cocotest. :)
85
+
86
+ ## Contributing
87
+
88
+ Before contributing, read [CONTRIBUTING.md](./CONTRIBUTING.md).
89
+
90
+ Note: contributions are closed for the moment.
91
+
92
+ ## Testing cocotest
93
+
94
+ Cocotest is made for running cocotb tests, but it must itself be tested so we know it works.
95
+ For this, we rely on good old pytest.
96
+
97
+ ### A word about test dependencies
98
+
99
+ Most of the tests can be run with the dev dependencies from the uv project.
100
+ However, some tests will try to spawn a cocotest subprocess.
101
+ With this cocotest call, they will try to run ghdl.
102
+ For that reason, you need to install ghdl if you want to be able to run all of the tests.
103
+
104
+ ### Running the tests
105
+
106
+ Once you have all the dependencies installed, you can run the tests using pytest:
107
+
108
+ ```sh
109
+ pytest tests
110
+
111
+ # Or with uv:
112
+ uv run pytest tests
113
+ ```
114
+
115
+ ## License
116
+
117
+ This work is distributed under the MIT license, see the LICENSE file for more information.
@@ -0,0 +1,100 @@
1
+ # cocotest
2
+
3
+ Lightweight, opinionated test orchestration framework for cocotb.
4
+
5
+ ## Quickstart
6
+
7
+ Cocotest is intended to work in similar fashion to pytest.
8
+ We do not intend to provide as many features as pytest, far from it, but we take a lot of inspiration from it.
9
+ Cocotest should feel familiar for developers with experience in the Python ecosystem.
10
+
11
+ ### Installation
12
+
13
+ You can install cocotest using pip or uv:
14
+
15
+ ```sh
16
+ pip install cocotest
17
+
18
+ # Or with uv:
19
+ uv add cocotest
20
+ ```
21
+
22
+ ### Getting started
23
+
24
+ Once cocotest is installed, you can run your tests with the following command:
25
+
26
+ ```sh
27
+ cocotest /path/to/tests
28
+
29
+ # Or with uv:
30
+ uv run cocotest /path/to/tests
31
+ ```
32
+
33
+ Of course, you need to write your tests in such a way that cocotest knows what to do with them:
34
+
35
+ ```Python
36
+ from cocotb.handle import HierarchyObject
37
+
38
+ from cocotest import DUTSpec
39
+
40
+ dut = DUTSpec(
41
+ simulator="ghdl",
42
+ sources=["testbench/heartbeat/heartbeat.vhd"],
43
+ hdl_toplevel="heartbeat",
44
+ lang="vhdl",
45
+ build_args=["--std=08"],
46
+ test_args=["--std=08"],
47
+ )
48
+
49
+
50
+ async def test_heartbeat_pass(dut: HierarchyObject):
51
+ print("Inside test: test_heartbeat_pass")
52
+ pass
53
+ ```
54
+
55
+ Here there are two important things:
56
+
57
+ - `dut = DUTSpec(...)`: this is where we tell cocotest about the DUT that we will use, so it knows how to launch cocotb;
58
+ - `async def test_heartbeat_pass(dut: HierarchyObject)`: here we declare a test.
59
+
60
+ There are 3 conditions for our test to be detected by cocotest:
61
+
62
+ - `async def`: the test function must be asynchronous, as it will be run inside cocotb and manipulate the DUT;
63
+ - `test_...`: its name must start with "test\_" so cocotest knows how to find it;
64
+ - `dut`: its DUT argument for the cocotb test must have the same name as some `DUTSpec` instance present in the scope. This way, cocotest will know what cocotb test to launch with which DUT.
65
+
66
+ And... that's it! Just use the `cocotest` command and your test will run.
67
+ No need for fancy makefiles, no need for `@cocotb.test`; you can now define various DUTs to use in various test cases which will be automatically run by cocotest. :)
68
+
69
+ ## Contributing
70
+
71
+ Before contributing, read [CONTRIBUTING.md](./CONTRIBUTING.md).
72
+
73
+ Note: contributions are closed for the moment.
74
+
75
+ ## Testing cocotest
76
+
77
+ Cocotest is made for running cocotb tests, but it must itself be tested so we know it works.
78
+ For this, we rely on good old pytest.
79
+
80
+ ### A word about test dependencies
81
+
82
+ Most of the tests can be run with the dev dependencies from the uv project.
83
+ However, some tests will try to spawn a cocotest subprocess.
84
+ With this cocotest call, they will try to run ghdl.
85
+ For that reason, you need to install ghdl if you want to be able to run all of the tests.
86
+
87
+ ### Running the tests
88
+
89
+ Once you have all the dependencies installed, you can run the tests using pytest:
90
+
91
+ ```sh
92
+ pytest tests
93
+
94
+ # Or with uv:
95
+ uv run pytest tests
96
+ ```
97
+
98
+ ## License
99
+
100
+ This work is distributed under the MIT license, see the LICENSE file for more information.
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "cocotest"
3
+ version = "0.1.0"
4
+ description = "Lightweight, opinionated test orchestration framework for cocotb."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Topic :: Software Development :: Testing",
11
+ ]
12
+ requires-python = ">=3.11"
13
+ dependencies = [
14
+ "cocotb~=2.0.1",
15
+ "psutil>=7.2.2",
16
+ ]
17
+
18
+ [[project.authors]]
19
+ name = "Pierre-Louis Nordmann"
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/p-nordmann/cocotest"
23
+ Repository = "https://github.com/p-nordmann/cocotest"
24
+ Issues = "https://github.com/p-nordmann/cocotest/issues"
25
+
26
+ [project.scripts]
27
+ cocotest = "cocotest.cli:main"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.12.5,<0.13"]
31
+ build-backend = "uv_build"
32
+
33
+ [dependency-groups]
34
+ dev = ["pytest>=9.1.1"]
35
+
36
+ [tool.pytest.ini_options]
37
+ collect_imported_tests = false
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "cocotest"
3
+ version = "0.1.0"
4
+ description = "Lightweight, opinionated test orchestration framework for cocotb."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Pierre-Louis Nordmann" }]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Topic :: Software Development :: Testing",
12
+ ]
13
+ requires-python = ">=3.11"
14
+ dependencies = ["cocotb~=2.0.1", "psutil>=7.2.2"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/p-nordmann/cocotest"
18
+ Repository = "https://github.com/p-nordmann/cocotest"
19
+ Issues = "https://github.com/p-nordmann/cocotest/issues"
20
+
21
+ [project.scripts]
22
+ cocotest = "cocotest.cli:main"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12.5,<0.13"]
26
+ build-backend = "uv_build"
27
+
28
+ [dependency-groups]
29
+ dev = ["pytest>=9.1.1"]
30
+
31
+ [tool.pytest.ini_options]
32
+ # We use the pytest option below to make sure that pytest does not try
33
+ # to interpret Cocotest's TestXxx classes as actual tests.
34
+ collect_imported_tests = false
@@ -0,0 +1,4 @@
1
+ from .core_types import DUTSpec
2
+ from .decorators import mark
3
+
4
+ __all__ = ["DUTSpec", "mark"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,54 @@
1
+ """
2
+ This module serves as an entrypoint for cocotb runner.
3
+
4
+ It makes sure to wrap the target test function into cocotb.test
5
+ """
6
+
7
+ import inspect
8
+ import os
9
+ import sys
10
+
11
+ import cocotb
12
+ from cocotb.handle import HierarchyObject
13
+
14
+ from .utils import get_module_name, import_from_path
15
+
16
+ # Retrieve environment variables provided by the runner
17
+ cocotest_import_root = os.environ["COCOTEST_IMPORT_ROOT"]
18
+ module_path = os.environ["COCOTEST_TEST_MODULE"]
19
+ function_name = os.environ["COCOTEST_TEST_FUNCTION"]
20
+ cocotest_cwd = os.environ["COCOTEST_CWD"]
21
+
22
+ # Make sure to retrieve the expected working directory from the
23
+ # environment variables and change current working directory.
24
+ # Otherwise, we get the current working directory from the simulator,
25
+ # which will most likely not match the expected working directory in
26
+ # the test case.
27
+ # Note: this is important to do that before importing anything dynamically.
28
+ os.chdir(cocotest_cwd)
29
+
30
+ # Reproduce the same import as cocotest
31
+ sys.path.insert(0, cocotest_import_root)
32
+ module_name = get_module_name(module_path)
33
+ module = import_from_path(module_path, module_name)
34
+ function = getattr(module, function_name)
35
+
36
+
37
+ # Wrap the test case into a function marked for cocotb
38
+ # TODO preprocess fixtures here
39
+ @cocotb.test(name=function_name)
40
+ async def test_case(dut: HierarchyObject):
41
+
42
+ # Recover the dut name from the function parameters
43
+ params = inspect.signature(function).parameters
44
+ if len(params.keys()) != 1:
45
+ raise AssertionError(f"function {function_name} has too many parameters")
46
+ dut_name = next(iter(params))
47
+
48
+ return await function(**{dut_name: dut})
49
+
50
+
51
+ # Hack the module name for display
52
+ module_name_short = os.path.basename(module_path)[:-3]
53
+ test_case.module = module_name_short
54
+ test_case.fullname = f"{module_name_short}.{function_name}"
@@ -0,0 +1,109 @@
1
+ import argparse
2
+ import os
3
+ import signal
4
+ import sys
5
+ from contextlib import contextmanager
6
+ from types import FrameType
7
+ from uuid import uuid4
8
+
9
+ from .decorators import _get_marks
10
+ from .discovery import discover_duts, discover_test_cases, discover_test_modules
11
+ from .execution import ExecutionOptions, run_test
12
+ from .utils import terminate_session
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser(
17
+ prog="cocotest",
18
+ description="collects and runs cocotb tests",
19
+ )
20
+ parser.add_argument(
21
+ "path",
22
+ default=".",
23
+ nargs="?",
24
+ help="test file or directory",
25
+ )
26
+ parser.add_argument(
27
+ "-m",
28
+ "--mark",
29
+ action="append",
30
+ help="filter tests with the requested mark",
31
+ )
32
+ parser.add_argument(
33
+ "--run-skipped",
34
+ action="store_true",
35
+ help="force skipped tests to run",
36
+ )
37
+ args = parser.parse_args()
38
+
39
+ # Make sure to prepend the current working directory to sys.path
40
+ sys.path.insert(0, os.getcwd())
41
+
42
+ # Discover modules, duts and test cases
43
+ test_modules = discover_test_modules(args.path)
44
+ dut_index = discover_duts(test_modules)
45
+ cases = discover_test_cases(test_modules, dut_index)
46
+
47
+ if args.mark is not None:
48
+ cases = [
49
+ case
50
+ for case in cases
51
+ if any(m in _get_marks(case.function) for m in args.mark)
52
+ ]
53
+
54
+ # We gather execution options for later.
55
+ options = ExecutionOptions(
56
+ run_skipped=args.run_skipped,
57
+ )
58
+
59
+ # Note: in the case of some simulators, it seems that SIGINT is ignored by the
60
+ # subprocesses when cocotb is terminated. This can be painful when developing
61
+ # testbenches, so we make sure to kill the subprocesses when we receive SIGINT or SIGTERM.
62
+ with _with_termination_cleanup():
63
+ # Run test cases and exit with an error code.
64
+ results = []
65
+ for case in cases:
66
+ result = run_test(case, options)
67
+ print(f"{case.node_id}: {result.status.name}")
68
+ results.append(result)
69
+
70
+ for result in results:
71
+ if result.is_failure():
72
+ raise SystemExit(1)
73
+ raise SystemExit(0)
74
+
75
+
76
+ @contextmanager
77
+ def _with_termination_cleanup():
78
+ """Prepares the environment for running tests and makes sure to terminate
79
+ subprocesses upon interruption.
80
+
81
+ In order to find all the subprocesses that we should kill, we use a trick: we add
82
+ a COCOTEST_SESSION environment variable before spawning them, so they inherit
83
+ it automatically. Then, when being terminated, we look for all the processes
84
+ with the correct COCOTEST_SESSION and kill them.
85
+
86
+ Returns an exit code.
87
+ """
88
+
89
+ # We raise a custom exception on SIGINT and SIGTERM
90
+ class Terminate(Exception):
91
+ """Used when cocotest is interrupted with SIGTERM or SIGINT."""
92
+
93
+ def on_terminate(signum: int, frame: FrameType | None):
94
+ raise Terminate
95
+
96
+ signal.signal(signal.SIGTERM, on_terminate)
97
+ signal.signal(signal.SIGINT, on_terminate)
98
+
99
+ # Here we prepare the environment variable
100
+ os.environ["COCOTEST_SESSION"] = uuid4().hex
101
+
102
+ # Then we wrap the actual logic, except, finally
103
+ try:
104
+ yield
105
+ except Terminate:
106
+ terminate_session()
107
+ raise SystemExit(2)
108
+ finally:
109
+ os.environ.pop("COCOTEST_SESSION", None)
@@ -0,0 +1,30 @@
1
+ from dataclasses import dataclass
2
+ from types import ModuleType
3
+ from typing import Any, Callable, NamedTuple
4
+
5
+
6
+ class TestModule(NamedTuple):
7
+ module: ModuleType
8
+ path: str
9
+
10
+
11
+ @dataclass
12
+ class DUTSpec:
13
+ simulator: str
14
+ sources: list[str]
15
+ hdl_toplevel: str
16
+ lang: str
17
+ build_args: list[str]
18
+ test_args: list[str]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TestCase:
23
+ module: ModuleType
24
+ path: str
25
+ function: Callable[..., Any]
26
+ dut: DUTSpec
27
+
28
+ @property
29
+ def node_id(self) -> str:
30
+ return f"{self.path}::{self.function.__name__}"
@@ -0,0 +1,50 @@
1
+ from typing import Awaitable, Callable, ParamSpec, Protocol, TypeVar
2
+
3
+ P = ParamSpec("P")
4
+ T = TypeVar("T")
5
+
6
+
7
+ class _MarkDecorator(Protocol):
8
+ def __call__(self, fn: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: ...
9
+
10
+
11
+ def _get_marks(fn: Callable[P, Awaitable[T]]) -> frozenset[str]:
12
+ marks = getattr(fn, "_cocotest_marks", frozenset())
13
+ if not isinstance(marks, frozenset):
14
+ raise ValueError(f"wrong attribute '_cocotest_marks' in '{fn.__name__}'")
15
+ return marks
16
+
17
+
18
+ def _add_mark(fn: Callable[P, Awaitable[T]], mark_name: str):
19
+ marks = _get_marks(fn)
20
+ setattr(fn, "_cocotest_marks", marks | {mark_name})
21
+
22
+
23
+ class Mark:
24
+ """Collection of markers."""
25
+
26
+ __slots__: tuple[()] = ()
27
+
28
+ @staticmethod
29
+ def skip(fn: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
30
+ """Notifies cocotest that a test must be skipped."""
31
+ _add_mark(fn, "skip")
32
+ return fn
33
+
34
+ @staticmethod
35
+ def xfail(fn: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
36
+ """Marks a test function as expected to fail."""
37
+ _add_mark(fn, "xfail")
38
+ return fn
39
+
40
+ def __getattr__(self, name: str) -> _MarkDecorator:
41
+ """Used for custom marks."""
42
+
43
+ def marker(fn: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
44
+ _add_mark(fn, name)
45
+ return fn
46
+
47
+ return marker
48
+
49
+
50
+ mark = Mark()
@@ -0,0 +1,88 @@
1
+ import os
2
+
3
+ from .core_types import DUTSpec, TestCase, TestModule
4
+ from .errors import DiscoveryError
5
+ from .utils import get_module_name, get_test_dut, import_from_path, is_test_case
6
+
7
+ ModuleDUTSpecs = dict[str, DUTSpec]
8
+ """Mapping dut_name->DUTSpec for a single module."""
9
+
10
+ DUTSpecIndex = dict[str, ModuleDUTSpecs]
11
+ """Mapping module_name->dut_name->DUTSpec for all modules."""
12
+
13
+
14
+ def is_test_file_name(name: str) -> bool:
15
+ return name.startswith("test_") and name.endswith(".py")
16
+
17
+
18
+ def discover_test_files(test_path: str) -> list[str]:
19
+ """Finds all test files under `test_path`."""
20
+
21
+ if not os.path.exists(test_path):
22
+ raise DiscoveryError(f"test path does not exist: {test_path}")
23
+
24
+ if os.path.isfile(test_path) and test_path.endswith(".py"):
25
+ # In case the user specifies a python file directly,
26
+ # we consider it a test file in any case.
27
+ return [test_path]
28
+
29
+ if os.path.isfile(test_path):
30
+ raise DiscoveryError(f"test path is not a python file: {test_path}")
31
+
32
+ test_files = []
33
+ for root, dirs, files in os.walk(test_path):
34
+ # Note: we make sure that the files are sorted in lexicographic order
35
+ # by sorting dirs and files here.
36
+ dirs.sort()
37
+ files.sort()
38
+ for name in files:
39
+ if is_test_file_name(name):
40
+ test_files.append(os.path.join(root, name))
41
+
42
+ return test_files
43
+
44
+
45
+ def discover_test_modules(test_path: str) -> list[TestModule]:
46
+ test_modules: list[TestModule] = []
47
+ for path in discover_test_files(test_path):
48
+ module_name = get_module_name(path)
49
+ module = import_from_path(path, module_name)
50
+ test_modules.append(TestModule(module, path))
51
+ return test_modules
52
+
53
+
54
+ def discover_duts(test_modules: list[TestModule]) -> DUTSpecIndex:
55
+ index: DUTSpecIndex = {}
56
+ for module, path in test_modules:
57
+ index[module.__name__] = {}
58
+
59
+ # Note: we prefer vars(module) to inspect.getmembers(module) because
60
+ # it preserves module definition/execution order
61
+ for name, value in vars(module).items():
62
+ if isinstance(value, DUTSpec):
63
+ index[module.__name__][name] = value
64
+ return index
65
+
66
+
67
+ def discover_test_cases(
68
+ test_modules: list[TestModule], dut_index: DUTSpecIndex
69
+ ) -> list[TestCase]:
70
+ cases = []
71
+ for module, path in test_modules:
72
+ duts: ModuleDUTSpecs = {}
73
+ if module.__name__ in dut_index:
74
+ duts = dut_index[module.__name__]
75
+
76
+ # Note: we prefer vars(module) to inspect.getmembers(module) because
77
+ # it preserves module definition/execution order
78
+ for name, candidate in vars(module).items():
79
+ if is_test_case(candidate, module=module, duts=duts):
80
+ cases.append(
81
+ TestCase(
82
+ module,
83
+ path,
84
+ candidate,
85
+ get_test_dut(candidate, duts=duts),
86
+ )
87
+ )
88
+ return cases
@@ -0,0 +1,2 @@
1
+ class DiscoveryError(Exception):
2
+ """Raised when a test module cannot be imported."""
@@ -0,0 +1,130 @@
1
+ import os
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from subprocess import CalledProcessError
6
+
7
+ from cocotb_tools.runner import get_results, get_runner
8
+
9
+ from .core_types import TestCase
10
+ from .decorators import _get_marks
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ExecutionOptions:
15
+ run_skipped: bool = False
16
+
17
+
18
+ class TestStatus(StrEnum):
19
+ PASS = "pass"
20
+ FAIL = "fail"
21
+ BUILD_ERROR = "build_error"
22
+ RUNTIME_ERROR = "runtime_error"
23
+ SKIP = "skip"
24
+ XFAIL = "xfail"
25
+ XPASS = "xpass"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TestResult:
30
+ status: TestStatus
31
+ build_log: str | None = None
32
+ test_log: str | None = None
33
+
34
+ def is_failure(self):
35
+ """Returns True if this result should fail the overall test suite."""
36
+ return self.status in [
37
+ TestStatus.FAIL,
38
+ TestStatus.BUILD_ERROR,
39
+ TestStatus.RUNTIME_ERROR,
40
+ ]
41
+
42
+
43
+ def run_test(case: TestCase, options: ExecutionOptions | None = None) -> TestResult:
44
+ if options is None:
45
+ options = ExecutionOptions()
46
+ marks = _get_marks(case.function)
47
+
48
+ if "skip" in marks and not options.run_skipped:
49
+ return TestResult(TestStatus.SKIP)
50
+
51
+ result = _run_test(case)
52
+ if "xfail" not in marks:
53
+ return result
54
+
55
+ # In case of xfail, we mark xfail if the test is failure, otherwise xpass.
56
+ if result.is_failure():
57
+ return TestResult(TestStatus.XFAIL, result.build_log, result.test_log)
58
+
59
+ return TestResult(TestStatus.XPASS, result.build_log, result.test_log)
60
+
61
+
62
+ def _run_test(case: TestCase) -> TestResult:
63
+ build_dir = os.path.join(
64
+ ".cocotest_cache", case.module.__name__, case.function.__name__
65
+ ) # TODO: one build dir per dut?
66
+
67
+ # cocotb Runner changes its failure semantics when PYTEST_CURRENT_TEST
68
+ # is present: failed cocotb tests cause SystemExit instead of returning
69
+ # the results XML. Cocotest needs consistent runner semantics regardless
70
+ # of whether its caller happens to be pytest.
71
+ with _without_pytest_context():
72
+ runner = get_runner(case.dut.simulator)
73
+
74
+ try:
75
+ runner.build(
76
+ hdl_library="work",
77
+ sources=case.dut.sources,
78
+ build_args=case.dut.build_args,
79
+ hdl_toplevel=case.dut.hdl_toplevel,
80
+ build_dir=build_dir,
81
+ log_file=os.path.join(build_dir, "build_logs.log"),
82
+ )
83
+ except CalledProcessError:
84
+ return TestResult(
85
+ TestStatus.BUILD_ERROR, os.path.join(build_dir, "build_logs.log")
86
+ )
87
+
88
+ try:
89
+ results_path = runner.test(
90
+ test_module="cocotest._cocotb_bootstrap",
91
+ hdl_toplevel=case.dut.hdl_toplevel,
92
+ hdl_toplevel_library="work",
93
+ hdl_toplevel_lang=case.dut.lang,
94
+ test_args=case.dut.test_args,
95
+ extra_env={
96
+ "COCOTEST_IMPORT_ROOT": os.getcwd(),
97
+ "COCOTEST_TEST_MODULE": os.path.abspath(case.path),
98
+ "COCOTEST_TEST_FUNCTION": case.function.__name__,
99
+ "COCOTEST_CWD": os.getcwd(),
100
+ },
101
+ build_dir=build_dir,
102
+ test_dir=build_dir, # WARNING: must be the same as build_dir
103
+ test_filter=f"\.{case.function.__name__}$",
104
+ log_file=os.path.join(build_dir, "test_logs.log"),
105
+ )
106
+ except SystemExit:
107
+ return TestResult(
108
+ TestStatus.RUNTIME_ERROR,
109
+ os.path.join(build_dir, "build_logs.log"),
110
+ os.path.join(build_dir, "test_logs.log"),
111
+ )
112
+
113
+ total, failures = get_results(results_path)
114
+
115
+ return TestResult(
116
+ TestStatus.PASS if failures == 0 else TestStatus.FAIL,
117
+ os.path.join(build_dir, "build_logs.log"),
118
+ os.path.join(build_dir, "test_logs.log"),
119
+ )
120
+
121
+
122
+ @contextmanager
123
+ def _without_pytest_context():
124
+ """Temporarily suppresses pytest's PYTEST_CURRENT_TEST env variable."""
125
+ value = os.environ.pop("PYTEST_CURRENT_TEST", None)
126
+ try:
127
+ yield
128
+ finally:
129
+ if value is not None:
130
+ os.environ["PYTEST_CURRENT_TEST"] = value
@@ -0,0 +1,110 @@
1
+ import hashlib
2
+ import inspect
3
+ import os
4
+ import sys
5
+ import warnings
6
+ from importlib.util import module_from_spec, spec_from_file_location
7
+ from types import FunctionType, ModuleType
8
+ from typing import Any
9
+
10
+ import psutil
11
+
12
+ from .core_types import DUTSpec
13
+ from .errors import DiscoveryError
14
+
15
+
16
+ def import_from_path(path: str, module_name: str) -> ModuleType:
17
+ spec = spec_from_file_location(module_name, path)
18
+ if spec is None:
19
+ raise DiscoveryError(f"failed to import {module_name}")
20
+ module = module_from_spec(spec)
21
+ sys.modules[module_name] = module
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ def get_module_name(path: str) -> str:
27
+ absolute_path = os.path.abspath(path)
28
+ digest = hashlib.sha1(absolute_path.encode()).hexdigest()[:12]
29
+
30
+ file_name = os.path.basename(path)
31
+ base_name = file_name[:-3] # .py
32
+
33
+ return f"cocotest_{base_name}_{digest}"
34
+
35
+
36
+ def is_test_case(
37
+ candidate: Any, *, module: ModuleType, duts: dict[str, DUTSpec]
38
+ ) -> bool:
39
+ if not inspect.iscoroutinefunction(candidate):
40
+ return False
41
+ if candidate.__module__ != module.__name__:
42
+ return False
43
+ if not candidate.__name__.startswith("test_"):
44
+ return False
45
+
46
+ params = inspect.signature(candidate).parameters
47
+ dut_params_count = 0
48
+ other_params_count = 0
49
+ for name in params:
50
+ if name in duts:
51
+ dut_params_count += 1
52
+ else:
53
+ other_params_count += 1
54
+
55
+ filename = inspect.getsourcefile(candidate) or ""
56
+ _, lineno = inspect.getsourcelines(candidate)
57
+
58
+ if dut_params_count == 0:
59
+ if other_params_count > 0:
60
+ warnings.warn_explicit(
61
+ f"function {candidate.__name__} looks like a test case but has no dut parameter. Is it a typo?",
62
+ UserWarning,
63
+ filename=filename,
64
+ lineno=lineno,
65
+ )
66
+ return False
67
+ if dut_params_count > 1:
68
+ warnings.warn_explicit(
69
+ f"test case {candidate.__name__} has {dut_params_count} dut parameters",
70
+ UserWarning,
71
+ filename=filename,
72
+ lineno=lineno,
73
+ )
74
+ return False
75
+ if other_params_count > 0:
76
+ warnings.warn_explicit(
77
+ f"test case {candidate.__name__} has {other_params_count} non-dut parameters",
78
+ UserWarning,
79
+ filename=filename,
80
+ lineno=lineno,
81
+ )
82
+ return False
83
+
84
+ return True
85
+
86
+
87
+ def get_test_dut(fx: FunctionType, *, duts: dict[str, DUTSpec]) -> DUTSpec:
88
+ params = inspect.signature(fx).parameters
89
+ for name in params:
90
+ if name in duts:
91
+ return duts[name]
92
+ raise RuntimeError("dut not found")
93
+
94
+
95
+ def terminate_session():
96
+ """Kills all the processes of the current cocotest session.
97
+
98
+ Heavily inspired by the "Kill process tree" example from psutil's documentation.
99
+ """
100
+ if "COCOTEST_SESSION" not in os.environ or os.environ["COCOTEST_SESSION"] is None:
101
+ return
102
+
103
+ for p in psutil.process_iter():
104
+ if p.pid == os.getpid():
105
+ continue
106
+ try:
107
+ if p.environ().get("COCOTEST_SESSION") == os.environ["COCOTEST_SESSION"]:
108
+ p.kill()
109
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
110
+ pass