From c9eac928ef25c7098f41cd0705f833f01c454fce Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Fri, 12 Jun 2026 14:17:04 +1000 Subject: [PATCH 1/2] backport bug fixes from ipfedor fork - export visualisations as .vis.xml to avoid overwriting a POU of the same name (Main program + Main visualisation collided on Main.xml) - snapshot children before removing in remove_tracked_communication_devices (mutation during iteration skipped entries) - map GUIDs for persistent GVLs and methods without a return type, which were silently skipped on export; name INTERFACE, TASK, IMAGEPOOL, TEXTLIST, GLOBAL_TEXTLIST, PROPERTY_METHOD GUIDs without export mapping - fall back to native export with a warning for unmapped GUIDs instead of silently dropping the object (seen with an LD nested inside a CFC) - use the project parameter instead of scriptengine.projects.primary in project_template.py - normalize DUT declaration whitespace on import for idempotent round-trips Credit: reimplemented from ipfedor/codescribe commits 2e93ff40, 2dbb0939, 7eaec7d1, 0dbb0939. Refs #24. --- README.md | 2 ++ src/communication_import_export.py | 3 ++- src/import_export.py | 28 ++++++++++++++++++++++++---- src/import_from_files.py | 6 ++++++ src/object_type.py | 18 +++++++++++++++++- src/project_template.py | 6 ++---- src/script_export_to_files.py | 23 +++++++++++++++++++++-- 7 files changed, 74 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 91ff886..b2d3c46 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Items are exported in formatted structured text (`.st`) where possible, and in n Since v0.2.0, Actions and Transitions export as `.st` rather than native xml. The kind is encoded in the filename (`MyPou.MyAction.action.st`, `MyPou.MyTransition.transition.st`) and the file contains the implementation text only, as these objects have no textual declaration. Exports made with earlier versions (where these objects were `.xml`) still import correctly; re-exporting once after upgrading migrates the tracked files to the new format. +Visualisations export as `.vis.xml`. Earlier versions wrote `.xml`, which silently collided with any POU of the same name (a `Main` program plus a `Main` visualisation is common). Old plain `.xml` exports still import correctly; re-exporting once migrates the tracked files. + ## Project Templates The intention of CODESCRIBE is not to export a complete copy of the project, but to only export the implementation logic of the project, enabling collaboration via git and other source control methods. An empty, but configured, underlying project file should also be committed to the repo to manage any other configuration that CODESYS provides (e.g. project level configuration, device configuration). For example, `Example Project_template_v1.project`: diff --git a/src/communication_import_export.py b/src/communication_import_export.py index 08e14fd..30d928c 100644 --- a/src/communication_import_export.py +++ b/src/communication_import_export.py @@ -59,5 +59,6 @@ def remove_tracked_communication_devices(communication_obj): # remove all children from top level devices for top_level_device in communication_obj.get_children(): - for child in top_level_device.get_children(): + # snapshot the children: removing while iterating the live collection skips entries + for child in list(top_level_device.get_children()): child.remove() diff --git a/src/import_export.py b/src/import_export.py index 08dd86d..e9879e1 100644 --- a/src/import_export.py +++ b/src/import_export.py @@ -123,6 +123,16 @@ def export_gvl(child_obj, parent_obj, parent_folder_path, export_child_fn): write_st_decl_only(child_obj, f) +def find_gvl_or_error(parent_obj, name, err): + # Persistent variable lists carry their own GUID, so a freshly imported GVL can be + # either type. + for gvl_type in (ObjectType.GVL, ObjectType.GVL_PERSISTENT): + found = first_of_type_or_none(parent_obj.find(name), gvl_type) + if found is not None: + return found + raise ValueError(err) + + def import_gvl(child, dir_path, dir_parent_obj, import_dir_fn): """ Import the native xml and then overwrite the textual definition with the structured text. @@ -140,8 +150,8 @@ def import_gvl(child, dir_path, dir_parent_obj, import_dir_fn): gvl_xml_path = os.path.join(dir_path, name + ".gvl.xml") if os.path.exists(gvl_xml_path): import_native(gvl_xml_path, dir_path, dir_parent_obj, import_dir_fn) - imported_obj = first_of_type_or_error( - dir_parent_obj.find(name), ObjectType.GVL, name + " GVL should have been created, but cannot be found" + imported_obj = find_gvl_or_error( + dir_parent_obj, name, name + " GVL should have been created, but cannot be found" ) else: imported_obj = dir_parent_obj.create_gvl(name) @@ -154,6 +164,12 @@ def export_native(child_obj, parent_obj, parent_folder_path, export_child_fn): write_native(child_obj, os.path.join(parent_folder_path, child_obj.get_name() + ".xml"), recursive=False) +def export_visualisation(child_obj, parent_obj, parent_folder_path, export_child_fn): + # Visualisations regularly share a name with a POU (a Main program and a Main + # visualisation, for example), which would collide on Main.xml. + write_native(child_obj, os.path.join(parent_folder_path, child_obj.get_name() + ".vis.xml"), recursive=False) + + def export_native_recursive(child_obj, parent_obj, parent_folder_path, export_child_fn): write_native(child_obj, os.path.join(parent_folder_path, child_obj.get_name() + ".xml"), recursive=True) @@ -172,7 +188,9 @@ def import_dut(child, dir_path, dir_parent_obj, import_dir_fn): dut_obj = dir_parent_obj.create_dut(filename) with open_utf8(os.path.join(dir_path, child), "r") as f: f.seek(0) - dut_obj.textual_declaration.replace(f.read()) + # Normalize trailing whitespace the same way import_st does, so that + # export -> import -> export round-trips are byte-identical. + dut_obj.textual_declaration.replace(f.read().strip() + u"\n") def export_method(child_obj, parent_obj, parent_folder_path, export_child_fn): @@ -287,11 +305,13 @@ def import_sub_pou(child, dir_path, dir_parent_obj, import_dir_fn): ObjectType.FOLDER: export_folder, ObjectType.POU: export_pou, ObjectType.GVL: export_gvl, # EVL, NVL are "special types" of GVL which show up with the same UUID + ObjectType.GVL_PERSISTENT: export_gvl, # persistent variable lists have their own GUID ObjectType.EVC: export_native, - ObjectType.VISUALISATION: export_native, + ObjectType.VISUALISATION: export_visualisation, ObjectType.TASK_CONFIGURATION: export_native_recursive, ObjectType.DUT: export_dut, ObjectType.METHOD: export_method, + ObjectType.METHOD_NORET: export_method, # methods without a return type have their own GUID ObjectType.PROPERTY: export_sub_pou, ObjectType.ACTION: export_action, ObjectType.TRANSITION: export_transition, diff --git a/src/import_from_files.py b/src/import_from_files.py index 326b0d1..f3b3956 100644 --- a/src/import_from_files.py +++ b/src/import_from_files.py @@ -33,6 +33,12 @@ def import_directory_child(child, dir_path, dir_parent_obj): pass if ext == ".st": import_gvl(child, dir_path, dir_parent_obj, import_directory) + elif filename.endswith(".vis"): + # Visualisations export with a .vis.xml suffix so they cannot collide with a + # POU of the same name. Plain .xml visualisations from older exports + # still arrive through the bare-xml import below. + if ext == ".xml": + import_native(child, dir_path, dir_parent_obj, import_directory) elif "." in filename: # . means some sort of sub POU (method, action, transition, property) if ext == ".xml": diff --git a/src/object_type.py b/src/object_type.py index 5dd66ad..8fdc932 100644 --- a/src/object_type.py +++ b/src/object_type.py @@ -5,12 +5,20 @@ class ObjectType: POU = "POU" DUT = "DUT" - GVL = "EVL" + GVL = "GVL" + GVL_PERSISTENT = "GVL_PERSISTENT" EVC = "EVC" + INTERFACE = "INTERFACE" + TASK = "TASK" METHOD = "METHOD" + METHOD_NORET = "METHOD_NORET" PROPERTY = "PROPERTY" + PROPERTY_METHOD = "PROPERTY_METHOD" ACTION = "ACTION" TRANSITION = "TRANSITION" + IMAGEPOOL = "IMAGEPOOL" + TEXTLIST = "TEXTLIST" + GLOBAL_TEXTLIST = "GLOBAL_TEXTLIST" LIBRARY_MANAGER = "LIBRARY_MANAGER" TASK_CONFIGURATION = "TASK_CONFIGURATION" PROJECT_INFORMATION = "PROJECT_INFORMATION" @@ -38,10 +46,18 @@ def __iter__(cls): "6f9dac99-8de1-4efc-8465-68ac443b7d08": ObjectType.POU, "2db5746d-d284-4425-9f7f-2663a34b0ebc": ObjectType.DUT, "ffbfa93a-b94d-45fc-a329-229860183b1d": ObjectType.GVL, + "261bd6e6-249c-4232-bb6f-84c2fbeef430": ObjectType.GVL_PERSISTENT, "327b6465-4e7f-4116-846a-8369c730fd66": ObjectType.EVC, + "6654496c-404d-479a-aad2-8551054e5f1e": ObjectType.INTERFACE, + "98a2708a-9b18-4f31-82ed-a1465b24fa2d": ObjectType.TASK, "f8a58466-d7f6-439f-bbb8-d4600e41d099": ObjectType.METHOD, + "f89f7675-27f1-46b3-8abb-b7da8e774ffd": ObjectType.METHOD_NORET, "5a3b8626-d3e9-4f37-98b5-66420063d91e": ObjectType.PROPERTY, + "792f2eb6-721e-4e64-ba20-bc98351056db": ObjectType.PROPERTY_METHOD, "8ac092e5-3128-4e26-9e7e-11016c6684f2": ObjectType.ACTION, + "bb0b9044-714e-4614-ad3e-33cbdf34d16b": ObjectType.IMAGEPOOL, + "2bef0454-1bd3-412a-ac2c-af0f31dbc40f": ObjectType.TEXTLIST, + "63784cbb-9ba0-45e6-9d69-babf3f040511": ObjectType.GLOBAL_TEXTLIST, "a10c6218-cb94-436f-91c6-e1652575253d": ObjectType.TRANSITION, "adb5cb65-8e1d-4a00-b70a-375ea27582f3": ObjectType.LIBRARY_MANAGER, "ae1de277-a207-4a28-9efb-456c06bd52f3": ObjectType.TASK_CONFIGURATION, diff --git a/src/project_template.py b/src/project_template.py index ee21ecc..a685fdf 100644 --- a/src/project_template.py +++ b/src/project_template.py @@ -1,15 +1,13 @@ # REMEMBER: this is python 2.7 import os -import scriptengine # type: ignore - PROJECT_EXT = ".project" TEMPLATE_FILEPART = "_template_v" def find_template_paths_and_versions(project): working_dir = os.path.dirname(project.path) - project_name, _ = os.path.splitext(os.path.basename(scriptengine.projects.primary.path)) + project_name, _ = os.path.splitext(os.path.basename(project.path)) template_name_start = project_name + TEMPLATE_FILEPART @@ -33,7 +31,7 @@ def find_template_paths_and_versions(project): def generate_template_path(project, version_number): working_dir = os.path.dirname(project.path) - project_name, _ = os.path.splitext(os.path.basename(scriptengine.projects.primary.path)) + project_name, _ = os.path.splitext(os.path.basename(project.path)) template_name_start = project_name + TEMPLATE_FILEPART diff --git a/src/script_export_to_files.py b/src/script_export_to_files.py index 169b870..d56b7de 100644 --- a/src/script_export_to_files.py +++ b/src/script_export_to_files.py @@ -8,8 +8,8 @@ from communication_import_export import export_communication from entrypoint import find_application, find_communication, get_device_entrypoints, get_src_folder -from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION -from object_type import get_object_type +from import_export import OBJECT_TYPE_TO_EXPORT_FUNCTION, write_native +from object_type import ObjectType, get_object_type from util import * @@ -18,6 +18,25 @@ def export_child(child_obj, parent_obj, parent_folder_path): export_fn = OBJECT_TYPE_TO_EXPORT_FUNCTION.get(child_obj_type) if export_fn is not None: export_fn(child_obj, parent_obj, parent_folder_path, export_child) + return + + if child_obj_type == ObjectType.UNKNOWN: + # An unmapped GUID would otherwise be dropped from the export without a trace + # (seen with diagram objects such as an LD nested inside a CFC). Fall back to a + # native export and keep walking the children. + try: + write_native(child_obj, os.path.join(parent_folder_path, child_obj.get_name() + ".xml"), recursive=False) + except Exception as fallback_error: + print( + "Warning: failed to export '" + + child_obj.get_name() + + "' (unmapped type GUID " + + str(child_obj.type) + + "): " + + str(fallback_error) + ) + for c in child_obj.get_children(): + export_child(c, child_obj, parent_folder_path) try: From f3cfdeaba17487fc9fc65ec790cb0828ab079647 Mon Sep 17 00:00:00 2001 From: Geoff Sokoll Date: Fri, 12 Jun 2026 17:26:11 +1000 Subject: [PATCH 2/2] name visualisation service GUIDs so they are not exported Live GUI testing showed that adding a visualisation brings a Visualization Manager whose GUID was unmapped, so the UNKNOWN fallback exported it as Visualization Manager.xml. Importing that file makes CODESYS raise an interactive overwrite dialog on every Import From Files, which cannot be answered from a script. The manager, target/web visualisation and __VisualizationStyle objects are now named in the GUID map without an export function, so they stay in the project template like the other service objects. Visualisations themselves still export as .vis.xml. GUIDs confirmed live on SP11 (4d3fdb8f, 8e687a04) and via the mapping list in 18thCentury/CodeSys export.py (bc63f5fa, 0fdbf158). --- src/object_type.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/object_type.py b/src/object_type.py index 8fdc932..5029874 100644 --- a/src/object_type.py +++ b/src/object_type.py @@ -27,6 +27,10 @@ class ObjectType: FOLDER = "FOLDER" CALL_TO_POU = "CALL_TO_POU" VISUALISATION = "VISUALISATION" + VISUALISATION_MANAGER = "VISUALISATION_MANAGER" + TARGET_VISUALISATION = "TARGET_VISUALISATION" + WEB_VISUALISATION = "WEB_VISUALISATION" + VISUALISATION_STYLE = "VISUALISATION_STYLE" UNKNOWN = "UNKNOWN" @classmethod @@ -67,6 +71,13 @@ def __iter__(cls): "738bea1e-99bb-4f04-90bb-a7a567e74e3a": ObjectType.FOLDER, "413e2a7d-adb1-4d2c-be29-6ae6e4fab820": ObjectType.CALL_TO_POU, "f18bec89-9fef-401d-9953-2f11739a6808": ObjectType.VISUALISATION, + # Visualisation service objects. Named so the UNKNOWN export fallback does not pick + # them up: exporting the manager makes every later import raise an interactive + # overwrite dialog, and the project template carries these objects anyway. + "4d3fdb8f-ab50-4c35-9d3a-d4bb9bb9a628": ObjectType.VISUALISATION_MANAGER, + "bc63f5fa-d286-4786-994e-7b27e4f97bd5": ObjectType.TARGET_VISUALISATION, + "0fdbf158-1ae0-47d9-9269-cd84be308e9d": ObjectType.WEB_VISUALISATION, + "8e687a04-7ca7-42d3-be06-fcbda676c5ef": ObjectType.VISUALISATION_STYLE, }