From 763a30761d3e54155bd0331ae6eb18bb8dfd13e1 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 20:50:15 +0200 Subject: [PATCH] fix(compile): deep-walk message structures with _coerce_for_json before json.dumps Empirical failure: TypeError 281474976710655L is not JSON serializable from get_message_objects(). Root cause: previous per-attribute coercion only flattened known fields (severity/text/line); nested dicts/lists carrying CLR longs slipped through. Fix: add a recursive _coerce_for_json helper that walks dicts/lists/tuples and downcasts long->int (or str if >Int64), keeps bool, then call it before every json.dumps in both compile_project.py and get_compile_messages.py. Docs: https://content.helpme-codesys.com/en/ScriptingEngine/ScriptSystem.html (get_message_objects), https://content.helpme-codesys.com/en/ScriptingEngine/ScriptObject.html Note: previous commit 418f678 added the per-field coercers; this commit strengthens that with a recursive walker per the bug doc's proposal -- defensive against any future API change that nests longs deeper. Manual smoke test only -- requires CODESYS-in-the-loop with a project that produces compile messages whose severity bitmask is 0xFFFFFFFFFFFF. ### Manual smoke test 1. Open a project with at least one warning or error. 2. Call mcp__codesys__compile_project on it. 3. Expect SCRIPT_SUCCESS and a populated COMPILE_MESSAGES_START block; no TypeError 'is not JSON serializable' anywhere in the output. 4. Repeat with mcp__codesys__get_compile_messages. --- src/scripts/compile_project.py | 50 +++++++++++++++++++++++++---- src/scripts/get_compile_messages.py | 34 ++++++++++++++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/scripts/compile_project.py b/src/scripts/compile_project.py index 735a220..358657c 100644 --- a/src/scripts/compile_project.py +++ b/src/scripts/compile_project.py @@ -27,6 +27,45 @@ def _coerce_str(v): return None +_JSON_INT64_MAX = 9223372036854775807 # 2**63 - 1 + + +def _coerce_for_json(obj): + """Deep-walk arbitrary message dicts/lists and coerce values that + json.dumps can't handle on IronPython 2.7. Specifically: + - bool stays bool (must come BEFORE int -- bool is a subclass). + - long/int that fits in System.Int64 -> int. + - long that exceeds Int64 -> str (avoid lossy downcast). + - dict/list/tuple recurse. + - everything else passes through; the json encoder's default=str + handler is the final safety net. + Helps the OPEN-BUGS-CROSS-REFERENCE Bug 2: 281474976710655L (severity + bitmask 0xFFFFFFFFFFFF) and similar CLR longs slipping through nested + dicts that the per-attribute coercion in _build_message_entry doesn't + cover.""" + if isinstance(obj, bool): + return obj + if isinstance(obj, (int, long)): + 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): """Extract a JSON-serializable dict from a single compile-message object. Centralised so both compile_project and get_compile_messages share the @@ -156,15 +195,14 @@ try: except Exception as e: print("DEBUG: system.get_messages() failed: %s" % e) - # Defensive json.dumps: if a stray field still slips past the coercion - # helpers, retry with a default=str fallback so a single odd type - # doesn't kill the whole emit. The default param converts unknown - # objects via str() instead of raising TypeError. + # Defensive json.dumps: deep-walk first via _coerce_for_json (handles + # `long` and nested CLR types that escape the per-field coercion in + # _build_message_entry), then dump with default=str as a final fallback. try: - messages_json = json.dumps(messages) + messages_json = json.dumps(_coerce_for_json(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)) + messages_json = json.dumps(_coerce_for_json(messages), default=lambda o: str(o)) print("### COMPILE_MESSAGES_START ###") print(messages_json) print("### COMPILE_MESSAGES_END ###") diff --git a/src/scripts/get_compile_messages.py b/src/scripts/get_compile_messages.py index 119d098..24a4527 100644 --- a/src/scripts/get_compile_messages.py +++ b/src/scripts/get_compile_messages.py @@ -23,6 +23,36 @@ def _coerce_str(v): return None +_JSON_INT64_MAX = 9223372036854775807 # 2**63 - 1 + + +def _coerce_for_json(obj): + """Deep-walk arbitrary message dicts/lists and coerce values that + json.dumps can't handle on IronPython 2.7. See compile_project.py for + the rationale; same helper, same shape.""" + if isinstance(obj, bool): + return obj + if isinstance(obj, (int, long)): + 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): entry = {} if hasattr(msg, 'severity'): @@ -135,10 +165,10 @@ try: print("DEBUG: system.get_messages() failed: %s" % e) try: - messages_json = json.dumps(messages) + messages_json = json.dumps(_coerce_for_json(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)) + messages_json = json.dumps(_coerce_for_json(messages), default=lambda o: str(o)) print("### COMPILE_MESSAGES_START ###") print(messages_json) print("### COMPILE_MESSAGES_END ###")