@aryacode/dsh-engineering 0.0.0-stage → 0.3.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 +115 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/cordis.patch.yml +122 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -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,704 @@
|
|
|
1
|
+
"""Resolve, inventory, and read MotionWorks IEC projects on disk.
|
|
2
|
+
|
|
3
|
+
A MotionWorks project on disk is an *expanded directory* plus a small sibling
|
|
4
|
+
``.mwt`` file. In the sample projects the ``.mwt`` is only 4096 bytes: it is a
|
|
5
|
+
wrapper, not the project. Everything editable lives in the expanded directory::
|
|
6
|
+
|
|
7
|
+
<Project>/
|
|
8
|
+
src.st1 project tree + view state (CFB)
|
|
9
|
+
LIST.POU textual POU list
|
|
10
|
+
NODES.LST node/task assignment list
|
|
11
|
+
@LIBRARY.LST library references
|
|
12
|
+
PROJECT.INF last-change stamp
|
|
13
|
+
NODES.LST ...
|
|
14
|
+
C/ configuration (holds VAR_GLOBAL + IO config)
|
|
15
|
+
DT/ HW/ LIB/ data types, hardware, library nodes
|
|
16
|
+
POE/<POU>/ one directory per POU
|
|
17
|
+
src.st1 <POU>T.TXT, <POU>V.VGR, <POU>V.VB, <POU>.STB|.GB
|
|
18
|
+
NodeProperties.xml worksheet GUIDs
|
|
19
|
+
<POU>.CCI POU descriptor
|
|
20
|
+
*.xml translation documents
|
|
21
|
+
|
|
22
|
+
Two layout generations appear in the sample data: ``C/Configuration/R/Resource``
|
|
23
|
+
and the older ``C/Resource/R/Resource``. Both are handled by searching for the
|
|
24
|
+
resource directory rather than assuming a fixed path.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from .cfb import CfbError, CompoundFile
|
|
34
|
+
from .errors import (
|
|
35
|
+
AmbiguousProject,
|
|
36
|
+
NotFound,
|
|
37
|
+
ProjectNotFound,
|
|
38
|
+
UnsupportedFormat,
|
|
39
|
+
)
|
|
40
|
+
from .variables import (
|
|
41
|
+
DeclarationTable,
|
|
42
|
+
Variable,
|
|
43
|
+
cross_check,
|
|
44
|
+
decode_declarations,
|
|
45
|
+
read_grid_variable_count,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: Extensions that mark a directory as an expanded MotionWorks project.
|
|
49
|
+
_ROOT_MARKERS = ("src.st1", "LIST.POU", "PROJECT.INF")
|
|
50
|
+
|
|
51
|
+
#: Default directory searched when a caller passes only a project name.
|
|
52
|
+
DEFAULT_SEARCH_ROOT = "Yaskawa"
|
|
53
|
+
|
|
54
|
+
_GLOBAL_STREAM_RE = re.compile(r"^GLOBAL_VARIABLES", re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PouInfo:
|
|
59
|
+
"""One POU directory."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
directory: Path
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def source_path(self) -> Path:
|
|
66
|
+
return self.directory / "src.st1"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def node_properties_path(self) -> Path:
|
|
70
|
+
return self.directory / "NodeProperties.xml"
|
|
71
|
+
|
|
72
|
+
def source(self) -> CompoundFile:
|
|
73
|
+
return CompoundFile(self.source_path)
|
|
74
|
+
|
|
75
|
+
# -- stream discovery -------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def stream_names(self) -> list[str]:
|
|
78
|
+
try:
|
|
79
|
+
return self.source().stream_names()
|
|
80
|
+
except CfbError:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
def body_stream(self) -> tuple[str, str] | None:
|
|
84
|
+
"""Return ``(stream_name, language)`` for the POU body.
|
|
85
|
+
|
|
86
|
+
Graphical streams share the .GB extension. Resolve LD/FBD from the
|
|
87
|
+
saved worksheet node; the extension alone cannot distinguish them.
|
|
88
|
+
"""
|
|
89
|
+
streams = self.stream_names()
|
|
90
|
+
il = [name for name in streams if name.upper().endswith('.AB')]
|
|
91
|
+
if il:
|
|
92
|
+
if len(il) != 1 or any(name.upper().endswith(('.STB', '.GB')) for name in streams):
|
|
93
|
+
raise ValueError('IL body stream is ambiguous')
|
|
94
|
+
from .tree import parse_document
|
|
95
|
+
root = self.directory.parent.parent
|
|
96
|
+
document = parse_document(CompoundFile(root/'src.st1').read_stream('PROJECT.TRE').decode('latin1'))
|
|
97
|
+
expected = ('POE/'+self.name+'/'+il[0]).casefold()
|
|
98
|
+
matches = [node for node,_ in document.walk_with_ancestors()
|
|
99
|
+
if node.path.split('\t')[0].replace('\\','/').casefold() == expected]
|
|
100
|
+
if document.warnings or len(matches) != 1 or document.lines[matches[0].start_line].strip() != '9':
|
|
101
|
+
raise ValueError('IL worksheet tree identity absent or ambiguous')
|
|
102
|
+
return il[0], 'IL'
|
|
103
|
+
for name in streams:
|
|
104
|
+
if name.upper().endswith(".STB"):
|
|
105
|
+
return name, "ST"
|
|
106
|
+
for name in streams:
|
|
107
|
+
if name.upper().endswith(".GB"):
|
|
108
|
+
from .tree import parse_document
|
|
109
|
+
root = self.directory.parent.parent
|
|
110
|
+
document = parse_document(CompoundFile(root / 'src.st1').read_stream('PROJECT.TRE').decode('latin1'))
|
|
111
|
+
expected = ('POE/' + self.name + '/' + name).casefold()
|
|
112
|
+
matches = [node for node, _ in document.walk_with_ancestors()
|
|
113
|
+
if node.path.split('\t')[0].replace('\\', '/').casefold() == expected]
|
|
114
|
+
if document.warnings or len(matches) != 1:
|
|
115
|
+
raise ValueError('Graphical worksheet tree identity absent or ambiguous')
|
|
116
|
+
# The leading record kind is 11=LD / 12=FBD. node_id is
|
|
117
|
+
# the separate, project-specific handle in the params line.
|
|
118
|
+
record_kind = document.lines[matches[0].start_line].strip()
|
|
119
|
+
return name, {'11': 'LD', '12': 'FBD'}.get(record_kind, 'GRAPHICAL')
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def language(self) -> str | None:
|
|
123
|
+
found = self.body_stream()
|
|
124
|
+
return found[1] if found else None
|
|
125
|
+
|
|
126
|
+
def declaration_stream_name(self) -> str | None:
|
|
127
|
+
for name in self.stream_names():
|
|
128
|
+
if name.upper().endswith("V.VB"):
|
|
129
|
+
return name
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def grid_stream_name(self) -> str | None:
|
|
133
|
+
for name in self.stream_names():
|
|
134
|
+
if name.upper().endswith("V.VGR"):
|
|
135
|
+
return name
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def comment_stream_name(self) -> str | None:
|
|
139
|
+
for name in self.stream_names():
|
|
140
|
+
if name.upper().endswith("T.TXT"):
|
|
141
|
+
return name
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# -- content ----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def declarations(self) -> DeclarationTable:
|
|
147
|
+
cfb = self.source()
|
|
148
|
+
stream_name = self.declaration_stream_name()
|
|
149
|
+
if stream_name is None:
|
|
150
|
+
return DeclarationTable(
|
|
151
|
+
warnings=["no <POU>V.VB declaration stream present"],
|
|
152
|
+
source_stream=None,
|
|
153
|
+
)
|
|
154
|
+
table = decode_declarations(cfb.read_stream(stream_name), stream_name)
|
|
155
|
+
grid_name = self.grid_stream_name()
|
|
156
|
+
if grid_name:
|
|
157
|
+
table.warnings.extend(
|
|
158
|
+
cross_check(table, read_grid_variable_count(cfb.read_stream(grid_name)))
|
|
159
|
+
)
|
|
160
|
+
return table
|
|
161
|
+
|
|
162
|
+
def st_body(self) -> str | None:
|
|
163
|
+
"""Return the Structured Text body, or None for a graphical POU."""
|
|
164
|
+
found = self.body_stream()
|
|
165
|
+
if not found or found[1] != "ST":
|
|
166
|
+
return None
|
|
167
|
+
return self.source().read_stream(found[0]).decode("latin1")
|
|
168
|
+
|
|
169
|
+
def st_body_text(self) -> str | None:
|
|
170
|
+
"""Return readable ST, restoring comments from native translations."""
|
|
171
|
+
return self.text_body_text() if self.language() == 'ST' else None
|
|
172
|
+
|
|
173
|
+
def text_body(self) -> str | None:
|
|
174
|
+
"""Read supported ST/IL source without interpreting graphical bytes."""
|
|
175
|
+
found = self.body_stream()
|
|
176
|
+
if not found or found[1] not in ('ST', 'IL'):
|
|
177
|
+
return None
|
|
178
|
+
body = self.source().read_stream(found[0]).decode('latin1')
|
|
179
|
+
if '\0' in body:
|
|
180
|
+
raise ValueError('Text worksheet contains unsupported binary data')
|
|
181
|
+
return body
|
|
182
|
+
|
|
183
|
+
def text_body_text(self) -> str | None:
|
|
184
|
+
"""Return readable ST/IL with exact native comment translations."""
|
|
185
|
+
body = self.text_body()
|
|
186
|
+
if body is None or '\x07' not in body:
|
|
187
|
+
return body
|
|
188
|
+
from .st_comments import resolve_comments
|
|
189
|
+
worksheet = self.body_stream()[0].rsplit('.', 1)[0]
|
|
190
|
+
matches = [p for p in self.directory.iterdir()
|
|
191
|
+
if p.name.casefold() == (worksheet + 'Translation.xml').casefold()]
|
|
192
|
+
if len(matches) != 1:
|
|
193
|
+
raise ValueError('Native ST comment translation is absent or ambiguous')
|
|
194
|
+
return resolve_comments(body, matches[0])
|
|
195
|
+
|
|
196
|
+
def summary(self) -> dict[str, object]:
|
|
197
|
+
try:
|
|
198
|
+
stream_names = self.stream_names()
|
|
199
|
+
except CfbError as exc:
|
|
200
|
+
return {"name": self.name, "error": str(exc)}
|
|
201
|
+
body = self.body_stream()
|
|
202
|
+
return {
|
|
203
|
+
"name": self.name,
|
|
204
|
+
"language": body[1] if body else None,
|
|
205
|
+
"body_stream": body[0] if body else None,
|
|
206
|
+
"streams": len(stream_names),
|
|
207
|
+
"directory": str(self.directory),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class Project:
|
|
213
|
+
"""An expanded MotionWorks project directory."""
|
|
214
|
+
|
|
215
|
+
root: Path
|
|
216
|
+
_pous: list[PouInfo] | None = field(default=None, repr=False)
|
|
217
|
+
|
|
218
|
+
def __post_init__(self) -> None:
|
|
219
|
+
# Accept str for convenience: every path operation below assumes a Path,
|
|
220
|
+
# and a raw string would fail late and confusingly.
|
|
221
|
+
if not isinstance(self.root, Path):
|
|
222
|
+
self.root = Path(self.root)
|
|
223
|
+
|
|
224
|
+
# -- identity ---------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def name(self) -> str:
|
|
228
|
+
return self.root.name
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def mwt_path(self) -> Path | None:
|
|
232
|
+
candidate = self.root.with_suffix(".mwt")
|
|
233
|
+
return candidate if candidate.is_file() else None
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def node_list_path(self) -> Path | None:
|
|
237
|
+
for candidate in (self.root / "NODES.LST",):
|
|
238
|
+
if candidate.is_file():
|
|
239
|
+
return candidate
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
# -- raw file reads (ANSI/latin1, as MotionWorks writes them) ---------
|
|
243
|
+
|
|
244
|
+
def _read_text(self, name: str) -> str | None:
|
|
245
|
+
path = self.root / name
|
|
246
|
+
if not path.is_file():
|
|
247
|
+
return None
|
|
248
|
+
return path.read_bytes().decode("latin1")
|
|
249
|
+
|
|
250
|
+
def list_pou_lines(self) -> list[str]:
|
|
251
|
+
text = self._read_text("LIST.POU")
|
|
252
|
+
return [line for line in (text or "").splitlines() if line.strip()]
|
|
253
|
+
|
|
254
|
+
def library_entries(self) -> list[str]:
|
|
255
|
+
text = self._read_text("@LIBRARY.LST")
|
|
256
|
+
if not text:
|
|
257
|
+
return []
|
|
258
|
+
# First line is a version banner ("Library List, V40").
|
|
259
|
+
return [line for line in text.splitlines()[1:] if line.strip()]
|
|
260
|
+
|
|
261
|
+
def node_lines(self) -> list[str]:
|
|
262
|
+
text = self._read_text("NODES.LST")
|
|
263
|
+
return [line for line in (text or "").splitlines() if line.strip()]
|
|
264
|
+
|
|
265
|
+
def last_change(self) -> str | None:
|
|
266
|
+
text = self._read_text("PROJECT.INF")
|
|
267
|
+
if not text:
|
|
268
|
+
return None
|
|
269
|
+
for line in text.splitlines():
|
|
270
|
+
if line.lower().startswith("lastchange"):
|
|
271
|
+
return line.split("=", 1)[-1].strip()
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def project_tree_streams(self) -> dict[str, int]:
|
|
275
|
+
path = self.root / "src.st1"
|
|
276
|
+
if not path.is_file():
|
|
277
|
+
return {}
|
|
278
|
+
return CompoundFile(path).summary()
|
|
279
|
+
|
|
280
|
+
# -- POUs -------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def pou_directory(self) -> Path:
|
|
284
|
+
return self.root / "POE"
|
|
285
|
+
|
|
286
|
+
def pous(self) -> list[PouInfo]:
|
|
287
|
+
if self._pous is None:
|
|
288
|
+
base = self.pou_directory
|
|
289
|
+
if not base.is_dir():
|
|
290
|
+
self._pous = []
|
|
291
|
+
else:
|
|
292
|
+
self._pous = [
|
|
293
|
+
PouInfo(name=child.name, directory=child)
|
|
294
|
+
for child in sorted(base.iterdir())
|
|
295
|
+
if child.is_dir() and (child / "src.st1").is_file()
|
|
296
|
+
]
|
|
297
|
+
return self._pous
|
|
298
|
+
|
|
299
|
+
def pou(self, name: str) -> PouInfo:
|
|
300
|
+
target = name.casefold()
|
|
301
|
+
for pou in self.pous():
|
|
302
|
+
if pou.name.casefold() == target:
|
|
303
|
+
return pou
|
|
304
|
+
available = ", ".join(p.name for p in self.pous()) or "<none>"
|
|
305
|
+
raise NotFound(f"No POU named {name!r} in {self.name}. Available: {available}")
|
|
306
|
+
|
|
307
|
+
# -- tasks ------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def task_assignments(self) -> dict[str, list[str]]:
|
|
310
|
+
"""Map task name -> assigned program names.
|
|
311
|
+
|
|
312
|
+
Read from the project tree, which is authoritative. ``NODES.LST`` is the
|
|
313
|
+
fallback, and is genuinely worse: ``RotaryKnife_ASP_v350`` has no
|
|
314
|
+
``NODES.LST`` at all, so a list-only reader reports no tasks for a project
|
|
315
|
+
that has four.
|
|
316
|
+
|
|
317
|
+
In the tree a task node's path points at the resource task directory and
|
|
318
|
+
its final component equals its name (``...\\Resource\\FastTsk`` for the
|
|
319
|
+
node ``FastTsk``); matching the directory alone also catches
|
|
320
|
+
``Global_Variables`` and ``IO_Configuration``, whose paths live there but
|
|
321
|
+
carry a file extension.
|
|
322
|
+
"""
|
|
323
|
+
from_tree = self._task_assignments_from_tree()
|
|
324
|
+
if from_tree:
|
|
325
|
+
return from_tree
|
|
326
|
+
return self._task_assignments_from_node_list()
|
|
327
|
+
|
|
328
|
+
def _task_assignments_from_tree(self) -> dict[str, list[str]]:
|
|
329
|
+
from .tree import TreeParseError, parse_tree, task_assignments
|
|
330
|
+
|
|
331
|
+
path = self.root / "src.st1"
|
|
332
|
+
if not path.is_file():
|
|
333
|
+
return {}
|
|
334
|
+
try:
|
|
335
|
+
text = CompoundFile(path).read_stream("PROJECT.TRE").decode("latin1")
|
|
336
|
+
roots, _ = parse_tree(text)
|
|
337
|
+
except (OSError, ValueError, TreeParseError):
|
|
338
|
+
return {}
|
|
339
|
+
return task_assignments(roots)
|
|
340
|
+
|
|
341
|
+
def _task_assignments_from_node_list(self) -> dict[str, list[str]]:
|
|
342
|
+
"""Read assignments from ``NODES.LST`` files.
|
|
343
|
+
|
|
344
|
+
Loose lines look like::
|
|
345
|
+
|
|
346
|
+
TASK 5 MedTsk eCLR MP2600iec
|
|
347
|
+
PROGRAM 6 ServoHoming ServoHoming eCLR MP2600iec
|
|
348
|
+
|
|
349
|
+
Both the project root and the resource directory are checked, because
|
|
350
|
+
projects differ in where they keep the file. Where both exist they
|
|
351
|
+
describe the same instances, so programs are de-duplicated in order --
|
|
352
|
+
without that, every program is reported twice.
|
|
353
|
+
"""
|
|
354
|
+
assignments: dict[str, list[str]] = {}
|
|
355
|
+
sources = [self.node_list_path]
|
|
356
|
+
for extra in (
|
|
357
|
+
self.root / "C/Configuration/R/Resource/NODES.LST",
|
|
358
|
+
self.root / "C/Resource/R/Resource/NODES.LST",
|
|
359
|
+
):
|
|
360
|
+
sources.append(extra)
|
|
361
|
+
|
|
362
|
+
for source in sources:
|
|
363
|
+
if source is None or not source.is_file():
|
|
364
|
+
continue
|
|
365
|
+
try:
|
|
366
|
+
lines = source.read_bytes().decode("latin1").splitlines()
|
|
367
|
+
except OSError:
|
|
368
|
+
continue
|
|
369
|
+
current_task: str | None = None
|
|
370
|
+
for line in lines:
|
|
371
|
+
if not line.strip():
|
|
372
|
+
continue
|
|
373
|
+
parts = line.split("\t")
|
|
374
|
+
kind = parts[0].strip().upper()
|
|
375
|
+
if kind == "TASK" and len(parts) >= 3:
|
|
376
|
+
current_task = parts[2].strip()
|
|
377
|
+
assignments.setdefault(current_task, [])
|
|
378
|
+
elif kind == "PROGRAM" and len(parts) >= 3 and current_task:
|
|
379
|
+
program = parts[2].strip()
|
|
380
|
+
seen = assignments[current_task]
|
|
381
|
+
if program not in seen:
|
|
382
|
+
seen.append(program)
|
|
383
|
+
return assignments
|
|
384
|
+
|
|
385
|
+
# -- variables --------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def global_variables(self) -> DeclarationTable:
|
|
388
|
+
"""Read ``VAR_GLOBAL`` declarations from the resource source.
|
|
389
|
+
|
|
390
|
+
If the resource stores only the binary ``.VGR`` grid (as the newer
|
|
391
|
+
TopCutter and RotaryKnife projects do), the textual declarations are
|
|
392
|
+
absent and that is reported rather than invented.
|
|
393
|
+
"""
|
|
394
|
+
resource = self.resource_source()
|
|
395
|
+
if resource is None:
|
|
396
|
+
return DeclarationTable(warnings=["no resource src.st1 found"])
|
|
397
|
+
|
|
398
|
+
cfb = CompoundFile(resource)
|
|
399
|
+
names = cfb.stream_names()
|
|
400
|
+
# The resource-level stream is named "Global_Variables.VB", which does
|
|
401
|
+
# not end in "V.VB" (that pattern is for "<POU>V.VB"), so match on the
|
|
402
|
+
# plain extension here.
|
|
403
|
+
decl_names = [n for n in names if n.upper().endswith(".VB")]
|
|
404
|
+
grid_names = [n for n in names if n.upper().endswith(".VGR")]
|
|
405
|
+
|
|
406
|
+
if not decl_names:
|
|
407
|
+
note = "resource src.st1 has no textual declaration stream"
|
|
408
|
+
if grid_names:
|
|
409
|
+
count = read_grid_variable_count(cfb.read_stream(grid_names[0]))
|
|
410
|
+
note += (
|
|
411
|
+
f"; the binary grid {grid_names[0]!r} records {count} variables, "
|
|
412
|
+
f"but their names and types are not stored as text"
|
|
413
|
+
)
|
|
414
|
+
return DeclarationTable(warnings=[note], source_stream=None)
|
|
415
|
+
|
|
416
|
+
table = decode_declarations(cfb.read_stream(decl_names[0]), decl_names[0])
|
|
417
|
+
if grid_names:
|
|
418
|
+
table.warnings.extend(
|
|
419
|
+
cross_check(table, read_grid_variable_count(cfb.read_stream(grid_names[0])))
|
|
420
|
+
)
|
|
421
|
+
return table
|
|
422
|
+
|
|
423
|
+
def resource_source(self) -> Path | None:
|
|
424
|
+
"""Locate the resource ``src.st1`` that holds ``VAR_GLOBAL`` data.
|
|
425
|
+
|
|
426
|
+
Handles both observed layouts without hardcoding either one.
|
|
427
|
+
"""
|
|
428
|
+
for pattern in (
|
|
429
|
+
"C/Configuration/R/Resource/src.st1",
|
|
430
|
+
"C/Resource/R/Resource/src.st1",
|
|
431
|
+
):
|
|
432
|
+
candidate = self.root / pattern
|
|
433
|
+
if candidate.is_file():
|
|
434
|
+
return candidate
|
|
435
|
+
# Fall back to a search, preferring a directory named Resource.
|
|
436
|
+
matches = [
|
|
437
|
+
p for p in self.root.glob("C/**/src.st1")
|
|
438
|
+
if p.parent.name.lower() == "resource"
|
|
439
|
+
]
|
|
440
|
+
return matches[0] if matches else None
|
|
441
|
+
|
|
442
|
+
# -- reporting --------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def inventory(self) -> dict[str, object]:
|
|
445
|
+
pous = self.pous()
|
|
446
|
+
languages: dict[str, int] = {}
|
|
447
|
+
for pou in pous:
|
|
448
|
+
languages[pou.language() or "?"] = languages.get(pou.language() or "?", 0) + 1
|
|
449
|
+
return {
|
|
450
|
+
"name": self.name,
|
|
451
|
+
"root": str(self.root),
|
|
452
|
+
"mwt": str(self.mwt_path) if self.mwt_path else None,
|
|
453
|
+
"last_change": self.last_change(),
|
|
454
|
+
"pou_count": len(pous),
|
|
455
|
+
"languages": languages,
|
|
456
|
+
"tasks": self.task_assignments(),
|
|
457
|
+
"libraries": self.library_entries(),
|
|
458
|
+
"tree_streams": self.project_tree_streams(),
|
|
459
|
+
"resource_source": str(self.resource_source()) if self.resource_source() else None,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# Discovery
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def looks_like_project(directory: Path) -> bool:
|
|
469
|
+
"""True when ``directory`` holds an expanded MotionWorks project."""
|
|
470
|
+
if not directory.is_dir():
|
|
471
|
+
return False
|
|
472
|
+
if (directory / "POE").is_dir():
|
|
473
|
+
return True
|
|
474
|
+
return any((directory / marker).is_file() for marker in _ROOT_MARKERS)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def discover(search_root: str | Path) -> list[Path]:
|
|
478
|
+
"""Return every expanded project directory under ``search_root``.
|
|
479
|
+
|
|
480
|
+
A directory qualifies when it contains a ``POE`` subdirectory (this is the
|
|
481
|
+
reliable marker: every sample project has one, including ones missing some
|
|
482
|
+
root metadata files).
|
|
483
|
+
"""
|
|
484
|
+
base = Path(search_root)
|
|
485
|
+
if not base.is_absolute():
|
|
486
|
+
base = Path.cwd() / base
|
|
487
|
+
if not base.is_dir():
|
|
488
|
+
return []
|
|
489
|
+
found = {p.parent for p in base.rglob("POE") if p.is_dir()}
|
|
490
|
+
return sorted(found)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _match_by_name(base: Path, wanted: str) -> list[Path]:
|
|
494
|
+
"""Match projects by name, supporting a disambiguating path suffix.
|
|
495
|
+
|
|
496
|
+
Two of the sample projects are both called ``TopCutter`` (one at the top
|
|
497
|
+
level, one under ``MP2600iec Program``), so a bare name is genuinely
|
|
498
|
+
ambiguous. Callers can qualify it::
|
|
499
|
+
|
|
500
|
+
TopCutter -> matches both
|
|
501
|
+
MP2600iec Program/TopCutter -> matches one
|
|
502
|
+
Yaskawa/TopCutter -> matches one
|
|
503
|
+
|
|
504
|
+
A supplied multi-segment query must match the project's trailing path
|
|
505
|
+
components, which is unambiguous without needing an absolute path.
|
|
506
|
+
"""
|
|
507
|
+
parts = [
|
|
508
|
+
p.casefold()
|
|
509
|
+
for p in re.split(r"[\\/]+", wanted.strip())
|
|
510
|
+
if p and p not in (".", "..")
|
|
511
|
+
]
|
|
512
|
+
if not parts:
|
|
513
|
+
return []
|
|
514
|
+
matches: list[Path] = []
|
|
515
|
+
for candidate in discover(base):
|
|
516
|
+
tail = [p.casefold() for p in candidate.parts[-len(parts):]]
|
|
517
|
+
if tail == parts:
|
|
518
|
+
matches.append(candidate)
|
|
519
|
+
return matches
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def resolve(
|
|
523
|
+
project: str | Path,
|
|
524
|
+
search_root: str | Path | None = None,
|
|
525
|
+
) -> Project:
|
|
526
|
+
"""Resolve a project by path, by ``.mwt`` file, or by bare/qualified name.
|
|
527
|
+
|
|
528
|
+
Accepts an expanded directory, an ``.mwt`` file, or a name that is searched
|
|
529
|
+
for under ``search_root`` (default :data:`DEFAULT_SEARCH_ROOT`).
|
|
530
|
+
"""
|
|
531
|
+
candidate = Path(project)
|
|
532
|
+
if candidate.exists():
|
|
533
|
+
resolved = candidate.resolve()
|
|
534
|
+
if resolved.is_file():
|
|
535
|
+
if resolved.suffix.lower() == ".mwt":
|
|
536
|
+
expanded = resolved.with_suffix("")
|
|
537
|
+
if expanded.is_dir():
|
|
538
|
+
return Project(root=expanded)
|
|
539
|
+
raise ProjectNotFound(
|
|
540
|
+
f"{resolved.name} has no expanded project directory beside it "
|
|
541
|
+
f"({expanded}). Open the project in MotionWorks once to expand it."
|
|
542
|
+
)
|
|
543
|
+
raise ProjectNotFound(
|
|
544
|
+
f"{resolved} is a file; expected an expanded project directory or .mwt"
|
|
545
|
+
)
|
|
546
|
+
if looks_like_project(resolved):
|
|
547
|
+
return Project(root=resolved)
|
|
548
|
+
raise ProjectNotFound(
|
|
549
|
+
f"{resolved} does not look like an expanded MotionWorks project "
|
|
550
|
+
f"(no POE directory or project markers)"
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# Treat the argument as a name (possibly path-qualified) and search. Try the
|
|
554
|
+
# whole query first: a query containing a separator is itself a qualified
|
|
555
|
+
# suffix such as "MP2600iec Program/TopCutter", and matching on just the last
|
|
556
|
+
# component would wrongly discard the qualifier.
|
|
557
|
+
base = Path(search_root) if search_root else Path(DEFAULT_SEARCH_ROOT)
|
|
558
|
+
if not base.is_absolute():
|
|
559
|
+
base = Path.cwd() / base
|
|
560
|
+
raw = str(project)
|
|
561
|
+
matches = _match_by_name(base, raw)
|
|
562
|
+
if not matches and candidate.name and candidate.name != raw:
|
|
563
|
+
matches = _match_by_name(base, candidate.name)
|
|
564
|
+
if len(matches) == 1:
|
|
565
|
+
return Project(root=matches[0])
|
|
566
|
+
if len(matches) > 1:
|
|
567
|
+
listing = "\n ".join(str(m) for m in matches)
|
|
568
|
+
raise AmbiguousProject(
|
|
569
|
+
f"{project!r} matches {len(matches)} projects; qualify it with a "
|
|
570
|
+
f"parent directory or pass a full path:\n {listing}"
|
|
571
|
+
)
|
|
572
|
+
available = discover(base)
|
|
573
|
+
listing = "\n ".join(str(p) for p in available) or "<none found>"
|
|
574
|
+
raise ProjectNotFound(
|
|
575
|
+
f"No project named {project!r} under {base}. Available projects:\n {listing}"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def describe_all(search_root: str | Path | None = None) -> list[dict[str, object]]:
|
|
580
|
+
"""Inventory every discovered project, for the list tool."""
|
|
581
|
+
base = Path(search_root) if search_root else Path(DEFAULT_SEARCH_ROOT)
|
|
582
|
+
if not base.is_absolute():
|
|
583
|
+
base = Path.cwd() / base
|
|
584
|
+
out: list[dict[str, object]] = []
|
|
585
|
+
for path in discover(base):
|
|
586
|
+
project = Project(root=path)
|
|
587
|
+
out.append(
|
|
588
|
+
{
|
|
589
|
+
"name": project.name,
|
|
590
|
+
"root": str(path),
|
|
591
|
+
"pou_count": len(project.pous()),
|
|
592
|
+
"languages": _language_counts(project),
|
|
593
|
+
"last_change": project.last_change(),
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
return out
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _language_counts(project: Project) -> dict[str, int]:
|
|
600
|
+
counts: dict[str, int] = {}
|
|
601
|
+
for pou in project.pous():
|
|
602
|
+
key = pou.language() or "?"
|
|
603
|
+
counts[key] = counts.get(key, 0) + 1
|
|
604
|
+
return counts
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def library_usage(project: Project) -> dict[str, int]:
|
|
608
|
+
"""Count how many POUs declare each function-block type.
|
|
609
|
+
|
|
610
|
+
Useful context for an agent: it shows which library blocks the project
|
|
611
|
+
actually depends on, and therefore which donor POUs are good examples.
|
|
612
|
+
"""
|
|
613
|
+
counts: dict[str, int] = {}
|
|
614
|
+
for pou in project.pous():
|
|
615
|
+
try:
|
|
616
|
+
table = pou.declarations()
|
|
617
|
+
except UnsupportedFormat:
|
|
618
|
+
continue
|
|
619
|
+
for variable in table.variables:
|
|
620
|
+
if variable.is_fb_instance:
|
|
621
|
+
counts[variable.type_name] = counts.get(variable.type_name, 0) + 1
|
|
622
|
+
return dict(sorted(counts.items(), key=lambda kv: -kv[1]))
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def symbol_index(project: Project) -> dict[str, list[str]]:
|
|
626
|
+
"""Map every declared symbol name to the POU(s) that declare it.
|
|
627
|
+
|
|
628
|
+
This is the cross-reference an agent needs before editing code, so it can
|
|
629
|
+
avoid inventing symbol names that do not exist.
|
|
630
|
+
"""
|
|
631
|
+
index: dict[str, list[str]] = {}
|
|
632
|
+
|
|
633
|
+
def record(name: str, where: str) -> None:
|
|
634
|
+
index.setdefault(name, [])
|
|
635
|
+
if where not in index[name]:
|
|
636
|
+
index[name].append(where)
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
for variable in project.global_variables().variables:
|
|
640
|
+
record(variable.name, "<global>")
|
|
641
|
+
except UnsupportedFormat:
|
|
642
|
+
pass
|
|
643
|
+
|
|
644
|
+
for pou in project.pous():
|
|
645
|
+
try:
|
|
646
|
+
table = pou.declarations()
|
|
647
|
+
except UnsupportedFormat:
|
|
648
|
+
continue
|
|
649
|
+
for variable in table.variables:
|
|
650
|
+
record(variable.name, pou.name)
|
|
651
|
+
return index
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def known_symbol_names(project: Project) -> set[str]:
|
|
655
|
+
"""Every name that could legitimately appear in a POU body.
|
|
656
|
+
|
|
657
|
+
This is the allowance list for the ST linter: declared symbols from the
|
|
658
|
+
whole project plus every type name in use. It is intentionally generous.
|
|
659
|
+
Flagging a name that exists elsewhere would be a false positive, and a
|
|
660
|
+
linter that cries wolf gets ignored; a name that is genuinely undefined will
|
|
661
|
+
still be caught, because it appears nowhere at all.
|
|
662
|
+
"""
|
|
663
|
+
names: set[str] = set(symbol_index(project).keys())
|
|
664
|
+
try:
|
|
665
|
+
names |= {v.type_name for v in project.global_variables().variables}
|
|
666
|
+
except UnsupportedFormat:
|
|
667
|
+
pass
|
|
668
|
+
for pou in project.pous():
|
|
669
|
+
try:
|
|
670
|
+
names |= {v.type_name for v in pou.declarations().variables}
|
|
671
|
+
except UnsupportedFormat:
|
|
672
|
+
continue
|
|
673
|
+
return names
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def global_names(project: Project) -> set[str]:
|
|
677
|
+
"""The names of the project's VAR_GLOBAL declarations.
|
|
678
|
+
|
|
679
|
+
Separate from ``known_symbol_names``, which is deliberately generous, because a
|
|
680
|
+
global is reachable from a POU ONLY if that POU declares it as VAR_EXTERNAL. The
|
|
681
|
+
linter needs to tell the two apart: a name that resolves nowhere is a spelling
|
|
682
|
+
mistake, while a global this POU has not imported is a build that STALLS with an
|
|
683
|
+
empty Errors pane. Measured with PLCMODE_ON, a system global the project has had
|
|
684
|
+
from the first byte.
|
|
685
|
+
"""
|
|
686
|
+
try:
|
|
687
|
+
return {v.name for v in project.global_variables().variables}
|
|
688
|
+
except UnsupportedFormat:
|
|
689
|
+
return set()
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def find_symbol(project: Project, symbol: str) -> list[Variable]:
|
|
693
|
+
"""Return every declaration of ``symbol`` anywhere in the project."""
|
|
694
|
+
found: list[Variable] = []
|
|
695
|
+
try:
|
|
696
|
+
found.extend(project.global_variables().variants(symbol))
|
|
697
|
+
except UnsupportedFormat:
|
|
698
|
+
pass
|
|
699
|
+
for pou in project.pous():
|
|
700
|
+
try:
|
|
701
|
+
found.extend(pou.declarations().variants(symbol))
|
|
702
|
+
except UnsupportedFormat:
|
|
703
|
+
continue
|
|
704
|
+
return found
|