@aryacode/dsh-engineering 0.4.6 → 0.4.20
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.
- package/CHANGELOG.md +199 -0
- package/README.md +37 -26
- package/cordis.patch.yml +7 -6
- package/entries/config.js +62 -0
- package/entries/ftview.js +5 -4
- package/entries/l5x.js +61 -13
- package/entries/mwi.js +26 -6
- package/entries/optix.js +105 -0
- package/lib/client.js +335 -6
- package/package.json +11 -10
- package/vendor/l5x/README.md +27 -5
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
- package/vendor/mwi/README.md +39 -18
- package/vendor/mwi/SKILL.md +26 -20
- package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -2763
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +128 -128
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
- package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
- package/vendor/mwi/lib/core/code-engine.js +18 -1
- package/vendor/mwi/lib/edit.d.ts +1 -1
- package/vendor/mwi/lib/edit.js +1 -1
- package/vendor/mwi/lib/read/service.js +8 -1
- package/vendor/mwi/lib/session/backup.d.ts +55 -0
- package/vendor/mwi/lib/session/backup.js +232 -0
- package/vendor/mwi/lib/session/project.d.ts +4 -1
- package/vendor/mwi/lib/session/project.js +13 -2
- package/vendor/mwi/lib/session/service.d.ts +13 -6
- package/vendor/mwi/lib/session/service.js +127 -36
- package/vendor/mwi/lib/session/workspace.d.ts +10 -0
- package/vendor/mwi/lib/session/workspace.js +53 -3
- package/vendor/mwi/lib/skill.d.ts +2 -2
- package/vendor/mwi/lib/skill.js +2 -2
- package/vendor/mwi/lib/tools/edit.d.ts +3 -3
- package/vendor/mwi/lib/tools/edit.js +14 -15
- package/vendor/mwi/lib/tools/read.js +1 -1
- package/vendor/mwi/lib/tools/session.js +13 -9
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
|
@@ -1,339 +1,339 @@
|
|
|
1
|
-
"""Drive a real MotionWorks build, and use the result to verify or revert a write.
|
|
2
|
-
|
|
3
|
-
The motivation is a measured limitation rather than a design preference. Static
|
|
4
|
-
checks can predict *some* declaration failures -- a non-``BOOL`` type on a bit
|
|
5
|
-
address, or a dangling reference -- but not all of them: one variable delete was
|
|
6
|
-
verified as unreferenced and still failed to compile, for reasons that remain
|
|
7
|
-
unexplained (see docs/tier2-format-notes.md).
|
|
8
|
-
|
|
9
|
-
The only trustworthy validation of a change is compiling it. So instead of
|
|
10
|
-
predicting, this module:
|
|
11
|
-
|
|
12
|
-
1. applies a write,
|
|
13
|
-
2. drives a real Build/Rebuild through COM,
|
|
14
|
-
3. reads whether the project compiled,
|
|
15
|
-
4. **restores the backup automatically if it did not.**
|
|
16
|
-
|
|
17
|
-
That turns an unpredictable operation into one whose failure is detected and
|
|
18
|
-
undone, which is a stronger guarantee than prediction.
|
|
19
|
-
|
|
20
|
-
The COM mechanism is the one proven in docs/tier3-com-build.md: a 32-bit shell is
|
|
21
|
-
required, the automation server only works while the IDE is running, and a trial
|
|
22
|
-
install's startup dialog must be dismissed first. All of that lives in
|
|
23
|
-
``tools/mw_build.ps1``; this module just invokes it and interprets the result.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
from __future__ import annotations
|
|
27
|
-
|
|
28
|
-
import json
|
|
29
|
-
import os
|
|
30
|
-
import shutil
|
|
31
|
-
import subprocess
|
|
32
|
-
import sys
|
|
33
|
-
from dataclasses import dataclass, field
|
|
34
|
-
from pathlib import Path
|
|
35
|
-
|
|
36
|
-
from .errors import MotionWorksError
|
|
37
|
-
|
|
38
|
-
#: Marker the PowerShell helper puts in front of its JSON document, so the
|
|
39
|
-
#: result can be found even when the IDE writes to the same stream.
|
|
40
|
-
_JSON_MARKER = "MWBUILD_JSON:"
|
|
41
|
-
|
|
42
|
-
#: 32-bit Windows PowerShell. Required because mwt.exe is 32-bit and its COM
|
|
43
|
-
#: wrapper is registered only in the 32-bit registry view.
|
|
44
|
-
POWERSHELL_32 = Path(
|
|
45
|
-
os.environ.get("SystemRoot", r"C:\Windows")
|
|
46
|
-
) / "SysWOW64/WindowsPowerShell/v1.0/powershell.exe"
|
|
47
|
-
|
|
48
|
-
#: The build helper lives in the repository's tools/ directory.
|
|
49
|
-
_HELPER = Path(__file__).resolve().parents[2] / "tools" / "mw_build.ps1"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class BuildUnavailable(MotionWorksError):
|
|
54
|
-
"""The build could not be driven at all.
|
|
55
|
-
|
|
56
|
-
Distinct from a build that ran and failed: this means the mechanism is
|
|
57
|
-
missing, so no conclusion about the project can be drawn.
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
@dataclass
|
|
62
|
-
class BuildResult:
|
|
63
|
-
"""Outcome of one build attempt."""
|
|
64
|
-
|
|
65
|
-
ok: bool = False
|
|
66
|
-
project: str = ""
|
|
67
|
-
loaded: bool = False
|
|
68
|
-
compiled: bool | None = None
|
|
69
|
-
modified: bool | None = None
|
|
70
|
-
dlls: int = 0
|
|
71
|
-
err: str = ""
|
|
72
|
-
stage: str = ""
|
|
73
|
-
error: str = ""
|
|
74
|
-
|
|
75
|
-
@property
|
|
76
|
-
def ide_would_not_load(self) -> bool:
|
|
77
|
-
"""True when the project never loaded, so no compile was attempted.
|
|
78
|
-
|
|
79
|
-
This is distinct from a genuine compile failure and must not be treated
|
|
80
|
-
as one. Observed cause: the IDE sitting at a startup prompt, in which
|
|
81
|
-
case *every* automation call -- including ``IsProjectOpen()`` on an
|
|
82
|
-
unmodified project -- returns "Internal error". Reporting that as "the
|
|
83
|
-
code does not compile" would be badly misleading.
|
|
84
|
-
"""
|
|
85
|
-
return self.error.startswith("project did not load") or self.stage == "load failed"
|
|
86
|
-
|
|
87
|
-
@classmethod
|
|
88
|
-
def from_payload(cls, payload: dict[str, object]) -> "BuildResult":
|
|
89
|
-
return cls(
|
|
90
|
-
ok=bool(payload.get("ok")),
|
|
91
|
-
project=str(payload.get("project", "")),
|
|
92
|
-
loaded=bool(payload.get("loaded")),
|
|
93
|
-
compiled=payload.get("compiled"), # type: ignore[arg-type]
|
|
94
|
-
modified=payload.get("modified"), # type: ignore[arg-type]
|
|
95
|
-
dlls=int(payload.get("dlls") or 0),
|
|
96
|
-
err=str(payload.get("err") or ""),
|
|
97
|
-
stage=str(payload.get("stage") or ""),
|
|
98
|
-
error=str(payload.get("error") or ""),
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
def summary(self) -> str:
|
|
102
|
-
bits = [
|
|
103
|
-
f"compiled={self.compiled}",
|
|
104
|
-
f"pou_dlls={self.dlls}",
|
|
105
|
-
]
|
|
106
|
-
if self.stage:
|
|
107
|
-
bits.append(f"stage={self.stage}")
|
|
108
|
-
if self.err.strip():
|
|
109
|
-
bits.append(f"compiler_errors={self.err.strip()[:200]!r}")
|
|
110
|
-
if self.error:
|
|
111
|
-
bits.append(f"error={self.error}")
|
|
112
|
-
return " ".join(bits)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def build_available() -> tuple[bool, str]:
|
|
116
|
-
"""Whether a build can be driven here, with the reason if not."""
|
|
117
|
-
if sys.platform != "win32":
|
|
118
|
-
return False, "not running on Windows"
|
|
119
|
-
if not POWERSHELL_32.is_file():
|
|
120
|
-
return False, f"32-bit PowerShell not found at {POWERSHELL_32}"
|
|
121
|
-
if not _HELPER.is_file():
|
|
122
|
-
return False, f"build helper not found at {_HELPER}"
|
|
123
|
-
exe = Path(
|
|
124
|
-
r"C:\Program Files (x86)\Yaskawa\MotionWorks IEC 3 Pro\Mwt.exe"
|
|
125
|
-
)
|
|
126
|
-
if not exe.is_file():
|
|
127
|
-
return False, f"MotionWorks not found at {exe}"
|
|
128
|
-
return True, "ok"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def _ps_quote(value: str) -> str:
|
|
132
|
-
"""Quote a literal for PowerShell -Command, including apostrophes in paths."""
|
|
133
|
-
return "'" + value.replace("'", "''") + "'"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def run_build(project_mwt: Path, timeout: int = 600) -> BuildResult:
|
|
137
|
-
"""Drive a Build/Rebuild of ``project_mwt`` and return the outcome.
|
|
138
|
-
|
|
139
|
-
Raises :class:`BuildUnavailable` when the mechanism is missing, so callers
|
|
140
|
-
can distinguish "could not try" from "tried and it failed".
|
|
141
|
-
"""
|
|
142
|
-
available, reason = build_available()
|
|
143
|
-
if not available:
|
|
144
|
-
raise BuildUnavailable(f"cannot drive a build: {reason}")
|
|
145
|
-
|
|
146
|
-
# A build opens the project. The wrapper's embedded directory is what loads,
|
|
147
|
-
# so an outside path here is the same wrong-project failure as an IDE open.
|
|
148
|
-
from .mwt_bind import embedded_paths
|
|
149
|
-
from .staging import StagingRefused, assert_proven
|
|
150
|
-
|
|
151
|
-
project_mwt = Path(project_mwt)
|
|
152
|
-
assert_proven(project_mwt)
|
|
153
|
-
paths = embedded_paths(project_mwt)
|
|
154
|
-
if not paths:
|
|
155
|
-
raise StagingRefused("REFUSED: wrapper has no verified project binding")
|
|
156
|
-
for embedded in paths:
|
|
157
|
-
if Path(embedded).resolve() != project_mwt.with_suffix("").resolve():
|
|
158
|
-
raise StagingRefused(
|
|
159
|
-
f"REFUSED: '{project_mwt}' still points at '{embedded}', which is outside "
|
|
160
|
-
"the staging root. Stage it with mw_ide_stage before building it."
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
# Keep the build in the workspace; quote paths rather than relocating them.
|
|
164
|
-
staged = project_mwt
|
|
165
|
-
# Invoked with -ExecutionPolicy Bypass AND -Command.
|
|
166
|
-
#
|
|
167
|
-
# Both are needed, which was established by testing each form:
|
|
168
|
-
# * with -File, PowerShell ignores the policy flag when launched from a
|
|
169
|
-
# non-shell parent and refuses the script with "is not digitally signed";
|
|
170
|
-
# * with -Command but no policy flag, the same refusal occurs;
|
|
171
|
-
# * -Command *with* the policy flag runs it.
|
|
172
|
-
# The helper reports its own outcome in JSON, so the process exit code is not
|
|
173
|
-
# used to decide success.
|
|
174
|
-
command = [
|
|
175
|
-
str(POWERSHELL_32),
|
|
176
|
-
"-NoProfile",
|
|
177
|
-
"-NonInteractive",
|
|
178
|
-
"-ExecutionPolicy", "Bypass",
|
|
179
|
-
"-Command",
|
|
180
|
-
"& " + _ps_quote(str(_HELPER)) + " -Project " + _ps_quote(str(staged)),
|
|
181
|
-
]
|
|
182
|
-
try:
|
|
183
|
-
completed = subprocess.run(
|
|
184
|
-
command,
|
|
185
|
-
capture_output=True,
|
|
186
|
-
text=True,
|
|
187
|
-
timeout=timeout,
|
|
188
|
-
encoding="utf-8",
|
|
189
|
-
errors="replace",
|
|
190
|
-
)
|
|
191
|
-
except subprocess.TimeoutExpired as exc:
|
|
192
|
-
raise BuildUnavailable(
|
|
193
|
-
f"build did not finish within {timeout}s"
|
|
194
|
-
) from exc
|
|
195
|
-
|
|
196
|
-
payload = _extract_payload(completed.stdout) or _extract_payload(completed.stderr)
|
|
197
|
-
if payload is None:
|
|
198
|
-
tail = (completed.stdout or "")[-400:]
|
|
199
|
-
raise BuildUnavailable(
|
|
200
|
-
f"build helper produced no result (exit {completed.returncode}). "
|
|
201
|
-
f"Last output: {tail!r}"
|
|
202
|
-
)
|
|
203
|
-
return BuildResult.from_payload(payload)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _extract_payload(text: str) -> dict[str, object] | None:
|
|
207
|
-
"""Find and decode the JSON document the helper emits."""
|
|
208
|
-
if not text:
|
|
209
|
-
return None
|
|
210
|
-
for line in reversed(text.splitlines()):
|
|
211
|
-
index = line.find(_JSON_MARKER)
|
|
212
|
-
if index < 0:
|
|
213
|
-
continue
|
|
214
|
-
raw = line[index + len(_JSON_MARKER):].strip()
|
|
215
|
-
try:
|
|
216
|
-
payload = json.loads(raw)
|
|
217
|
-
except json.JSONDecodeError:
|
|
218
|
-
continue
|
|
219
|
-
if isinstance(payload, dict):
|
|
220
|
-
return payload
|
|
221
|
-
return None
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
@dataclass
|
|
225
|
-
class VerifiedWrite:
|
|
226
|
-
"""A write plus the build verdict, and whether it had to be undone."""
|
|
227
|
-
|
|
228
|
-
written: dict[str, object] = field(default_factory=dict)
|
|
229
|
-
build: BuildResult | None = None
|
|
230
|
-
reverted: bool = False
|
|
231
|
-
revert_paths: list[str] = field(default_factory=list)
|
|
232
|
-
notes: list[str] = field(default_factory=list)
|
|
233
|
-
|
|
234
|
-
@property
|
|
235
|
-
def ok(self) -> bool:
|
|
236
|
-
if self.build is None:
|
|
237
|
-
return False
|
|
238
|
-
return bool(self.build.ok) and not self.reverted
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def apply_and_verify(
|
|
242
|
-
plan: object,
|
|
243
|
-
project_root: Path,
|
|
244
|
-
project_mwt: Path | None = None,
|
|
245
|
-
revert_on_failure: bool = True,
|
|
246
|
-
timeout: int = 600,
|
|
247
|
-
) -> VerifiedWrite:
|
|
248
|
-
"""Apply ``plan``, build the project, and revert if it did not compile.
|
|
249
|
-
|
|
250
|
-
``plan`` is a :class:`motionworks_iec_mcp.writer.WritePlan`. Typed loosely to
|
|
251
|
-
avoid an import cycle.
|
|
252
|
-
"""
|
|
253
|
-
from . import writer as W
|
|
254
|
-
|
|
255
|
-
outcome = VerifiedWrite()
|
|
256
|
-
result = W.apply_plan(plan, project_root) # type: ignore[arg-type]
|
|
257
|
-
outcome.written = result
|
|
258
|
-
backups = [Path(p) for p in result.get("backups", [])] # type: ignore[union-attr]
|
|
259
|
-
|
|
260
|
-
if not result.get("applied"):
|
|
261
|
-
outcome.notes.append("nothing was written, so nothing to verify")
|
|
262
|
-
return outcome
|
|
263
|
-
|
|
264
|
-
if project_mwt is None:
|
|
265
|
-
project_mwt = _default_mwt(project_root)
|
|
266
|
-
if project_mwt is None or not project_mwt.is_file():
|
|
267
|
-
outcome.notes.append(
|
|
268
|
-
"no .mwt found, so the write could not be verified by building"
|
|
269
|
-
)
|
|
270
|
-
return outcome
|
|
271
|
-
|
|
272
|
-
try:
|
|
273
|
-
outcome.build = run_build(project_mwt, timeout=timeout)
|
|
274
|
-
except BuildUnavailable as exc:
|
|
275
|
-
outcome.notes.append(f"build unavailable: {exc}")
|
|
276
|
-
return outcome
|
|
277
|
-
|
|
278
|
-
if outcome.build.ok:
|
|
279
|
-
return outcome
|
|
280
|
-
|
|
281
|
-
# A project that never loaded is not a compile failure, and reverting because
|
|
282
|
-
# of it would destroy a perfectly good change on the strength of an
|
|
283
|
-
# environmental problem. Report it and leave the write in place.
|
|
284
|
-
if outcome.build.ide_would_not_load:
|
|
285
|
-
outcome.notes.append(
|
|
286
|
-
"the IDE did not load the project, so no compile was attempted. "
|
|
287
|
-
"This is an environment problem, NOT a compile failure; the write "
|
|
288
|
-
"has been left in place. Check the MotionWorks window for a startup "
|
|
289
|
-
"or licensing prompt."
|
|
290
|
-
)
|
|
291
|
-
return outcome
|
|
292
|
-
|
|
293
|
-
outcome.notes.append(f"build failed: {outcome.build.summary()}")
|
|
294
|
-
if not revert_on_failure:
|
|
295
|
-
outcome.notes.append("not reverting")
|
|
296
|
-
return outcome
|
|
297
|
-
|
|
298
|
-
pairs = result.get("backup_pairs") or []
|
|
299
|
-
if not pairs:
|
|
300
|
-
outcome.notes.append("no backup recorded, so the change could not be reverted")
|
|
301
|
-
return outcome
|
|
302
|
-
|
|
303
|
-
outcome.revert_paths = _restore(
|
|
304
|
-
[{"original": p["original"], "backup": p["backup"]} for p in pairs] # type: ignore[index]
|
|
305
|
-
)
|
|
306
|
-
outcome.reverted = True
|
|
307
|
-
outcome.notes.append(
|
|
308
|
-
f"reverted {len(outcome.revert_paths)} file(s) from backup"
|
|
309
|
-
)
|
|
310
|
-
return outcome
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def _default_mwt(project_root: Path) -> Path | None:
|
|
314
|
-
"""The ``.mwt`` beside an expanded project directory."""
|
|
315
|
-
candidate = project_root.with_suffix(".mwt")
|
|
316
|
-
if candidate.is_file():
|
|
317
|
-
return candidate
|
|
318
|
-
sibling = project_root.parent / f"{project_root.name}.mwt"
|
|
319
|
-
return sibling if sibling.is_file() else None
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
def _restore(pairs: list[dict[str, str]]) -> list[str]:
|
|
323
|
-
"""Copy backups back over the exact files they came from.
|
|
324
|
-
|
|
325
|
-
``pairs`` entries are ``{"original": <path>, "backup": <path>}``. The
|
|
326
|
-
original path is supplied rather than inferred: an earlier version searched
|
|
327
|
-
the backup path for the project name, which finds the wrong position when the
|
|
328
|
-
name occurs twice in the path and would restore to the wrong file.
|
|
329
|
-
"""
|
|
330
|
-
restored: list[str] = []
|
|
331
|
-
for pair in pairs:
|
|
332
|
-
original = Path(pair["original"])
|
|
333
|
-
backup = Path(pair["backup"])
|
|
334
|
-
if not backup.is_file():
|
|
335
|
-
continue
|
|
336
|
-
original.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
-
shutil.copy2(backup, original)
|
|
338
|
-
restored.append(str(original))
|
|
339
|
-
return restored
|
|
1
|
+
"""Drive a real MotionWorks build, and use the result to verify or revert a write.
|
|
2
|
+
|
|
3
|
+
The motivation is a measured limitation rather than a design preference. Static
|
|
4
|
+
checks can predict *some* declaration failures -- a non-``BOOL`` type on a bit
|
|
5
|
+
address, or a dangling reference -- but not all of them: one variable delete was
|
|
6
|
+
verified as unreferenced and still failed to compile, for reasons that remain
|
|
7
|
+
unexplained (see docs/tier2-format-notes.md).
|
|
8
|
+
|
|
9
|
+
The only trustworthy validation of a change is compiling it. So instead of
|
|
10
|
+
predicting, this module:
|
|
11
|
+
|
|
12
|
+
1. applies a write,
|
|
13
|
+
2. drives a real Build/Rebuild through COM,
|
|
14
|
+
3. reads whether the project compiled,
|
|
15
|
+
4. **restores the backup automatically if it did not.**
|
|
16
|
+
|
|
17
|
+
That turns an unpredictable operation into one whose failure is detected and
|
|
18
|
+
undone, which is a stronger guarantee than prediction.
|
|
19
|
+
|
|
20
|
+
The COM mechanism is the one proven in docs/tier3-com-build.md: a 32-bit shell is
|
|
21
|
+
required, the automation server only works while the IDE is running, and a trial
|
|
22
|
+
install's startup dialog must be dismissed first. All of that lives in
|
|
23
|
+
``tools/mw_build.ps1``; this module just invokes it and interprets the result.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from .errors import MotionWorksError
|
|
37
|
+
|
|
38
|
+
#: Marker the PowerShell helper puts in front of its JSON document, so the
|
|
39
|
+
#: result can be found even when the IDE writes to the same stream.
|
|
40
|
+
_JSON_MARKER = "MWBUILD_JSON:"
|
|
41
|
+
|
|
42
|
+
#: 32-bit Windows PowerShell. Required because mwt.exe is 32-bit and its COM
|
|
43
|
+
#: wrapper is registered only in the 32-bit registry view.
|
|
44
|
+
POWERSHELL_32 = Path(
|
|
45
|
+
os.environ.get("SystemRoot", r"C:\Windows")
|
|
46
|
+
) / "SysWOW64/WindowsPowerShell/v1.0/powershell.exe"
|
|
47
|
+
|
|
48
|
+
#: The build helper lives in the repository's tools/ directory.
|
|
49
|
+
_HELPER = Path(__file__).resolve().parents[2] / "tools" / "mw_build.ps1"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BuildUnavailable(MotionWorksError):
|
|
54
|
+
"""The build could not be driven at all.
|
|
55
|
+
|
|
56
|
+
Distinct from a build that ran and failed: this means the mechanism is
|
|
57
|
+
missing, so no conclusion about the project can be drawn.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class BuildResult:
|
|
63
|
+
"""Outcome of one build attempt."""
|
|
64
|
+
|
|
65
|
+
ok: bool = False
|
|
66
|
+
project: str = ""
|
|
67
|
+
loaded: bool = False
|
|
68
|
+
compiled: bool | None = None
|
|
69
|
+
modified: bool | None = None
|
|
70
|
+
dlls: int = 0
|
|
71
|
+
err: str = ""
|
|
72
|
+
stage: str = ""
|
|
73
|
+
error: str = ""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def ide_would_not_load(self) -> bool:
|
|
77
|
+
"""True when the project never loaded, so no compile was attempted.
|
|
78
|
+
|
|
79
|
+
This is distinct from a genuine compile failure and must not be treated
|
|
80
|
+
as one. Observed cause: the IDE sitting at a startup prompt, in which
|
|
81
|
+
case *every* automation call -- including ``IsProjectOpen()`` on an
|
|
82
|
+
unmodified project -- returns "Internal error". Reporting that as "the
|
|
83
|
+
code does not compile" would be badly misleading.
|
|
84
|
+
"""
|
|
85
|
+
return self.error.startswith("project did not load") or self.stage == "load failed"
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_payload(cls, payload: dict[str, object]) -> "BuildResult":
|
|
89
|
+
return cls(
|
|
90
|
+
ok=bool(payload.get("ok")),
|
|
91
|
+
project=str(payload.get("project", "")),
|
|
92
|
+
loaded=bool(payload.get("loaded")),
|
|
93
|
+
compiled=payload.get("compiled"), # type: ignore[arg-type]
|
|
94
|
+
modified=payload.get("modified"), # type: ignore[arg-type]
|
|
95
|
+
dlls=int(payload.get("dlls") or 0),
|
|
96
|
+
err=str(payload.get("err") or ""),
|
|
97
|
+
stage=str(payload.get("stage") or ""),
|
|
98
|
+
error=str(payload.get("error") or ""),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def summary(self) -> str:
|
|
102
|
+
bits = [
|
|
103
|
+
f"compiled={self.compiled}",
|
|
104
|
+
f"pou_dlls={self.dlls}",
|
|
105
|
+
]
|
|
106
|
+
if self.stage:
|
|
107
|
+
bits.append(f"stage={self.stage}")
|
|
108
|
+
if self.err.strip():
|
|
109
|
+
bits.append(f"compiler_errors={self.err.strip()[:200]!r}")
|
|
110
|
+
if self.error:
|
|
111
|
+
bits.append(f"error={self.error}")
|
|
112
|
+
return " ".join(bits)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_available() -> tuple[bool, str]:
|
|
116
|
+
"""Whether a build can be driven here, with the reason if not."""
|
|
117
|
+
if sys.platform != "win32":
|
|
118
|
+
return False, "not running on Windows"
|
|
119
|
+
if not POWERSHELL_32.is_file():
|
|
120
|
+
return False, f"32-bit PowerShell not found at {POWERSHELL_32}"
|
|
121
|
+
if not _HELPER.is_file():
|
|
122
|
+
return False, f"build helper not found at {_HELPER}"
|
|
123
|
+
exe = Path(
|
|
124
|
+
r"C:\Program Files (x86)\Yaskawa\MotionWorks IEC 3 Pro\Mwt.exe"
|
|
125
|
+
)
|
|
126
|
+
if not exe.is_file():
|
|
127
|
+
return False, f"MotionWorks not found at {exe}"
|
|
128
|
+
return True, "ok"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _ps_quote(value: str) -> str:
|
|
132
|
+
"""Quote a literal for PowerShell -Command, including apostrophes in paths."""
|
|
133
|
+
return "'" + value.replace("'", "''") + "'"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_build(project_mwt: Path, timeout: int = 600) -> BuildResult:
|
|
137
|
+
"""Drive a Build/Rebuild of ``project_mwt`` and return the outcome.
|
|
138
|
+
|
|
139
|
+
Raises :class:`BuildUnavailable` when the mechanism is missing, so callers
|
|
140
|
+
can distinguish "could not try" from "tried and it failed".
|
|
141
|
+
"""
|
|
142
|
+
available, reason = build_available()
|
|
143
|
+
if not available:
|
|
144
|
+
raise BuildUnavailable(f"cannot drive a build: {reason}")
|
|
145
|
+
|
|
146
|
+
# A build opens the project. The wrapper's embedded directory is what loads,
|
|
147
|
+
# so an outside path here is the same wrong-project failure as an IDE open.
|
|
148
|
+
from .mwt_bind import embedded_paths
|
|
149
|
+
from .staging import StagingRefused, assert_proven
|
|
150
|
+
|
|
151
|
+
project_mwt = Path(project_mwt)
|
|
152
|
+
assert_proven(project_mwt)
|
|
153
|
+
paths = embedded_paths(project_mwt)
|
|
154
|
+
if not paths:
|
|
155
|
+
raise StagingRefused("REFUSED: wrapper has no verified project binding")
|
|
156
|
+
for embedded in paths:
|
|
157
|
+
if Path(embedded).resolve() != project_mwt.with_suffix("").resolve():
|
|
158
|
+
raise StagingRefused(
|
|
159
|
+
f"REFUSED: '{project_mwt}' still points at '{embedded}', which is outside "
|
|
160
|
+
"the staging root. Stage it with mw_ide_stage before building it."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Keep the build in the workspace; quote paths rather than relocating them.
|
|
164
|
+
staged = project_mwt
|
|
165
|
+
# Invoked with -ExecutionPolicy Bypass AND -Command.
|
|
166
|
+
#
|
|
167
|
+
# Both are needed, which was established by testing each form:
|
|
168
|
+
# * with -File, PowerShell ignores the policy flag when launched from a
|
|
169
|
+
# non-shell parent and refuses the script with "is not digitally signed";
|
|
170
|
+
# * with -Command but no policy flag, the same refusal occurs;
|
|
171
|
+
# * -Command *with* the policy flag runs it.
|
|
172
|
+
# The helper reports its own outcome in JSON, so the process exit code is not
|
|
173
|
+
# used to decide success.
|
|
174
|
+
command = [
|
|
175
|
+
str(POWERSHELL_32),
|
|
176
|
+
"-NoProfile",
|
|
177
|
+
"-NonInteractive",
|
|
178
|
+
"-ExecutionPolicy", "Bypass",
|
|
179
|
+
"-Command",
|
|
180
|
+
"& " + _ps_quote(str(_HELPER)) + " -Project " + _ps_quote(str(staged)),
|
|
181
|
+
]
|
|
182
|
+
try:
|
|
183
|
+
completed = subprocess.run(
|
|
184
|
+
command,
|
|
185
|
+
capture_output=True,
|
|
186
|
+
text=True,
|
|
187
|
+
timeout=timeout,
|
|
188
|
+
encoding="utf-8",
|
|
189
|
+
errors="replace",
|
|
190
|
+
)
|
|
191
|
+
except subprocess.TimeoutExpired as exc:
|
|
192
|
+
raise BuildUnavailable(
|
|
193
|
+
f"build did not finish within {timeout}s"
|
|
194
|
+
) from exc
|
|
195
|
+
|
|
196
|
+
payload = _extract_payload(completed.stdout) or _extract_payload(completed.stderr)
|
|
197
|
+
if payload is None:
|
|
198
|
+
tail = (completed.stdout or "")[-400:]
|
|
199
|
+
raise BuildUnavailable(
|
|
200
|
+
f"build helper produced no result (exit {completed.returncode}). "
|
|
201
|
+
f"Last output: {tail!r}"
|
|
202
|
+
)
|
|
203
|
+
return BuildResult.from_payload(payload)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract_payload(text: str) -> dict[str, object] | None:
|
|
207
|
+
"""Find and decode the JSON document the helper emits."""
|
|
208
|
+
if not text:
|
|
209
|
+
return None
|
|
210
|
+
for line in reversed(text.splitlines()):
|
|
211
|
+
index = line.find(_JSON_MARKER)
|
|
212
|
+
if index < 0:
|
|
213
|
+
continue
|
|
214
|
+
raw = line[index + len(_JSON_MARKER):].strip()
|
|
215
|
+
try:
|
|
216
|
+
payload = json.loads(raw)
|
|
217
|
+
except json.JSONDecodeError:
|
|
218
|
+
continue
|
|
219
|
+
if isinstance(payload, dict):
|
|
220
|
+
return payload
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class VerifiedWrite:
|
|
226
|
+
"""A write plus the build verdict, and whether it had to be undone."""
|
|
227
|
+
|
|
228
|
+
written: dict[str, object] = field(default_factory=dict)
|
|
229
|
+
build: BuildResult | None = None
|
|
230
|
+
reverted: bool = False
|
|
231
|
+
revert_paths: list[str] = field(default_factory=list)
|
|
232
|
+
notes: list[str] = field(default_factory=list)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def ok(self) -> bool:
|
|
236
|
+
if self.build is None:
|
|
237
|
+
return False
|
|
238
|
+
return bool(self.build.ok) and not self.reverted
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def apply_and_verify(
|
|
242
|
+
plan: object,
|
|
243
|
+
project_root: Path,
|
|
244
|
+
project_mwt: Path | None = None,
|
|
245
|
+
revert_on_failure: bool = True,
|
|
246
|
+
timeout: int = 600,
|
|
247
|
+
) -> VerifiedWrite:
|
|
248
|
+
"""Apply ``plan``, build the project, and revert if it did not compile.
|
|
249
|
+
|
|
250
|
+
``plan`` is a :class:`motionworks_iec_mcp.writer.WritePlan`. Typed loosely to
|
|
251
|
+
avoid an import cycle.
|
|
252
|
+
"""
|
|
253
|
+
from . import writer as W
|
|
254
|
+
|
|
255
|
+
outcome = VerifiedWrite()
|
|
256
|
+
result = W.apply_plan(plan, project_root) # type: ignore[arg-type]
|
|
257
|
+
outcome.written = result
|
|
258
|
+
backups = [Path(p) for p in result.get("backups", [])] # type: ignore[union-attr]
|
|
259
|
+
|
|
260
|
+
if not result.get("applied"):
|
|
261
|
+
outcome.notes.append("nothing was written, so nothing to verify")
|
|
262
|
+
return outcome
|
|
263
|
+
|
|
264
|
+
if project_mwt is None:
|
|
265
|
+
project_mwt = _default_mwt(project_root)
|
|
266
|
+
if project_mwt is None or not project_mwt.is_file():
|
|
267
|
+
outcome.notes.append(
|
|
268
|
+
"no .mwt found, so the write could not be verified by building"
|
|
269
|
+
)
|
|
270
|
+
return outcome
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
outcome.build = run_build(project_mwt, timeout=timeout)
|
|
274
|
+
except BuildUnavailable as exc:
|
|
275
|
+
outcome.notes.append(f"build unavailable: {exc}")
|
|
276
|
+
return outcome
|
|
277
|
+
|
|
278
|
+
if outcome.build.ok:
|
|
279
|
+
return outcome
|
|
280
|
+
|
|
281
|
+
# A project that never loaded is not a compile failure, and reverting because
|
|
282
|
+
# of it would destroy a perfectly good change on the strength of an
|
|
283
|
+
# environmental problem. Report it and leave the write in place.
|
|
284
|
+
if outcome.build.ide_would_not_load:
|
|
285
|
+
outcome.notes.append(
|
|
286
|
+
"the IDE did not load the project, so no compile was attempted. "
|
|
287
|
+
"This is an environment problem, NOT a compile failure; the write "
|
|
288
|
+
"has been left in place. Check the MotionWorks window for a startup "
|
|
289
|
+
"or licensing prompt."
|
|
290
|
+
)
|
|
291
|
+
return outcome
|
|
292
|
+
|
|
293
|
+
outcome.notes.append(f"build failed: {outcome.build.summary()}")
|
|
294
|
+
if not revert_on_failure:
|
|
295
|
+
outcome.notes.append("not reverting")
|
|
296
|
+
return outcome
|
|
297
|
+
|
|
298
|
+
pairs = result.get("backup_pairs") or []
|
|
299
|
+
if not pairs:
|
|
300
|
+
outcome.notes.append("no backup recorded, so the change could not be reverted")
|
|
301
|
+
return outcome
|
|
302
|
+
|
|
303
|
+
outcome.revert_paths = _restore(
|
|
304
|
+
[{"original": p["original"], "backup": p["backup"]} for p in pairs] # type: ignore[index]
|
|
305
|
+
)
|
|
306
|
+
outcome.reverted = True
|
|
307
|
+
outcome.notes.append(
|
|
308
|
+
f"reverted {len(outcome.revert_paths)} file(s) from backup"
|
|
309
|
+
)
|
|
310
|
+
return outcome
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _default_mwt(project_root: Path) -> Path | None:
|
|
314
|
+
"""The ``.mwt`` beside an expanded project directory."""
|
|
315
|
+
candidate = project_root.with_suffix(".mwt")
|
|
316
|
+
if candidate.is_file():
|
|
317
|
+
return candidate
|
|
318
|
+
sibling = project_root.parent / f"{project_root.name}.mwt"
|
|
319
|
+
return sibling if sibling.is_file() else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _restore(pairs: list[dict[str, str]]) -> list[str]:
|
|
323
|
+
"""Copy backups back over the exact files they came from.
|
|
324
|
+
|
|
325
|
+
``pairs`` entries are ``{"original": <path>, "backup": <path>}``. The
|
|
326
|
+
original path is supplied rather than inferred: an earlier version searched
|
|
327
|
+
the backup path for the project name, which finds the wrong position when the
|
|
328
|
+
name occurs twice in the path and would restore to the wrong file.
|
|
329
|
+
"""
|
|
330
|
+
restored: list[str] = []
|
|
331
|
+
for pair in pairs:
|
|
332
|
+
original = Path(pair["original"])
|
|
333
|
+
backup = Path(pair["backup"])
|
|
334
|
+
if not backup.is_file():
|
|
335
|
+
continue
|
|
336
|
+
original.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
shutil.copy2(backup, original)
|
|
338
|
+
restored.append(str(original))
|
|
339
|
+
return restored
|