Skip to content

Commit f4c4df6

Browse files
committed
Initial release: RevitDevTool.PyTest v0.0.1
Made-with: Cursor
0 parents  commit f4c4df6

18 files changed

Lines changed: 1076 additions & 0 deletions

‎.github/workflows/publish.yml‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

‎.gitignore‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
__pycache__/
2+
*.py[cod]
3+
*$py.class
4+
*.egg-info/
5+
dist/
6+
build/
7+
.eggs/
8+
*.egg
9+
.pytest_cache/
10+
.mypy_cache/
11+
.ruff_cache/
12+
*.so
13+
*.dll
14+
.env
15+
.venv/
16+
venv/

‎.vscode/settings.json‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

‎README.md‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# RevitDevTool.PyTest
2+
3+
[![PyPI version](https://img.shields.io/pypi/v/RevitDevTool.PyTest)](https://pypi.org/project/RevitDevTool.PyTest/)
4+
[![Python](https://img.shields.io/pypi/pyversions/RevitDevTool.PyTest)](https://pypi.org/project/RevitDevTool.PyTest/)
5+
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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+
## Dependencies
17+
18+
| Package | Version |
19+
|---|---|
20+
| Python | >= 3.10 |
21+
| pytest | >= 9.0 |
22+
| pywin32 | >= 311 |
23+
24+
## Requirements
25+
26+
- Windows (Named Pipes)
27+
- Revit with [RevitDevTool](https://github.com/trgiangv/RevitDevTool) add-in installed
28+
29+
## Usage
30+
31+
```python
32+
def test_revit_version():
33+
app = __revit__.Application
34+
assert "2025" in app.VersionName
35+
```
36+
37+
```bash
38+
# Auto-detect running Revit 2025
39+
pytest --revit-version=2025 -v
40+
41+
# Auto-launch Revit 2025 if not running
42+
pytest --revit-launch --revit-version=2025 -v
43+
```
44+
45+
## CLI Options
46+
47+
| Option | Description |
48+
|---|---|
49+
| `--revit-version` | Revit version year (e.g. 2025). Required with `--revit-launch`. |
50+
| `--revit-launch` | Auto-launch Revit if no running instance found. |
51+
| `--revit-timeout` | Per-test timeout in seconds (default: 60). |
52+
| `--revit-launch-timeout` | Revit startup timeout in seconds (default: 120). |
53+
| `--revit-pipe` | Explicit pipe name (bypasses auto-discovery). |
54+
55+
## How It Works
56+
57+
1. pytest discovers tests locally as usual
58+
2. The plugin intercepts test execution via `pytest_pyfunc_call`
59+
3. Test source code is serialized and sent over Named Pipe to Revit
60+
4. RevitDevTool add-in executes the test inside Revit's Python (pythonnet) environment
61+
5. Results are mapped back to pytest pass/fail/skip
62+
63+
## VSCode Integration
64+
65+
Add to `.vscode/settings.json`:
66+
67+
```json
68+
{
69+
"python.testing.pytestEnabled": true,
70+
"python.testing.pytestArgs": [
71+
"--revit-launch",
72+
"--revit-version=2025",
73+
"tests"
74+
]
75+
}
76+
```
77+
78+
## License
79+
80+
[MIT](LICENSE)

‎pyproject.toml‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.1"
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"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""RevitDevTool.PyTest: Run pytest tests inside a live Revit process via Named Pipe bridge."""
2+
3+
__version__ = "0.0.1"

‎src/revitdevtool_pytest/bridge.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)