revitdevtool-pytest 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/CHANGELOG.md +30 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/PKG-INFO +6 -4
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/README.md +3 -1
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/pyproject.toml +2 -2
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/__init__.py +1 -1
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/connection.py +4 -3
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/constants.py +29 -50
- revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/discovery.py +117 -0
- revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/__init__.py +29 -0
- revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/acad.py +127 -0
- revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/revit.py +59 -0
- revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/version.py +39 -0
- revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/winreg.py +24 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/plugin.py +2 -1
- revitdevtool_pytest-0.5.0/tests/plugin/test_acad_discovery.py +45 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/uv.lock +26 -23
- revitdevtool_pytest-0.4.0/src/revitdevtool_pytest/discovery.py +0 -293
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.github/workflows/publish.yml +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.gitignore +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.run/Attach.run.xml +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.vscode/settings.json +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/AGENTS.md +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/CLAUDE.md +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/LICENSE +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/bridge.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/dialog_resolver.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/ipy_collect.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/models.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/reporting.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/suite_leasing.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/suite_lock.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/conftest.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_active_state.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_civil3d_api.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_document.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_geometry.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_smoke.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/_session_probe.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/conftest.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/__init__.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/constants.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/contracts.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/custom_workflow.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/field_mapping.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/header_workflow.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/model.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/serializer.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/table_cell.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/template_workflow.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/test.json +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_active_state.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_dependencies.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_image_export.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_revit_basic.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_revit_document.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_revit_transactions.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_custom_copy_revit.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_field_mapping_revit.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_header_capture_revit.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_header_copy_revit.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_scheduleformatting_revit.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_session_cleanliness.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_smoke.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_streaming.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/conftest.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/ipylib/__init__.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/ipylib/geom.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/nested/__init__.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/nested/test_nested_import_ipy.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/pyproject.toml +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/test_document_ipy.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/test_ipylib_ipy.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/conftest.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_fan_suite_results.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_ipy_collect.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_plugin_runtestloop.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_reporting.py +0 -0
- {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_suite_leasing.py +0 -0
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- AutoCAD-family discovery reads `HKLM\SOFTWARE\Autodesk\AutoCAD\*\InstalledProducts`.
|
|
10
|
+
The default value is the install directory. The subkey is the product code
|
|
11
|
+
(`ACAD`, `C3D`, `PLNT3D`, `ACA`, `ACADM`, `MEP`, `ACADE`, `MAP`). The year is
|
|
12
|
+
the last token of the folder name (`AutoCAD 2026`, `Civil 3D 2026`), at or
|
|
13
|
+
above 2022. The filesystem fallback applies only to `autocad`.
|
|
14
|
+
- Revit discovery lives in its own resolver: `Autodesk Revit {version}` in the
|
|
15
|
+
registry, then `Revit {version}` on disk.
|
|
16
|
+
- `host_name` is the only key. The names are the lowercase `HostApp` values
|
|
17
|
+
(`Civil3D` → `civil3d`) and are declared once in `constants.py`.
|
|
18
|
+
`HOST_REGISTRY` and the AutoCAD product-code map both use those constants.
|
|
19
|
+
- `HostConfig` keeps `pipe_prefix`, `exe_name`, and `launch_args`. Install
|
|
20
|
+
paths stay in the resolver for that host.
|
|
21
|
+
- Requires `pytest>=9.1.1,<10` and `pywin32>=312`, matching the host pixi runtime.
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- `find_host_executable()`. Callers use `find_executable(host_name, version)`.
|
|
26
|
+
- `HostConfig.acad_product_code` and `is_acad_family()`.
|
|
27
|
+
- Registry product-id lookup (`ACAD-XXNN`, `UPIRELEASE`, `GlobUPILocation`,
|
|
28
|
+
`AcadLocation`).
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- A local plugin test run no longer swallows pytest's own test loop when the
|
|
33
|
+
session has no remote results.
|
|
34
|
+
|
|
5
35
|
## [0.4.0] - 2026-08-31
|
|
6
36
|
|
|
7
37
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: revitdevtool_pytest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: pytest plugin for testing CAD/BIM API code inside host applications via RevitDevTool Named Pipe bridge
|
|
5
5
|
Project-URL: Homepage, https://github.com/trgiangv/RevitDevTool.PyTest
|
|
6
6
|
Project-URL: Repository, https://github.com/trgiangv/RevitDevTool.PyTest
|
|
@@ -21,8 +21,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
22
|
Classifier: Topic :: Software Development :: Testing
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
|
-
Requires-Dist: pytest
|
|
25
|
-
Requires-Dist: pywin32>=
|
|
24
|
+
Requires-Dist: pytest<10,>=9.1.1
|
|
25
|
+
Requires-Dist: pywin32>=312
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# RevitDevTool.PyTest
|
|
@@ -46,13 +46,15 @@ uv add revitdevtool_pytest
|
|
|
46
46
|
| Requirement | Version |
|
|
47
47
|
|---|---|
|
|
48
48
|
| Python | >= 3.10 |
|
|
49
|
-
| pytest | >= 9.
|
|
49
|
+
| pytest | >= 9.1.1, < 10 |
|
|
50
50
|
| pywin32 | >= 311 |
|
|
51
51
|
| OS | Windows (Named Pipes) |
|
|
52
52
|
| Host | [RevitDevTool](https://github.com/trgiangv/RevitDevTool) add-in loaded |
|
|
53
53
|
|
|
54
54
|
## Supported hosts
|
|
55
55
|
|
|
56
|
+
`host_name` is the lowercase name of the RevitDevTool `HostApp` enum (`Revit` → `revit`, `Civil3D` → `civil3d`). Each name is declared once in `src/revitdevtool_pytest/constants.py`. `HOST_REGISTRY` and the AutoCAD `InstalledProducts` map (`ACAD`, `C3D`, `PLNT3D`, …) both use those constants as keys. Do not add a second spelling for the same host.
|
|
57
|
+
|
|
56
58
|
### Supported today
|
|
57
59
|
|
|
58
60
|
| `host_name` | Application | Pipe prefix |
|
|
@@ -19,13 +19,15 @@ uv add revitdevtool_pytest
|
|
|
19
19
|
| Requirement | Version |
|
|
20
20
|
|---|---|
|
|
21
21
|
| Python | >= 3.10 |
|
|
22
|
-
| pytest | >= 9.
|
|
22
|
+
| pytest | >= 9.1.1, < 10 |
|
|
23
23
|
| pywin32 | >= 311 |
|
|
24
24
|
| OS | Windows (Named Pipes) |
|
|
25
25
|
| Host | [RevitDevTool](https://github.com/trgiangv/RevitDevTool) add-in loaded |
|
|
26
26
|
|
|
27
27
|
## Supported hosts
|
|
28
28
|
|
|
29
|
+
`host_name` is the lowercase name of the RevitDevTool `HostApp` enum (`Revit` → `revit`, `Civil3D` → `civil3d`). Each name is declared once in `src/revitdevtool_pytest/constants.py`. `HOST_REGISTRY` and the AutoCAD `InstalledProducts` map (`ACAD`, `C3D`, `PLNT3D`, …) both use those constants as keys. Do not add a second spelling for the same host.
|
|
30
|
+
|
|
29
31
|
### Supported today
|
|
30
32
|
|
|
31
33
|
| `host_name` | Application | Pipe prefix |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "revitdevtool_pytest"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "pytest plugin for testing CAD/BIM API code inside host applications via RevitDevTool Named Pipe bridge"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Programming Language :: Python :: 3.13",
|
|
25
25
|
"Topic :: Software Development :: Testing",
|
|
26
26
|
]
|
|
27
|
-
dependencies = ["pytest>=9.
|
|
27
|
+
dependencies = ["pytest>=9.1.1,<10", "pywin32>=312"]
|
|
28
28
|
|
|
29
29
|
[project.entry-points."pytest11"]
|
|
30
30
|
revitdevtool = "revitdevtool_pytest.plugin"
|
{revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/connection.py
RENAMED
|
@@ -17,16 +17,17 @@ import pytest
|
|
|
17
17
|
|
|
18
18
|
from .bridge import HostBridge
|
|
19
19
|
from .constants import (
|
|
20
|
+
DEFAULT_HOST,
|
|
20
21
|
EXIT_CODE_CONFIG_ERROR,
|
|
21
22
|
PLUGIN_NAME,
|
|
22
23
|
get_host_config,
|
|
23
24
|
)
|
|
24
25
|
from .discovery import (
|
|
25
|
-
find_host_executable,
|
|
26
26
|
find_host_pipes,
|
|
27
27
|
start_host,
|
|
28
28
|
wait_for_host_pipe,
|
|
29
29
|
)
|
|
30
|
+
from .hosts import find_executable
|
|
30
31
|
|
|
31
32
|
if TYPE_CHECKING:
|
|
32
33
|
from .dialog_resolver import StartupDialogResolver
|
|
@@ -122,7 +123,7 @@ class LaunchResult:
|
|
|
122
123
|
|
|
123
124
|
|
|
124
125
|
def auto_launch(host_name: str, version: str, launch_timeout_s: float) -> LaunchResult:
|
|
125
|
-
if
|
|
126
|
+
if find_executable(host_name, version) is None:
|
|
126
127
|
pytest.exit(
|
|
127
128
|
f"{PLUGIN_NAME}: {host_name} {version} is not installed on this machine.",
|
|
128
129
|
returncode=EXIT_CODE_CONFIG_ERROR,
|
|
@@ -174,7 +175,7 @@ def find_instance_by_pid(
|
|
|
174
175
|
return None
|
|
175
176
|
|
|
176
177
|
|
|
177
|
-
def is_process_alive(process_id: int, host_name: str =
|
|
178
|
+
def is_process_alive(process_id: int, host_name: str = DEFAULT_HOST) -> bool:
|
|
178
179
|
"""Check if process is alive AND matches the expected executable name.
|
|
179
180
|
|
|
180
181
|
Plain PID-alive checks are vulnerable to Windows PID reuse.
|
{revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/constants.py
RENAMED
|
@@ -19,7 +19,21 @@ OPT_PIPE: Final = "host_pipe"
|
|
|
19
19
|
OPT_FORCE_LAUNCH: Final = "force_launch"
|
|
20
20
|
OPT_LAUNCH_TIMEOUT: Final = "launch_timeout"
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
# Lowercase form of the RevitDevTool HostApp enum
|
|
23
|
+
REVIT: Final = "revit" # HostApp.Revit
|
|
24
|
+
AUTOCAD: Final = "autocad" # HostApp.AutoCad
|
|
25
|
+
CIVIL3D: Final = "civil3d" # HostApp.Civil3D
|
|
26
|
+
PLANT3D: Final = "plant3d" # HostApp.Plant3D
|
|
27
|
+
ACAD_ARCH: Final = "acadarch" # HostApp.AcadArch
|
|
28
|
+
ACAD_MECH: Final = "acadmech" # HostApp.AcadMech
|
|
29
|
+
ACAD_ELEC: Final = "acadelec" # HostApp.AcadElec
|
|
30
|
+
ACAD_MEP: Final = "acadmep" # HostApp.AcadMep
|
|
31
|
+
ACAD_MAP3D: Final = "acadmap3d" # HostApp.AcadMap3D
|
|
32
|
+
NAVISWORKS: Final = "navisworks" # HostApp.Navisworks
|
|
33
|
+
RHINO: Final = "rhino" # HostApp.Rhino
|
|
34
|
+
TEKLA: Final = "tekla" # HostApp.Tekla
|
|
35
|
+
|
|
36
|
+
DEFAULT_HOST: Final = REVIT
|
|
23
37
|
DEFAULT_TEST_TIMEOUT_S: Final = 60.0
|
|
24
38
|
DEFAULT_LAUNCH_TIMEOUT_S: Final = 120.0
|
|
25
39
|
DEFAULT_CONNECT_TIMEOUT_MS: Final = 30_000
|
|
@@ -65,93 +79,64 @@ class HostConfig:
|
|
|
65
79
|
pipe_prefix: str
|
|
66
80
|
exe_name: str | None = None
|
|
67
81
|
launch_args: list[str] = field(default_factory=list)
|
|
68
|
-
registry_key: str | None = None
|
|
69
|
-
registry_value: str | None = None
|
|
70
|
-
default_dir_pattern: str | None = None
|
|
71
|
-
acad_product_ids: list[str] | None = None
|
|
72
82
|
|
|
73
83
|
|
|
74
|
-
ACAD_REGISTRY_ROOT: Final = r"SOFTWARE\Autodesk\AutoCAD"
|
|
75
84
|
_ACAD_EXE: Final = "acad.exe"
|
|
76
|
-
_ACAD_DIR: Final = "AutoCAD {version}"
|
|
77
85
|
_ACAD_ARGS: Final = ["/nologo"]
|
|
78
86
|
|
|
79
87
|
HOST_REGISTRY: dict[str, HostConfig] = {
|
|
80
|
-
|
|
81
|
-
"revit": HostConfig(
|
|
88
|
+
REVIT: HostConfig(
|
|
82
89
|
pipe_prefix="Revit",
|
|
83
90
|
exe_name="Revit.exe",
|
|
84
|
-
registry_key=r"SOFTWARE\Autodesk\Revit\Autodesk Revit {version}",
|
|
85
|
-
registry_value="InstallationLocation",
|
|
86
|
-
default_dir_pattern="Revit {version}",
|
|
87
91
|
launch_args=["/nosplash"],
|
|
88
92
|
),
|
|
89
|
-
|
|
90
|
-
"autocad": HostConfig(
|
|
93
|
+
AUTOCAD: HostConfig(
|
|
91
94
|
pipe_prefix="AutoCad",
|
|
92
95
|
exe_name=_ACAD_EXE,
|
|
93
|
-
default_dir_pattern=_ACAD_DIR,
|
|
94
96
|
launch_args=_ACAD_ARGS,
|
|
95
|
-
acad_product_ids=["01"],
|
|
96
97
|
),
|
|
97
|
-
|
|
98
|
+
CIVIL3D: HostConfig(
|
|
98
99
|
pipe_prefix="Civil3D",
|
|
99
100
|
exe_name=_ACAD_EXE,
|
|
100
|
-
default_dir_pattern=_ACAD_DIR,
|
|
101
101
|
launch_args=_ACAD_ARGS,
|
|
102
|
-
acad_product_ids=["00"],
|
|
103
102
|
),
|
|
104
|
-
|
|
103
|
+
PLANT3D: HostConfig(
|
|
105
104
|
pipe_prefix="Plant3D",
|
|
106
105
|
exe_name=_ACAD_EXE,
|
|
107
|
-
default_dir_pattern=_ACAD_DIR,
|
|
108
106
|
launch_args=_ACAD_ARGS,
|
|
109
|
-
acad_product_ids=["17"],
|
|
110
107
|
),
|
|
111
|
-
|
|
108
|
+
ACAD_ARCH: HostConfig(
|
|
112
109
|
pipe_prefix="AcadArch",
|
|
113
110
|
exe_name=_ACAD_EXE,
|
|
114
|
-
default_dir_pattern=_ACAD_DIR,
|
|
115
111
|
launch_args=_ACAD_ARGS,
|
|
116
|
-
acad_product_ids=["04"],
|
|
117
112
|
),
|
|
118
|
-
|
|
113
|
+
ACAD_MECH: HostConfig(
|
|
119
114
|
pipe_prefix="AcadMech",
|
|
120
115
|
exe_name=_ACAD_EXE,
|
|
121
|
-
default_dir_pattern=_ACAD_DIR,
|
|
122
116
|
launch_args=_ACAD_ARGS,
|
|
123
|
-
acad_product_ids=["05"],
|
|
124
117
|
),
|
|
125
|
-
|
|
126
|
-
pipe_prefix="
|
|
118
|
+
ACAD_ELEC: HostConfig(
|
|
119
|
+
pipe_prefix="AcadElec",
|
|
127
120
|
exe_name=_ACAD_EXE,
|
|
128
|
-
default_dir_pattern=_ACAD_DIR,
|
|
129
121
|
launch_args=_ACAD_ARGS,
|
|
130
|
-
acad_product_ids=["06"],
|
|
131
122
|
),
|
|
132
|
-
|
|
133
|
-
pipe_prefix="
|
|
123
|
+
ACAD_MEP: HostConfig(
|
|
124
|
+
pipe_prefix="AcadMep",
|
|
134
125
|
exe_name=_ACAD_EXE,
|
|
135
|
-
default_dir_pattern=_ACAD_DIR,
|
|
136
126
|
launch_args=_ACAD_ARGS,
|
|
137
|
-
acad_product_ids=["07"],
|
|
138
127
|
),
|
|
139
|
-
|
|
128
|
+
ACAD_MAP3D: HostConfig(
|
|
140
129
|
pipe_prefix="AcadMap3D",
|
|
141
130
|
exe_name=_ACAD_EXE,
|
|
142
|
-
default_dir_pattern=_ACAD_DIR,
|
|
143
131
|
launch_args=_ACAD_ARGS,
|
|
144
|
-
acad_product_ids=["02"],
|
|
145
132
|
),
|
|
146
|
-
|
|
147
|
-
"navisworks": HostConfig(
|
|
133
|
+
NAVISWORKS: HostConfig(
|
|
148
134
|
pipe_prefix="Navisworks",
|
|
149
135
|
),
|
|
150
|
-
|
|
151
|
-
"rhino": HostConfig(
|
|
136
|
+
RHINO: HostConfig(
|
|
152
137
|
pipe_prefix="Rhino",
|
|
153
138
|
),
|
|
154
|
-
|
|
139
|
+
TEKLA: HostConfig(
|
|
155
140
|
pipe_prefix="Tekla",
|
|
156
141
|
),
|
|
157
142
|
}
|
|
@@ -169,9 +154,3 @@ def get_host_config(host_name: str) -> HostConfig:
|
|
|
169
154
|
if config is not None:
|
|
170
155
|
return config
|
|
171
156
|
return HostConfig(pipe_prefix=host_name)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def is_acad_family(host_name: str) -> bool:
|
|
175
|
-
"""Return True if *host_name* is an AutoCAD-family product."""
|
|
176
|
-
cfg = HOST_REGISTRY.get(host_name.strip().lower())
|
|
177
|
-
return cfg is not None and cfg.acad_product_ids is not None
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Discover running host instances and launch hosts if needed.
|
|
2
|
+
|
|
3
|
+
Executable lookup is delegated to ``hosts``. This module owns pipes and process start.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .constants import (
|
|
15
|
+
DEFAULT_LAUNCH_TIMEOUT_S,
|
|
16
|
+
DEFAULT_POLL_INTERVAL_S,
|
|
17
|
+
HOST_REGISTRY,
|
|
18
|
+
PIPE_DIR,
|
|
19
|
+
PIPE_PATTERN,
|
|
20
|
+
get_host_config,
|
|
21
|
+
)
|
|
22
|
+
from .hosts import find_executable
|
|
23
|
+
|
|
24
|
+
_PIPE_RE = re.compile(PIPE_PATTERN)
|
|
25
|
+
|
|
26
|
+
_PREFIX_TO_HOST: dict[str, str] = {
|
|
27
|
+
cfg.pipe_prefix.lower(): name for name, cfg in HOST_REGISTRY.items()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class HostInstance:
|
|
33
|
+
pipe_name: str
|
|
34
|
+
host_name: str
|
|
35
|
+
version: str
|
|
36
|
+
process_id: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_host_pipes(host_name: str | None = None) -> list[HostInstance]:
|
|
40
|
+
"""Scan Named Pipes for pipes matching ``{Host}_{Version}_{PID}``.
|
|
41
|
+
|
|
42
|
+
When *host_name* is given, only pipes whose prefix matches the
|
|
43
|
+
host's ``pipe_prefix`` are returned.
|
|
44
|
+
"""
|
|
45
|
+
target_prefix: str | None = None
|
|
46
|
+
if host_name is not None:
|
|
47
|
+
cfg = get_host_config(host_name)
|
|
48
|
+
target_prefix = cfg.pipe_prefix.lower()
|
|
49
|
+
|
|
50
|
+
instances: list[HostInstance] = []
|
|
51
|
+
for name in _list_named_pipes():
|
|
52
|
+
m = _PIPE_RE.match(name)
|
|
53
|
+
if not m:
|
|
54
|
+
continue
|
|
55
|
+
prefix_raw = m.group(1)
|
|
56
|
+
prefix_lower = prefix_raw.lower()
|
|
57
|
+
if target_prefix is not None and prefix_lower != target_prefix:
|
|
58
|
+
continue
|
|
59
|
+
resolved = _PREFIX_TO_HOST.get(prefix_lower, prefix_raw)
|
|
60
|
+
instances.append(HostInstance(
|
|
61
|
+
pipe_name=name,
|
|
62
|
+
host_name=resolved,
|
|
63
|
+
version=m.group(2),
|
|
64
|
+
process_id=int(m.group(3)),
|
|
65
|
+
))
|
|
66
|
+
return instances
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def start_host(host_name: str, version: str) -> int:
|
|
70
|
+
"""Start the host application and return the spawned process id."""
|
|
71
|
+
cfg = get_host_config(host_name)
|
|
72
|
+
exe_path = find_executable(host_name, version)
|
|
73
|
+
if exe_path is None:
|
|
74
|
+
raise FileNotFoundError(f"{host_name} {version} installation not found.")
|
|
75
|
+
|
|
76
|
+
process = subprocess.Popen( # noqa: S603
|
|
77
|
+
[exe_path, *cfg.launch_args],
|
|
78
|
+
creationflags=subprocess.DETACHED_PROCESS,
|
|
79
|
+
)
|
|
80
|
+
return int(process.pid)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def wait_for_host_pipe(
|
|
84
|
+
host_name: str,
|
|
85
|
+
version: str | None = None,
|
|
86
|
+
timeout_s: float = DEFAULT_LAUNCH_TIMEOUT_S,
|
|
87
|
+
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
|
|
88
|
+
process_id: int | None = None,
|
|
89
|
+
) -> HostInstance | None:
|
|
90
|
+
"""Block until a host pipe appears.
|
|
91
|
+
|
|
92
|
+
When *process_id* is given, wait for that exact process to register its pipe.
|
|
93
|
+
"""
|
|
94
|
+
deadline = time.monotonic() + timeout_s
|
|
95
|
+
while time.monotonic() < deadline:
|
|
96
|
+
instances = find_host_pipes(host_name)
|
|
97
|
+
if process_id is not None:
|
|
98
|
+
match = next((i for i in instances if i.process_id == process_id), None)
|
|
99
|
+
elif not instances:
|
|
100
|
+
match = None
|
|
101
|
+
elif version is not None:
|
|
102
|
+
matches = [i for i in instances if i.version == version]
|
|
103
|
+
match = max(matches, key=lambda i: i.process_id) if matches else None
|
|
104
|
+
else:
|
|
105
|
+
match = max(instances, key=lambda i: (i.version, i.process_id))
|
|
106
|
+
if match is not None:
|
|
107
|
+
return match
|
|
108
|
+
time.sleep(poll_interval_s)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _list_named_pipes() -> list[str]:
|
|
113
|
+
"""Return base names of all Named Pipes visible to the current user."""
|
|
114
|
+
try:
|
|
115
|
+
return os.listdir(PIPE_DIR)
|
|
116
|
+
except OSError:
|
|
117
|
+
return []
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Per-host executable resolvers.
|
|
2
|
+
|
|
3
|
+
``discovery`` finds pipes and launches a process. Which file to launch lives
|
|
4
|
+
here, one module per install layout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..constants import get_host_config
|
|
10
|
+
from .acad import find_acad_executable, product_code
|
|
11
|
+
from .revit import find_revit_executable, supports as revit_supports
|
|
12
|
+
|
|
13
|
+
__all__ = ["find_executable"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_executable(host_name: str, version: str) -> str | None:
|
|
17
|
+
"""Return the host executable for *version*, or None when it is not installed.
|
|
18
|
+
|
|
19
|
+
*host_name* is the only key. Hosts with no ``exe_name`` are pipe-only.
|
|
20
|
+
"""
|
|
21
|
+
cfg = get_host_config(host_name)
|
|
22
|
+
if cfg.exe_name is None:
|
|
23
|
+
return None
|
|
24
|
+
key = host_name.strip().lower()
|
|
25
|
+
if revit_supports(key):
|
|
26
|
+
return find_revit_executable(version)
|
|
27
|
+
if product_code(key) is not None:
|
|
28
|
+
return find_acad_executable(key, cfg, version)
|
|
29
|
+
return None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""AutoCAD-family installs via InstalledProducts (mirrors AcadPathResolver)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import winreg
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from ..constants import (
|
|
11
|
+
ACAD_ARCH,
|
|
12
|
+
ACAD_ELEC,
|
|
13
|
+
ACAD_MAP3D,
|
|
14
|
+
ACAD_MECH,
|
|
15
|
+
ACAD_MEP,
|
|
16
|
+
AUTOCAD,
|
|
17
|
+
CIVIL3D,
|
|
18
|
+
PLANT3D,
|
|
19
|
+
HostConfig,
|
|
20
|
+
)
|
|
21
|
+
from .version import is_autodesk_version, version_from_install_dir
|
|
22
|
+
from .winreg import open_hklm, subkey_count
|
|
23
|
+
|
|
24
|
+
ACAD_REGISTRY_ROOT: Final = r"SOFTWARE\Autodesk\AutoCAD"
|
|
25
|
+
ACAD_INSTALLED_PRODUCTS: Final = "InstalledProducts"
|
|
26
|
+
|
|
27
|
+
# InstalledProducts subkey, keyed by the host name constants. Mirrors AcadProductCatalog.ProductCodes.
|
|
28
|
+
_PRODUCT_CODES: Final[dict[str, str]] = {
|
|
29
|
+
AUTOCAD: "ACAD",
|
|
30
|
+
CIVIL3D: "C3D",
|
|
31
|
+
PLANT3D: "PLNT3D",
|
|
32
|
+
ACAD_MAP3D: "MAP",
|
|
33
|
+
ACAD_ARCH: "ACA",
|
|
34
|
+
ACAD_MECH: "ACADM",
|
|
35
|
+
ACAD_MEP: "MEP",
|
|
36
|
+
ACAD_ELEC: "ACADE",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def product_code(host_name: str) -> str | None:
|
|
41
|
+
"""Return the InstalledProducts subkey for *host_name*."""
|
|
42
|
+
return _PRODUCT_CODES.get(host_name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_acad_executable(host_name: str, cfg: HostConfig, version: str) -> str | None:
|
|
46
|
+
"""Locate acad.exe for *host_name* at *version*.
|
|
47
|
+
|
|
48
|
+
Verticals resolve only through InstalledProducts. Vanilla AutoCAD may
|
|
49
|
+
fall back to ``AutoCAD {version}`` on disk.
|
|
50
|
+
"""
|
|
51
|
+
code = product_code(host_name)
|
|
52
|
+
if code is None or not is_autodesk_version(version):
|
|
53
|
+
return None
|
|
54
|
+
exe = _from_registry(code, cfg, version)
|
|
55
|
+
if exe:
|
|
56
|
+
return exe
|
|
57
|
+
if host_name != AUTOCAD:
|
|
58
|
+
return None
|
|
59
|
+
return _from_filesystem(cfg, version)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _from_registry(code: str, cfg: HostConfig, version: str) -> str | None:
|
|
63
|
+
try:
|
|
64
|
+
root = open_hklm(ACAD_REGISTRY_ROOT)
|
|
65
|
+
except OSError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
for i in range(subkey_count(root)):
|
|
70
|
+
release_name = winreg.EnumKey(root, i)
|
|
71
|
+
try:
|
|
72
|
+
release_key = winreg.OpenKey(root, release_name)
|
|
73
|
+
except OSError:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
result = _scan_installed_products(release_key, code, cfg, version)
|
|
77
|
+
if result is not None:
|
|
78
|
+
return result
|
|
79
|
+
finally:
|
|
80
|
+
winreg.CloseKey(release_key)
|
|
81
|
+
finally:
|
|
82
|
+
winreg.CloseKey(root)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _scan_installed_products(
|
|
87
|
+
release_key: winreg.HKEYType, code: str, cfg: HostConfig, version: str
|
|
88
|
+
) -> str | None:
|
|
89
|
+
try:
|
|
90
|
+
products = winreg.OpenKey(release_key, ACAD_INSTALLED_PRODUCTS)
|
|
91
|
+
except OSError:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
try:
|
|
95
|
+
install_dir, _ = winreg.QueryValueEx(products, "")
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
if not isinstance(install_dir, str) or not install_dir.strip():
|
|
99
|
+
return None
|
|
100
|
+
if version_from_install_dir(install_dir) != version:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
sub = winreg.OpenKey(products, code)
|
|
104
|
+
except OSError:
|
|
105
|
+
return None
|
|
106
|
+
winreg.CloseKey(sub)
|
|
107
|
+
assert cfg.exe_name is not None
|
|
108
|
+
exe = os.path.join(install_dir.rstrip("\\/"), cfg.exe_name)
|
|
109
|
+
return exe if os.path.isfile(exe) else None
|
|
110
|
+
finally:
|
|
111
|
+
winreg.CloseKey(products)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _from_filesystem(cfg: HostConfig, version: str) -> str | None:
|
|
115
|
+
program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
116
|
+
autodesk_dir = os.path.join(program_files, "Autodesk")
|
|
117
|
+
if not os.path.isdir(autodesk_dir):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
patterns = [f"AutoCAD {version}", f"AutoCAD {version} *"]
|
|
121
|
+
for pattern in patterns:
|
|
122
|
+
for entry in sorted(Path(autodesk_dir).glob(pattern)):
|
|
123
|
+
assert cfg.exe_name is not None
|
|
124
|
+
exe = entry / cfg.exe_name
|
|
125
|
+
if exe.is_file():
|
|
126
|
+
return str(exe)
|
|
127
|
+
return None
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Revit installs via the Autodesk Revit registry key (mirrors RevitPathResolver)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import winreg
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from ..constants import REVIT
|
|
11
|
+
from .version import is_autodesk_version
|
|
12
|
+
from .winreg import open_hklm
|
|
13
|
+
|
|
14
|
+
_HOST: Final = REVIT
|
|
15
|
+
_EXE: Final = "Revit.exe"
|
|
16
|
+
_REGISTRY_KEY: Final = r"SOFTWARE\Autodesk\Revit\Autodesk Revit {version}"
|
|
17
|
+
_REGISTRY_VALUE: Final = "InstallationLocation"
|
|
18
|
+
_FOLDER: Final = "Revit {version}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def supports(host_name: str) -> bool:
|
|
22
|
+
return host_name == _HOST
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_revit_executable(version: str) -> str | None:
|
|
26
|
+
if not is_autodesk_version(version):
|
|
27
|
+
return None
|
|
28
|
+
path = _from_registry(version)
|
|
29
|
+
if path:
|
|
30
|
+
return path
|
|
31
|
+
return _from_filesystem(version)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _from_registry(version: str) -> str | None:
|
|
35
|
+
try:
|
|
36
|
+
with open_hklm(_REGISTRY_KEY.format(version=version)) as key:
|
|
37
|
+
install_dir, _ = winreg.QueryValueEx(key, _REGISTRY_VALUE)
|
|
38
|
+
if not isinstance(install_dir, str):
|
|
39
|
+
return None
|
|
40
|
+
exe = os.path.join(install_dir, _EXE)
|
|
41
|
+
return exe if os.path.isfile(exe) else None
|
|
42
|
+
except OSError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _from_filesystem(version: str) -> str | None:
|
|
47
|
+
program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
48
|
+
autodesk_dir = os.path.join(program_files, "Autodesk")
|
|
49
|
+
folder = _FOLDER.format(version=version)
|
|
50
|
+
exact = os.path.join(autodesk_dir, folder, _EXE)
|
|
51
|
+
if os.path.isfile(exact):
|
|
52
|
+
return exact
|
|
53
|
+
if not os.path.isdir(autodesk_dir):
|
|
54
|
+
return None
|
|
55
|
+
for entry in sorted(Path(autodesk_dir).glob(f"{folder}*")):
|
|
56
|
+
exe = entry / _EXE
|
|
57
|
+
if exe.is_file():
|
|
58
|
+
return str(exe)
|
|
59
|
+
return None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Autodesk version vocabulary.
|
|
2
|
+
|
|
3
|
+
Revit and the AutoCAD family are addressed by a calendar year (``2026``),
|
|
4
|
+
the same token used by ``--host-version`` and pipe names. The floor matches
|
|
5
|
+
``HostVersions.AutodeskMinimal``. Other hosts do not use this pattern.
|
|
6
|
+
|
|
7
|
+
An Autodesk install folder matches when its last whitespace-separated token
|
|
8
|
+
is that year. ``AutoCAD 2026`` and ``Civil 3D 2026`` both yield ``2026``.
|
|
9
|
+
``AutoCAD 12026`` does not.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
AUTODESK_VERSION_MINIMAL: Final = 2022
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_autodesk_version(version: str) -> bool:
|
|
21
|
+
"""Return True when *version* is an Autodesk calendar-year token."""
|
|
22
|
+
if len(version) != 4 or not version.isdigit():
|
|
23
|
+
return False
|
|
24
|
+
return int(version) >= AUTODESK_VERSION_MINIMAL
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def version_from_folder(folder: str) -> str | None:
|
|
28
|
+
"""Return the Autodesk year at the end of an install folder name."""
|
|
29
|
+
name = folder.strip()
|
|
30
|
+
if not name:
|
|
31
|
+
return None
|
|
32
|
+
token = name.rsplit(maxsplit=1)[-1]
|
|
33
|
+
return token if is_autodesk_version(token) else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def version_from_install_dir(install_dir: str) -> str | None:
|
|
37
|
+
"""Return the Autodesk year of an install directory."""
|
|
38
|
+
folder = os.path.basename(install_dir.rstrip("\\/"))
|
|
39
|
+
return version_from_folder(folder)
|