@aryacode/dsh-engineering 0.0.0-stage → 0.4.0
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 +128 -0
- package/LICENSE +21 -0
- package/README.md +230 -2
- package/cordis.patch.yml +123 -0
- package/lib/client.js +3448 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +9 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -3
- package/vendor/ftview/README.md +55 -0
- package/vendor/ftview/SKILL.md +74 -0
- package/vendor/ftview/lib/checks.d.ts +27 -0
- package/vendor/ftview/lib/checks.js +39 -0
- package/vendor/ftview/lib/core.d.ts +208 -0
- package/vendor/ftview/lib/core.js +160 -0
- package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
- package/vendor/ftview/lib/edit/drafts.js +84 -0
- package/vendor/ftview/lib/edit/operations.d.ts +62 -0
- package/vendor/ftview/lib/edit/operations.js +126 -0
- package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
- package/vendor/ftview/lib/edit/prepare.js +133 -0
- package/vendor/ftview/lib/edit/tools.d.ts +25 -0
- package/vendor/ftview/lib/edit/tools.js +198 -0
- package/vendor/ftview/lib/edit.d.ts +30 -0
- package/vendor/ftview/lib/edit.js +38 -0
- package/vendor/ftview/lib/index.d.ts +31 -0
- package/vendor/ftview/lib/index.js +36 -0
- package/vendor/ftview/lib/model.d.ts +80 -0
- package/vendor/ftview/lib/model.js +267 -0
- package/vendor/ftview/lib/session.d.ts +48 -0
- package/vendor/ftview/lib/session.js +98 -0
- package/vendor/ftview/lib/skill.d.ts +29 -0
- package/vendor/ftview/lib/skill.js +43 -0
- package/vendor/ftview/lib/studio/discover.d.ts +36 -0
- package/vendor/ftview/lib/studio/discover.js +87 -0
- package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
- package/vendor/ftview/lib/studio/preflight.js +64 -0
- package/vendor/ftview/lib/studio/tools.d.ts +15 -0
- package/vendor/ftview/lib/studio/tools.js +83 -0
- package/vendor/ftview/lib/studio.d.ts +30 -0
- package/vendor/ftview/lib/studio.js +37 -0
- package/vendor/ftview/lib/tools.d.ts +14 -0
- package/vendor/ftview/lib/tools.js +202 -0
- package/vendor/l5x/README.md +62 -0
- package/vendor/l5x/SKILL.md +81 -0
- package/vendor/l5x/lib/approvals.d.ts +50 -0
- package/vendor/l5x/lib/approvals.js +93 -0
- package/vendor/l5x/lib/client.js +412 -0
- package/vendor/l5x/lib/client.js.map +1 -0
- package/vendor/l5x/lib/core/errors.d.ts +38 -0
- package/vendor/l5x/lib/core/errors.js +48 -0
- package/vendor/l5x/lib/core/params.d.ts +49 -0
- package/vendor/l5x/lib/core/params.js +39 -0
- package/vendor/l5x/lib/core/result.d.ts +46 -0
- package/vendor/l5x/lib/core/result.js +21 -0
- package/vendor/l5x/lib/core/tool.d.ts +102 -0
- package/vendor/l5x/lib/core/tool.js +19 -0
- package/vendor/l5x/lib/drafts.d.ts +66 -0
- package/vendor/l5x/lib/drafts.js +98 -0
- package/vendor/l5x/lib/editing.d.ts +30 -0
- package/vendor/l5x/lib/editing.js +42 -0
- package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
- package/vendor/l5x/lib/engine/acd-edit.js +115 -0
- package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
- package/vendor/l5x/lib/engine/analysis.js +258 -0
- package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
- package/vendor/l5x/lib/engine/capabilities.js +40 -0
- package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
- package/vendor/l5x/lib/engine/change-set.js +446 -0
- package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
- package/vendor/l5x/lib/engine/configuration.js +42 -0
- package/vendor/l5x/lib/engine/details.d.ts +47 -0
- package/vendor/l5x/lib/engine/details.js +173 -0
- package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
- package/vendor/l5x/lib/engine/direct-polling.js +193 -0
- package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
- package/vendor/l5x/lib/engine/download-review.js +63 -0
- package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
- package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
- package/vendor/l5x/lib/engine/observations.d.ts +41 -0
- package/vendor/l5x/lib/engine/observations.js +67 -0
- package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
- package/vendor/l5x/lib/engine/project-analysis.js +314 -0
- package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
- package/vendor/l5x/lib/engine/review-evidence.js +49 -0
- package/vendor/l5x/lib/engine/review.d.ts +18 -0
- package/vendor/l5x/lib/engine/review.js +123 -0
- package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
- package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
- package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
- package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
- package/vendor/l5x/lib/engine/usage.d.ts +16 -0
- package/vendor/l5x/lib/engine/usage.js +92 -0
- package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
- package/vendor/l5x/lib/engine/xml-regions.js +77 -0
- package/vendor/l5x/lib/fs.d.ts +17 -0
- package/vendor/l5x/lib/fs.js +36 -0
- package/vendor/l5x/lib/index.d.ts +36 -0
- package/vendor/l5x/lib/index.js +42 -0
- package/vendor/l5x/lib/live.d.ts +48 -0
- package/vendor/l5x/lib/live.js +48 -0
- package/vendor/l5x/lib/model/aois.d.ts +39 -0
- package/vendor/l5x/lib/model/aois.js +112 -0
- package/vendor/l5x/lib/model/errors.d.ts +15 -0
- package/vendor/l5x/lib/model/errors.js +18 -0
- package/vendor/l5x/lib/model/index.d.ts +20 -0
- package/vendor/l5x/lib/model/index.js +19 -0
- package/vendor/l5x/lib/model/module.d.ts +37 -0
- package/vendor/l5x/lib/model/module.js +54 -0
- package/vendor/l5x/lib/model/modules.d.ts +24 -0
- package/vendor/l5x/lib/model/modules.js +55 -0
- package/vendor/l5x/lib/model/parse.d.ts +32 -0
- package/vendor/l5x/lib/model/parse.js +116 -0
- package/vendor/l5x/lib/model/project.d.ts +15 -0
- package/vendor/l5x/lib/model/project.js +31 -0
- package/vendor/l5x/lib/model/routines.d.ts +71 -0
- package/vendor/l5x/lib/model/routines.js +183 -0
- package/vendor/l5x/lib/model/tags.d.ts +51 -0
- package/vendor/l5x/lib/model/tags.js +119 -0
- package/vendor/l5x/lib/model/types.d.ts +304 -0
- package/vendor/l5x/lib/model/types.js +12 -0
- package/vendor/l5x/lib/model/udts.d.ts +34 -0
- package/vendor/l5x/lib/model/udts.js +81 -0
- package/vendor/l5x/lib/model/xml.d.ts +139 -0
- package/vendor/l5x/lib/model/xml.js +291 -0
- package/vendor/l5x/lib/model/xref.d.ts +28 -0
- package/vendor/l5x/lib/model/xref.js +154 -0
- package/vendor/l5x/lib/session.d.ts +119 -0
- package/vendor/l5x/lib/session.js +236 -0
- package/vendor/l5x/lib/skill.d.ts +30 -0
- package/vendor/l5x/lib/skill.js +44 -0
- package/vendor/l5x/lib/studio-state.d.ts +77 -0
- package/vendor/l5x/lib/studio-state.js +79 -0
- package/vendor/l5x/lib/studio.d.ts +29 -0
- package/vendor/l5x/lib/studio.js +40 -0
- package/vendor/l5x/lib/tools/checks.d.ts +19 -0
- package/vendor/l5x/lib/tools/checks.js +140 -0
- package/vendor/l5x/lib/tools/common.d.ts +41 -0
- package/vendor/l5x/lib/tools/common.js +84 -0
- package/vendor/l5x/lib/tools/controller.d.ts +21 -0
- package/vendor/l5x/lib/tools/controller.js +170 -0
- package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
- package/vendor/l5x/lib/tools/drafts.js +270 -0
- package/vendor/l5x/lib/tools/live.d.ts +21 -0
- package/vendor/l5x/lib/tools/live.js +175 -0
- package/vendor/l5x/lib/tools/read.d.ts +19 -0
- package/vendor/l5x/lib/tools/read.js +245 -0
- package/vendor/l5x/lib/tools/references.d.ts +20 -0
- package/vendor/l5x/lib/tools/references.js +269 -0
- package/vendor/l5x/lib/tools/studio.d.ts +21 -0
- package/vendor/l5x/lib/tools/studio.js +193 -0
- package/vendor/l5x/poll-worker.mjs +77 -0
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
- package/vendor/l5x/studio-bridge/Program.cs +304 -0
- package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
- package/vendor/mwi/README.md +60 -0
- package/vendor/mwi/SKILL.md +87 -0
- package/vendor/mwi/bridge/README.md +8 -0
- package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
- package/vendor/mwi/bridge/start_bridge.cmd +21 -0
- package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
- package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
- 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
- package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
- package/vendor/mwi/code/mw_code.py +1258 -0
- package/vendor/mwi/legacy/edit-session.js +50 -0
- package/vendor/mwi/legacy/fb-insertion.js +74 -0
- package/vendor/mwi/legacy/native-code.js +83 -0
- package/vendor/mwi/legacy/native-groups.js +75 -0
- package/vendor/mwi/legacy/native-structure.js +143 -0
- package/vendor/mwi/legacy/native-variables.js +77 -0
- package/vendor/mwi/legacy/pou-conversion.js +47 -0
- package/vendor/mwi/legacy/pou-package.js +66 -0
- package/vendor/mwi/lib/core/bridge.d.ts +71 -0
- package/vendor/mwi/lib/core/bridge.js +53 -0
- package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
- package/vendor/mwi/lib/core/code-engine.js +109 -0
- package/vendor/mwi/lib/core/errors.d.ts +37 -0
- package/vendor/mwi/lib/core/errors.js +47 -0
- package/vendor/mwi/lib/core/params.d.ts +52 -0
- package/vendor/mwi/lib/core/params.js +46 -0
- package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
- package/vendor/mwi/lib/core/ps-bridge.js +127 -0
- package/vendor/mwi/lib/core/result.d.ts +45 -0
- package/vendor/mwi/lib/core/result.js +20 -0
- package/vendor/mwi/lib/core/tool.d.ts +80 -0
- package/vendor/mwi/lib/core/tool.js +55 -0
- package/vendor/mwi/lib/edit/compare.d.ts +33 -0
- package/vendor/mwi/lib/edit/compare.js +70 -0
- package/vendor/mwi/lib/edit.d.ts +20 -0
- package/vendor/mwi/lib/edit.js +32 -0
- package/vendor/mwi/lib/index.d.ts +21 -0
- package/vendor/mwi/lib/index.js +33 -0
- package/vendor/mwi/lib/read/service.d.ts +117 -0
- package/vendor/mwi/lib/read/service.js +229 -0
- package/vendor/mwi/lib/read.d.ts +21 -0
- package/vendor/mwi/lib/read.js +33 -0
- package/vendor/mwi/lib/session/project.d.ts +34 -0
- package/vendor/mwi/lib/session/project.js +54 -0
- package/vendor/mwi/lib/session/service.d.ts +167 -0
- package/vendor/mwi/lib/session/service.js +566 -0
- package/vendor/mwi/lib/session/stage.d.ts +84 -0
- package/vendor/mwi/lib/session/stage.js +187 -0
- package/vendor/mwi/lib/session/workspace.d.ts +52 -0
- package/vendor/mwi/lib/session/workspace.js +90 -0
- package/vendor/mwi/lib/skill.d.ts +36 -0
- package/vendor/mwi/lib/skill.js +56 -0
- package/vendor/mwi/lib/tools/edit.d.ts +20 -0
- package/vendor/mwi/lib/tools/edit.js +286 -0
- package/vendor/mwi/lib/tools/read.d.ts +19 -0
- package/vendor/mwi/lib/tools/read.js +161 -0
- package/vendor/mwi/lib/tools/session.d.ts +20 -0
- package/vendor/mwi/lib/tools/session.js +166 -0
- package/vendor/mwi/lib/tools/verify.d.ts +19 -0
- package/vendor/mwi/lib/tools/verify.js +85 -0
- package/vendor/mwi/lib/verify.d.ts +17 -0
- package/vendor/mwi/lib/verify.js +29 -0
|
@@ -0,0 +1,1547 @@
|
|
|
1
|
+
"""Plan POU creation: produce a reviewable patch, without writing.
|
|
2
|
+
|
|
3
|
+
Creating a POU adds four nodes to ``PROJECT.TRE`` -- the POU container plus its
|
|
4
|
+
``T`` (comments), ``V`` (variables) and body worksheets. Their record shape is
|
|
5
|
+
fully known, but **not every field in them is derivable**, and this module says so
|
|
6
|
+
rather than inventing values.
|
|
7
|
+
|
|
8
|
+
Fields that can be derived with confidence:
|
|
9
|
+
|
|
10
|
+
* the record layout (line count and field order), read from a real POU;
|
|
11
|
+
* node ids, which run in one global increasing sequence;
|
|
12
|
+
* the four worksheet GUIDs, which are fresh per POU and appear both in the tree and
|
|
13
|
+
in ``NodeProperties.xml``;
|
|
14
|
+
* the name, path and level fields.
|
|
15
|
+
|
|
16
|
+
Fields that **cannot**, and are reported as unresolved:
|
|
17
|
+
|
|
18
|
+
* the worksheet state lines at the container and body, which carry opaque
|
|
19
|
+
handle-like tokens (``dbf1a023``, ``9442c853``) that differ between POUs in ways
|
|
20
|
+
unrelated to the POU's identity, so copying a template's values would be a guess;
|
|
21
|
+
* the trailing state line of a body, which differs by body kind -- an ``.STB`` body
|
|
22
|
+
has ``0\\t0\\t0\\t70...`` where a ``.GB`` body has ``1\\t80000000\\t2803f...``;
|
|
23
|
+
* the first line of each record, a small integer (bodies are 11 or 23) whose
|
|
24
|
+
meaning is not established.
|
|
25
|
+
|
|
26
|
+
The proven case study writes literal values for these. It was validated on a
|
|
27
|
+
project whose template had a plainer record, so its literals do not transfer here,
|
|
28
|
+
and guessing them risks a project MotionWorks cannot open. Reporting them is the
|
|
29
|
+
useful step: the patch can be reviewed and the missing values supplied.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
import shutil
|
|
36
|
+
import struct
|
|
37
|
+
import tempfile
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from .errors import IdeRunning, MotionWorksError, NotFound, VerificationFailed
|
|
42
|
+
from .tree import TreeDocument, TreeNode, parse_document
|
|
43
|
+
from .tree_writer import guid_line
|
|
44
|
+
|
|
45
|
+
#: Levels in a real tree: a POU container is 2, its worksheets 3.
|
|
46
|
+
POU_CONTAINER_LEVEL = 2
|
|
47
|
+
POU_WORKSHEET_LEVEL = 3
|
|
48
|
+
|
|
49
|
+
#: Nodes one POU contributes: container + T + V + body.
|
|
50
|
+
POU_NODE_COUNT = 4
|
|
51
|
+
|
|
52
|
+
#: Record layout, read off real POU nodes. A node is a count line, a params line,
|
|
53
|
+
#: a name, a path, then a blank/state/blank/GUID/state group. The blank line that
|
|
54
|
+
#: separates one record from the next is *not* part of either.
|
|
55
|
+
CONTAINER_LINES = 10 # count, params, name, path, blank, state, blank, GUID, state, marker
|
|
56
|
+
WORKSHEET_LINES = 9 # count, params, name, path, blank, state, blank, GUID, state
|
|
57
|
+
|
|
58
|
+
#: Field offsets within a record, so nothing has to count blanks by hand.
|
|
59
|
+
F_COUNT, F_PARAMS, F_NAME, F_PATH = 0, 1, 2, 3
|
|
60
|
+
F_STATE, F_GUID = 5, 7
|
|
61
|
+
F_TRAILING = 8
|
|
62
|
+
F_CONTAINER_MARKER = 9
|
|
63
|
+
|
|
64
|
+
#: The first line of each record is a small integer that is **fixed per kind**,
|
|
65
|
+
#: measured across all five projects rather than assumed:
|
|
66
|
+
#:
|
|
67
|
+
#: * POU containers are 7 in all 29 POUs;
|
|
68
|
+
#: * ``T`` worksheets are 42 in all 29;
|
|
69
|
+
#: * ``V`` worksheets are 8 in all 29;
|
|
70
|
+
#: * ``.STB`` bodies are 23 in all 15.
|
|
71
|
+
#:
|
|
72
|
+
#: Only the ``.GB`` body varies (11 in ten cases, 12 in four), so a graphical body
|
|
73
|
+
#: is the one case where this cannot be derived and must be supplied.
|
|
74
|
+
#:
|
|
75
|
+
#: The container value applies to **POUs only**, and a POU is identified
|
|
76
|
+
#: *structurally*: it has exactly three worksheets named ``<name>T``, ``<name>V``
|
|
77
|
+
#: and ``<name>``. Level is not enough -- libraries are level 2 with 31 or 3, and
|
|
78
|
+
#: RotaryKnife's ``RK_FunctionBlocks`` group is level 2 under ``Logical POUs`` with
|
|
79
|
+
#: 32. Both exceptions were found by checking the rule against real data rather
|
|
80
|
+
#: than assuming it generalised.
|
|
81
|
+
CONTAINER_COUNT_LINE = 7
|
|
82
|
+
WORKSHEET_COUNT_LINE = {"T": 42, "V": 8}
|
|
83
|
+
#: Keyed by extension *without* the leading dot, matching the caller's form.
|
|
84
|
+
BODY_COUNT_LINE = {"STB": 23}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_pou_container(node: TreeNode) -> bool:
|
|
88
|
+
"""True when a node is a POU: three worksheets named ``<name>T/V/``.
|
|
89
|
+
|
|
90
|
+
Identifying a POU structurally rather than by level or count line is what makes
|
|
91
|
+
this reliable -- libraries and RotaryKnife's function-block group are also
|
|
92
|
+
level-2 containers but are not POUs.
|
|
93
|
+
"""
|
|
94
|
+
if len(node.children) != 3:
|
|
95
|
+
return False
|
|
96
|
+
names = [child.name for child in node.children]
|
|
97
|
+
return sorted(names) == sorted([f"{node.name}T", f"{node.name}V", node.name])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def count_line_for(kind: str, body_suffix: str) -> int | None:
|
|
101
|
+
"""The record's first line, or ``None`` when it varies and must be supplied."""
|
|
102
|
+
if kind in WORKSHEET_COUNT_LINE:
|
|
103
|
+
return WORKSHEET_COUNT_LINE[kind]
|
|
104
|
+
return BODY_COUNT_LINE.get(body_suffix.upper())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PouPlanError(MotionWorksError):
|
|
108
|
+
"""The plan could not be produced."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class FieldNote:
|
|
113
|
+
"""One field's provenance: derived, templated, or needing a value."""
|
|
114
|
+
|
|
115
|
+
record: str
|
|
116
|
+
line_index: int
|
|
117
|
+
name: str
|
|
118
|
+
status: str # "derived" | "from-template" | "unresolved"
|
|
119
|
+
detail: str = ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class PouCreationPlan:
|
|
124
|
+
"""A reviewable description of the POU that would be created."""
|
|
125
|
+
|
|
126
|
+
pou_name: str
|
|
127
|
+
template_name: str
|
|
128
|
+
project_root: Path
|
|
129
|
+
node_ids: list[int]
|
|
130
|
+
guids: list[str]
|
|
131
|
+
insert_at: int
|
|
132
|
+
records: list[str]
|
|
133
|
+
files: list[str]
|
|
134
|
+
notes: list[FieldNote] = field(default_factory=list)
|
|
135
|
+
warnings: list[str] = field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
def unresolved(self) -> list[FieldNote]:
|
|
138
|
+
return [n for n in self.notes if n.status == "unresolved"]
|
|
139
|
+
|
|
140
|
+
def summary(self) -> str:
|
|
141
|
+
lines = [
|
|
142
|
+
"PLAN ONLY - nothing written.",
|
|
143
|
+
f"Create POU {self.pou_name!r} by cloning template {self.template_name!r}",
|
|
144
|
+
f" project : {self.project_root}",
|
|
145
|
+
f" node ids : {self.node_ids}",
|
|
146
|
+
f" insert at : tree line {self.insert_at} (before Physical Hardware)",
|
|
147
|
+
f" node total : +{POU_NODE_COUNT}",
|
|
148
|
+
"",
|
|
149
|
+
f" Files that would be created ({len(self.files)}):",
|
|
150
|
+
]
|
|
151
|
+
lines.extend(f" {name}" for name in self.files)
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.append(
|
|
154
|
+
f" Tree records that would be inserted ({len(self.records)} lines; "
|
|
155
|
+
f"unresolved values are blank):"
|
|
156
|
+
)
|
|
157
|
+
lines.extend(f" {record!r}" for record in self.records)
|
|
158
|
+
|
|
159
|
+
derived = [n for n in self.notes if n.status == "derived"]
|
|
160
|
+
templated = [n for n in self.notes if n.status == "from-template"]
|
|
161
|
+
unresolved = self.unresolved()
|
|
162
|
+
|
|
163
|
+
lines.append("")
|
|
164
|
+
lines.append(f" Fields derived with confidence ({len(derived)}):")
|
|
165
|
+
for note in derived:
|
|
166
|
+
lines.append(
|
|
167
|
+
f" {note.record} [{note.line_index}] {note.name}: {note.detail}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if templated:
|
|
171
|
+
lines.append("")
|
|
172
|
+
lines.append(f" Fields taken from the template ({len(templated)}):")
|
|
173
|
+
for note in templated:
|
|
174
|
+
lines.append(
|
|
175
|
+
f" {note.record} [{note.line_index}] {note.name}: {note.detail}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
lines.append("")
|
|
179
|
+
if unresolved:
|
|
180
|
+
lines.append(
|
|
181
|
+
f" UNRESOLVED ({len(unresolved)}) - must be supplied before writing:"
|
|
182
|
+
)
|
|
183
|
+
for note in unresolved:
|
|
184
|
+
lines.append(
|
|
185
|
+
f" {note.record} [{note.line_index}] {note.name}: {note.detail}"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
lines.append(" No unresolved fields.")
|
|
189
|
+
for warning in self.warnings:
|
|
190
|
+
lines.append(f" WARNING: {warning}")
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class PouDeletionPlan:
|
|
196
|
+
"""A planned POU deletion."""
|
|
197
|
+
|
|
198
|
+
pou_name: str
|
|
199
|
+
project_root: Path
|
|
200
|
+
#: ``(start, end)`` line span of the POU's own records, removed entirely.
|
|
201
|
+
span: tuple[int, int]
|
|
202
|
+
#: Task instance blocks to remove: ``(task_name, span)``.
|
|
203
|
+
instances: list[tuple[str, tuple[int, int]]] = field(default_factory=list)
|
|
204
|
+
#: Count fields to decrement: ``(span, replacement)``.
|
|
205
|
+
count_edits: list[tuple[tuple[int, int], list[str]]] = field(default_factory=list)
|
|
206
|
+
total_before: int = 0
|
|
207
|
+
total_after: int = 0
|
|
208
|
+
#: Other POUs whose bodies name this one.
|
|
209
|
+
referenced_by: list[str] = field(default_factory=list)
|
|
210
|
+
#: Tasks this POU is currently assigned to.
|
|
211
|
+
assigned_to: list[str] = field(default_factory=list)
|
|
212
|
+
notes: list[str] = field(default_factory=list)
|
|
213
|
+
|
|
214
|
+
def summary(self) -> str:
|
|
215
|
+
lines = [
|
|
216
|
+
f"DELETE POU {self.pou_name}",
|
|
217
|
+
f" project : {self.project_root}",
|
|
218
|
+
f" tree records: lines {self.span[0]}..{self.span[1]} "
|
|
219
|
+
f"({self.span[1] - self.span[0]} lines removed)",
|
|
220
|
+
f" node total : {self.total_before} -> {self.total_after}",
|
|
221
|
+
]
|
|
222
|
+
if self.assigned_to:
|
|
223
|
+
lines.append(
|
|
224
|
+
f" assigned to : {', '.join(self.assigned_to)} "
|
|
225
|
+
f"({len(self.instances)} instance block(s) removed)"
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
lines.append(" assigned to : nothing")
|
|
229
|
+
if self.referenced_by:
|
|
230
|
+
lines.append(
|
|
231
|
+
f" REFERENCED BY: {', '.join(self.referenced_by)} - deleting it "
|
|
232
|
+
f"leaves a dangling call"
|
|
233
|
+
)
|
|
234
|
+
lines.extend(f" note: {note}" for note in self.notes)
|
|
235
|
+
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def find_pou_references(project_root: Path, pou_name: str) -> list[str]:
|
|
239
|
+
"""Other POUs whose ST bodies name ``pou_name``.
|
|
240
|
+
|
|
241
|
+
The name is matched as a whole word, so deleting a POU another POU calls can be
|
|
242
|
+
refused rather than leaving a dangling reference -- the same guard variable
|
|
243
|
+
deletion uses.
|
|
244
|
+
"""
|
|
245
|
+
from . import project as P
|
|
246
|
+
from .variables import CompressedStreamError
|
|
247
|
+
|
|
248
|
+
found: list[str] = []
|
|
249
|
+
project = P.Project(root=Path(project_root))
|
|
250
|
+
pous = project.pous() if callable(project.pous) else project.pous
|
|
251
|
+
pattern = re.compile(r"\b" + re.escape(pou_name) + r"\b")
|
|
252
|
+
for pou in pous:
|
|
253
|
+
if pou.name == pou_name:
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
body = pou.st_body()
|
|
257
|
+
except CompressedStreamError:
|
|
258
|
+
continue
|
|
259
|
+
except Exception: # noqa: BLE001 - an unreadable POU cannot be checked
|
|
260
|
+
continue
|
|
261
|
+
if body and pattern.search(body):
|
|
262
|
+
found.append(pou.name)
|
|
263
|
+
return sorted(found)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def plan_pou_deletion(
|
|
267
|
+
project_root: Path, pou_name: str, *, force: bool = False
|
|
268
|
+
) -> PouDeletionPlan:
|
|
269
|
+
"""Plan deleting a POU, its task assignments and its registry entries.
|
|
270
|
+
|
|
271
|
+
Refuses when another POU calls the one being deleted, unless ``force`` is set.
|
|
272
|
+
The tree edit itself is safe either way, but the project would no longer
|
|
273
|
+
compile, and a silent dangling call is worse than a refusal.
|
|
274
|
+
"""
|
|
275
|
+
from .cfb import CompoundFile
|
|
276
|
+
|
|
277
|
+
project_root = Path(project_root)
|
|
278
|
+
src = project_root / "src.st1"
|
|
279
|
+
if not src.is_file():
|
|
280
|
+
raise NotFound(f"no src.st1 under {project_root}")
|
|
281
|
+
document = parse_document(
|
|
282
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
283
|
+
)
|
|
284
|
+
target = _pou_container(document, pou_name)
|
|
285
|
+
if target is None:
|
|
286
|
+
raise NotFound(f"no POU named {pou_name!r} in the project tree")
|
|
287
|
+
if not is_pou_container(target):
|
|
288
|
+
raise PouPlanError(
|
|
289
|
+
f"{pou_name!r} is not a well-formed POU container; refusing to delete"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
total_before = int(document.lines[1])
|
|
293
|
+
|
|
294
|
+
# Extend the removal over the blank line that TERMINATES the POU's records.
|
|
295
|
+
#
|
|
296
|
+
# TreeNode.span stops one line short of that blank - the same off-by-one that
|
|
297
|
+
# made creation drop the separators between records. Removing exactly the span
|
|
298
|
+
# therefore leaves a stray blank behind, and the tree no longer round-trips:
|
|
299
|
+
# measured, create-then-delete left PROJECT.TRE one line longer than the
|
|
300
|
+
# original and the project then refused to open with
|
|
301
|
+
# "Internal error in 'OpenProject'". Create and delete have to consume the same
|
|
302
|
+
# lines or the pair is not an inverse.
|
|
303
|
+
delete_span = target.span
|
|
304
|
+
if delete_span[1] < len(document.lines) and document.lines[delete_span[1]].strip() == "":
|
|
305
|
+
delete_span = (delete_span[0], delete_span[1] + 1)
|
|
306
|
+
|
|
307
|
+
plan = PouDeletionPlan(
|
|
308
|
+
pou_name=pou_name,
|
|
309
|
+
project_root=project_root,
|
|
310
|
+
span=delete_span,
|
|
311
|
+
total_before=total_before,
|
|
312
|
+
total_after=total_before - POU_NODE_COUNT,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# A task instance is a child of a task named after the POU.
|
|
316
|
+
instance_ancestor_decrements: dict[int, tuple[TreeNode, int]] = {}
|
|
317
|
+
for node, ancestors in document.walk_with_ancestors():
|
|
318
|
+
if node.params[1] != 5:
|
|
319
|
+
continue
|
|
320
|
+
for child in node.children:
|
|
321
|
+
if child.name == pou_name:
|
|
322
|
+
if child.children:
|
|
323
|
+
raise PouPlanError('Task instance has nested records; deletion layout is unsupported')
|
|
324
|
+
plan.instances.append((node.name, child.span))
|
|
325
|
+
plan.assigned_to.append(node.name)
|
|
326
|
+
# Measured after live COM assignment: Physical Hardware,
|
|
327
|
+
# Configuration, Resource, Tasks and task counts all include
|
|
328
|
+
# the instance. The parser may nest Hardware under Logical
|
|
329
|
+
# POUs, so use the physical branch boundary, not that artifact.
|
|
330
|
+
physical_branch = False
|
|
331
|
+
for ancestor in [*ancestors, node]:
|
|
332
|
+
physical_branch = physical_branch or ancestor.name == 'Physical Hardware'
|
|
333
|
+
if physical_branch:
|
|
334
|
+
prior = instance_ancestor_decrements.get(ancestor.line, (ancestor, 0))
|
|
335
|
+
instance_ancestor_decrements[ancestor.line] = (ancestor, prior[1] + 1)
|
|
336
|
+
|
|
337
|
+
plan.total_after -= len(plan.instances)
|
|
338
|
+
|
|
339
|
+
# The logical container loses four POU nodes. The root additionally loses
|
|
340
|
+
# every removed task instance; physical-branch ancestor decrements follow.
|
|
341
|
+
container = next(
|
|
342
|
+
node
|
|
343
|
+
for node, _ in document.walk_with_ancestors()
|
|
344
|
+
if node.name == "Logical POUs"
|
|
345
|
+
)
|
|
346
|
+
plan.count_edits.append(
|
|
347
|
+
(
|
|
348
|
+
(container.line, container.line + 1),
|
|
349
|
+
[_params_with(container.params, 2, container.params[2] - POU_NODE_COUNT)],
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
root = document.roots[0]
|
|
353
|
+
plan.count_edits.append(
|
|
354
|
+
(
|
|
355
|
+
(root.line, root.line + 1),
|
|
356
|
+
[_params_with(root.params, 2, root.params[2] - POU_NODE_COUNT - len(plan.instances))],
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
# Aggregate instance decrements once per physical ancestor/task.
|
|
360
|
+
for task, removed_instances in instance_ancestor_decrements.values():
|
|
361
|
+
plan.count_edits.append(
|
|
362
|
+
(
|
|
363
|
+
(task.line, task.line + 1),
|
|
364
|
+
[_params_with(task.params, 2, task.params[2] - removed_instances)],
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
plan.referenced_by = find_pou_references(project_root, pou_name)
|
|
369
|
+
if plan.referenced_by and not force:
|
|
370
|
+
raise PouPlanError(
|
|
371
|
+
f"refusing to delete {pou_name!r}: it is called by "
|
|
372
|
+
f"{', '.join(plan.referenced_by)}. Remove those calls first, or pass "
|
|
373
|
+
f"force=True to delete anyway and accept that the project will not "
|
|
374
|
+
f"compile."
|
|
375
|
+
)
|
|
376
|
+
if plan.referenced_by:
|
|
377
|
+
plan.notes.append(
|
|
378
|
+
f"forced despite being called by {', '.join(plan.referenced_by)}"
|
|
379
|
+
)
|
|
380
|
+
return plan
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _params_with(params: tuple[int, int, int, int], index: int, value: int) -> str:
|
|
384
|
+
values = list(params)
|
|
385
|
+
values[index] = max(0, value)
|
|
386
|
+
return " ".join(str(v) for v in values)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def render_pou_deletion(document: TreeDocument, plan: PouDeletionPlan) -> str:
|
|
390
|
+
"""Remove the POU's records and its task instances, and adjust the counts."""
|
|
391
|
+
edits: list[tuple[int, int, list[str]]] = [
|
|
392
|
+
(plan.span[0], plan.span[1], []),
|
|
393
|
+
]
|
|
394
|
+
for _, span in plan.instances:
|
|
395
|
+
edits.append((span[0], span[1], []))
|
|
396
|
+
for span, replacement in plan.count_edits:
|
|
397
|
+
edits.append((span[0], span[1], replacement))
|
|
398
|
+
edits.append((1, 2, [str(plan.total_after)]))
|
|
399
|
+
return document.splice(edits)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _verify_deletion(
|
|
403
|
+
document: TreeDocument, plan: PouDeletionPlan, previous_total: int
|
|
404
|
+
) -> list[str]:
|
|
405
|
+
"""Check the invariants a POU deletion must satisfy."""
|
|
406
|
+
problems: list[str] = []
|
|
407
|
+
if _pou_container(document, plan.pou_name) is not None:
|
|
408
|
+
problems.append(f"{plan.pou_name!r} is still in the tree")
|
|
409
|
+
for node, _ in document.walk_with_ancestors():
|
|
410
|
+
if node.params[1] == 5 and any(
|
|
411
|
+
child.name == plan.pou_name for child in node.children
|
|
412
|
+
):
|
|
413
|
+
problems.append(f"a task instance of {plan.pou_name!r} remains")
|
|
414
|
+
break
|
|
415
|
+
expected_total = previous_total - POU_NODE_COUNT - len(plan.instances)
|
|
416
|
+
if int(document.lines[1]) != expected_total:
|
|
417
|
+
problems.append(
|
|
418
|
+
f"node total is {document.lines[1]}, expected "
|
|
419
|
+
f"{expected_total}"
|
|
420
|
+
)
|
|
421
|
+
remaining = [
|
|
422
|
+
node.name
|
|
423
|
+
for node, _ in document.walk_with_ancestors()
|
|
424
|
+
if node.params[1] == POU_CONTAINER_LEVEL and is_pou_container(node)
|
|
425
|
+
]
|
|
426
|
+
if not remaining:
|
|
427
|
+
problems.append("the deletion removed every POU")
|
|
428
|
+
return problems
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def apply_pou_deletion(
|
|
432
|
+
plan: PouDeletionPlan,
|
|
433
|
+
*,
|
|
434
|
+
backup_dir: Path,
|
|
435
|
+
archive_dir: Path | None = None,
|
|
436
|
+
) -> dict[str, object]:
|
|
437
|
+
"""Delete a POU: archive its directory, splice the tree, update the registries.
|
|
438
|
+
|
|
439
|
+
The deleted POU directory is **moved to an archive**, not removed, so the
|
|
440
|
+
deletion is recoverable without depending on the backup copies -- the case study
|
|
441
|
+
does the same.
|
|
442
|
+
|
|
443
|
+
As with creation, the tree edit is rendered and verified in a scratch container
|
|
444
|
+
before anything is written.
|
|
445
|
+
"""
|
|
446
|
+
from .cfb import CompoundFile
|
|
447
|
+
from .ide import ensure_ide_closed
|
|
448
|
+
from .tree_writer import backup_and_write
|
|
449
|
+
|
|
450
|
+
from .staging import assert_proven
|
|
451
|
+
assert_proven(plan.project_root)
|
|
452
|
+
ensure_ide_closed()
|
|
453
|
+
|
|
454
|
+
project_root = Path(plan.project_root)
|
|
455
|
+
src = project_root / "src.st1"
|
|
456
|
+
original = src.read_bytes()
|
|
457
|
+
document = parse_document(
|
|
458
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
459
|
+
)
|
|
460
|
+
previous_total = int(document.lines[1])
|
|
461
|
+
|
|
462
|
+
rendered = render_pou_deletion(document, plan)
|
|
463
|
+
with tempfile.NamedTemporaryFile(
|
|
464
|
+
dir=src.parent, delete=False, suffix=".verify"
|
|
465
|
+
) as handle:
|
|
466
|
+
scratch = Path(handle.name)
|
|
467
|
+
try:
|
|
468
|
+
CompoundFile(src).replace_streams(
|
|
469
|
+
{"PROJECT.TRE": rendered.encode("latin1")}, destination=scratch
|
|
470
|
+
)
|
|
471
|
+
check = parse_document(
|
|
472
|
+
CompoundFile(scratch).read_stream("PROJECT.TRE").decode("latin1")
|
|
473
|
+
)
|
|
474
|
+
problems = _verify_deletion(check, plan, previous_total)
|
|
475
|
+
if problems:
|
|
476
|
+
raise PouPlanError(
|
|
477
|
+
"refusing to delete the POU: the edited tree failed verification:\n "
|
|
478
|
+
+ "\n ".join(problems)
|
|
479
|
+
)
|
|
480
|
+
finally:
|
|
481
|
+
scratch.unlink(missing_ok=True)
|
|
482
|
+
|
|
483
|
+
pou_dir = _pou_root(project_root) / plan.pou_name
|
|
484
|
+
archive = (archive_dir or (backup_dir / "archived-pous")) / plan.pou_name
|
|
485
|
+
if archive.exists():
|
|
486
|
+
raise PouPlanError(f"the archive location already exists: {archive}")
|
|
487
|
+
|
|
488
|
+
writes: list[tuple[Path, bytes, bytes]] = [
|
|
489
|
+
(src, original, _edited_container_bytes(src, {"PROJECT.TRE": rendered.encode("latin1"),
|
|
490
|
+
"PRMVIEWALL.DAT": _view_change(src, plan.pou_name, getattr(plan, "template_name", None))}))
|
|
491
|
+
]
|
|
492
|
+
writes.extend(_deletion_registry_writes(project_root, plan))
|
|
493
|
+
|
|
494
|
+
moved = False
|
|
495
|
+
try:
|
|
496
|
+
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
497
|
+
if pou_dir.is_dir():
|
|
498
|
+
shutil.move(str(pou_dir), str(archive))
|
|
499
|
+
moved = True
|
|
500
|
+
backups = backup_and_write(writes, backup_dir, project_root=project_root)
|
|
501
|
+
except Exception:
|
|
502
|
+
# Put the directory back so a failed deletion leaves the project as it was.
|
|
503
|
+
if moved and archive.is_dir() and not pou_dir.exists():
|
|
504
|
+
shutil.move(str(archive), str(pou_dir))
|
|
505
|
+
raise
|
|
506
|
+
|
|
507
|
+
stored = parse_document(
|
|
508
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
509
|
+
)
|
|
510
|
+
problems = _verify_deletion(stored, plan, previous_total)
|
|
511
|
+
if problems:
|
|
512
|
+
raise VerificationFailed(
|
|
513
|
+
"the deleted POU failed read-back verification:\n " + "\n ".join(problems)
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
"pou": plan.pou_name,
|
|
518
|
+
"node_total": int(stored.lines[1]),
|
|
519
|
+
"archived_to": str(archive) if moved else None,
|
|
520
|
+
"files_written": [str(path) for path, _, _ in writes],
|
|
521
|
+
"backups": backups,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _deletion_registry_writes(
|
|
526
|
+
project_root: Path, plan: PouDeletionPlan
|
|
527
|
+
) -> list[tuple[Path, bytes, bytes]]:
|
|
528
|
+
"""Plan removing the POU from ``LIST.POU``, the node lists and ``PROJECT.INF``."""
|
|
529
|
+
from datetime import datetime
|
|
530
|
+
|
|
531
|
+
writes: list[tuple[Path, bytes, bytes]] = []
|
|
532
|
+
|
|
533
|
+
candidates = [project_root / "LIST.POU"]
|
|
534
|
+
for relative in (
|
|
535
|
+
"NODES.LST",
|
|
536
|
+
"C/Configuration/R/Resource/NODES.LST",
|
|
537
|
+
"C/Resource/R/Resource/NODES.LST",
|
|
538
|
+
):
|
|
539
|
+
candidates.append(project_root / relative)
|
|
540
|
+
|
|
541
|
+
for path in candidates:
|
|
542
|
+
if not path.is_file():
|
|
543
|
+
continue
|
|
544
|
+
raw = path.read_bytes()
|
|
545
|
+
lines = raw.splitlines(keepends=True)
|
|
546
|
+
pattern = re.compile(
|
|
547
|
+
r"\b" + re.escape(plan.pou_name) + r"\b"
|
|
548
|
+
)
|
|
549
|
+
matched = [i for i, line in enumerate(lines) if pattern.search(line.decode("latin1"))]
|
|
550
|
+
if not matched:
|
|
551
|
+
continue
|
|
552
|
+
# Every matching line must be a record for this POU; anything else means the
|
|
553
|
+
# name is used for something we do not understand, so leave the file alone.
|
|
554
|
+
for index in sorted(matched, reverse=True):
|
|
555
|
+
del lines[index]
|
|
556
|
+
writes.append((path, raw, b"".join(lines)))
|
|
557
|
+
|
|
558
|
+
project_inf = project_root / "PROJECT.INF"
|
|
559
|
+
if project_inf.is_file():
|
|
560
|
+
raw = project_inf.read_bytes()
|
|
561
|
+
text = raw.decode("latin1")
|
|
562
|
+
stamp = datetime.now().strftime("%m/%d/%Y %I:%M:%S %p")
|
|
563
|
+
replaced = re.sub(r"(?m)^LastChange=.*$", f"LastChange={stamp}", text)
|
|
564
|
+
if replaced != text:
|
|
565
|
+
writes.append((project_inf, raw, replaced.encode("latin1")))
|
|
566
|
+
|
|
567
|
+
return writes
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@dataclass
|
|
571
|
+
class TransplantPlan:
|
|
572
|
+
"""A planned LD body transplantation from a donor POU."""
|
|
573
|
+
|
|
574
|
+
donor_name: str
|
|
575
|
+
target_name: str
|
|
576
|
+
project_root: Path
|
|
577
|
+
#: The streams to write into the target, as ``name -> payload``.
|
|
578
|
+
payloads: dict[str, bytes] = field(default_factory=dict)
|
|
579
|
+
#: Streams in the target that will be replaced: ``name -> before``.
|
|
580
|
+
before: dict[str, bytes] = field(default_factory=dict)
|
|
581
|
+
#: Variables whose grid usage was converted from external to local.
|
|
582
|
+
localized: int = 0
|
|
583
|
+
#: Function-block instances, which keep their usage value.
|
|
584
|
+
fb_instances: int = 0
|
|
585
|
+
declared_variables: int = 0
|
|
586
|
+
notes: list[str] = field(default_factory=list)
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def changed(self) -> bool:
|
|
590
|
+
return self.payloads != self.before
|
|
591
|
+
|
|
592
|
+
def summary(self) -> str:
|
|
593
|
+
lines = [
|
|
594
|
+
f"TRANSPLANT LD body: {self.donor_name} -> {self.target_name}",
|
|
595
|
+
f" project : {self.project_root}",
|
|
596
|
+
f" variables : {self.declared_variables} declared, "
|
|
597
|
+
f"{self.localized} external localized to local, "
|
|
598
|
+
f"{self.fb_instances} function-block instance(s) kept",
|
|
599
|
+
" streams written:",
|
|
600
|
+
]
|
|
601
|
+
for name, payload in sorted(self.payloads.items()):
|
|
602
|
+
was = len(self.before.get(name, b""))
|
|
603
|
+
lines.append(f" {name:28} {was} -> {len(payload)} bytes")
|
|
604
|
+
lines.extend(f" note: {note}" for note in self.notes)
|
|
605
|
+
return "\n".join(lines)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def read_variable_grid_count(raw: bytes) -> int | None:
|
|
609
|
+
"""The declared-variable count a ``.VGR`` stream claims, if it looks valid."""
|
|
610
|
+
if len(raw) < 12:
|
|
611
|
+
return None
|
|
612
|
+
count = struct.unpack_from("<I", raw, 8)[0]
|
|
613
|
+
# A plausible grid has a count far below its byte length; a compressed stream
|
|
614
|
+
# reports a value in the billions, which is how it is spotted.
|
|
615
|
+
return count if count <= len(raw) else None
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def localize_variable_grid(raw: bytes) -> tuple[bytes, int, int]:
|
|
619
|
+
"""Convert external variable records in a ``.VGR`` grid to local ones.
|
|
620
|
+
|
|
621
|
+
The record layout, from the proven case study: from offset 12 each record begins
|
|
622
|
+
with six little-endian ``uint32`` values -- handle, usage, group, flags,
|
|
623
|
+
worksheet row, final flags. ``usage`` is 1 for a local variable, 5 for an
|
|
624
|
+
external one, and ``0x00040001`` for a function-block instance.
|
|
625
|
+
|
|
626
|
+
A transplanted body must have its externals localized, because the donor's
|
|
627
|
+
external references belong to the donor's project. Function-block instances
|
|
628
|
+
keep their usage value.
|
|
629
|
+
|
|
630
|
+
Records are found by their field pattern rather than a fixed stride, which is
|
|
631
|
+
what defeated an earlier attempt at this format: the variable-length name and
|
|
632
|
+
type make the stride uneven. The count found must match the count the stream
|
|
633
|
+
declares, so a compressed or otherwise unexpected grid is refused rather than
|
|
634
|
+
rewritten.
|
|
635
|
+
"""
|
|
636
|
+
if len(raw) < 12:
|
|
637
|
+
raise PouPlanError("the variable grid is too short to parse")
|
|
638
|
+
expected = struct.unpack_from("<I", raw, 8)[0]
|
|
639
|
+
data = bytearray(raw)
|
|
640
|
+
offsets: list[int] = []
|
|
641
|
+
for offset in range(12, max(12, len(data) - 23)):
|
|
642
|
+
handle, usage, group, flags, row, final_flags = struct.unpack_from(
|
|
643
|
+
"<6I", data, offset
|
|
644
|
+
)
|
|
645
|
+
if (
|
|
646
|
+
1000 <= handle <= 10000
|
|
647
|
+
and usage in (1, 5, 0x00040001)
|
|
648
|
+
and group == 1
|
|
649
|
+
and flags == 0
|
|
650
|
+
and 1 <= row <= 999
|
|
651
|
+
and final_flags == 0
|
|
652
|
+
):
|
|
653
|
+
offsets.append(offset)
|
|
654
|
+
|
|
655
|
+
if len(offsets) != expected:
|
|
656
|
+
raise PouPlanError(
|
|
657
|
+
f"the variable grid holds {len(offsets)} variable record(s) but declares "
|
|
658
|
+
f"{expected}; refusing to rewrite a grid whose layout is not the expected "
|
|
659
|
+
f"one (a compressed stream looks like this)"
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
localized = 0
|
|
663
|
+
fb_instances = 0
|
|
664
|
+
marker_moved = 0
|
|
665
|
+
for offset in offsets:
|
|
666
|
+
usage = struct.unpack_from("<I", data, offset + 4)[0]
|
|
667
|
+
if usage == 5:
|
|
668
|
+
struct.pack_into("<I", data, offset + 4, 1)
|
|
669
|
+
# An external record says so twice: header usage 5, and 0xFFFFFFFF as the third
|
|
670
|
+
# word of its trailing run. Moving only the header left each converted record
|
|
671
|
+
# contradicting itself, and the clone then stalled the build and blew its grid up
|
|
672
|
+
# to 79 MB on the next append. Both halves move together.
|
|
673
|
+
tail = _record_tail_offset(data, offset)
|
|
674
|
+
if tail is not None and struct.unpack_from("<I", data, tail + 8)[0] == 0xFFFFFFFF:
|
|
675
|
+
struct.pack_into("<I", data, tail + 8, 0)
|
|
676
|
+
marker_moved += 1
|
|
677
|
+
localized += 1
|
|
678
|
+
elif usage == 0x00040001:
|
|
679
|
+
fb_instances += 1
|
|
680
|
+
if localized and marker_moved != localized:
|
|
681
|
+
raise PouPlanError(
|
|
682
|
+
f"localized {localized} external record(s) but moved only {marker_moved} external "
|
|
683
|
+
f"marker(s); refusing to leave a record whose header says LOCAL while its trailing "
|
|
684
|
+
f"run still says EXTERNAL"
|
|
685
|
+
)
|
|
686
|
+
return bytes(data), localized, fb_instances
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _record_tail_offset(data: bytes | bytearray, offset: int) -> int | None:
|
|
690
|
+
"""Where a record's trailing run begins, or None if its strings do not parse.
|
|
691
|
+
|
|
692
|
+
Six uint32 of header, then four length-prefixed UTF-16LE strings - type, address, initial
|
|
693
|
+
value, name - and then the trailing run. Located per record because the strings vary in
|
|
694
|
+
length, which is why this format defeated a fixed stride.
|
|
695
|
+
"""
|
|
696
|
+
try:
|
|
697
|
+
cursor = offset + 24
|
|
698
|
+
for _ in range(4):
|
|
699
|
+
length = struct.unpack_from("<I", data, cursor)[0]
|
|
700
|
+
if length % 2 or cursor + 4 + length > len(data):
|
|
701
|
+
return None
|
|
702
|
+
cursor += 4 + length
|
|
703
|
+
if cursor + 16 > len(data):
|
|
704
|
+
return None
|
|
705
|
+
return cursor
|
|
706
|
+
except struct.error:
|
|
707
|
+
return None
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _body_streams(pou) -> tuple[str | None, str | None, str | None]:
|
|
711
|
+
"""``(graphical body, declarations, grid)`` stream names for a POU."""
|
|
712
|
+
names = pou.stream_names()
|
|
713
|
+
graphical = next((n for n in names if n.endswith(".GB")), None)
|
|
714
|
+
declarations = next((n for n in names if n.endswith("V.VB")), None)
|
|
715
|
+
grid = next((n for n in names if n.endswith("V.VGR")), None)
|
|
716
|
+
return graphical, declarations, grid
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def plan_transplant(
|
|
720
|
+
project_root: Path, donor_name: str, target_name: str
|
|
721
|
+
) -> TransplantPlan:
|
|
722
|
+
"""Plan copying a donor's graphical (LD) body into another POU.
|
|
723
|
+
|
|
724
|
+
Only graphical bodies are transplanted. An ST body is replaced as text by
|
|
725
|
+
:func:`plan_st_body`; a graphical one is binary and cannot be authored from
|
|
726
|
+
scratch, which is why the supported route is a proven donor.
|
|
727
|
+
"""
|
|
728
|
+
from . import project as P
|
|
729
|
+
from .cfb import CompoundFile
|
|
730
|
+
from .variables import CompressedStreamError
|
|
731
|
+
|
|
732
|
+
project_root = Path(project_root)
|
|
733
|
+
project = P.Project(root=project_root)
|
|
734
|
+
donor = project.pou(donor_name)
|
|
735
|
+
target = project.pou(target_name)
|
|
736
|
+
if donor_name == target_name:
|
|
737
|
+
raise PouPlanError("the donor and the target are the same POU")
|
|
738
|
+
|
|
739
|
+
donor_streams = _body_streams(donor)
|
|
740
|
+
target_streams = _body_streams(target)
|
|
741
|
+
if donor_streams[0] is None:
|
|
742
|
+
raise PouPlanError(
|
|
743
|
+
f"donor {donor_name!r} has no graphical body; only LD bodies are "
|
|
744
|
+
f"transplanted (use mw_set_st_body for structured text)"
|
|
745
|
+
)
|
|
746
|
+
if None in target_streams:
|
|
747
|
+
raise PouPlanError(
|
|
748
|
+
f"target {target_name!r} has no graphical body to replace"
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
donor.declarations()
|
|
753
|
+
except CompressedStreamError as exc:
|
|
754
|
+
raise PouPlanError(
|
|
755
|
+
f"donor {donor_name!r} has a compressed declaration stream, which cannot "
|
|
756
|
+
f"be localized: {exc}"
|
|
757
|
+
) from exc
|
|
758
|
+
|
|
759
|
+
donor_cfb = CompoundFile(donor.source_path)
|
|
760
|
+
source_body = donor_cfb.read_stream(donor_streams[0])
|
|
761
|
+
source_vb = donor_cfb.read_stream(donor_streams[1])
|
|
762
|
+
source_vgr = donor_cfb.read_stream(donor_streams[2])
|
|
763
|
+
|
|
764
|
+
from .grid import validate_pair, parse
|
|
765
|
+
validate_pair(source_vb.decode("latin1"), source_vgr)
|
|
766
|
+
localized_vb, localized_vgr = source_vb, source_vgr
|
|
767
|
+
localized = 0
|
|
768
|
+
fb_instances = sum(r["usage"] == 0x40001 for r in parse(source_vgr))
|
|
769
|
+
|
|
770
|
+
target_cfb = CompoundFile(target.source_path)
|
|
771
|
+
plan = TransplantPlan(
|
|
772
|
+
donor_name=donor_name,
|
|
773
|
+
target_name=target_name,
|
|
774
|
+
project_root=project_root,
|
|
775
|
+
payloads={
|
|
776
|
+
target_streams[0]: source_body,
|
|
777
|
+
target_streams[1]: localized_vb,
|
|
778
|
+
target_streams[2]: localized_vgr,
|
|
779
|
+
},
|
|
780
|
+
before={
|
|
781
|
+
target_streams[0]: target_cfb.read_stream(target_streams[0]),
|
|
782
|
+
target_streams[1]: target_cfb.read_stream(target_streams[1]),
|
|
783
|
+
target_streams[2]: target_cfb.read_stream(target_streams[2]),
|
|
784
|
+
},
|
|
785
|
+
localized=localized,
|
|
786
|
+
fb_instances=fb_instances,
|
|
787
|
+
)
|
|
788
|
+
plan.declared_variables = read_variable_grid_count(source_vgr) or 0
|
|
789
|
+
if not plan.changed:
|
|
790
|
+
plan.notes.append("the target already matches the donor")
|
|
791
|
+
plan.notes.append(
|
|
792
|
+
"Native declaration usages are preserved; donor and target share the staged project"
|
|
793
|
+
)
|
|
794
|
+
return plan
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def apply_transplant(plan: TransplantPlan, *, backup_dir: Path) -> dict[str, object]:
|
|
798
|
+
"""Write a transplantation, verifying every stream reads back first."""
|
|
799
|
+
from . import project as P
|
|
800
|
+
from .cfb import CompoundFile
|
|
801
|
+
from .ide import ensure_ide_closed
|
|
802
|
+
from .tree_writer import backup_and_write
|
|
803
|
+
|
|
804
|
+
from .staging import assert_proven
|
|
805
|
+
assert_proven(plan.project_root)
|
|
806
|
+
ensure_ide_closed()
|
|
807
|
+
|
|
808
|
+
project_root = Path(plan.project_root)
|
|
809
|
+
project = P.Project(root=plan.project_root)
|
|
810
|
+
target = project.pou(plan.target_name)
|
|
811
|
+
source = target.source_path
|
|
812
|
+
original = source.read_bytes()
|
|
813
|
+
|
|
814
|
+
# Build the new container in a scratch file and verify every stream there, so a
|
|
815
|
+
# container defect becomes a refusal rather than a damaged POU.
|
|
816
|
+
with tempfile.NamedTemporaryFile(
|
|
817
|
+
dir=source.parent, delete=False, suffix=".verify"
|
|
818
|
+
) as handle:
|
|
819
|
+
scratch = Path(handle.name)
|
|
820
|
+
try:
|
|
821
|
+
CompoundFile(source).replace_streams(plan.payloads, destination=scratch)
|
|
822
|
+
staged = CompoundFile(scratch)
|
|
823
|
+
for name, payload in plan.payloads.items():
|
|
824
|
+
if staged.read_stream(name) != payload:
|
|
825
|
+
raise VerificationFailed(
|
|
826
|
+
f"refusing to transplant: the container did not reproduce "
|
|
827
|
+
f"{name!r}. Nothing was written."
|
|
828
|
+
)
|
|
829
|
+
finally:
|
|
830
|
+
scratch.unlink(missing_ok=True)
|
|
831
|
+
|
|
832
|
+
writes = [(source, original, _edited_container_bytes(source, plan.payloads))]
|
|
833
|
+
backups = backup_and_write(writes, backup_dir, project_root=plan.project_root)
|
|
834
|
+
|
|
835
|
+
stored = CompoundFile(source)
|
|
836
|
+
problems = [
|
|
837
|
+
name for name, payload in plan.payloads.items()
|
|
838
|
+
if stored.read_stream(name) != payload
|
|
839
|
+
]
|
|
840
|
+
if problems:
|
|
841
|
+
raise VerificationFailed(
|
|
842
|
+
f"the transplanted POU failed read-back verification for {problems}"
|
|
843
|
+
)
|
|
844
|
+
return {
|
|
845
|
+
"donor": plan.donor_name,
|
|
846
|
+
"target": plan.target_name,
|
|
847
|
+
"streams": sorted(plan.payloads),
|
|
848
|
+
"localized": plan.localized,
|
|
849
|
+
"fb_instances": plan.fb_instances,
|
|
850
|
+
"files_written": [str(path) for path, _, _ in writes],
|
|
851
|
+
"backups": backups,
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def apply_pou_creation(
|
|
856
|
+
plan: PouCreationPlan,
|
|
857
|
+
*,
|
|
858
|
+
backup_dir: Path,
|
|
859
|
+
project_root: Path | None = None,
|
|
860
|
+
) -> dict[str, object]:
|
|
861
|
+
"""Create a POU: clone the directory, splice the tree, update the registries.
|
|
862
|
+
|
|
863
|
+
The steps are the validated workflow from the case study:
|
|
864
|
+
|
|
865
|
+
1. clone the template directory, renaming files that mention the template;
|
|
866
|
+
2. rename the streams inside the new POU's ``src.st1``;
|
|
867
|
+
3. give the four worksheet GUIDs fresh values, in ``NodeProperties.xml`` and in
|
|
868
|
+
the tree records (which must agree);
|
|
869
|
+
4. insert four nodes into ``PROJECT.TRE`` and move the container, root and total
|
|
870
|
+
counts;
|
|
871
|
+
5. register the POU in ``LIST.POU`` and ``PROJECT.INF``.
|
|
872
|
+
|
|
873
|
+
Nothing is written until the tree edit has been rendered and verified in a
|
|
874
|
+
scratch container -- the same discipline the other writers use, because a
|
|
875
|
+
verification that runs after the write cannot prevent damage.
|
|
876
|
+
"""
|
|
877
|
+
from .cfb import CompoundFile
|
|
878
|
+
from .ide import ensure_ide_closed
|
|
879
|
+
from .tree_writer import backup_and_write
|
|
880
|
+
|
|
881
|
+
from .staging import assert_proven
|
|
882
|
+
assert_proven(project_root or plan.project_root)
|
|
883
|
+
ensure_ide_closed()
|
|
884
|
+
|
|
885
|
+
project_root = Path(project_root or plan.project_root)
|
|
886
|
+
if plan.unresolved():
|
|
887
|
+
raise PouPlanError(
|
|
888
|
+
"the plan still has unresolved fields, so nothing was written: "
|
|
889
|
+
+ ", ".join(f"{n.record}.{n.name}" for n in plan.unresolved())
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
src = project_root / "src.st1"
|
|
893
|
+
template_dir = _pou_root(project_root) / plan.template_name
|
|
894
|
+
target_dir = _pou_root(project_root) / plan.pou_name
|
|
895
|
+
if target_dir.exists():
|
|
896
|
+
raise PouPlanError(f"{target_dir} already exists")
|
|
897
|
+
|
|
898
|
+
original = src.read_bytes()
|
|
899
|
+
document = parse_document(
|
|
900
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
901
|
+
)
|
|
902
|
+
previous_total = int(document.lines[1])
|
|
903
|
+
|
|
904
|
+
# --- render the tree edit, and prove it in a scratch container first
|
|
905
|
+
rendered = _render_tree(document, plan)
|
|
906
|
+
with tempfile.NamedTemporaryFile(
|
|
907
|
+
dir=src.parent, delete=False, suffix=".verify"
|
|
908
|
+
) as handle:
|
|
909
|
+
scratch = Path(handle.name)
|
|
910
|
+
try:
|
|
911
|
+
CompoundFile(src).replace_streams(
|
|
912
|
+
{"PROJECT.TRE": rendered.encode("latin1")}, destination=scratch
|
|
913
|
+
)
|
|
914
|
+
check = parse_document(
|
|
915
|
+
CompoundFile(scratch).read_stream("PROJECT.TRE").decode("latin1")
|
|
916
|
+
)
|
|
917
|
+
problems = _verify_tree(check, plan, previous_total)
|
|
918
|
+
if problems:
|
|
919
|
+
raise PouPlanError(
|
|
920
|
+
"refusing to create the POU: the edited tree failed verification:\n "
|
|
921
|
+
+ "\n ".join(problems)
|
|
922
|
+
)
|
|
923
|
+
finally:
|
|
924
|
+
scratch.unlink(missing_ok=True)
|
|
925
|
+
|
|
926
|
+
# --- clone the directory, then write the tree and the registries
|
|
927
|
+
target_dir.mkdir(parents=True, exist_ok=False)
|
|
928
|
+
try:
|
|
929
|
+
created: list[str] = []
|
|
930
|
+
for path in sorted(template_dir.iterdir()):
|
|
931
|
+
if not path.is_file() or path.name == "tmp.sto":
|
|
932
|
+
continue
|
|
933
|
+
destination = target_dir / _rename_for(
|
|
934
|
+
path.name, plan.template_name, plan.pou_name
|
|
935
|
+
)
|
|
936
|
+
shutil.copy2(path, destination)
|
|
937
|
+
created.append(str(destination))
|
|
938
|
+
|
|
939
|
+
_rewrite_node_properties(target_dir, plan)
|
|
940
|
+
_rename_pou_streams(target_dir, plan)
|
|
941
|
+
# Preserve the donor's native local/external/FB semantics.
|
|
942
|
+
localization = {"preserved_native_declarations": True}
|
|
943
|
+
|
|
944
|
+
writes: list[tuple[Path, bytes, bytes]] = [
|
|
945
|
+
(src, original, _edited_container_bytes(src, {"PROJECT.TRE": rendered.encode("latin1"),
|
|
946
|
+
"PRMVIEWALL.DAT": _view_change(src, plan.pou_name, getattr(plan, "template_name", None))}))
|
|
947
|
+
]
|
|
948
|
+
writes.extend(_registry_writes(project_root, plan))
|
|
949
|
+
backups = backup_and_write(writes, backup_dir, project_root=project_root)
|
|
950
|
+
except Exception:
|
|
951
|
+
# A half-created POU is worse than none: remove it so a retry is clean.
|
|
952
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
953
|
+
raise
|
|
954
|
+
|
|
955
|
+
stored = parse_document(
|
|
956
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
957
|
+
)
|
|
958
|
+
problems = _verify_tree(stored, plan, previous_total)
|
|
959
|
+
if problems:
|
|
960
|
+
raise VerificationFailed(
|
|
961
|
+
"the created POU failed read-back verification:\n " + "\n ".join(problems)
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
"pou": plan.pou_name,
|
|
966
|
+
"template": plan.template_name,
|
|
967
|
+
"node_ids": plan.node_ids,
|
|
968
|
+
"node_total": int(stored.lines[1]),
|
|
969
|
+
"files_created": created,
|
|
970
|
+
"files_written": [str(path) for path, _, _ in writes],
|
|
971
|
+
"backups": backups,
|
|
972
|
+
# Cloning preserves native declaration usages; unlike a transplant it
|
|
973
|
+
# must not silently turn global bindings into unrelated local storage.
|
|
974
|
+
"localization": localization,
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _view_change(source: Path, name: str, template: str | None) -> bytes:
|
|
979
|
+
from .cfb import CompoundFile
|
|
980
|
+
raw = CompoundFile(source).read_stream("PRMVIEWALL.DAT")
|
|
981
|
+
lines = raw.decode("latin1").splitlines(keepends=True)
|
|
982
|
+
target = "@POUGRP." + name
|
|
983
|
+
matches = [i for i, line in enumerate(lines) if line.rstrip("\r\n").casefold() == target.casefold()]
|
|
984
|
+
if template is None:
|
|
985
|
+
if len(matches) > 1: raise PouPlanError("Duplicate native POU view entries")
|
|
986
|
+
if matches: del lines[matches[0]]
|
|
987
|
+
else:
|
|
988
|
+
if matches: raise PouPlanError("POU view already contains the new name")
|
|
989
|
+
donors = [i for i, line in enumerate(lines) if line.rstrip("\r\n").casefold() == ("@POUGRP." + template).casefold()]
|
|
990
|
+
if not donors:
|
|
991
|
+
donors = [i for i, line in enumerate(lines) if line.rstrip("\r\n") == "@POUGRP"]
|
|
992
|
+
if len(donors) != 1: raise PouPlanError("Expected one native POU view anchor")
|
|
993
|
+
original = lines[donors[0]]
|
|
994
|
+
ending = "\r\n" if original.endswith("\r\n") else "\n"
|
|
995
|
+
lines.insert(donors[0]+1, target + ending)
|
|
996
|
+
return "".join(lines).encode("latin1")
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _edited_container_bytes(src: Path, replacements: dict[str, bytes] | str) -> bytes:
|
|
1000
|
+
"""Build the edited container in memory, via a scratch destination.
|
|
1001
|
+
|
|
1002
|
+
Accepts either a ``{stream: payload}`` mapping or a bare ``PROJECT.TRE`` string,
|
|
1003
|
+
since both callers need the same scratch-container discipline.
|
|
1004
|
+
"""
|
|
1005
|
+
from .cfb import CompoundFile
|
|
1006
|
+
|
|
1007
|
+
if isinstance(replacements, str):
|
|
1008
|
+
replacements = {"PROJECT.TRE": replacements.encode("latin1")}
|
|
1009
|
+
|
|
1010
|
+
with tempfile.NamedTemporaryFile(
|
|
1011
|
+
dir=src.parent, delete=False, suffix=".pending"
|
|
1012
|
+
) as handle:
|
|
1013
|
+
scratch = Path(handle.name)
|
|
1014
|
+
try:
|
|
1015
|
+
CompoundFile(src).replace_streams(replacements, destination=scratch)
|
|
1016
|
+
return scratch.read_bytes()
|
|
1017
|
+
finally:
|
|
1018
|
+
scratch.unlink(missing_ok=True)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _render_tree(document: TreeDocument, plan: PouCreationPlan) -> str:
|
|
1022
|
+
"""Insert the four records and move the container, root and total counts."""
|
|
1023
|
+
container = next(
|
|
1024
|
+
node
|
|
1025
|
+
for node, _ in document.walk_with_ancestors()
|
|
1026
|
+
if node.name == "Logical POUs"
|
|
1027
|
+
)
|
|
1028
|
+
container_params = list(container.params)
|
|
1029
|
+
container_params[2] = container.params[2] + POU_NODE_COUNT
|
|
1030
|
+
|
|
1031
|
+
root = document.roots[0]
|
|
1032
|
+
root_params = list(root.params)
|
|
1033
|
+
root_params[2] = root.params[2] + POU_NODE_COUNT
|
|
1034
|
+
|
|
1035
|
+
total = int(document.lines[1]) + POU_NODE_COUNT
|
|
1036
|
+
return document.splice(
|
|
1037
|
+
[
|
|
1038
|
+
(plan.insert_at, plan.insert_at, plan.records),
|
|
1039
|
+
(container.line, container.line + 1,
|
|
1040
|
+
[" ".join(str(v) for v in container_params)]),
|
|
1041
|
+
(root.line, root.line + 1, [" ".join(str(v) for v in root_params)]),
|
|
1042
|
+
(1, 2, [str(total)]),
|
|
1043
|
+
]
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _verify_tree(
|
|
1048
|
+
document: TreeDocument, plan: PouCreationPlan, previous_total: int
|
|
1049
|
+
) -> list[str]:
|
|
1050
|
+
"""Check the invariants a POU creation must satisfy."""
|
|
1051
|
+
problems: list[str] = []
|
|
1052
|
+
created = _pou_container(document, plan.pou_name)
|
|
1053
|
+
if created is None:
|
|
1054
|
+
problems.append(f"{plan.pou_name!r} is not in the tree after the edit")
|
|
1055
|
+
return problems
|
|
1056
|
+
if not is_pou_container(created):
|
|
1057
|
+
problems.append(
|
|
1058
|
+
f"{plan.pou_name!r} is not a well-formed POU container: children "
|
|
1059
|
+
f"{[c.name for c in created.children]}"
|
|
1060
|
+
)
|
|
1061
|
+
if created.params[0] != plan.node_ids[0]:
|
|
1062
|
+
problems.append(
|
|
1063
|
+
f"container node id is {created.params[0]}, expected {plan.node_ids[0]}"
|
|
1064
|
+
)
|
|
1065
|
+
for expected, child in zip(plan.node_ids[1:], created.children):
|
|
1066
|
+
if child.params[0] != expected:
|
|
1067
|
+
problems.append(
|
|
1068
|
+
f"worksheet {child.name!r} has node id {child.params[0]}, "
|
|
1069
|
+
f"expected {expected}"
|
|
1070
|
+
)
|
|
1071
|
+
if int(document.lines[1]) != previous_total + POU_NODE_COUNT:
|
|
1072
|
+
problems.append(
|
|
1073
|
+
f"node total is {document.lines[1]}, expected "
|
|
1074
|
+
f"{previous_total + POU_NODE_COUNT}"
|
|
1075
|
+
)
|
|
1076
|
+
existing = [
|
|
1077
|
+
node.name
|
|
1078
|
+
for node, _ in document.walk_with_ancestors()
|
|
1079
|
+
if node.params[1] == POU_CONTAINER_LEVEL and is_pou_container(node)
|
|
1080
|
+
]
|
|
1081
|
+
if len(existing) < 2:
|
|
1082
|
+
problems.append("the edit left fewer than two POUs in the tree")
|
|
1083
|
+
return problems
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _rewrite_node_properties(target_dir: Path, plan: PouCreationPlan) -> None:
|
|
1087
|
+
"""Replace the template's four worksheet GUIDs with the new POU's."""
|
|
1088
|
+
path = target_dir / "NodeProperties.xml"
|
|
1089
|
+
if not path.is_file():
|
|
1090
|
+
return
|
|
1091
|
+
text = path.read_bytes().decode("utf-16")
|
|
1092
|
+
template_dir = _pou_root(plan.project_root) / plan.template_name
|
|
1093
|
+
old_guids = _template_guids(template_dir)
|
|
1094
|
+
if len(old_guids) != len(plan.guids):
|
|
1095
|
+
raise PouPlanError(
|
|
1096
|
+
f"template records {len(old_guids)} GUID(s) but the plan has "
|
|
1097
|
+
f"{len(plan.guids)}; refusing to rewrite NodeProperties.xml"
|
|
1098
|
+
)
|
|
1099
|
+
for old, new in zip(old_guids, plan.guids):
|
|
1100
|
+
text = text.replace(old, tree_form_to_guid(new), 1)
|
|
1101
|
+
path.write_bytes(text.encode("utf-16"))
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _localize_pou_variables(target_dir: Path) -> dict[str, int]:
|
|
1105
|
+
"""Make a clone's declarations LOCAL instead of external.
|
|
1106
|
+
|
|
1107
|
+
A clone inherits the template's variable records verbatim, including any declared
|
|
1108
|
+
``VAR_EXTERNAL`` and their ``usage == 5`` binary grid records. Those expect a
|
|
1109
|
+
matching ``VAR_GLOBAL`` belonging to the ORIGINAL project, which a clone has no
|
|
1110
|
+
claim on.
|
|
1111
|
+
|
|
1112
|
+
Nothing compiles an unassigned POU, so this stays invisible - and then the assign
|
|
1113
|
+
makes the compiler evaluate every one of them at once. Measured on a template that
|
|
1114
|
+
carries externals: create, declare variables, write the body and add a global were
|
|
1115
|
+
ALL clean (is_compiled=true), and the ASSIGN alone turned the project into 125
|
|
1116
|
+
"No matching global variable found" errors. The failing build then drops a task
|
|
1117
|
+
assignment, so the project ends up both uncompilable and missing a task - which is
|
|
1118
|
+
what "the first attempt corrupted the project" actually was.
|
|
1119
|
+
|
|
1120
|
+
The transplanted-body path already does exactly this conversion (``plan_transplant``
|
|
1121
|
+
renames ``VAR_EXTERNAL`` to ``VAR`` and localizes the grid); creation did not, and
|
|
1122
|
+
that difference was the entire bug.
|
|
1123
|
+
|
|
1124
|
+
A grid whose layout is not the expected one is left untouched rather than guessed
|
|
1125
|
+
at, and the counts are returned so the caller can report what happened.
|
|
1126
|
+
"""
|
|
1127
|
+
from .cfb import CompoundFile
|
|
1128
|
+
|
|
1129
|
+
result = {"declarations_rewritten": 0, "grid_localized": 0, "fb_instances": 0, "grid_skipped": 0}
|
|
1130
|
+
src = target_dir / "src.st1"
|
|
1131
|
+
if not src.is_file():
|
|
1132
|
+
return result
|
|
1133
|
+
|
|
1134
|
+
cfb = CompoundFile(src)
|
|
1135
|
+
names = cfb.stream_names()
|
|
1136
|
+
declaration_stream = next((n for n in names if n.endswith("V.VB")), None)
|
|
1137
|
+
grid_stream = next((n for n in names if n.endswith("V.VGR")), None)
|
|
1138
|
+
|
|
1139
|
+
replacements: dict[str, bytes] = {}
|
|
1140
|
+
|
|
1141
|
+
if declaration_stream:
|
|
1142
|
+
raw = cfb.read_stream(declaration_stream)
|
|
1143
|
+
if b"VAR_EXTERNAL" in raw:
|
|
1144
|
+
result["declarations_rewritten"] = raw.count(b"VAR_EXTERNAL")
|
|
1145
|
+
replacements[declaration_stream] = raw.replace(b"VAR_EXTERNAL", b"VAR")
|
|
1146
|
+
|
|
1147
|
+
if grid_stream:
|
|
1148
|
+
raw = cfb.read_stream(grid_stream)
|
|
1149
|
+
try:
|
|
1150
|
+
localized_bytes, localized, fb_instances = localize_variable_grid(raw)
|
|
1151
|
+
result["grid_localized"] = localized
|
|
1152
|
+
result["fb_instances"] = fb_instances
|
|
1153
|
+
if localized:
|
|
1154
|
+
replacements[grid_stream] = localized_bytes
|
|
1155
|
+
except PouPlanError:
|
|
1156
|
+
# Refusing to rewrite an unexpected layout is the safe choice; report it
|
|
1157
|
+
# rather than silently leaving externals in place.
|
|
1158
|
+
result["grid_skipped"] = 1
|
|
1159
|
+
|
|
1160
|
+
if replacements:
|
|
1161
|
+
cfb.replace_streams(replacements)
|
|
1162
|
+
return result
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _rename_pou_streams(target_dir: Path, plan: PouCreationPlan) -> None:
|
|
1166
|
+
"""Rename the streams inside the new POU's ``src.st1`` to its own name."""
|
|
1167
|
+
from .cfb import CompoundFile
|
|
1168
|
+
|
|
1169
|
+
src = target_dir / "src.st1"
|
|
1170
|
+
if not src.is_file():
|
|
1171
|
+
return
|
|
1172
|
+
cfb = CompoundFile(src)
|
|
1173
|
+
renames = {
|
|
1174
|
+
stream: _rename_for(stream, plan.template_name, plan.pou_name)
|
|
1175
|
+
for stream in cfb.stream_names()
|
|
1176
|
+
if plan.template_name.lower() in stream.lower()
|
|
1177
|
+
}
|
|
1178
|
+
if renames:
|
|
1179
|
+
cfb.rename_streams(renames)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _registry_writes(
|
|
1183
|
+
project_root: Path, plan: PouCreationPlan
|
|
1184
|
+
) -> list[tuple[Path, bytes, bytes]]:
|
|
1185
|
+
"""Plan the ``LIST.POU`` and ``PROJECT.INF`` updates."""
|
|
1186
|
+
from datetime import datetime
|
|
1187
|
+
|
|
1188
|
+
writes: list[tuple[Path, bytes, bytes]] = []
|
|
1189
|
+
|
|
1190
|
+
list_pou = project_root / "LIST.POU"
|
|
1191
|
+
if list_pou.is_file():
|
|
1192
|
+
raw = list_pou.read_bytes()
|
|
1193
|
+
text = raw.decode("latin1")
|
|
1194
|
+
ending = "\r\n" if "\r\n" in text else "\n"
|
|
1195
|
+
line = f"PROGRAM\t{plan.pou_name}\t\t\tPT=1\t{plan.pou_name}\tHIDDEN=0\t"
|
|
1196
|
+
if f"PROGRAM\t{plan.pou_name}\t" in text:
|
|
1197
|
+
raise PouPlanError(f"{plan.pou_name!r} already appears in LIST.POU")
|
|
1198
|
+
after = text + ("" if text.endswith(("\n", "\r")) else ending) + line + ending
|
|
1199
|
+
writes.append((list_pou, raw, after.encode("latin1")))
|
|
1200
|
+
|
|
1201
|
+
project_inf = project_root / "PROJECT.INF"
|
|
1202
|
+
if project_inf.is_file():
|
|
1203
|
+
raw = project_inf.read_bytes()
|
|
1204
|
+
text = raw.decode("latin1")
|
|
1205
|
+
stamp = datetime.now().strftime("%m/%d/%Y %I:%M:%S %p")
|
|
1206
|
+
replaced = re.sub(r"(?m)^LastChange=.*$", f"LastChange={stamp}", text)
|
|
1207
|
+
if replaced != text:
|
|
1208
|
+
writes.append((project_inf, raw, replaced.encode("latin1")))
|
|
1209
|
+
|
|
1210
|
+
return writes
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _pou_container(document: TreeDocument, name: str) -> TreeNode | None:
|
|
1214
|
+
for node, _ in document.walk_with_ancestors():
|
|
1215
|
+
if node.params[1] == POU_CONTAINER_LEVEL and node.name == name:
|
|
1216
|
+
return node
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _physical_hardware(document: TreeDocument) -> TreeNode | None:
|
|
1221
|
+
"""The node POU records are inserted before, as the case study does.
|
|
1222
|
+
|
|
1223
|
+
The parser's subtree bookkeeping nests this under ``Logical POUs``, so it is
|
|
1224
|
+
found by name and position rather than by walking parents.
|
|
1225
|
+
"""
|
|
1226
|
+
for node, _ in document.walk_with_ancestors():
|
|
1227
|
+
record = node.path.split("\t")[0].replace("/", "\\")
|
|
1228
|
+
if node.name == "Physical Hardware" or record.endswith("\\HW"):
|
|
1229
|
+
return node
|
|
1230
|
+
return None
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _pou_root(project_root: Path) -> Path:
|
|
1234
|
+
candidate = Path(project_root) / "POE"
|
|
1235
|
+
if not candidate.is_dir():
|
|
1236
|
+
raise NotFound(f"no POE directory under {project_root}")
|
|
1237
|
+
return candidate
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _template_guids(directory: Path) -> list[str]:
|
|
1241
|
+
path = directory / "NodeProperties.xml"
|
|
1242
|
+
if not path.is_file():
|
|
1243
|
+
return []
|
|
1244
|
+
text = path.read_bytes().decode("utf-16")
|
|
1245
|
+
return re.findall(r'GUID="([0-9A-Fa-f-]{36})"', text)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def guid_to_tree_form(dashed: str) -> str:
|
|
1249
|
+
"""Convert ``XXXXXXXX-XXXX-...`` to the tree's space-separated form."""
|
|
1250
|
+
parts = dashed.split("-")
|
|
1251
|
+
if len(parts) != 5:
|
|
1252
|
+
raise PouPlanError(f"not a dashed GUID: {dashed!r}")
|
|
1253
|
+
tail = " ".join(parts[4][i : i + 2] for i in range(0, len(parts[4]), 2))
|
|
1254
|
+
return f"{parts[0]} {parts[1]} {parts[2]} {parts[3][:2]} {parts[3][2:]} {tail}"
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def tree_form_to_guid(spaced: str) -> str:
|
|
1258
|
+
"""Convert the tree's spaced form back to ``XXXXXXXX-XXXX-...``.
|
|
1259
|
+
|
|
1260
|
+
The two representations are different strings, and ``NodeProperties.xml`` wants
|
|
1261
|
+
the dashed one. Handing a tree-form value to :func:`guid_to_tree_form` raises
|
|
1262
|
+
rather than writing a GUID the tree and the XML file would disagree about.
|
|
1263
|
+
"""
|
|
1264
|
+
parts = spaced.split()
|
|
1265
|
+
if len(parts) != 11:
|
|
1266
|
+
raise PouPlanError(f"not a tree-form GUID: {spaced!r}")
|
|
1267
|
+
return (
|
|
1268
|
+
f"{parts[0]}-{parts[1]}-{parts[2]}-"
|
|
1269
|
+
f"{parts[3]}{parts[4]}-" + "".join(parts[5:])
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def plan_pou_creation(
|
|
1274
|
+
project_root: Path, pou_name: str, template_name: str
|
|
1275
|
+
) -> PouCreationPlan:
|
|
1276
|
+
"""Produce a POU creation plan, marking every field that cannot be derived."""
|
|
1277
|
+
from .cfb import CompoundFile
|
|
1278
|
+
import re
|
|
1279
|
+
for value in (pou_name, template_name):
|
|
1280
|
+
if not re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*', value):
|
|
1281
|
+
raise PouPlanError('POU names must be IEC identifiers, not paths')
|
|
1282
|
+
import re
|
|
1283
|
+
for value in (pou_name, template_name):
|
|
1284
|
+
if not re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*', value):
|
|
1285
|
+
raise PouPlanError('POU names must be IEC identifiers, not paths')
|
|
1286
|
+
|
|
1287
|
+
project_root = Path(project_root)
|
|
1288
|
+
src = project_root / "src.st1"
|
|
1289
|
+
if not src.is_file():
|
|
1290
|
+
raise NotFound(f"no src.st1 under {project_root}")
|
|
1291
|
+
document = parse_document(
|
|
1292
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
if _pou_container(document, pou_name) is not None:
|
|
1296
|
+
raise PouPlanError(f"{pou_name!r} already exists in the project tree")
|
|
1297
|
+
|
|
1298
|
+
pou_root = _pou_root(project_root)
|
|
1299
|
+
template_dir = pou_root / template_name
|
|
1300
|
+
if not template_dir.is_dir():
|
|
1301
|
+
available = sorted(p.name for p in pou_root.iterdir() if p.is_dir())
|
|
1302
|
+
raise NotFound(f"no template POU {template_name!r}. Available: {available}")
|
|
1303
|
+
|
|
1304
|
+
template = _pou_container(document, template_name)
|
|
1305
|
+
if template is None:
|
|
1306
|
+
raise PouPlanError(
|
|
1307
|
+
f"template POU {template_name!r} has a directory but no tree node"
|
|
1308
|
+
)
|
|
1309
|
+
if not is_pou_container(template):
|
|
1310
|
+
raise PouPlanError(
|
|
1311
|
+
f"template {template_name!r} is not a POU container: it has "
|
|
1312
|
+
f"{len(template.children)} worksheet(s), expected exactly "
|
|
1313
|
+
f"<name>T, <name>V and <name>"
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
warnings: list[str] = []
|
|
1317
|
+
template_guids = _template_guids(template_dir)
|
|
1318
|
+
if len(template_guids) != POU_NODE_COUNT:
|
|
1319
|
+
# Refuse here rather than during the clone: a template whose
|
|
1320
|
+
# NodeProperties.xml does not record exactly four worksheet GUIDs cannot be
|
|
1321
|
+
# given fresh ones that agree with the tree records, and failing before
|
|
1322
|
+
# anything is created leaves the project untouched. Measured: one sample
|
|
1323
|
+
# POU records 12.
|
|
1324
|
+
raise PouPlanError(
|
|
1325
|
+
f"template {template_name!r} records {len(template_guids)} GUID(s) in "
|
|
1326
|
+
f"NodeProperties.xml but a POU needs {POU_NODE_COUNT}; it cannot be "
|
|
1327
|
+
f"cloned safely. Do not remove historical records to force a clone. "
|
|
1328
|
+
f"Four-GUID template candidates (still require plan validation): "
|
|
1329
|
+
f"{[p.name for p in sorted(pou_root.iterdir()) if p.is_dir() and len(_template_guids(p)) == POU_NODE_COUNT]}"
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
physical = _physical_hardware(document)
|
|
1333
|
+
insert_at = physical.start_line if physical else document.roots[0].end_line
|
|
1334
|
+
|
|
1335
|
+
highest = max(
|
|
1336
|
+
(node.node_id for node, _ in document.walk_with_ancestors()), default=0
|
|
1337
|
+
)
|
|
1338
|
+
node_ids = [highest + i for i in range(1, POU_NODE_COUNT + 1)]
|
|
1339
|
+
guids = [guid_line() for _ in range(POU_NODE_COUNT)]
|
|
1340
|
+
|
|
1341
|
+
def kind_of(child: TreeNode) -> str:
|
|
1342
|
+
return "T" if child.name.endswith("T") else (
|
|
1343
|
+
"V" if child.name.endswith("V") else ""
|
|
1344
|
+
)
|
|
1345
|
+
|
|
1346
|
+
body_child = next(child for child in template.children if kind_of(child) == "")
|
|
1347
|
+
body_suffix = Path(body_child.path.split("\t")[0]).suffix or ".STB"
|
|
1348
|
+
|
|
1349
|
+
records, notes = build_records(
|
|
1350
|
+
document,
|
|
1351
|
+
template,
|
|
1352
|
+
pou_name,
|
|
1353
|
+
node_ids,
|
|
1354
|
+
guids,
|
|
1355
|
+
body_child.path.split("\t")[0],
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
target = pou_root / pou_name
|
|
1359
|
+
# Rename any file whose name mentions the template, in any case. Matching only
|
|
1360
|
+
# the exact and upper-cased forms leaves mixed-case names behind, and replacing
|
|
1361
|
+
# twice in a row can double-substitute.
|
|
1362
|
+
#
|
|
1363
|
+
# Files that do *not* mention the template are copied unchanged, but called out:
|
|
1364
|
+
# a POU directory holds a few files whose purpose is not established
|
|
1365
|
+
# (``Initialize.CCI`` in one template, ``EnableV.cfb`` in another), and whether
|
|
1366
|
+
# those are per-POU or shared with a group cannot be decided from the files.
|
|
1367
|
+
# Reporting them is honest; renaming them on a guess could break the clone.
|
|
1368
|
+
files = sorted(
|
|
1369
|
+
str(target / _rename_for(p.name, template_name, pou_name))
|
|
1370
|
+
for p in template_dir.iterdir()
|
|
1371
|
+
if p.is_file() and p.name != "tmp.sto"
|
|
1372
|
+
)
|
|
1373
|
+
# Files whose names are fixed for every POU, so not mentioning the template is
|
|
1374
|
+
# expected and not worth flagging.
|
|
1375
|
+
fixed_names = {
|
|
1376
|
+
"nodeproperties.xml",
|
|
1377
|
+
"poureserve.prs",
|
|
1378
|
+
"src.st1",
|
|
1379
|
+
"tmp.sto",
|
|
1380
|
+
}
|
|
1381
|
+
unnamed = sorted(
|
|
1382
|
+
p.name
|
|
1383
|
+
for p in template_dir.iterdir()
|
|
1384
|
+
if p.is_file()
|
|
1385
|
+
and p.name.lower() not in fixed_names
|
|
1386
|
+
and template_name.lower() not in p.name.lower()
|
|
1387
|
+
)
|
|
1388
|
+
if unnamed:
|
|
1389
|
+
warnings.append(
|
|
1390
|
+
"copied unchanged, since their names do not mention the template - "
|
|
1391
|
+
"check whether they are POU-specific and should be renamed: "
|
|
1392
|
+
+ ", ".join(unnamed)
|
|
1393
|
+
)
|
|
1394
|
+
if not files:
|
|
1395
|
+
warnings.append("template directory has no cloneable files")
|
|
1396
|
+
|
|
1397
|
+
return PouCreationPlan(
|
|
1398
|
+
pou_name=pou_name,
|
|
1399
|
+
template_name=template_name,
|
|
1400
|
+
project_root=project_root,
|
|
1401
|
+
node_ids=node_ids,
|
|
1402
|
+
guids=guids,
|
|
1403
|
+
insert_at=insert_at,
|
|
1404
|
+
records=records,
|
|
1405
|
+
files=files,
|
|
1406
|
+
notes=notes,
|
|
1407
|
+
warnings=warnings,
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _rename_for(filename: str, template_name: str, pou_name: str) -> str:
|
|
1412
|
+
"""Rename a template file for the new POU, case-insensitively and once.
|
|
1413
|
+
|
|
1414
|
+
A single regex substitution avoids the two failure modes of chained
|
|
1415
|
+
``str.replace`` calls: leaving a mixed-case occurrence untouched, and
|
|
1416
|
+
substituting twice when the replacement itself contains the search text.
|
|
1417
|
+
"""
|
|
1418
|
+
import re as _re
|
|
1419
|
+
|
|
1420
|
+
return _re.sub(_re.escape(template_name), pou_name, filename, flags=_re.IGNORECASE)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _template_record_lines(document: TreeDocument, node: TreeNode) -> list[str]:
|
|
1424
|
+
"""The raw record lines a template node occupies, INCLUDING its trailing blank.
|
|
1425
|
+
|
|
1426
|
+
A blank line separates one tree record from the next, and that blank belongs to
|
|
1427
|
+
the record's extent: ``end_line`` points AT it. ``range(start, end)`` is
|
|
1428
|
+
exclusive, so the earlier version silently dropped it and the records cloned into
|
|
1429
|
+
a new POU ran together with no separator.
|
|
1430
|
+
|
|
1431
|
+
That produced a project which passed every internal check and then would not
|
|
1432
|
+
load. Measured directly: a fresh copy of the same project opens fine, a copy with
|
|
1433
|
+
one created POU returns "Internal error in 'OpenProject'", and diffing the two
|
|
1434
|
+
trees showed the inserted worksheet records missing exactly the blank lines every
|
|
1435
|
+
existing POU in the file has.
|
|
1436
|
+
|
|
1437
|
+
Reattaching the blank is safe for the container caller, which slices these lines
|
|
1438
|
+
with ``[: first_child - start_line]`` and so drops the extra line.
|
|
1439
|
+
"""
|
|
1440
|
+
lines = [document.lines[i] for i in range(node.start_line, node.end_line)]
|
|
1441
|
+
following = node.end_line
|
|
1442
|
+
if 0 <= following < len(document.lines) and document.lines[following].strip() == "":
|
|
1443
|
+
lines.append(document.lines[following])
|
|
1444
|
+
return lines
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def build_records(
|
|
1448
|
+
document: TreeDocument,
|
|
1449
|
+
template: TreeNode,
|
|
1450
|
+
pou_name: str,
|
|
1451
|
+
node_ids: list[int],
|
|
1452
|
+
guids: list[str],
|
|
1453
|
+
body_stream: str,
|
|
1454
|
+
) -> tuple[list[str], list[FieldNote]]:
|
|
1455
|
+
"""Build the four tree records for a new POU by cloning the template's.
|
|
1456
|
+
|
|
1457
|
+
Every field is either derived -- node id, level, name, path, GUID, and the
|
|
1458
|
+
count line, all of which were measured across the whole sample set -- or copied
|
|
1459
|
+
**verbatim from the template**.
|
|
1460
|
+
|
|
1461
|
+
Copying is deliberate for the worksheet state lines. They carry opaque
|
|
1462
|
+
project-local handles (``dbf1a023``, ``9442c853``) that appear exactly once in
|
|
1463
|
+
the tree and are not derived from the POU name, the node GUID, or any other
|
|
1464
|
+
in-tree value; hashes of the name do not reproduce them, and the same POU name
|
|
1465
|
+
in two projects carries different handles. Inventing them is not possible from
|
|
1466
|
+
the files alone, so the template's values are reused and each one is reported as
|
|
1467
|
+
``from-template`` rather than presented as derived.
|
|
1468
|
+
|
|
1469
|
+
``body_stream`` is the template's body stream name (``Template.STB``); the new
|
|
1470
|
+
POU's body takes the same extension.
|
|
1471
|
+
"""
|
|
1472
|
+
directory = f"POE\\{pou_name}"
|
|
1473
|
+
body_suffix = Path(body_stream).suffix or ".STB"
|
|
1474
|
+
extension = {"T": "TXT", "V": "VGR", "": body_suffix.lstrip(".")}
|
|
1475
|
+
|
|
1476
|
+
def kind_of(child: TreeNode) -> str:
|
|
1477
|
+
return "T" if child.name.endswith("T") else (
|
|
1478
|
+
"V" if child.name.endswith("V") else ""
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
container_lines = _template_record_lines(document, template)
|
|
1482
|
+
# The container's own lines are everything before its first worksheet.
|
|
1483
|
+
first_child = min(child.start_line for child in template.children)
|
|
1484
|
+
container_own = [
|
|
1485
|
+
line for line in container_lines if line is not None
|
|
1486
|
+
][: first_child - template.start_line]
|
|
1487
|
+
|
|
1488
|
+
records: list[str] = list(container_own)
|
|
1489
|
+
records[F_PARAMS] = f"{node_ids[0]} {POU_CONTAINER_LEVEL} {len(template.children)} 0"
|
|
1490
|
+
records[F_NAME] = f"{pou_name}\t0\t0\tConfiguration.Resource"
|
|
1491
|
+
records[F_PATH] = f"{directory}\t\t\t\t-1\t"
|
|
1492
|
+
records[F_GUID] = guids[0]
|
|
1493
|
+
|
|
1494
|
+
notes: list[FieldNote] = [
|
|
1495
|
+
FieldNote("container", F_PARAMS, "params", "derived",
|
|
1496
|
+
f"node id {node_ids[0]}, level {POU_CONTAINER_LEVEL}, "
|
|
1497
|
+
f"{len(template.children)} children"),
|
|
1498
|
+
FieldNote("container", F_NAME, "name", "derived", pou_name),
|
|
1499
|
+
FieldNote("container", F_PATH, "path", "derived", directory),
|
|
1500
|
+
FieldNote("container", F_GUID, "GUID", "derived", guids[0]),
|
|
1501
|
+
FieldNote("container", F_STATE, "state line", "from-template",
|
|
1502
|
+
f"copied from {template.name!r}: opaque project-local handles, not "
|
|
1503
|
+
f"derivable from any file"),
|
|
1504
|
+
]
|
|
1505
|
+
|
|
1506
|
+
for offset, child in enumerate(template.children, start=1):
|
|
1507
|
+
kind = kind_of(child)
|
|
1508
|
+
worksheet_name = f"{pou_name}{kind}"
|
|
1509
|
+
filename = f"{worksheet_name}.{extension[kind]}"
|
|
1510
|
+
label = f"worksheet {kind or 'body'}"
|
|
1511
|
+
|
|
1512
|
+
record = _template_record_lines(document, child)
|
|
1513
|
+
record[F_PARAMS] = f"{node_ids[offset]} {POU_WORKSHEET_LEVEL} 0 0"
|
|
1514
|
+
record[F_NAME] = f"{worksheet_name}\t0\t0\tConfiguration.Resource"
|
|
1515
|
+
record[F_PATH] = f"{directory}\\{filename}\t\t\t\t-1\t"
|
|
1516
|
+
record[F_GUID] = guids[offset]
|
|
1517
|
+
records.extend(record)
|
|
1518
|
+
|
|
1519
|
+
notes += [
|
|
1520
|
+
FieldNote(label, F_COUNT, "count line", "from-template",
|
|
1521
|
+
f"copied from the template ({document.lines[child.start_line]!r})"),
|
|
1522
|
+
FieldNote(label, F_PARAMS, "params", "derived",
|
|
1523
|
+
f"node id {node_ids[offset]}, level {POU_WORKSHEET_LEVEL}"),
|
|
1524
|
+
FieldNote(label, F_NAME, "name", "derived", worksheet_name),
|
|
1525
|
+
FieldNote(label, F_PATH, "path", "derived", f"{directory}\\{filename}"),
|
|
1526
|
+
FieldNote(label, F_STATE, "state line", "from-template",
|
|
1527
|
+
"copied from the template; opaque handles, not derivable"),
|
|
1528
|
+
FieldNote(label, F_GUID, "GUID", "derived", guids[offset]),
|
|
1529
|
+
]
|
|
1530
|
+
if len(record) > F_TRAILING:
|
|
1531
|
+
notes.append(
|
|
1532
|
+
FieldNote(label, F_TRAILING, "trailing state", "from-template",
|
|
1533
|
+
"copied from the template; differs by body kind and is not "
|
|
1534
|
+
"derivable")
|
|
1535
|
+
)
|
|
1536
|
+
return records, notes
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _default_backup_dir(project_root: Path) -> Path:
|
|
1540
|
+
"""Where POU-creation backups go, outside the project tree.
|
|
1541
|
+
|
|
1542
|
+
Reuses the tree writer's location and stamping so a project's backups stay in
|
|
1543
|
+
one place.
|
|
1544
|
+
"""
|
|
1545
|
+
from .tree_writer import default_backup_dir
|
|
1546
|
+
|
|
1547
|
+
return default_backup_dir(project_root)
|