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.
Files changed (78) hide show
  1. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/CHANGELOG.md +30 -0
  2. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/PKG-INFO +6 -4
  3. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/README.md +3 -1
  4. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/pyproject.toml +2 -2
  5. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/__init__.py +1 -1
  6. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/connection.py +4 -3
  7. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/constants.py +29 -50
  8. revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/discovery.py +117 -0
  9. revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/__init__.py +29 -0
  10. revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/acad.py +127 -0
  11. revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/revit.py +59 -0
  12. revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/version.py +39 -0
  13. revitdevtool_pytest-0.5.0/src/revitdevtool_pytest/hosts/winreg.py +24 -0
  14. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/plugin.py +2 -1
  15. revitdevtool_pytest-0.5.0/tests/plugin/test_acad_discovery.py +45 -0
  16. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/uv.lock +26 -23
  17. revitdevtool_pytest-0.4.0/src/revitdevtool_pytest/discovery.py +0 -293
  18. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.github/workflows/publish.yml +0 -0
  19. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.gitignore +0 -0
  20. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.run/Attach.run.xml +0 -0
  21. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/.vscode/settings.json +0 -0
  22. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/AGENTS.md +0 -0
  23. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/CLAUDE.md +0 -0
  24. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/LICENSE +0 -0
  25. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/bridge.py +0 -0
  26. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/dialog_resolver.py +0 -0
  27. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/ipy_collect.py +0 -0
  28. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/models.py +0 -0
  29. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/reporting.py +0 -0
  30. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/suite_leasing.py +0 -0
  31. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/src/revitdevtool_pytest/suite_lock.py +0 -0
  32. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/conftest.py +0 -0
  33. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_active_state.py +0 -0
  34. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_civil3d_api.py +0 -0
  35. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_document.py +0 -0
  36. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_geometry.py +0 -0
  37. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Civil3d/test_smoke.py +0 -0
  38. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/_session_probe.py +0 -0
  39. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/conftest.py +0 -0
  40. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/__init__.py +0 -0
  41. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/constants.py +0 -0
  42. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/contracts.py +0 -0
  43. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/custom_workflow.py +0 -0
  44. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/field_mapping.py +0 -0
  45. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/header_workflow.py +0 -0
  46. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/model.py +0 -0
  47. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/serializer.py +0 -0
  48. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/table_cell.py +0 -0
  49. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/template_workflow.py +0 -0
  50. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/schedule/test.json +0 -0
  51. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_active_state.py +0 -0
  52. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_dependencies.py +0 -0
  53. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_image_export.py +0 -0
  54. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_revit_basic.py +0 -0
  55. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_revit_document.py +0 -0
  56. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_revit_transactions.py +0 -0
  57. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_custom_copy_revit.py +0 -0
  58. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_field_mapping_revit.py +0 -0
  59. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_header_capture_revit.py +0 -0
  60. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_schedule_header_copy_revit.py +0 -0
  61. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_scheduleformatting_revit.py +0 -0
  62. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_session_cleanliness.py +0 -0
  63. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_smoke.py +0 -0
  64. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit/test_streaming.py +0 -0
  65. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/conftest.py +0 -0
  66. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/ipylib/__init__.py +0 -0
  67. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/ipylib/geom.py +0 -0
  68. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/nested/__init__.py +0 -0
  69. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/nested/test_nested_import_ipy.py +0 -0
  70. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/pyproject.toml +0 -0
  71. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/test_document_ipy.py +0 -0
  72. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/Revit_Ipy/test_ipylib_ipy.py +0 -0
  73. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/conftest.py +0 -0
  74. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_fan_suite_results.py +0 -0
  75. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_ipy_collect.py +0 -0
  76. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_plugin_runtestloop.py +0 -0
  77. {revitdevtool_pytest-0.4.0 → revitdevtool_pytest-0.5.0}/tests/plugin/test_reporting.py +0 -0
  78. {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.4.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>=9.0
25
- Requires-Dist: pywin32>=311
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.0 |
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.0 |
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.4.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.0", "pywin32>=311"]
27
+ dependencies = ["pytest>=9.1.1,<10", "pywin32>=312"]
28
28
 
29
29
  [project.entry-points."pytest11"]
30
30
  revitdevtool = "revitdevtool_pytest.plugin"
@@ -1,3 +1,3 @@
1
1
  """RevitDevTool.PyTest: Run pytest tests inside a live Autodesk host (Revit, AutoCAD, Civil3D, etc.) via Named Pipe bridge."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -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 find_host_executable(host_name, version) is None:
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 = "revit") -> bool:
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.
@@ -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
- DEFAULT_HOST: Final = "revit"
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
- # --- Autodesk Revit ---
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
- # --- Autodesk AutoCAD family ---
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
- "civil3d": HostConfig(
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
- "plant3d": HostConfig(
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
- "acadarch": HostConfig(
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
- "acadmech": HostConfig(
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
- "acadmep": HostConfig(
126
- pipe_prefix="AcadMep",
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
- "acadelec": HostConfig(
133
- pipe_prefix="AcadElec",
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
- "acadmap3d": HostConfig(
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
- # --- Autodesk Navisworks ---
147
- "navisworks": HostConfig(
133
+ NAVISWORKS: HostConfig(
148
134
  pipe_prefix="Navisworks",
149
135
  ),
150
- # --- Non-Autodesk hosts ---
151
- "rhino": HostConfig(
136
+ RHINO: HostConfig(
152
137
  pipe_prefix="Rhino",
153
138
  ),
154
- "tekla": HostConfig(
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)