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 +21 -0
- cocotest-0.1.0/PKG-INFO +117 -0
- cocotest-0.1.0/README.md +100 -0
- cocotest-0.1.0/pyproject.toml +37 -0
- cocotest-0.1.0/pyproject.toml.orig +34 -0
- cocotest-0.1.0/src/cocotest/__init__.py +4 -0
- cocotest-0.1.0/src/cocotest/__main__.py +3 -0
- cocotest-0.1.0/src/cocotest/_cocotb_bootstrap.py +54 -0
- cocotest-0.1.0/src/cocotest/cli.py +109 -0
- cocotest-0.1.0/src/cocotest/core_types.py +30 -0
- cocotest-0.1.0/src/cocotest/decorators.py +50 -0
- cocotest-0.1.0/src/cocotest/discovery.py +88 -0
- cocotest-0.1.0/src/cocotest/errors.py +2 -0
- cocotest-0.1.0/src/cocotest/execution.py +130 -0
- cocotest-0.1.0/src/cocotest/utils.py +110 -0
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.
|
cocotest-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
cocotest-0.1.0/README.md
ADDED
|
@@ -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,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,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
|