revitdevtool-pytest 0.0.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.
- revitdevtool_pytest-0.0.2/.github/workflows/publish.yml +30 -0
- revitdevtool_pytest-0.0.2/.gitignore +16 -0
- revitdevtool_pytest-0.0.2/.vscode/settings.json +31 -0
- revitdevtool_pytest-0.0.2/CHANGELOG.md +38 -0
- revitdevtool_pytest-0.0.2/LICENSE +21 -0
- revitdevtool_pytest-0.0.2/PKG-INFO +127 -0
- revitdevtool_pytest-0.0.2/README.md +100 -0
- revitdevtool_pytest-0.0.2/pyproject.toml +38 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/__init__.py +3 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/bridge.py +127 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/constants.py +28 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/dialog_resolver.py +149 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/discovery.py +123 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/models.py +95 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/plugin.py +199 -0
- revitdevtool_pytest-0.0.2/src/revitdevtool_pytest/serializer.py +21 -0
- revitdevtool_pytest-0.0.2/tests/conftest.py +21 -0
- revitdevtool_pytest-0.0.2/tests/test_revit_basic.py +35 -0
- revitdevtool_pytest-0.0.2/tests/test_revit_transactions.py +41 -0
- revitdevtool_pytest-0.0.2/tests/test_smoke.py +18 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment:
|
|
14
|
+
name: pypi
|
|
15
|
+
url: https://pypi.org/p/RevitDevTool.PyTest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.13"
|
|
22
|
+
|
|
23
|
+
- name: Install build tools
|
|
24
|
+
run: pip install build
|
|
25
|
+
|
|
26
|
+
- name: Build package
|
|
27
|
+
run: python -m build
|
|
28
|
+
|
|
29
|
+
- name: Publish to PyPI
|
|
30
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"python.autoComplete.extraPaths": [
|
|
3
|
+
"~/AppData/Roaming/RevitDevTool/2025/Stubs",
|
|
4
|
+
"~/AppData/Roaming/pyRevit-Master/pyrevitlib",
|
|
5
|
+
"~/AppData/Roaming/pyRevit-Master/site-packages"
|
|
6
|
+
],
|
|
7
|
+
"python.analysis.extraPaths": [
|
|
8
|
+
"~/AppData/Roaming/RevitDevTool/2025/Stubs",
|
|
9
|
+
"~/AppData/Roaming/pyRevit-Master/pyrevitlib",
|
|
10
|
+
"~/AppData/Roaming/pyRevit-Master/site-packages"
|
|
11
|
+
],
|
|
12
|
+
"cursorpyright.analysis.extraPaths": [
|
|
13
|
+
"~/AppData/Roaming/RevitDevTool/2025/Stubs",
|
|
14
|
+
"~/AppData/Roaming/pyRevit-Master/pyrevitlib",
|
|
15
|
+
"~/AppData/Roaming/pyRevit-Master/site-packages"
|
|
16
|
+
],
|
|
17
|
+
"basedpyright.analysis.extraPaths": [
|
|
18
|
+
"~/AppData/Roaming/RevitDevTool/2025/Stubs",
|
|
19
|
+
"~/AppData/Roaming/pyRevit-Master/pyrevitlib",
|
|
20
|
+
"~/AppData/Roaming/pyRevit-Master/site-packages"
|
|
21
|
+
],
|
|
22
|
+
"basedpyright.importStrategy": "useBundled",
|
|
23
|
+
"basedpyright.analysis.typeCheckingMode": "off",
|
|
24
|
+
"python.testing.pytestEnabled": true,
|
|
25
|
+
"python.testing.pytestArgs": [
|
|
26
|
+
"--revit-launch",
|
|
27
|
+
"--revit-version=2025",
|
|
28
|
+
"--revit-launch-timeout=180",
|
|
29
|
+
"tests"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.0.2] - 2026-04-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed pytest plugin auto-loading via the `pytest11` entry point.
|
|
10
|
+
- Fixed the `pytest_unconfigure` hook signature in `revitdevtool_pytest.plugin` so Pluggy accepts the plugin during pytest startup.
|
|
11
|
+
- Restored the expected standalone package behavior so `RevitDevTool.PyTest` can be discovered and activated by a normal Python/pytest environment before connecting to Revit.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Aligned the published package metadata name with the recommended normalized package name `revitdevtool_pytest`.
|
|
16
|
+
|
|
17
|
+
### Notes
|
|
18
|
+
|
|
19
|
+
- This release is intended as a bugfix release for the `0.0.1` package.
|
|
20
|
+
- No user-facing CLI options are changed in this release.
|
|
21
|
+
- This name alignment is intended to remain compatible with existing PyPI installs because Python package indexes and installers normalize `.`, `-`, and `_` in distribution names.
|
|
22
|
+
|
|
23
|
+
## [0.0.1] - 2026-04-09
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Initial standalone `pytest` plugin package for running Revit API tests through the RevitDevTool Named Pipe bridge.
|
|
28
|
+
- Automatic discovery of running Revit instances through named pipes.
|
|
29
|
+
- Optional Revit auto-launch flow via `--revit-launch` and `--revit-version`.
|
|
30
|
+
- Remote test execution by intercepting `pytest_pyfunc_call` and forwarding serialized test code to Revit.
|
|
31
|
+
- Support for explicit pipe targeting with `--revit-pipe`.
|
|
32
|
+
- Per-test timeout and launch timeout configuration.
|
|
33
|
+
- Revit startup dialog resolver integration.
|
|
34
|
+
- Example test suite and VS Code integration guidance in the project documentation.
|
|
35
|
+
|
|
36
|
+
### Notes
|
|
37
|
+
|
|
38
|
+
- `0.0.1` introduced the core test bridge workflow, but the published package contains a plugin loading issue fixed in `0.0.2`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Truong Giang Vu
|
|
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,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: revitdevtool_pytest
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: pytest plugin for testing Revit API code via RevitDevTool Named Pipe bridge
|
|
5
|
+
Project-URL: Homepage, https://github.com/trgiangv/RevitDevTool.PyTest
|
|
6
|
+
Project-URL: Repository, https://github.com/trgiangv/RevitDevTool.PyTest
|
|
7
|
+
Project-URL: Issues, https://github.com/trgiangv/RevitDevTool.PyTest/issues
|
|
8
|
+
Author: Truong Giang Vu
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bim,pytest,revit,revit-api,revitdevtool,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: pytest>=9.0
|
|
25
|
+
Requires-Dist: pywin32>=311
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# RevitDevTool.PyTest
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/RevitDevTool.PyTest/)
|
|
31
|
+
[](https://pypi.org/project/RevitDevTool.PyTest/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
pytest plugin for testing Revit API code via RevitDevTool Named Pipe bridge.
|
|
35
|
+
Tests run inside a live Revit process — write standard pytest, execute remotely.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install revitdevtool_pytest
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Recommended package name for manifests and package managers such as Pixi:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
revitdevtool_pytest
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`pip` normalizes package names, so the following forms are equivalent when installing from PyPI:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install revitdevtool_pytest
|
|
53
|
+
pip install revitdevtool-pytest
|
|
54
|
+
pip install RevitDevTool.PyTest
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The Python import/module name used by the package is:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import revitdevtool_pytest
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Dependencies
|
|
64
|
+
|
|
65
|
+
| Package | Version |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Python | >= 3.10 |
|
|
68
|
+
| pytest | >= 9.0 |
|
|
69
|
+
| pywin32 | >= 311 |
|
|
70
|
+
|
|
71
|
+
## Requirements
|
|
72
|
+
|
|
73
|
+
- Windows (Named Pipes)
|
|
74
|
+
- Revit with [RevitDevTool](https://github.com/trgiangv/RevitDevTool) add-in installed
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
def test_revit_version():
|
|
80
|
+
app = __revit__.Application
|
|
81
|
+
assert "2025" in app.VersionName
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# Auto-detect running Revit 2025
|
|
86
|
+
pytest --revit-version=2025 -v
|
|
87
|
+
|
|
88
|
+
# Auto-launch Revit 2025 if not running
|
|
89
|
+
pytest --revit-launch --revit-version=2025 -v
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## CLI Options
|
|
93
|
+
|
|
94
|
+
| Option | Description |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `--revit-version` | Revit version year (e.g. 2025). Required with `--revit-launch`. |
|
|
97
|
+
| `--revit-launch` | Auto-launch Revit if no running instance found. |
|
|
98
|
+
| `--revit-timeout` | Per-test timeout in seconds (default: 60). |
|
|
99
|
+
| `--revit-launch-timeout` | Revit startup timeout in seconds (default: 120). |
|
|
100
|
+
| `--revit-pipe` | Explicit pipe name (bypasses auto-discovery). |
|
|
101
|
+
|
|
102
|
+
## How It Works
|
|
103
|
+
|
|
104
|
+
1. pytest discovers tests locally as usual
|
|
105
|
+
2. The plugin intercepts test execution via `pytest_pyfunc_call`
|
|
106
|
+
3. Test source code is serialized and sent over Named Pipe to Revit
|
|
107
|
+
4. RevitDevTool add-in executes the test inside Revit's Python (pythonnet) environment
|
|
108
|
+
5. Results are mapped back to pytest pass/fail/skip
|
|
109
|
+
|
|
110
|
+
## VSCode Integration
|
|
111
|
+
|
|
112
|
+
Add to `.vscode/settings.json`:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"python.testing.pytestEnabled": true,
|
|
117
|
+
"python.testing.pytestArgs": [
|
|
118
|
+
"--revit-launch",
|
|
119
|
+
"--revit-version=2025",
|
|
120
|
+
"tests"
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# RevitDevTool.PyTest
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/RevitDevTool.PyTest/)
|
|
4
|
+
[](https://pypi.org/project/RevitDevTool.PyTest/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
pytest plugin for testing Revit API code via RevitDevTool Named Pipe bridge.
|
|
8
|
+
Tests run inside a live Revit process — write standard pytest, execute remotely.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install revitdevtool_pytest
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Recommended package name for manifests and package managers such as Pixi:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
revitdevtool_pytest
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`pip` normalizes package names, so the following forms are equivalent when installing from PyPI:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install revitdevtool_pytest
|
|
26
|
+
pip install revitdevtool-pytest
|
|
27
|
+
pip install RevitDevTool.PyTest
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The Python import/module name used by the package is:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import revitdevtool_pytest
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Dependencies
|
|
37
|
+
|
|
38
|
+
| Package | Version |
|
|
39
|
+
|---|---|
|
|
40
|
+
| Python | >= 3.10 |
|
|
41
|
+
| pytest | >= 9.0 |
|
|
42
|
+
| pywin32 | >= 311 |
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- Windows (Named Pipes)
|
|
47
|
+
- Revit with [RevitDevTool](https://github.com/trgiangv/RevitDevTool) add-in installed
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
def test_revit_version():
|
|
53
|
+
app = __revit__.Application
|
|
54
|
+
assert "2025" in app.VersionName
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Auto-detect running Revit 2025
|
|
59
|
+
pytest --revit-version=2025 -v
|
|
60
|
+
|
|
61
|
+
# Auto-launch Revit 2025 if not running
|
|
62
|
+
pytest --revit-launch --revit-version=2025 -v
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## CLI Options
|
|
66
|
+
|
|
67
|
+
| Option | Description |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `--revit-version` | Revit version year (e.g. 2025). Required with `--revit-launch`. |
|
|
70
|
+
| `--revit-launch` | Auto-launch Revit if no running instance found. |
|
|
71
|
+
| `--revit-timeout` | Per-test timeout in seconds (default: 60). |
|
|
72
|
+
| `--revit-launch-timeout` | Revit startup timeout in seconds (default: 120). |
|
|
73
|
+
| `--revit-pipe` | Explicit pipe name (bypasses auto-discovery). |
|
|
74
|
+
|
|
75
|
+
## How It Works
|
|
76
|
+
|
|
77
|
+
1. pytest discovers tests locally as usual
|
|
78
|
+
2. The plugin intercepts test execution via `pytest_pyfunc_call`
|
|
79
|
+
3. Test source code is serialized and sent over Named Pipe to Revit
|
|
80
|
+
4. RevitDevTool add-in executes the test inside Revit's Python (pythonnet) environment
|
|
81
|
+
5. Results are mapped back to pytest pass/fail/skip
|
|
82
|
+
|
|
83
|
+
## VSCode Integration
|
|
84
|
+
|
|
85
|
+
Add to `.vscode/settings.json`:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"python.testing.pytestEnabled": true,
|
|
90
|
+
"python.testing.pytestArgs": [
|
|
91
|
+
"--revit-launch",
|
|
92
|
+
"--revit-version=2025",
|
|
93
|
+
"tests"
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "revitdevtool_pytest"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "pytest plugin for testing Revit API code via RevitDevTool Named Pipe bridge"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Truong Giang Vu" }]
|
|
13
|
+
keywords = ["pytest", "revit", "revit-api", "testing", "bim", "revitdevtool"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: Pytest",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: Microsoft :: Windows",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Testing",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["pytest>=9.0", "pywin32>=311"]
|
|
28
|
+
|
|
29
|
+
[project.entry-points."pytest11"]
|
|
30
|
+
revitdevtool = "revitdevtool_pytest.plugin"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/trgiangv/RevitDevTool.PyTest"
|
|
34
|
+
Repository = "https://github.com/trgiangv/RevitDevTool.PyTest"
|
|
35
|
+
Issues = "https://github.com/trgiangv/RevitDevTool.PyTest/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/revitdevtool_pytest"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Named Pipe client for the RevitDevTool bridge.
|
|
2
|
+
|
|
3
|
+
Protocol: ``[4-byte LE body length][UTF-8 JSON body]``
|
|
4
|
+
Matches ``RevitDevTool.McpParser.Models.BridgePipeConnection``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import struct
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .constants import (
|
|
15
|
+
BRIDGE_METHOD_TESTS_EXECUTE,
|
|
16
|
+
BRIDGE_MSG_TYPE_NOTIFICATION,
|
|
17
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
18
|
+
DEFAULT_TEST_TIMEOUT_S,
|
|
19
|
+
)
|
|
20
|
+
from .models import BridgeRequest, BridgeResponse, TestOutcome, TestRequest, TestResult
|
|
21
|
+
|
|
22
|
+
_MAX_FRAME_SIZE = 16 * 1024 * 1024
|
|
23
|
+
_HEADER_FMT = "<I"
|
|
24
|
+
_HEADER_LEN = struct.calcsize(_HEADER_FMT)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RevitBridge:
|
|
28
|
+
"""Synchronous Named Pipe client for the RevitDevTool bridge."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, pipe_name: str, *, connect_timeout_ms: int = DEFAULT_CONNECT_TIMEOUT_MS) -> None:
|
|
31
|
+
self._pipe_name = pipe_name
|
|
32
|
+
self._connect_timeout_ms = connect_timeout_ms
|
|
33
|
+
self._handle: Any = None
|
|
34
|
+
|
|
35
|
+
def connect(self) -> None:
|
|
36
|
+
import win32file # type: ignore[import-untyped]
|
|
37
|
+
import win32pipe # type: ignore[import-untyped]
|
|
38
|
+
|
|
39
|
+
pipe_path = rf"\\.\pipe\{self._pipe_name}"
|
|
40
|
+
deadline = time.monotonic() + self._connect_timeout_ms / 1000.0
|
|
41
|
+
|
|
42
|
+
while True:
|
|
43
|
+
try:
|
|
44
|
+
self._handle = win32file.CreateFile(
|
|
45
|
+
pipe_path,
|
|
46
|
+
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
|
47
|
+
0, None,
|
|
48
|
+
win32file.OPEN_EXISTING, 0, None,
|
|
49
|
+
)
|
|
50
|
+
win32pipe.SetNamedPipeHandleState(
|
|
51
|
+
self._handle, win32pipe.PIPE_READMODE_BYTE, None, None,
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
except Exception:
|
|
55
|
+
if time.monotonic() >= deadline:
|
|
56
|
+
raise ConnectionError(
|
|
57
|
+
f"Cannot connect to pipe '{self._pipe_name}' "
|
|
58
|
+
f"within {self._connect_timeout_ms}ms"
|
|
59
|
+
)
|
|
60
|
+
time.sleep(0.2)
|
|
61
|
+
|
|
62
|
+
def disconnect(self) -> None:
|
|
63
|
+
handle, self._handle = self._handle, None
|
|
64
|
+
if handle is None:
|
|
65
|
+
return
|
|
66
|
+
import win32file # type: ignore[import-untyped]
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
win32file.CloseHandle(handle)
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def connected(self) -> bool:
|
|
75
|
+
return self._handle is not None
|
|
76
|
+
|
|
77
|
+
def execute_test(self, request: TestRequest, *, timeout_s: float = DEFAULT_TEST_TIMEOUT_S) -> TestResult:
|
|
78
|
+
response = self._request(
|
|
79
|
+
BridgeRequest(method=BRIDGE_METHOD_TESTS_EXECUTE, params=request.to_bridge_params()),
|
|
80
|
+
timeout_s,
|
|
81
|
+
)
|
|
82
|
+
if response.is_error:
|
|
83
|
+
return TestResult(outcome=TestOutcome.ERROR, message=response.error_message)
|
|
84
|
+
if isinstance(response.result, dict):
|
|
85
|
+
return TestResult.from_dict(response.result)
|
|
86
|
+
return TestResult(outcome=TestOutcome.ERROR, message=f"Unexpected response: {response.result}")
|
|
87
|
+
|
|
88
|
+
def _request(self, req: BridgeRequest, timeout_s: float) -> BridgeResponse:
|
|
89
|
+
self._write_frame(req.to_json_bytes())
|
|
90
|
+
deadline = time.monotonic() + timeout_s
|
|
91
|
+
while True:
|
|
92
|
+
remaining = deadline - time.monotonic()
|
|
93
|
+
if remaining <= 0:
|
|
94
|
+
raise TimeoutError(f"Timed out waiting for response to {req.id}")
|
|
95
|
+
data = self._read_frame(remaining)
|
|
96
|
+
parsed = json.loads(data)
|
|
97
|
+
if parsed.get("type") == BRIDGE_MSG_TYPE_NOTIFICATION:
|
|
98
|
+
continue
|
|
99
|
+
resp = BridgeResponse.from_json(parsed)
|
|
100
|
+
if resp.id == req.id:
|
|
101
|
+
return resp
|
|
102
|
+
|
|
103
|
+
def _write_frame(self, body: bytes) -> None:
|
|
104
|
+
import win32file # type: ignore[import-untyped]
|
|
105
|
+
|
|
106
|
+
win32file.WriteFile(self._handle, struct.pack(_HEADER_FMT, len(body)) + body)
|
|
107
|
+
|
|
108
|
+
def _read_frame(self, timeout_s: float) -> bytes:
|
|
109
|
+
header = self._read_exact(_HEADER_LEN, timeout_s)
|
|
110
|
+
(body_len,) = struct.unpack(_HEADER_FMT, header)
|
|
111
|
+
if body_len <= 0 or body_len > _MAX_FRAME_SIZE:
|
|
112
|
+
raise RuntimeError(f"Invalid frame length: {body_len}")
|
|
113
|
+
return self._read_exact(body_len, timeout_s)
|
|
114
|
+
|
|
115
|
+
def _read_exact(self, count: int, timeout_s: float) -> bytes:
|
|
116
|
+
import win32file # type: ignore[import-untyped]
|
|
117
|
+
|
|
118
|
+
buf = bytearray()
|
|
119
|
+
deadline = time.monotonic() + timeout_s
|
|
120
|
+
while len(buf) < count:
|
|
121
|
+
if time.monotonic() >= deadline:
|
|
122
|
+
raise TimeoutError(f"Timed out reading {count} bytes (got {len(buf)})")
|
|
123
|
+
_, data = win32file.ReadFile(self._handle, count - len(buf))
|
|
124
|
+
if not data:
|
|
125
|
+
raise ConnectionError("Pipe closed while reading")
|
|
126
|
+
buf.extend(data)
|
|
127
|
+
return bytes(buf)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Centralized constants for RevitDevTool.PyTest."""
|
|
2
|
+
|
|
3
|
+
PLUGIN_NAME = "RevitDevTool.PyTest"
|
|
4
|
+
|
|
5
|
+
BRIDGE_METHOD_TESTS_EXECUTE = "tests/execute"
|
|
6
|
+
BRIDGE_MSG_TYPE_NOTIFICATION = "notification"
|
|
7
|
+
|
|
8
|
+
OPT_VERSION = "revit_version"
|
|
9
|
+
OPT_TIMEOUT = "revit_timeout"
|
|
10
|
+
OPT_PIPE = "revit_pipe"
|
|
11
|
+
OPT_LAUNCH = "revit_launch"
|
|
12
|
+
OPT_LAUNCH_TIMEOUT = "revit_launch_timeout"
|
|
13
|
+
|
|
14
|
+
DEFAULT_TEST_TIMEOUT_S = 60.0
|
|
15
|
+
DEFAULT_LAUNCH_TIMEOUT_S = 120.0
|
|
16
|
+
DEFAULT_CONNECT_TIMEOUT_MS = 30_000
|
|
17
|
+
DEFAULT_POLL_INTERVAL_S = 2.0
|
|
18
|
+
|
|
19
|
+
PIPE_DIR = r"//./pipe"
|
|
20
|
+
PIPE_PATTERN = r"^Revit_(\d{4})_(\d+)$"
|
|
21
|
+
|
|
22
|
+
REVIT_EXE = "Revit.exe"
|
|
23
|
+
REVIT_NOSPLASH = "/nosplash"
|
|
24
|
+
REVIT_DEFAULT_DIR = r"C:\Program Files\Autodesk"
|
|
25
|
+
REVIT_REGISTRY_KEY = r"SOFTWARE\Autodesk\Revit\Autodesk Revit {version}"
|
|
26
|
+
REVIT_REGISTRY_VALUE = "InstallationLocation"
|
|
27
|
+
|
|
28
|
+
EXIT_CODE_CONFIG_ERROR = 4
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Auto-dismiss Revit startup dialogs via Win32 polling.
|
|
2
|
+
|
|
3
|
+
Ported from ``RevitDevTool.Console.Services.Hosting.StartupDialogResolver``.
|
|
4
|
+
Runs in a background thread and clicks whitelisted buttons on known dialog
|
|
5
|
+
windows belonging to the Revit process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import ctypes.wintypes
|
|
12
|
+
import threading
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
_BM_CLICK = 0x00F5
|
|
16
|
+
_DIALOG_CLASS = "#32770"
|
|
17
|
+
_BUTTON_CLASS = "button"
|
|
18
|
+
|
|
19
|
+
_user32 = ctypes.windll.user32 # type: ignore[attr-defined]
|
|
20
|
+
|
|
21
|
+
_EnumWindowsProc = ctypes.WINFUNCTYPE(
|
|
22
|
+
ctypes.wintypes.BOOL, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_window_visible(hwnd: int) -> bool:
|
|
27
|
+
return bool(_user32.IsWindowVisible(hwnd))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_window_text(hwnd: int) -> str:
|
|
31
|
+
length = _user32.GetWindowTextLengthW(hwnd)
|
|
32
|
+
if length <= 0:
|
|
33
|
+
return ""
|
|
34
|
+
buf = ctypes.create_unicode_buffer(length + 1)
|
|
35
|
+
_user32.GetWindowTextW(hwnd, buf, len(buf))
|
|
36
|
+
return buf.value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_class_name(hwnd: int) -> str:
|
|
40
|
+
buf = ctypes.create_unicode_buffer(256)
|
|
41
|
+
_user32.GetClassNameW(hwnd, buf, 256)
|
|
42
|
+
return buf.value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_window_pid(hwnd: int) -> int:
|
|
46
|
+
pid = ctypes.wintypes.DWORD()
|
|
47
|
+
_user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
48
|
+
return pid.value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _send_click(hwnd: int) -> None:
|
|
52
|
+
_user32.SendMessageW(hwnd, _BM_CLICK, 0, 0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class DialogResolverOptions:
|
|
57
|
+
poll_interval_s: float = 0.5
|
|
58
|
+
dialog_title_keywords: list[str] = field(default_factory=lambda: [
|
|
59
|
+
"Autodesk", "Revit", "Load", "Security", "Warning", "Add-in", "Addin",
|
|
60
|
+
])
|
|
61
|
+
preferred_button_keywords: list[str] = field(default_factory=lambda: [
|
|
62
|
+
"Always Load", "Load", "OK", "Yes", "Accept", "Continue", "Close",
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StartupDialogResolver:
|
|
67
|
+
"""Poll for Revit startup dialogs and auto-click whitelisted buttons."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, process_id: int, options: DialogResolverOptions | None = None) -> None:
|
|
70
|
+
self._pid = process_id
|
|
71
|
+
self._opts = options or DialogResolverOptions()
|
|
72
|
+
self._stop = threading.Event()
|
|
73
|
+
self._thread: threading.Thread | None = None
|
|
74
|
+
self._clicked: set[int] = set()
|
|
75
|
+
|
|
76
|
+
def start(self) -> None:
|
|
77
|
+
if self._thread is not None:
|
|
78
|
+
return
|
|
79
|
+
self._stop.clear()
|
|
80
|
+
self._thread = threading.Thread(target=self._run, daemon=True, name="revit-dialog-resolver")
|
|
81
|
+
self._thread.start()
|
|
82
|
+
|
|
83
|
+
def stop(self) -> None:
|
|
84
|
+
self._stop.set()
|
|
85
|
+
if self._thread is not None:
|
|
86
|
+
self._thread.join(timeout=5.0)
|
|
87
|
+
self._thread = None
|
|
88
|
+
|
|
89
|
+
def _run(self) -> None:
|
|
90
|
+
while not self._stop.is_set():
|
|
91
|
+
self._scan()
|
|
92
|
+
self._stop.wait(self._opts.poll_interval_s)
|
|
93
|
+
|
|
94
|
+
def _scan(self) -> None:
|
|
95
|
+
for hwnd in self._enum_dialog_windows():
|
|
96
|
+
title = _get_window_text(hwnd)
|
|
97
|
+
if not title or not self._is_whitelisted(title):
|
|
98
|
+
continue
|
|
99
|
+
button = self._find_button(hwnd)
|
|
100
|
+
if button and button not in self._clicked:
|
|
101
|
+
_send_click(button)
|
|
102
|
+
self._clicked.add(button)
|
|
103
|
+
|
|
104
|
+
def _is_whitelisted(self, title: str) -> bool:
|
|
105
|
+
lower = title.lower()
|
|
106
|
+
return any(kw.lower() in lower for kw in self._opts.dialog_title_keywords)
|
|
107
|
+
|
|
108
|
+
def _find_button(self, parent: int) -> int | None:
|
|
109
|
+
result: list[int] = []
|
|
110
|
+
|
|
111
|
+
@_EnumWindowsProc
|
|
112
|
+
def _cb(child: int, _: int) -> bool:
|
|
113
|
+
if _is_preferred_button(child, self._opts.preferred_button_keywords):
|
|
114
|
+
result.append(child)
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
_user32.EnumChildWindows(parent, _cb, 0)
|
|
119
|
+
return result[0] if result else None
|
|
120
|
+
|
|
121
|
+
def _enum_dialog_windows(self) -> list[int]:
|
|
122
|
+
windows: list[int] = []
|
|
123
|
+
|
|
124
|
+
@_EnumWindowsProc
|
|
125
|
+
def _cb(hwnd: int, _: int) -> bool:
|
|
126
|
+
if _is_target_dialog(hwnd, self._pid):
|
|
127
|
+
windows.append(hwnd)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
_user32.EnumWindows(_cb, 0)
|
|
131
|
+
return windows
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_preferred_button(hwnd: int, keywords: list[str]) -> bool:
|
|
135
|
+
if _get_class_name(hwnd).lower() != _BUTTON_CLASS:
|
|
136
|
+
return False
|
|
137
|
+
text = _get_window_text(hwnd)
|
|
138
|
+
if not text:
|
|
139
|
+
return False
|
|
140
|
+
text_lower = text.lower()
|
|
141
|
+
return any(kw.lower() in text_lower for kw in keywords)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_target_dialog(hwnd: int, target_pid: int) -> bool:
|
|
145
|
+
if not _is_window_visible(hwnd):
|
|
146
|
+
return False
|
|
147
|
+
if _get_class_name(hwnd) != _DIALOG_CLASS:
|
|
148
|
+
return False
|
|
149
|
+
return _get_window_pid(hwnd) == target_pid
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Discover running Revit instances and launch Revit if needed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import winreg
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .constants import (
|
|
13
|
+
DEFAULT_LAUNCH_TIMEOUT_S,
|
|
14
|
+
DEFAULT_POLL_INTERVAL_S,
|
|
15
|
+
PIPE_DIR,
|
|
16
|
+
PIPE_PATTERN,
|
|
17
|
+
REVIT_DEFAULT_DIR,
|
|
18
|
+
REVIT_EXE,
|
|
19
|
+
REVIT_NOSPLASH,
|
|
20
|
+
REVIT_REGISTRY_KEY,
|
|
21
|
+
REVIT_REGISTRY_VALUE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_PIPE_RE = re.compile(PIPE_PATTERN)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class RevitInstance:
|
|
29
|
+
pipe_name: str
|
|
30
|
+
version: int
|
|
31
|
+
process_id: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_revit_pipes() -> list[RevitInstance]:
|
|
35
|
+
"""Scan Named Pipes for pipes matching ``Revit_{year}_{pid}``."""
|
|
36
|
+
instances: list[RevitInstance] = []
|
|
37
|
+
for name in _list_named_pipes():
|
|
38
|
+
m = _PIPE_RE.match(name)
|
|
39
|
+
if m:
|
|
40
|
+
instances.append(RevitInstance(name, int(m.group(1)), int(m.group(2))))
|
|
41
|
+
return instances
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def select_instance(
|
|
45
|
+
instances: list[RevitInstance],
|
|
46
|
+
version: int | None = None,
|
|
47
|
+
) -> RevitInstance | None:
|
|
48
|
+
"""Find a running instance matching *version*, or the latest if unspecified.
|
|
49
|
+
|
|
50
|
+
When *version* is given, only an exact match is returned — no fallback.
|
|
51
|
+
"""
|
|
52
|
+
if not instances:
|
|
53
|
+
return None
|
|
54
|
+
if version is not None:
|
|
55
|
+
matches = [i for i in instances if i.version == version]
|
|
56
|
+
return matches[0] if matches else None
|
|
57
|
+
return max(instances, key=lambda i: i.version)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def find_revit_path(version: int) -> str | None:
|
|
61
|
+
"""Locate ``Revit.exe`` via registry, falling back to the default path."""
|
|
62
|
+
path = _find_from_registry(version)
|
|
63
|
+
if path:
|
|
64
|
+
return path
|
|
65
|
+
default = os.path.join(REVIT_DEFAULT_DIR, f"Revit {version}", REVIT_EXE)
|
|
66
|
+
return default if os.path.isfile(default) else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def launch_revit(
|
|
70
|
+
version: int,
|
|
71
|
+
wait_timeout_s: float = DEFAULT_LAUNCH_TIMEOUT_S,
|
|
72
|
+
) -> RevitInstance | None:
|
|
73
|
+
"""Start Revit with ``/nosplash`` and wait for its Named Pipe to appear.
|
|
74
|
+
|
|
75
|
+
Returns the discovered ``RevitInstance``, or ``None`` on timeout.
|
|
76
|
+
Raises ``FileNotFoundError`` if the requested version is not installed.
|
|
77
|
+
"""
|
|
78
|
+
exe_path = find_revit_path(version)
|
|
79
|
+
if exe_path is None:
|
|
80
|
+
raise FileNotFoundError(f"Revit {version} installation not found.")
|
|
81
|
+
|
|
82
|
+
subprocess.Popen( # noqa: S603
|
|
83
|
+
[exe_path, REVIT_NOSPLASH],
|
|
84
|
+
creationflags=subprocess.DETACHED_PROCESS,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return wait_for_revit_pipe(version, timeout_s=wait_timeout_s)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def wait_for_revit_pipe(
|
|
91
|
+
version: int | None = None,
|
|
92
|
+
timeout_s: float = DEFAULT_LAUNCH_TIMEOUT_S,
|
|
93
|
+
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
|
|
94
|
+
) -> RevitInstance | None:
|
|
95
|
+
"""Block until a Revit pipe matching *version* appears."""
|
|
96
|
+
deadline = time.monotonic() + timeout_s
|
|
97
|
+
while time.monotonic() < deadline:
|
|
98
|
+
match = select_instance(find_revit_pipes(), version)
|
|
99
|
+
if match is not None:
|
|
100
|
+
return match
|
|
101
|
+
time.sleep(poll_interval_s)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _find_from_registry(version: int) -> str | None:
|
|
106
|
+
try:
|
|
107
|
+
with winreg.OpenKey(
|
|
108
|
+
winreg.HKEY_LOCAL_MACHINE,
|
|
109
|
+
REVIT_REGISTRY_KEY.format(version=version),
|
|
110
|
+
) as key:
|
|
111
|
+
install_dir, _ = winreg.QueryValueEx(key, REVIT_REGISTRY_VALUE)
|
|
112
|
+
exe = os.path.join(install_dir, REVIT_EXE)
|
|
113
|
+
return exe if os.path.isfile(exe) else None
|
|
114
|
+
except OSError:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _list_named_pipes() -> list[str]:
|
|
119
|
+
"""Return base names of all Named Pipes visible to the current user."""
|
|
120
|
+
try:
|
|
121
|
+
return os.listdir(PIPE_DIR)
|
|
122
|
+
except OSError:
|
|
123
|
+
return []
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Data models for the RevitDevTool bridge protocol.
|
|
2
|
+
|
|
3
|
+
Mirrors ``RevitDevTool.McpParser.Models.BridgeMessage`` on the C# side.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestOutcome(Enum):
|
|
16
|
+
PASSED = "passed"
|
|
17
|
+
FAILED = "failed"
|
|
18
|
+
SKIPPED = "skipped"
|
|
19
|
+
ERROR = "error"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class TestRequest:
|
|
24
|
+
module_source: str
|
|
25
|
+
test_name: str
|
|
26
|
+
file_path: str
|
|
27
|
+
class_name: str | None = None
|
|
28
|
+
|
|
29
|
+
def to_bridge_params(self) -> dict[str, Any]:
|
|
30
|
+
params: dict[str, Any] = {
|
|
31
|
+
"module_source": self.module_source,
|
|
32
|
+
"test_name": self.test_name,
|
|
33
|
+
"file_path": self.file_path,
|
|
34
|
+
}
|
|
35
|
+
if self.class_name:
|
|
36
|
+
params["class_name"] = self.class_name
|
|
37
|
+
return params
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class TestResult:
|
|
42
|
+
outcome: TestOutcome = TestOutcome.ERROR
|
|
43
|
+
message: str = ""
|
|
44
|
+
traceback: str = ""
|
|
45
|
+
stdout: str = ""
|
|
46
|
+
duration_ms: float = 0.0
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: dict[str, Any]) -> TestResult:
|
|
50
|
+
raw = data.get("outcome", "error")
|
|
51
|
+
try:
|
|
52
|
+
outcome = TestOutcome(raw)
|
|
53
|
+
except ValueError:
|
|
54
|
+
outcome = TestOutcome.ERROR
|
|
55
|
+
return cls(
|
|
56
|
+
outcome=outcome,
|
|
57
|
+
message=data.get("message", ""),
|
|
58
|
+
traceback=data.get("traceback", ""),
|
|
59
|
+
stdout=data.get("stdout", ""),
|
|
60
|
+
duration_ms=data.get("duration_ms", 0.0),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class BridgeRequest:
|
|
66
|
+
method: str
|
|
67
|
+
params: dict[str, Any] | None = None
|
|
68
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
69
|
+
|
|
70
|
+
def to_json_bytes(self) -> bytes:
|
|
71
|
+
msg: dict[str, Any] = {
|
|
72
|
+
"type": "request",
|
|
73
|
+
"id": self.id,
|
|
74
|
+
"method": self.method,
|
|
75
|
+
}
|
|
76
|
+
if self.params is not None:
|
|
77
|
+
msg["params"] = self.params
|
|
78
|
+
return json.dumps(msg, ensure_ascii=False).encode("utf-8")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class BridgeResponse:
|
|
83
|
+
id: str
|
|
84
|
+
result: Any = None
|
|
85
|
+
is_error: bool = False
|
|
86
|
+
error_message: str = ""
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_json(cls, data: dict[str, Any]) -> BridgeResponse:
|
|
90
|
+
return cls(
|
|
91
|
+
id=data.get("id", ""),
|
|
92
|
+
result=data.get("result"),
|
|
93
|
+
is_error=data.get("isError", False),
|
|
94
|
+
error_message=data.get("errorMessage", ""),
|
|
95
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""pytest plugin — redirect test execution to a running Revit instance.
|
|
2
|
+
|
|
3
|
+
``--revit-launch`` requires ``--revit-version`` — no fallback guessing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from .bridge import RevitBridge
|
|
14
|
+
from .constants import (
|
|
15
|
+
DEFAULT_LAUNCH_TIMEOUT_S,
|
|
16
|
+
DEFAULT_TEST_TIMEOUT_S,
|
|
17
|
+
EXIT_CODE_CONFIG_ERROR,
|
|
18
|
+
OPT_LAUNCH,
|
|
19
|
+
OPT_LAUNCH_TIMEOUT,
|
|
20
|
+
OPT_PIPE,
|
|
21
|
+
OPT_TIMEOUT,
|
|
22
|
+
OPT_VERSION,
|
|
23
|
+
PLUGIN_NAME,
|
|
24
|
+
)
|
|
25
|
+
from .discovery import find_revit_path, find_revit_pipes, launch_revit, select_instance
|
|
26
|
+
from .models import TestOutcome
|
|
27
|
+
from .serializer import serialize_test
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .dialog_resolver import StartupDialogResolver
|
|
31
|
+
from .discovery import RevitInstance
|
|
32
|
+
|
|
33
|
+
_bridge: RevitBridge | None = None
|
|
34
|
+
_dialog_resolver: StartupDialogResolver | None = None
|
|
35
|
+
_no_revit_key = pytest.StashKey[bool]()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
39
|
+
grp = parser.getgroup("revit", "Revit API testing")
|
|
40
|
+
grp.addoption("--revit-version", dest=OPT_VERSION, default=None, type=int,
|
|
41
|
+
help="Revit version year (e.g. 2025). Required when --revit-launch is set.")
|
|
42
|
+
grp.addoption("--revit-timeout", dest=OPT_TIMEOUT, default=None, type=float,
|
|
43
|
+
help=f"Per-test execution timeout in seconds (default: {DEFAULT_TEST_TIMEOUT_S}).")
|
|
44
|
+
grp.addoption("--revit-pipe", dest=OPT_PIPE, default=None,
|
|
45
|
+
help="Explicit pipe name (bypasses auto-discovery).")
|
|
46
|
+
grp.addoption("--revit-launch", dest=OPT_LAUNCH, action="store_true", default=False,
|
|
47
|
+
help="Auto-launch Revit if no running instance is found. Requires --revit-version.")
|
|
48
|
+
grp.addoption("--revit-launch-timeout", dest=OPT_LAUNCH_TIMEOUT, default=None, type=float,
|
|
49
|
+
help=f"Seconds to wait for Revit to start (default: {DEFAULT_LAUNCH_TIMEOUT_S}).")
|
|
50
|
+
|
|
51
|
+
parser.addini(OPT_VERSION, "Revit version year", type="string", default=None)
|
|
52
|
+
parser.addini(OPT_TIMEOUT, "Per-test timeout (seconds)", type="string",
|
|
53
|
+
default=str(DEFAULT_TEST_TIMEOUT_S))
|
|
54
|
+
parser.addini(OPT_PIPE, "Explicit pipe name", type="string", default=None)
|
|
55
|
+
parser.addini(OPT_LAUNCH, "Auto-launch Revit", type="bool", default=False)
|
|
56
|
+
parser.addini(OPT_LAUNCH_TIMEOUT, "Launch timeout (seconds)", type="string",
|
|
57
|
+
default=str(DEFAULT_LAUNCH_TIMEOUT_S))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
61
|
+
config.addinivalue_line("markers", "revit: mark test to run inside Revit process")
|
|
62
|
+
|
|
63
|
+
global _bridge, _dialog_resolver
|
|
64
|
+
|
|
65
|
+
explicit_pipe = _opt(config, OPT_PIPE, OPT_PIPE)
|
|
66
|
+
if explicit_pipe:
|
|
67
|
+
pipe_name = explicit_pipe
|
|
68
|
+
else:
|
|
69
|
+
version = _opt_int(config, OPT_VERSION, OPT_VERSION)
|
|
70
|
+
want_launch = _opt_bool(config, OPT_LAUNCH, OPT_LAUNCH)
|
|
71
|
+
|
|
72
|
+
if want_launch and version is None:
|
|
73
|
+
pytest.exit(
|
|
74
|
+
f"{PLUGIN_NAME}: --revit-launch requires --revit-version (e.g. --revit-version=2025)",
|
|
75
|
+
returncode=EXIT_CODE_CONFIG_ERROR,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
instance = select_instance(find_revit_pipes(), version)
|
|
79
|
+
|
|
80
|
+
if instance is None and want_launch:
|
|
81
|
+
instance = _auto_launch(version, config) # type: ignore[arg-type]
|
|
82
|
+
|
|
83
|
+
if instance is None:
|
|
84
|
+
config.stash[_no_revit_key] = True
|
|
85
|
+
return
|
|
86
|
+
pipe_name = instance.pipe_name
|
|
87
|
+
|
|
88
|
+
bridge = RevitBridge(pipe_name)
|
|
89
|
+
try:
|
|
90
|
+
bridge.connect()
|
|
91
|
+
_bridge = bridge
|
|
92
|
+
config.stash[_no_revit_key] = False
|
|
93
|
+
except ConnectionError as exc:
|
|
94
|
+
pytest.exit(f"{PLUGIN_NAME}: Could not connect to Revit: {exc}", returncode=EXIT_CODE_CONFIG_ERROR)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.hookimpl(tryfirst=True)
|
|
98
|
+
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> object:
|
|
99
|
+
"""Replace local execution with remote execution inside Revit.
|
|
100
|
+
|
|
101
|
+
Returning ``True`` tells pytest to skip calling the test function locally.
|
|
102
|
+
"""
|
|
103
|
+
if pyfuncitem.config.stash.get(_no_revit_key, True):
|
|
104
|
+
pytest.skip("No Revit instance available")
|
|
105
|
+
|
|
106
|
+
if _bridge is None or not _bridge.connected:
|
|
107
|
+
pytest.skip("Not connected to Revit")
|
|
108
|
+
|
|
109
|
+
timeout = _opt_float(pyfuncitem.config, OPT_TIMEOUT, OPT_TIMEOUT) or DEFAULT_TEST_TIMEOUT_S
|
|
110
|
+
result = _bridge.execute_test(serialize_test(pyfuncitem), timeout_s=timeout)
|
|
111
|
+
|
|
112
|
+
if result.stdout:
|
|
113
|
+
sys.stdout.write(result.stdout)
|
|
114
|
+
|
|
115
|
+
if result.outcome == TestOutcome.PASSED:
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
if result.outcome == TestOutcome.SKIPPED:
|
|
119
|
+
pytest.skip(result.message)
|
|
120
|
+
|
|
121
|
+
msg = result.message
|
|
122
|
+
if result.outcome != TestOutcome.FAILED:
|
|
123
|
+
msg = f"Test execution error: {msg}"
|
|
124
|
+
if result.traceback:
|
|
125
|
+
msg += "\n" + result.traceback
|
|
126
|
+
pytest.fail(msg, pytrace=False)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pytest_unconfigure(config: pytest.Config) -> None:
|
|
130
|
+
global _bridge, _dialog_resolver
|
|
131
|
+
if _dialog_resolver is not None:
|
|
132
|
+
_dialog_resolver.stop()
|
|
133
|
+
_dialog_resolver = None
|
|
134
|
+
if _bridge is not None:
|
|
135
|
+
_bridge.disconnect()
|
|
136
|
+
_bridge = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _auto_launch(version: int, config: pytest.Config) -> RevitInstance:
|
|
140
|
+
"""Launch exactly the requested Revit version, or fail fast.
|
|
141
|
+
|
|
142
|
+
Every failure path calls ``pytest.exit()`` — this function never returns ``None``.
|
|
143
|
+
"""
|
|
144
|
+
global _dialog_resolver
|
|
145
|
+
|
|
146
|
+
timeout = _opt_float(config, OPT_LAUNCH_TIMEOUT, OPT_LAUNCH_TIMEOUT) or DEFAULT_LAUNCH_TIMEOUT_S
|
|
147
|
+
|
|
148
|
+
if find_revit_path(version) is None:
|
|
149
|
+
pytest.exit(
|
|
150
|
+
f"{PLUGIN_NAME}: Revit {version} is not installed on this machine.",
|
|
151
|
+
returncode=EXIT_CODE_CONFIG_ERROR,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
print(f"{PLUGIN_NAME}: Launching Revit {version}...")
|
|
155
|
+
instance = launch_revit(version, wait_timeout_s=timeout)
|
|
156
|
+
|
|
157
|
+
if instance is None:
|
|
158
|
+
pytest.exit(
|
|
159
|
+
f"{PLUGIN_NAME}: Revit {version} launched but Named Pipe did not appear within {timeout}s.",
|
|
160
|
+
returncode=EXIT_CODE_CONFIG_ERROR,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
from .dialog_resolver import StartupDialogResolver as _Resolver
|
|
165
|
+
|
|
166
|
+
_dialog_resolver = _Resolver(instance.process_id)
|
|
167
|
+
_dialog_resolver.start()
|
|
168
|
+
except ImportError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
print(f"{PLUGIN_NAME}: Connected to Revit {instance.version} (pid={instance.process_id})")
|
|
172
|
+
return instance
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _opt(config: pytest.Config, cli: str, ini: str) -> str | None:
|
|
176
|
+
val = config.getoption(cli, default=None)
|
|
177
|
+
if val is not None:
|
|
178
|
+
return str(val)
|
|
179
|
+
ini_val = config.getini(ini)
|
|
180
|
+
return str(ini_val) if ini_val not in (None, "") else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _opt_int(config: pytest.Config, cli: str, ini: str) -> int | None:
|
|
184
|
+
raw = _opt(config, cli, ini)
|
|
185
|
+
return int(raw) if raw else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _opt_float(config: pytest.Config, cli: str, ini: str) -> float | None:
|
|
189
|
+
raw = _opt(config, cli, ini)
|
|
190
|
+
return float(raw) if raw else None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _opt_bool(config: pytest.Config, cli: str, ini: str) -> bool:
|
|
194
|
+
if config.getoption(cli, default=False):
|
|
195
|
+
return True
|
|
196
|
+
ini_val = config.getini(ini)
|
|
197
|
+
if isinstance(ini_val, bool):
|
|
198
|
+
return ini_val
|
|
199
|
+
return str(ini_val).lower() in ("true", "1", "yes") if ini_val else False
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Serialize a collected pytest item into a TestRequest for remote execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .models import TestRequest
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def serialize_test(item: pytest.Item) -> TestRequest:
|
|
15
|
+
"""Read the full module source and extract test/class names."""
|
|
16
|
+
return TestRequest(
|
|
17
|
+
module_source=Path(str(item.fspath)).read_text(encoding="utf-8"),
|
|
18
|
+
test_name=item.name,
|
|
19
|
+
file_path=str(item.fspath),
|
|
20
|
+
class_name=item.cls.__name__ if item.cls is not None else None,
|
|
21
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Sample conftest.py — user-defined fixtures for Revit testing.
|
|
2
|
+
|
|
3
|
+
The plugin only provides the connection to Revit and remote execution;
|
|
4
|
+
everything else is user-controlled.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(scope="session")
|
|
11
|
+
def revit_app():
|
|
12
|
+
"""Provide the Revit Application object for the entire test session."""
|
|
13
|
+
app = __revit__.Application # noqa: F821
|
|
14
|
+
yield app
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(scope="session")
|
|
18
|
+
def revit_doc():
|
|
19
|
+
"""Provide the active Document. Assumes a document is already open in Revit."""
|
|
20
|
+
doc = __revit__.ActiveUIDocument.Document # noqa: F821
|
|
21
|
+
yield doc
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Basic Revit API tests — demonstrates RevitDevTool.PyTest usage.
|
|
2
|
+
|
|
3
|
+
All Revit API imports must be inside function bodies (lazy imports).
|
|
4
|
+
__revit__ is available globally (injected by RevitDevTool).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_revit_version():
|
|
9
|
+
"""Verify Revit is running and we can read the version."""
|
|
10
|
+
app = __revit__.Application # noqa: F821
|
|
11
|
+
assert app.VersionNumber is not None
|
|
12
|
+
print(f"Revit version: {app.VersionName}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_active_document_exists():
|
|
16
|
+
"""Verify a document is open in Revit."""
|
|
17
|
+
uidoc = __revit__.ActiveUIDocument # noqa: F821
|
|
18
|
+
assert uidoc is not None
|
|
19
|
+
assert uidoc.Document is not None
|
|
20
|
+
print(f"Active document: {uidoc.Document.Title}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_wall_collector():
|
|
24
|
+
"""Query walls from the active document using FilteredElementCollector."""
|
|
25
|
+
from Autodesk.Revit.DB import FilteredElementCollector, BuiltInCategory
|
|
26
|
+
|
|
27
|
+
doc = __revit__.ActiveUIDocument.Document # noqa: F821
|
|
28
|
+
collector = (
|
|
29
|
+
FilteredElementCollector(doc)
|
|
30
|
+
.OfCategory(BuiltInCategory.OST_Walls)
|
|
31
|
+
.WhereElementIsNotElementType()
|
|
32
|
+
)
|
|
33
|
+
walls = list(collector)
|
|
34
|
+
print(f"Found {len(walls)} wall instances")
|
|
35
|
+
assert isinstance(walls, list)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Transaction tests — demonstrates creating and rolling back changes.
|
|
2
|
+
|
|
3
|
+
Shows how users can build their own transaction isolation patterns
|
|
4
|
+
without any framework-imposed behavior.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_create_and_delete_wall():
|
|
9
|
+
"""Create a wall inside a transaction, then roll back."""
|
|
10
|
+
from Autodesk.Revit.DB import (
|
|
11
|
+
Transaction,
|
|
12
|
+
Line,
|
|
13
|
+
XYZ,
|
|
14
|
+
FilteredElementCollector,
|
|
15
|
+
BuiltInCategory,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
doc = __revit__.ActiveUIDocument.Document # noqa: F821
|
|
19
|
+
|
|
20
|
+
collector = FilteredElementCollector(doc).OfCategory(
|
|
21
|
+
BuiltInCategory.OST_Walls
|
|
22
|
+
).WhereElementIsNotElementType()
|
|
23
|
+
count_before = len(list(collector))
|
|
24
|
+
|
|
25
|
+
tx = Transaction(doc, "pytest: create test wall")
|
|
26
|
+
tx.Start()
|
|
27
|
+
try:
|
|
28
|
+
line = Line.CreateBound(XYZ(0, 0, 0), XYZ(10, 0, 0))
|
|
29
|
+
print(f"Transaction started, walls before: {count_before}")
|
|
30
|
+
finally:
|
|
31
|
+
tx.RollBack()
|
|
32
|
+
print("Transaction rolled back")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_read_project_info():
|
|
36
|
+
"""Read project information — no transaction needed for read-only."""
|
|
37
|
+
doc = __revit__.ActiveUIDocument.Document # noqa: F821
|
|
38
|
+
info = doc.ProjectInformation
|
|
39
|
+
assert info is not None
|
|
40
|
+
print(f"Project name: {info.Name}")
|
|
41
|
+
print(f"Project number: {info.Number}")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Smoke test — minimal test to verify RevitDevTool.PyTest protocol works."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_revit_application_available():
|
|
5
|
+
"""Verify __revit__ is injected and Application is accessible."""
|
|
6
|
+
app = __revit__.Application # noqa: F821
|
|
7
|
+
version = app.VersionNumber
|
|
8
|
+
assert version is not None
|
|
9
|
+
assert len(version) == 4 # e.g. "2025"
|
|
10
|
+
print(f"Revit {app.VersionName} (build {app.VersionBuild})")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_uiapplication_available():
|
|
14
|
+
"""Verify UIApplication is accessible."""
|
|
15
|
+
uiapp = __revit__ # noqa: F821
|
|
16
|
+
assert uiapp is not None
|
|
17
|
+
assert uiapp.Application is not None
|
|
18
|
+
print(f"UIApplication OK — version {uiapp.Application.VersionNumber}")
|