create_folder SP21+ fallback, ensure_project_open cross-project switch)
+ bench results
compile_project.py + get_compile_messages.py:
- IronPython 2.7's json.dumps cannot serialize System.Int64-backed `long`
values, which is what CODESYS's compile-message objects expose as
line_number / position. Added _coerce_int + _coerce_str helpers and a
shared _build_message_entry function. Three duplicated message-building
blocks collapsed into single helper calls.
- Defensive `try: json.dumps(...) except TypeError: json.dumps(default=str)`
so a stray field that slips past the helpers doesn't kill the emit.
connect_to_device.py:
- SP21+ may expose the login enum as LoginMode rather than
OnlineChangeOption. Extended the candidate sweep to probe both
script_engine.LoginMode and script_engine.OnlineChangeOption AND
online_app.LoginMode/OnlineChangeOption (some builds attach it to the
app object). Added "OnlineChange" + "Login" + "Download" to the
preferred-priority list. Added a 3-arg call shape variant for SPs
that take (mode, secondary-mode, force-bool).
create_folder.py:
- parent_object.create_folder() is no longer exposed on every parent type
in SP21+. Added two fallback factories tried in order:
1. parent.create_object(typeUuid='85d1215e-6520-4983-9a55-2d39d1f24cb4', name=...)
2. parent.add(script_engine.types.IecFolder, name=...)
with detailed warnings when each path fails. Final TypeError now lists
every factory tried so a future SP rotation surfaces clearly.
ensure_project_open.py:
- Uncommented the close-prior-project branch (was a TODO since the
initial fork). Cross-project switches in a persistent CODESYS now do
save() -> close() -> 500ms pump -> open(target). Without this,
projects.open against a different already-primary project fails
intermittently on file lock contention or pops a "project in use"
modal that freezes the IDE thread.
- save() is best-effort: if it raises (transient lock, save-as required)
we still proceed with close + open rather than getting stuck in a
half-switched state forever.
tests/bench-results.json:
- Captured timings from a clean run on MCPTest2 (PLCWinNT, 5 lib refs,
~12 POUs). 9 tools x 2 modes x iterations.
Headers (mean ms): persistent vs headless --
open_project 7700 vs 40041 (~5x; first call cold)
mirror_export 1547 vs 23723 (~15x)
list_project_libraries 1565 vs 23322 (~15x)
get_all_pou_code 1607 vs 23376 (~15x)
save_project 2095 vs 23321 (~11x)
create_pou (FB) 1540 vs 23903 (~16x)
delete_object 1544 vs 27420 (~18x)
bump_project_version 1540 vs 30678 (~20x)
bump_project_version #2 1556 vs 37769 (~24x)
- set_pou_code FAILED in both modes -- bench harness param-shape issue
(multi-line code passed verbatim to triple-quoted-string interpolation
doesn't survive the round-trip). Tool itself works fine through the
MCP tool call path; bench needs to escape newlines / use the same
prepareScriptWithHelpers shape the server uses. Filed for follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
5.6 KiB
Python
154 lines
5.6 KiB
Python
import sys, scriptengine as script_engine, os, traceback, json
|
|
|
|
|
|
def _coerce_int(v):
|
|
"""IronPython 2.7's json.dumps cannot serialize System.Int64-backed
|
|
`long` values, which is what CODESYS's compile-message objects expose
|
|
as line_number / position. Coerce to native int."""
|
|
if v is None:
|
|
return None
|
|
try:
|
|
return int(v)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return None
|
|
|
|
|
|
def _coerce_str(v):
|
|
"""Force str() on CLR-typed fields (System.Uri etc.)."""
|
|
if v is None:
|
|
return None
|
|
try:
|
|
return str(v)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _build_message_entry(msg):
|
|
entry = {}
|
|
if hasattr(msg, 'severity'):
|
|
try:
|
|
sev = str(msg.severity).lower()
|
|
except Exception:
|
|
sev = 'unknown'
|
|
if 'error' in sev:
|
|
entry['severity'] = 'error'
|
|
elif 'warning' in sev:
|
|
entry['severity'] = 'warning'
|
|
elif 'info' in sev:
|
|
entry['severity'] = 'info'
|
|
else:
|
|
entry['severity'] = sev
|
|
else:
|
|
entry['severity'] = 'unknown'
|
|
text = None
|
|
for attr in ('text', 'message'):
|
|
if hasattr(msg, attr):
|
|
text = _coerce_str(getattr(msg, attr))
|
|
if text is not None:
|
|
break
|
|
if text is None:
|
|
text = _coerce_str(msg)
|
|
entry['text'] = text
|
|
if hasattr(msg, 'object_name'):
|
|
entry['object'] = _coerce_str(msg.object_name)
|
|
elif hasattr(msg, 'source'):
|
|
entry['object'] = _coerce_str(msg.source)
|
|
if hasattr(msg, 'line_number'):
|
|
entry['line'] = _coerce_int(msg.line_number)
|
|
elif hasattr(msg, 'position'):
|
|
entry['line'] = _coerce_int(msg.position)
|
|
return entry
|
|
|
|
|
|
try:
|
|
print("DEBUG: get_compile_messages script: Project='%s'" % PROJECT_FILE_PATH)
|
|
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
|
project_name = os.path.basename(PROJECT_FILE_PATH)
|
|
target_app = None
|
|
app_name = "N/A"
|
|
|
|
# Try getting active application first
|
|
try:
|
|
target_app = primary_project.active_application
|
|
if target_app:
|
|
app_name = getattr(target_app, 'get_name', lambda: "Unnamed App")()
|
|
except Exception as active_err:
|
|
print("WARN: Could not get active application: %s" % active_err)
|
|
|
|
# If no active app, search for the first one
|
|
if not target_app:
|
|
try:
|
|
all_children = primary_project.get_children(True)
|
|
for child in all_children:
|
|
if hasattr(child, 'is_application') and child.is_application:
|
|
target_app = child
|
|
app_name = getattr(child, 'get_name', lambda: "Unnamed App")()
|
|
break
|
|
except Exception as find_err:
|
|
print("WARN: Error finding application object: %s" % find_err)
|
|
|
|
if not target_app:
|
|
raise RuntimeError("No application found in project '%s'" % project_name)
|
|
|
|
# Extract compiler messages using multiple API patterns
|
|
messages = []
|
|
messages_found = False
|
|
|
|
# Pattern 1: target_app.get_message_objects()
|
|
if hasattr(target_app, 'get_message_objects'):
|
|
try:
|
|
msg_objects = target_app.get_message_objects()
|
|
if msg_objects is not None:
|
|
messages_found = True
|
|
for msg in msg_objects:
|
|
messages.append(_build_message_entry(msg))
|
|
print("DEBUG: Got %d messages from app.get_message_objects()" % len(messages))
|
|
except Exception as e:
|
|
print("DEBUG: app.get_message_objects() failed: %s" % e)
|
|
|
|
# Pattern 2: script_engine.system.get_message_objects()
|
|
if not messages_found and hasattr(script_engine, 'system'):
|
|
se_sys = script_engine.system
|
|
if hasattr(se_sys, 'get_message_objects'):
|
|
try:
|
|
msg_objects = se_sys.get_message_objects()
|
|
if msg_objects is not None:
|
|
messages_found = True
|
|
for msg in msg_objects:
|
|
messages.append(_build_message_entry(msg))
|
|
print("DEBUG: Got %d messages from system.get_message_objects()" % len(messages))
|
|
except Exception as e:
|
|
print("DEBUG: system.get_message_objects() failed: %s" % e)
|
|
|
|
# Pattern 3: script_engine.system.get_messages() (older API)
|
|
if not messages_found and hasattr(script_engine, 'system'):
|
|
se_sys = script_engine.system
|
|
if hasattr(se_sys, 'get_messages'):
|
|
try:
|
|
msg_objects = se_sys.get_messages()
|
|
if msg_objects is not None:
|
|
messages_found = True
|
|
for msg in msg_objects:
|
|
messages.append(_build_message_entry(msg))
|
|
print("DEBUG: Got %d messages from system.get_messages()" % len(messages))
|
|
except Exception as e:
|
|
print("DEBUG: system.get_messages() failed: %s" % e)
|
|
|
|
try:
|
|
messages_json = json.dumps(messages)
|
|
except TypeError as je:
|
|
print("WARN: json.dumps raised %s -- retrying with default=str fallback" % je)
|
|
messages_json = json.dumps(messages, default=lambda o: str(o))
|
|
print("### COMPILE_MESSAGES_START ###")
|
|
print(messages_json)
|
|
print("### COMPILE_MESSAGES_END ###")
|
|
print("Messages Found: %s" % messages_found)
|
|
print("Message Count: %d" % len(messages))
|
|
print("SCRIPT_SUCCESS: Compile messages retrieved.")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
detailed_error = traceback.format_exc()
|
|
error_message = "Error getting compile messages for project %s: %s\n%s" % (PROJECT_FILE_PATH, e, detailed_error)
|
|
print(error_message)
|
|
print("SCRIPT_ERROR: %s" % error_message)
|
|
sys.exit(1)
|