0
0
Fork 0
Codesys-MCP-SP21-plus/src/scripts/compile_project.py
Karstein Phobic Nyvold Kvistad d4e71f61ca fix(compile_messages): discover real category GUIDs via system.get_message_categories()
The 0.6.3 fix replaced 'first-non-empty-pattern-wins' with category
iteration but used a HARDCODED list of 'well-known V3.5 GUIDs' that was
wrong. None of those GUIDs matched the actual Build category, so
compile errors stayed invisible (compile_project still reported '0
errors' even when the IDE-side download path saw them).

Diagnosed via a one-shot probe injected into compile_project on
2026-04-29: dumped attrs of script_engine.system, then called
script_engine.system.get_message_categories() (the METHOD) directly.
That returned the actual 7 category GUIDs in this CODESYS V3.5 SP22
Patch 1 install:

  05581bd1-66d3-4251-aff2-047cc8e9adf7  Offline Help
  936e1a33-3af8-47fa-b40b-903f0ae0b6cc  Application Composer
  a9b26e07-6ae1-4c06-9cd1-9ddddd397a2d  SVN
  0a6fcb64-7f24-43c6-a6d3-f70cb5d31114  (no parameterless ctor; Git)
  194b48a9-ab51-43ae-b9a9-51d3edaaddf3  Script Messages
  97f48d64-a2a3-4856-b640-75c046e37ea9  Build              <-- the one we needed
  220493a1-f49b-4416-9a3f-a545db707cbe  Additional code checks

Real fix: replace the hardcoded list in _enumerate_categories() in both
compile_project.py and get_compile_messages.py with a call to
system.get_message_categories(); label each one via
get_message_category_description(guid). Iterate per category as before.
GUIDs are now discovered at runtime so the same code works on any SP
and any locale.

Verification: injected `THIS_IS_NOT_VALID_IEC_KEYWORD;` into
PLC_PRG.implementation, ran compile_project; output now reads
"1 error(s), 2 warning(s). ERROR: Identifier
'THIS_IS_NOT_VALID_IEC_KEYWORD' not defined". Restored PLC_PRG;
0 error(s) again. End-to-end fix confirmed.

Bumped 0.6.3 -> 0.6.4.
2026-04-29 07:39:31 +02:00

336 lines
12 KiB
Python

import sys, scriptengine as script_engine, os, traceback, json
# ============================================================================
# Inlined helpers (shared shape with get_compile_messages.py).
# Inlined rather than imported because the IPC executor concatenates helper
# scripts at runtime; siblings in src/scripts/ don't have a sys.path entry.
# Keep these two copies in sync.
# ============================================================================
_JSON_INT64_MAX = 9223372036854775807
def _coerce_int(v):
if v is None:
return None
try:
return int(v)
except (TypeError, ValueError, OverflowError):
return None
def _coerce_str(v):
if v is None:
return None
try:
return str(v)
except Exception:
return None
def _coerce_for_json(obj):
if isinstance(obj, bool):
return obj
if isinstance(obj, (int, long)): # noqa: F821
try:
if obj > _JSON_INT64_MAX or obj < -_JSON_INT64_MAX - 1:
return str(obj)
return int(obj)
except Exception:
return str(obj)
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
try:
key = k if isinstance(k, str) else str(k)
except Exception:
continue
out[key] = _coerce_for_json(v)
return out
if isinstance(obj, (list, tuple)):
return [_coerce_for_json(v) for v in obj]
return obj
def _build_message_entry(msg, category_name=None):
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)
cat = None
for attr in ('category', 'category_name', 'category_guid'):
if hasattr(msg, attr):
try:
v = getattr(msg, attr)
if v is not None:
cat = _coerce_str(v)
if cat:
break
except Exception:
pass
if not cat and category_name:
cat = category_name
if cat:
entry['category'] = cat
return entry
def _enumerate_categories(script_engine_arg):
"""Returns a list of (label, category_guid_or_None) tuples for every
message category the IDE exposes plus a (None, None) sentinel for the
no-filter call.
Discovery path: script_engine.system.get_message_categories() (the
METHOD) returns the live List[Guid] of category IDs registered in
this IDE session. script_engine.system.get_message_category_description(guid)
gives the human-readable label per category ('Build', 'Application
Composer', 'SVN', 'Script Messages', 'Additional code checks', etc.).
History: pre-0.6.4 the function looked at `system.message_categories`
(a property) which doesn't exist, then fell back to a hardcoded list
of GUIDs that happened to NOT include the actual Build category
(97f48d64-a2a3-4856-b640-75c046e37ea9). Result: compile errors were
invisible. Verified by diagnostic probe with intentional syntax-error
injection on 2026-04-29."""
cats = [('<default-no-filter>', None)]
se_sys = getattr(script_engine_arg, 'system', None)
if se_sys is None or not hasattr(se_sys, 'get_message_categories'):
return cats
try:
guids = se_sys.get_message_categories()
except Exception as e:
print("DEBUG: get_message_categories() raised: %s" % e)
return cats
if guids is None:
return cats
for g in guids:
label = None
try:
if hasattr(se_sys, 'get_message_category_description'):
label = _coerce_str(se_sys.get_message_category_description(g))
except Exception:
label = None
if not label:
label = _coerce_str(g) or '<unnamed>'
cats.append((label, g))
return cats
def _extract_all_messages(target_app, script_engine_arg):
all_entries = []
seen = set()
def _add(entry):
key = (
entry.get('severity'),
entry.get('text'),
entry.get('object'),
entry.get('line'),
)
if key in seen:
return False
seen.add(key)
all_entries.append(entry)
return True
if target_app is not None and hasattr(target_app, 'get_message_objects'):
for label, cat in _enumerate_categories(script_engine_arg):
try:
if cat is None:
msgs = target_app.get_message_objects()
else:
try:
msgs = target_app.get_message_objects(cat)
except TypeError:
continue
except Exception as e:
print("DEBUG: app.get_message_objects(%s) failed: %s" % (label, e))
continue
if not msgs:
continue
count_before = len(all_entries)
try:
for m in msgs:
try:
_add(_build_message_entry(m, label))
except Exception:
pass
except Exception:
pass
added = len(all_entries) - count_before
if added > 0:
print("DEBUG: app.get_message_objects(%s) added %d new" % (label, added))
se_sys = getattr(script_engine_arg, 'system', None)
if se_sys is not None and hasattr(se_sys, 'get_message_objects'):
for label, cat in _enumerate_categories(script_engine_arg):
try:
if cat is None:
msgs = se_sys.get_message_objects()
else:
try:
msgs = se_sys.get_message_objects(cat)
except TypeError:
continue
except Exception as e:
print("DEBUG: system.get_message_objects(%s) failed: %s" % (label, e))
continue
if not msgs:
continue
count_before = len(all_entries)
try:
for m in msgs:
try:
_add(_build_message_entry(m, label))
except Exception:
pass
except Exception:
pass
added = len(all_entries) - count_before
if added > 0:
print("DEBUG: system.get_message_objects(%s) added %d new" % (label, added))
if se_sys is not None and hasattr(se_sys, 'get_messages'):
try:
msgs = se_sys.get_messages()
if msgs:
count_before = len(all_entries)
for m in msgs:
try:
_add(_build_message_entry(m, '<legacy>'))
except Exception:
pass
added = len(all_entries) - count_before
if added > 0:
print("DEBUG: system.get_messages() added %d new" % added)
except Exception as e:
print("DEBUG: system.get_messages() failed: %s" % e)
return all_entries
def _count_severity(entries):
e = w = i = o = 0
for entry in entries:
sev = entry.get('severity', 'unknown')
if sev == 'error':
e += 1
elif sev == 'warning':
w += 1
elif sev == 'info':
i += 1
else:
o += 1
return e, w, i, o
def _render_messages_block(entries):
try:
messages_json = json.dumps(_coerce_for_json(entries))
except TypeError as je:
print("WARN: json.dumps raised %s -- retrying with default=str fallback" % je)
messages_json = json.dumps(_coerce_for_json(entries), default=lambda o: str(o))
e, w, i, o = _count_severity(entries)
return messages_json, e, w, i, o
# ============================================================================
# Main
# ============================================================================
try:
print("DEBUG: compile_project 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:
target_app = primary_project.active_application
if target_app:
app_name = getattr(target_app, 'get_name', lambda: "Unnamed App (Active)")()
print("DEBUG: Found active application: %s" % app_name)
except Exception as active_err:
print("WARN: Could not get active application: %s. Searching..." % active_err)
if not target_app:
print("DEBUG: Searching for first compilable application...")
apps = []
try:
all_children = primary_project.get_children(True)
for child in all_children:
if hasattr(child, 'is_application') and child.is_application and hasattr(child, 'build'):
app_name_found = getattr(child, 'get_name', lambda: "Unnamed App")()
print("DEBUG: Found potential application object: %s" % app_name_found)
apps.append(child)
break
except Exception as find_err:
print("WARN: Error finding application object: %s" % find_err)
if not apps:
raise RuntimeError("No compilable application found in project '%s'" % project_name)
target_app = apps[0]
app_name = getattr(target_app, 'get_name', lambda: "Unnamed App (First Found)")()
print("WARN: Compiling first found application: %s" % app_name)
if not hasattr(target_app, 'build'):
raise TypeError("Selected object '%s' is not an application or doesn't support build()." % app_name)
print("DEBUG: Calling build() on app '%s'..." % app_name)
target_app.build()
print("DEBUG: Build command executed for application '%s'." % app_name)
# Aggregate messages across every category we can probe (the old
# 'first-non-empty-pattern-wins' logic missed Build/Compile messages
# because their category isn't the IDE's default-active one).
messages = _extract_all_messages(target_app, script_engine)
messages_json, errors, warnings, infos, others = _render_messages_block(messages)
print("### COMPILE_MESSAGES_START ###")
print(messages_json)
print("### COMPILE_MESSAGES_END ###")
print("Compile Initiated For Application: %s" % app_name)
print("In Project: %s" % project_name)
print("Errors: %d" % errors)
print("Warnings: %d" % warnings)
print("Infos: %d" % infos)
print("Others: %d" % others)
print("Total: %d" % len(messages))
print("SCRIPT_SUCCESS: Application compilation initiated.")
sys.exit(0)
except Exception as e:
detailed_error = traceback.format_exc()
error_message = "Error initiating compilation for project %s: %s\n%s" % (PROJECT_FILE_PATH, e, detailed_error)
print(error_message)
print("SCRIPT_ERROR: %s" % error_message)
sys.exit(1)