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..5029874 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" @@ -19,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 @@ -38,10 +50,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, @@ -51,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, } 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: