diff --git a/README.md b/README.md index b2d3c46..3ff096b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ Since v0.2.0, Actions and Transitions export as `.st` rather than native xml. Th 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. +## Export folder locked (Windows) + +`Export To Files` writes the export into a sibling folder named `.codescribe_staging` and only swaps it into place once the export completes. Earlier versions deleted the target folder up front, so a locked folder (an open Explorer window, IDE, git client or antivirus) aborted the export immediately and a mid-export crash left the on-disk copy destroyed. + +If the target folder is locked and cannot be swapped, the staged files are synced into it instead and the export still succeeds. If that also fails, the error dialog reports the staging folder path; the completed export is preserved there, so nothing is lost. + ## 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/script_export_to_files.py b/src/script_export_to_files.py index d56b7de..19d2077 100644 --- a/src/script_export_to_files.py +++ b/src/script_export_to_files.py @@ -2,7 +2,6 @@ from __future__ import print_function import os -import shutil import scriptengine # type: ignore @@ -46,12 +45,10 @@ def export_child(child_obj, parent_obj, parent_folder_path): src_folder = get_src_folder(scriptengine.projects.primary) print("Writing to: " + src_folder) - if os.path.exists(src_folder): - shutil.rmtree(src_folder) - os.mkdir(src_folder) + staging_folder = begin_export_folder(src_folder) for device_obj in get_device_entrypoints(scriptengine.projects.primary): - device_folder = os.path.join(src_folder, device_obj.get_name()) + device_folder = os.path.join(staging_folder, device_obj.get_name()) os.mkdir(device_folder) application = find_application(device_obj) @@ -64,6 +61,8 @@ def export_child(child_obj, parent_obj, parent_folder_path): communication = find_communication(device_obj) if communication is not None: export_communication(communication, device_folder) + + finalize_export_folder(src_folder, staging_folder) except Exception as e: print(e) ui_error_with_traceback("Export To Files failed!") diff --git a/src/util.py b/src/util.py index c424dc6..f937be4 100644 --- a/src/util.py +++ b/src/util.py @@ -1,6 +1,7 @@ # REMEMBER: this is python 2.7 import io import os +import shutil import sys import traceback @@ -8,6 +9,73 @@ from object_type import get_object_type +EXPORT_STAGING_SUFFIX = ".codescribe_staging" +EXPORT_BACKUP_SUFFIX = ".codescribe_backup" + + +class ExportFolderLockedError(EnvironmentError): + """The target export folder could not be replaced. The completed export is + preserved in the staging folder.""" + + +def begin_export_folder(target_folder): + # The export is written into a sibling staging folder and only swapped into + # place once it completes, so a locked target folder or a mid-export crash + # cannot destroy the existing on-disk copy. + staging_folder = target_folder + EXPORT_STAGING_SUFFIX + if os.path.exists(staging_folder): + shutil.rmtree(staging_folder) + os.mkdir(staging_folder) + return staging_folder + + +def _sync_export_files(staging_folder, target_folder): + # Overwrite sync only; files that exist in the target but not in staging are + # left in place, because deleting them would require the same folder access + # that already failed for the rename. + for dir_path, dir_names, file_names in os.walk(staging_folder): + relative = os.path.relpath(dir_path, staging_folder) + destination = target_folder if relative == "." else os.path.join(target_folder, relative) + if not os.path.isdir(destination): + os.makedirs(destination) + for file_name in file_names: + shutil.copy2(os.path.join(dir_path, file_name), os.path.join(destination, file_name)) + + +def finalize_export_folder(target_folder, staging_folder): + backup_folder = target_folder + EXPORT_BACKUP_SUFFIX + try: + if os.path.exists(target_folder): + if os.path.exists(backup_folder): + shutil.rmtree(backup_folder) + os.rename(target_folder, backup_folder) + os.rename(staging_folder, target_folder) + except (OSError, IOError) as rename_error: + # On Windows these renames fail while another program holds a handle on + # the target folder. Fall back to copying the staged files into it. + try: + _sync_export_files(staging_folder, target_folder) + shutil.rmtree(staging_folder) + except (OSError, IOError): + raise ExportFolderLockedError( + "Could not replace the export folder " + + target_folder + + ". It is likely locked by another program (an open Explorer window, IDE, git client or antivirus)." + + " The completed export is preserved in " + + staging_folder + + ". Original error: " + + str(rename_error) + ) + print("Export folder " + target_folder + " is in use; synced the staged files into it instead of swapping folders.") + return + try: + if os.path.exists(backup_folder): + shutil.rmtree(backup_folder) + except (OSError, IOError) as backup_error: + # The export itself succeeded at this point; a leftover backup folder is + # not worth failing it over. + print("Warning: could not delete backup folder " + backup_folder + ": " + str(backup_error)) + def ui_info(message): # Blocking dialog; the message view is easy to miss, so entry scripts report