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.
@@ -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,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/
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/RevitDevTool.PyTest)](https://pypi.org/project/RevitDevTool.PyTest/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/RevitDevTool.PyTest)](https://pypi.org/project/RevitDevTool.PyTest/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
+ [![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
+ 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,3 @@
1
+ """RevitDevTool.PyTest: Run pytest tests inside a live Revit process via Named Pipe bridge."""
2
+
3
+ __version__ = "0.0.1"
@@ -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}")