From 2dee048f0ec07aea94ab6be84e368cedbe641cfe Mon Sep 17 00:00:00 2001 From: sliptonic Date: Tue, 16 Sep 2025 11:12:25 -0500 Subject: [PATCH 1/7] template directory to camassets --- src/Mod/CAM/Path/Main/Gui/JobCmd.py | 2 +- src/Mod/CAM/Path/Preferences.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Mod/CAM/Path/Main/Gui/JobCmd.py b/src/Mod/CAM/Path/Main/Gui/JobCmd.py index 0867a9e9c4..30dcfbc00d 100644 --- a/src/Mod/CAM/Path/Main/Gui/JobCmd.py +++ b/src/Mod/CAM/Path/Main/Gui/JobCmd.py @@ -134,7 +134,7 @@ class CommandJobTemplateExport: foo = QtGui.QFileDialog.getSaveFileName( QtGui.QApplication.activeWindow(), "Path - Job Template", - Path.Preferences.filePath(), + str(Path.Preferences.getTemplateDirectory()), "job_*.json", )[0] if foo: diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 965b896c3c..6cb04c7cd9 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -158,6 +158,13 @@ def getToolBitPath() -> pathlib.Path: return getAssetPath() / "Tools" / "Bit" +def getTemplateDirectory() -> pathlib.Path: + """Returns the directory where job templates should be saved.""" + template_path = getAssetPath() / "Templates" + template_path.mkdir(parents=True, exist_ok=True) + return template_path + + def getLastToolLibrary() -> Optional[str]: pref = tool_preferences() return pref.GetString(LastToolLibrary) or None @@ -239,6 +246,9 @@ def macroFilePath(): def searchPaths(): paths = [] + # Add new CamAssets/Templates directory first (highest priority) + paths.append(str(getTemplateDirectory())) + # Add legacy locations for backward compatibility p = defaultFilePath() if p: paths.append(p) From f65a826b08ca9d43df10f5e1d88d4249b9836050 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Tue, 16 Sep 2025 13:29:44 -0500 Subject: [PATCH 2/7] library and bit import improvements --- src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 109 +++++++++++++++++- .../CAM/Path/Tool/library/serializers/fctl.py | 85 ++++++++++++++ src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 24 +++- 3 files changed, 212 insertions(+), 6 deletions(-) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index 32ba281114..22a6f2cb98 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -74,21 +74,79 @@ class AssetOpenDialog(QFileDialog): try: raw_data = file_path.read_bytes() dependencies = serializer_class.extract_dependencies(raw_data) + external_toolbits = [] # Track toolbits found externally + for dependency_uri in dependencies: - if not self.asset_manager.exists(dependency_uri, store=["local", "builtin"]): + # First check if dependency exists in asset manager stores + if self.asset_manager.exists(dependency_uri, store=["local", "builtin"]): + continue + + # If not in stores, check if it exists relative to the library file + dependency_found = False + if dependency_uri.asset_type == "toolbit": + # Look for toolbit files in parallel Bit directory + # Library is in Library/, toolbits are in parallel Bit/ + library_dir = file_path.parent # e.g., /path/to/Library/ + tools_dir = library_dir.parent # e.g., /path/to/ + bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ + + if bit_dir.exists(): + possible_extensions = [".fctb", ".json", ".yaml", ".yml"] + for ext in possible_extensions: + toolbit_file = bit_dir / f"{dependency_uri.asset_id}{ext}" + if toolbit_file.exists(): + dependency_found = True + external_toolbits.append((dependency_uri, toolbit_file)) + break + + if not dependency_found: QMessageBox.critical( self, "Error", - f"Failed to import {file_path}: required dependency {dependency_uri} not found", + f"Failed to import {file_path}: required dependency {dependency_uri} not found in stores or in parallel Bit directory", ) return None + + # If we found external toolbits, ask user if they want to import them + if external_toolbits: + from PySide.QtGui import QMessageBox + toolbit_names = [uri.asset_id for uri, _ in external_toolbits] + reply = QMessageBox.question( + self, + "Import External Toolbits", + f"This library references {len(external_toolbits)} toolbit(s) that are not in your local store:\n\n" + + "\n".join(f"• {name}" for name in toolbit_names) + + f"\n\nWould you like to import these toolbits into your local store?\n" + + "This will make them permanently available for use in other libraries.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes + ) + + if reply == QMessageBox.Yes: + # Import the external toolbits into local store + self._import_external_toolbits(external_toolbits) + # After importing, use regular deserialization since toolbits are now in local store + use_context_deserialize = False + else: + # User declined import, use context-aware deserialization for external loading + use_context_deserialize = True + else: + # No external toolbits found, use regular deserialization + use_context_deserialize = False except Exception as e: QMessageBox.critical(self, "Error", f"{file_path}: Failed to check dependencies: {e}") return None # Load and return the asset. try: - asset = serializer_class.deep_deserialize(raw_data) + # Choose deserialization method based on whether toolbits were imported + if use_context_deserialize and hasattr(serializer_class, 'deep_deserialize_with_context'): + # Pass file path context for external dependency resolution + asset = serializer_class.deep_deserialize_with_context(raw_data, file_path) + else: + # Use regular deserialization - toolbits should be in stores now + asset = serializer_class.deep_deserialize(raw_data) + if not isinstance(asset, self.asset_class): raise TypeError(f"Deserialized asset is not of type {self.asset_class.__name__}") return asset @@ -124,6 +182,51 @@ class AssetOpenDialog(QFileDialog): # Fallback to home directory if anything goes wrong return pathlib.Path.home() + def _import_external_toolbits(self, external_toolbits): + """Import external toolbits into the local asset store.""" + from ...toolbit.serializers import all_serializers as toolbit_serializers + from .util import get_serializer_from_extension + + imported_count = 0 + failed_imports = [] + + for dependency_uri, toolbit_file in external_toolbits: + try: + # Find appropriate serializer for the file + file_extension = toolbit_file.suffix.lower() + serializer_class = get_serializer_from_extension( + toolbit_serializers, file_extension, for_import=True + ) + + if not serializer_class: + failed_imports.append(f"{dependency_uri.asset_id}: No serializer for {file_extension}") + continue + + # Load and deserialize the toolbit + raw_toolbit_data = toolbit_file.read_bytes() + toolbit = serializer_class.deep_deserialize(raw_toolbit_data) + + # Ensure the toolbit ID matches what the library expects + if toolbit.id != dependency_uri.asset_id: + toolbit.id = dependency_uri.asset_id + + # Import the toolbit into local store + imported_uri = self.asset_manager.add(toolbit, store="local") + imported_count += 1 + + except Exception as e: + failed_imports.append(f"{dependency_uri.asset_id}: {str(e)}") + + # Show results to user + if imported_count > 0: + message = f"Successfully imported {imported_count} toolbit(s) into your local store." + if failed_imports: + message += f"\n\nFailed to import {len(failed_imports)} toolbit(s):\n" + "\n".join(failed_imports) + QMessageBox.information(self, "Import Results", message) + elif failed_imports: + message = f"Failed to import all {len(failed_imports)} toolbit(s):\n" + "\n".join(failed_imports) + QMessageBox.warning(self, "Import Failed", message) + class AssetSaveDialog(QFileDialog): def __init__( diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index f8fe8555fc..857db214c7 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -181,3 +181,88 @@ class FCTLSerializer(AssetSerializer): # Now deserialize with the resolved dependencies return cls.deserialize(data, library_id, resolved_dependencies) + + @classmethod + def deep_deserialize_with_context(cls, data: bytes, file_path: "pathlib.Path"): + """Deep deserialize a library with file path context for external dependencies.""" + import uuid + import pathlib + from ...camassets import cam_assets + from ...toolbit.serializers import all_serializers as toolbit_serializers + + # Generate a unique ID for this library instance + library_id = str(uuid.uuid4()) + + Path.Log.info( + f"FCTL DEEP_DESERIALIZE_WITH_CONTEXT: Starting deep deserialization for library from {file_path}" + ) + + # Extract dependency URIs from the library data + dependency_uris = cls.extract_dependencies(data) + Path.Log.info( + f"FCTL DEEP_DESERIALIZE_WITH_CONTEXT: Found {len(dependency_uris)} toolbit dependencies: {[uri.asset_id for uri in dependency_uris]}" + ) + + # Fetch all toolbit dependencies + resolved_dependencies = {} + for dep_uri in dependency_uris: + try: + # First try to get from asset manager stores + Path.Log.info( + f"FCTL EXTERNAL: Trying to fetch toolbit '{dep_uri.asset_id}' from stores ['local', 'builtin']" + ) + toolbit = cam_assets.get(dep_uri, store=["local", "builtin"], depth=0) + resolved_dependencies[dep_uri] = toolbit + Path.Log.info( + f"FCTL EXTERNAL: Successfully fetched toolbit '{dep_uri.asset_id}' from stores" + ) + except Exception as e: + # If not in stores, try to load from parallel Bit directory + Path.Log.info( + f"FCTL EXTERNAL: Toolbit '{dep_uri.asset_id}' not in stores, trying external file: {e}" + ) + + # Look for toolbit files in parallel Bit directory + library_dir = file_path.parent # e.g., /path/to/Library/ + tools_dir = library_dir.parent # e.g., /path/to/ + bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ + + toolbit_loaded = False + if bit_dir.exists(): + possible_extensions = [".fctb", ".json", ".yaml", ".yml"] + for ext in possible_extensions: + toolbit_file = bit_dir / f"{dep_uri.asset_id}{ext}" + if toolbit_file.exists(): + try: + # Find appropriate serializer for the file + from ...assets.ui.util import get_serializer_from_extension + serializer_class = get_serializer_from_extension( + toolbit_serializers, ext, for_import=True + ) + if serializer_class: + # Load and deserialize the toolbit + raw_toolbit_data = toolbit_file.read_bytes() + toolbit = serializer_class.deep_deserialize(raw_toolbit_data) + resolved_dependencies[dep_uri] = toolbit + toolbit_loaded = True + Path.Log.info( + f"FCTL EXTERNAL: Successfully loaded toolbit '{dep_uri.asset_id}' from {toolbit_file}" + ) + break + except Exception as load_error: + Path.Log.warning( + f"FCTL EXTERNAL: Failed to load toolbit from {toolbit_file}: {load_error}" + ) + continue + + if not toolbit_loaded: + Path.Log.warning( + f"FCTL EXTERNAL: Could not load toolbit '{dep_uri.asset_id}' from external files" + ) + + Path.Log.info( + f"FCTL EXTERNAL: Resolved {len(resolved_dependencies)} of {len(dependency_uris)} dependencies" + ) + + # Now deserialize with the resolved dependencies + return cls.deserialize(data, library_id, resolved_dependencies) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index e6f6991c6a..2c5ea7f33f 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -267,9 +267,27 @@ class ToolBitBrowserWidget(QtGui.QWidget): uri_string = item.data(ToolBitUriRole) if not uri_string: return - toolbit = self._asset_manager.get(AssetUri(uri_string)) - if toolbit: - self.itemDoubleClicked.emit(toolbit) + try: + toolbit = self._asset_manager.get(AssetUri(uri_string)) + if toolbit: + self.itemDoubleClicked.emit(toolbit) + except FileNotFoundError: + # Handle missing/placeholder toolbits gracefully + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Missing Toolbit"), + FreeCAD.Qt.translate( + "CAM", + "This toolbit is missing from your local store. It may be a placeholder for a toolbit that was not found during library import." + ), + ) + except Exception as e: + # Handle other errors + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error"), + FreeCAD.Qt.translate("CAM", f"Failed to load toolbit: {e}"), + ) def _on_item_selection_changed(self): """Emits toolSelected signal and tracks selected URIs.""" From fbd81cf4f89c14145566c70a42e5558576697b31 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Wed, 17 Sep 2025 15:33:33 -0500 Subject: [PATCH 3/7] Versioning directories --- src/Mod/CAM/InitGui.py | 134 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index 23e6e003f1..24bcaac8c9 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -22,11 +22,49 @@ # * * # *************************************************************************** import FreeCAD +import Path.Preferences +import shutil +import pathlib + +if FreeCAD.GuiUp: + import FreeCADGui + from FreeCADGui import Workbench + from PySide.QtWidgets import QMessageBox +else: + # Provide a dummy Workbench class when GUI is not available + class Workbench: + pass FreeCAD.__unit_test__ += ["TestCAMGui"] + +# if FreeCAD.GuiUp: +# import FreeCADGui +# import Path.Base.Gui.PreferencesAdvanced as PathPreferencesAdvanced +# import Path.Base.Gui.PreferencesJob as PathPreferencesPathJob +# import Path.Dressup.Gui.Preferences as PathPreferencesPathDressup +# import Path.Tool.assets.ui.preferences as AssetPreferences + +# FreeCADGui.addPreferencePage( +# PathPreferencesPathJob.JobPreferencesPage, +# QT_TRANSLATE_NOOP("QObject", "CAM"), +# ) +# FreeCADGui.addPreferencePage( +# AssetPreferences.AssetPreferencesPage, +# QT_TRANSLATE_NOOP("QObject", "CAM"), +# ) +# FreeCADGui.addPreferencePage( +# PathPreferencesPathDressup.DressupPreferencesPage, +# QT_TRANSLATE_NOOP("QObject", "CAM"), +# ) +# FreeCADGui.addPreferencePage( +# PathPreferencesAdvanced.AdvancedPreferencesPage, +# QT_TRANSLATE_NOOP("QObject", "CAM"), +# ) + + class PathCommandGroup: def __init__(self, cmdlist, menu, tooltip=None): self.cmdlist = cmdlist @@ -58,6 +96,94 @@ class CAMWorkbench(Workbench): self.__class__.MenuText = "CAM" self.__class__.ToolTip = "CAM workbench" + def _migrate_cam_assets(self, current_version, cam_asset_path): + """ + Migrate CAM assets to a versioned directory. + """ + try: + # Create versioned directory name + cam_asset_path = pathlib.Path(cam_asset_path) + parent_dir = cam_asset_path.parent + versioned_name = f"{cam_asset_path.name}_{current_version}" + versioned_path = parent_dir / versioned_name + + # Copy the existing assets to the versioned directory + if cam_asset_path.exists(): + shutil.copytree(cam_asset_path, versioned_path, dirs_exist_ok=True) + + # Update the CAM asset preference to point to the new location + Path.Preferences.setAssetPath(str(versioned_path)) + + Path.Log.info(f"CAM assets migrated to versioned directory: {versioned_path}") + else: + Path.Log.warning(f"CAM asset path does not exist: {cam_asset_path}") + + except Exception as e: + Path.Log.error(f"Failed to migrate CAM assets: {e}") + if FreeCAD.GuiUp: + QMessageBox.critical( + FreeCADGui.getMainWindow(), + "CAM Asset Migration Failed", + f"Failed to migrate CAM assets: {str(e)}" + ) + + def _offer_cam_asset_migration(self, current_version, cam_asset_path, pref_group, known_versions): + """ + Offer CAM asset migration to the user. + """ + if not FreeCAD.GuiUp: + return + + result = QMessageBox.question( + FreeCADGui.getMainWindow(), + "CAM Asset Migration", + f"CAM Assets are stored in a custom location:\n{cam_asset_path}\n\n" + f"Would you like to create a versioned copy for FreeCAD v{current_version}?", + QMessageBox.Yes | QMessageBox.No + ) + + # Record that we offered migration for this version + known_versions.add(current_version) + pref_group.SetASCII("OfferedToMigrateCAMAssets", ','.join(known_versions)) + + if result == QMessageBox.Yes: + self._migrate_cam_assets(current_version, cam_asset_path) + + def _check_cam_asset_migration(self): + """ + Check if CAM asset migration is needed for version upgrade. + """ + try: + # Only proceed if not using custom directories (same as main system) + if FreeCAD.Application.directories().usingCustomDirectories(): + return + + # Get the current CAM asset path + cam_asset_path = Path.Preferences.getAssetPath() + user_app_data_dir = pathlib.Path(FreeCAD.getUserAppDataDir()) + + # Only migrate if CamAssets is outside the standard user data directory + if pathlib.Path(cam_asset_path).is_relative_to(user_app_data_dir): + return # CamAssets is in default location, no custom migration needed + + # Check if we've already offered migration for this version + pref_group = FreeCAD.GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/CAM/Migration") + + major = int(FreeCAD.Application.Config()["BuildVersionMajor"]) + minor = int(FreeCAD.Application.Config()["BuildVersionMinor"]) + current_version = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) + + offered_versions = pref_group.GetASCII("OfferedToMigrateCAMAssets", "") + known_versions = set(offered_versions.split(',')) if offered_versions else set() + + if current_version not in known_versions: + # Offer migration + self._offer_cam_asset_migration(current_version, cam_asset_path, pref_group, known_versions) + + except Exception as e: + Path.Log.error(f"Error checking CAM asset migration: {e}") + def Initialize(self): global PathCommandGroup @@ -83,6 +209,13 @@ class CAMWorkbench(Workbench): from Path.Tool.toolbit.ui import cmd as PathToolBitCmd from Path.Tool.library.ui import cmd as PathToolBitLibraryCmd + from Path.Tool.camassets import cam_assets + + cam_assets.setup() + + # Check if CAM asset migration is needed for version upgrade + self._check_cam_asset_migration() + from PySide.QtCore import QT_TRANSLATE_NOOP import PathCommands @@ -339,6 +472,7 @@ class CAMWorkbench(Workbench): "Profile" in selectedName or "Contour" in selectedName or "Dressup" in selectedName + or "Pocket" in selectedName ): self.appendContextMenu("", "Separator") # self.appendContextMenu("", ["Set_StartPoint"]) From 260aa3abf14aef7d28eb9621df79345a3daeb017 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Sat, 20 Sep 2025 10:02:45 -0500 Subject: [PATCH 4/7] refactor migration --- src/Mod/CAM/CMakeLists.txt | 13 ++ src/Mod/CAM/InitGui.py | 122 +--------- src/Mod/CAM/Path/Preferences.py | 15 +- .../CAM/Path/Tool/assets/ui/preferences.py | 9 +- src/Mod/CAM/Path/Tool/camassets.py | 7 +- .../CAM/Path/Tool/library/serializers/fctl.py | 6 +- src/Mod/CAM/Path/Tool/migration/__init__.py | 32 +++ src/Mod/CAM/Path/Tool/migration/migration.py | 220 ++++++++++++++++++ 8 files changed, 296 insertions(+), 128 deletions(-) create mode 100644 src/Mod/CAM/Path/Tool/migration/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/migration/migration.py diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 5cc41ed1b3..6e965b361a 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -128,6 +128,11 @@ SET(PathPythonToolsAssets_SRCS Path/Tool/assets/uri.py ) +SET(PathPythonToolsMigration_SRCS + Path/Tool/migration/__init__.py + Path/Tool/migration/migration.py +) + SET(PathPythonToolsAssetsStore_SRCS Path/Tool/assets/store/__init__.py Path/Tool/assets/store/base.py @@ -600,6 +605,7 @@ SET(all_files ${PathPythonPostScripts_SRCS} ${PathPythonTools_SRCS} ${PathPythonToolsAssets_SRCS} + ${PathPythonToolsMigration_SRCS} ${PathPythonToolsAssetsStore_SRCS} ${PathPythonToolsAssetsUi_SRCS} ${PathPythonToolsDocObject_SRCS} @@ -755,6 +761,13 @@ INSTALL( Mod/CAM/Path/Tool/assets ) +INSTALL( + FILES + ${PathPythonToolsMigration_SRCS} + DESTINATION + Mod/CAM/Path/Tool/migration +) + INSTALL( FILES ${PathPythonToolsAssetsStore_SRCS} diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index 24bcaac8c9..38b10015f5 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -22,15 +22,10 @@ # * * # *************************************************************************** import FreeCAD -import Path.Preferences - -import shutil -import pathlib if FreeCAD.GuiUp: import FreeCADGui from FreeCADGui import Workbench - from PySide.QtWidgets import QMessageBox else: # Provide a dummy Workbench class when GUI is not available class Workbench: @@ -39,32 +34,6 @@ else: FreeCAD.__unit_test__ += ["TestCAMGui"] - -# if FreeCAD.GuiUp: -# import FreeCADGui -# import Path.Base.Gui.PreferencesAdvanced as PathPreferencesAdvanced -# import Path.Base.Gui.PreferencesJob as PathPreferencesPathJob -# import Path.Dressup.Gui.Preferences as PathPreferencesPathDressup -# import Path.Tool.assets.ui.preferences as AssetPreferences - -# FreeCADGui.addPreferencePage( -# PathPreferencesPathJob.JobPreferencesPage, -# QT_TRANSLATE_NOOP("QObject", "CAM"), -# ) -# FreeCADGui.addPreferencePage( -# AssetPreferences.AssetPreferencesPage, -# QT_TRANSLATE_NOOP("QObject", "CAM"), -# ) -# FreeCADGui.addPreferencePage( -# PathPreferencesPathDressup.DressupPreferencesPage, -# QT_TRANSLATE_NOOP("QObject", "CAM"), -# ) -# FreeCADGui.addPreferencePage( -# PathPreferencesAdvanced.AdvancedPreferencesPage, -# QT_TRANSLATE_NOOP("QObject", "CAM"), -# ) - - class PathCommandGroup: def __init__(self, cmdlist, menu, tooltip=None): self.cmdlist = cmdlist @@ -96,93 +65,6 @@ class CAMWorkbench(Workbench): self.__class__.MenuText = "CAM" self.__class__.ToolTip = "CAM workbench" - def _migrate_cam_assets(self, current_version, cam_asset_path): - """ - Migrate CAM assets to a versioned directory. - """ - try: - # Create versioned directory name - cam_asset_path = pathlib.Path(cam_asset_path) - parent_dir = cam_asset_path.parent - versioned_name = f"{cam_asset_path.name}_{current_version}" - versioned_path = parent_dir / versioned_name - - # Copy the existing assets to the versioned directory - if cam_asset_path.exists(): - shutil.copytree(cam_asset_path, versioned_path, dirs_exist_ok=True) - - # Update the CAM asset preference to point to the new location - Path.Preferences.setAssetPath(str(versioned_path)) - - Path.Log.info(f"CAM assets migrated to versioned directory: {versioned_path}") - else: - Path.Log.warning(f"CAM asset path does not exist: {cam_asset_path}") - - except Exception as e: - Path.Log.error(f"Failed to migrate CAM assets: {e}") - if FreeCAD.GuiUp: - QMessageBox.critical( - FreeCADGui.getMainWindow(), - "CAM Asset Migration Failed", - f"Failed to migrate CAM assets: {str(e)}" - ) - - def _offer_cam_asset_migration(self, current_version, cam_asset_path, pref_group, known_versions): - """ - Offer CAM asset migration to the user. - """ - if not FreeCAD.GuiUp: - return - - result = QMessageBox.question( - FreeCADGui.getMainWindow(), - "CAM Asset Migration", - f"CAM Assets are stored in a custom location:\n{cam_asset_path}\n\n" - f"Would you like to create a versioned copy for FreeCAD v{current_version}?", - QMessageBox.Yes | QMessageBox.No - ) - - # Record that we offered migration for this version - known_versions.add(current_version) - pref_group.SetASCII("OfferedToMigrateCAMAssets", ','.join(known_versions)) - - if result == QMessageBox.Yes: - self._migrate_cam_assets(current_version, cam_asset_path) - - def _check_cam_asset_migration(self): - """ - Check if CAM asset migration is needed for version upgrade. - """ - try: - # Only proceed if not using custom directories (same as main system) - if FreeCAD.Application.directories().usingCustomDirectories(): - return - - # Get the current CAM asset path - cam_asset_path = Path.Preferences.getAssetPath() - user_app_data_dir = pathlib.Path(FreeCAD.getUserAppDataDir()) - - # Only migrate if CamAssets is outside the standard user data directory - if pathlib.Path(cam_asset_path).is_relative_to(user_app_data_dir): - return # CamAssets is in default location, no custom migration needed - - # Check if we've already offered migration for this version - pref_group = FreeCAD.GetApplication().GetParameterGroupByPath( - "User parameter:BaseApp/Preferences/CAM/Migration") - - major = int(FreeCAD.Application.Config()["BuildVersionMajor"]) - minor = int(FreeCAD.Application.Config()["BuildVersionMinor"]) - current_version = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) - - offered_versions = pref_group.GetASCII("OfferedToMigrateCAMAssets", "") - known_versions = set(offered_versions.split(',')) if offered_versions else set() - - if current_version not in known_versions: - # Offer migration - self._offer_cam_asset_migration(current_version, cam_asset_path, pref_group, known_versions) - - except Exception as e: - Path.Log.error(f"Error checking CAM asset migration: {e}") def Initialize(self): global PathCommandGroup @@ -214,7 +96,9 @@ class CAMWorkbench(Workbench): cam_assets.setup() # Check if CAM asset migration is needed for version upgrade - self._check_cam_asset_migration() + from Path.Tool.migration.migration import CAMAssetMigrator + migrator = CAMAssetMigrator() + migrator.check_migration_needed() from PySide.QtCore import QT_TRANSLATE_NOOP diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 6cb04c7cd9..83e475863d 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -29,7 +29,7 @@ from collections import defaultdict from typing import Optional -if False: +if True: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: @@ -128,7 +128,9 @@ def getAssetPath() -> pathlib.Path: # Check if we have a CamAssets path already set cam_assets_path = pref.GetString(ToolPath, "") if cam_assets_path: - return pathlib.Path(cam_assets_path) + # Use mostRecentConfigFromBase to get the most recent versioned path + most_recent_path = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase(cam_assets_path) + return pathlib.Path(most_recent_path) # Migration: Check for legacy DefaultFilePath and use it for CamAssets legacy_path = defaultFilePath() @@ -137,14 +139,19 @@ def getAssetPath() -> pathlib.Path: if legacy_path_obj.exists() and legacy_path_obj.is_dir(): # Migrate: Set the legacy path as the new CamAssets path setAssetPath(legacy_path_obj) - return legacy_path_obj + # Return the most recent version of the legacy path + most_recent_legacy = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase(str(legacy_path_obj)) + return pathlib.Path(most_recent_legacy) # Fallback to default if no legacy path found default = getDefaultAssetPath() - return pathlib.Path(default) + # Return the most recent version of the default path + most_recent_default = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase(str(default)) + return pathlib.Path(most_recent_default) def setAssetPath(path: pathlib.Path): + Path.Log.info(f"Setting asset path to {path}") assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}" pref = tool_preferences() current_path = pref.GetString(ToolPath, "") diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index e74305ad05..2e322c6004 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -124,6 +124,9 @@ class AssetPreferencesPage: return True def loadSettings(self): - # use getAssetPath() to initialize UI - asset_path = Path.Preferences.getAssetPath() - self.asset_path_edit.setText(str(asset_path)) + # Get the raw preference value, not the versioned path + pref = Path.Preferences.tool_preferences() + asset_path = pref.GetString(Path.Preferences.ToolPath, "") + if not asset_path: + asset_path = str(Path.Preferences.getDefaultAssetPath()) + self.asset_path_edit.setText(asset_path) \ No newline at end of file diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index 8bffbeaec3..a6695e2261 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -27,6 +27,11 @@ from Path import Preferences from Path.Preferences import addToolPreferenceObserver from .assets import AssetManager, AssetUri, Asset, FileStore +if True: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): """ @@ -151,7 +156,7 @@ def ensure_assets_initialized(asset_manager: AssetManager, store="local"): def _on_asset_path_changed(group, key, value): Path.Log.info(f"CAM asset directory changed in preferences: {group} {key} {value}") - user_asset_store.set_dir(Preferences.getAssetPath()) + user_asset_store.set_dir(value) ensure_assets_initialized(cam_assets) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index 857db214c7..37c8cd0d7d 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -72,7 +72,11 @@ class FCTLSerializer(AssetSerializer): # for the asset being deserialized. We should use this ID for the library # instance, overriding any 'id' that might be in the data_dict (which # is from an older version of the format). - library = Library(data_dict.get("label", id or "Unnamed Library"), id=id) + + # For the label, prefer data_dict["label"], then "name", then fallback to "Unnamed Library" + # Avoid using the UUID as the library name + label = data_dict.get("label") or data_dict.get("name") or "Unnamed Library" + library = Library(label, id=id) if dependencies is None: Path.Log.debug( diff --git a/src/Mod/CAM/Path/Tool/migration/__init__.py b/src/Mod/CAM/Path/Tool/migration/__init__.py new file mode 100644 index 0000000000..41501293ad --- /dev/null +++ b/src/Mod/CAM/Path/Tool/migration/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 sliptonic * +# * * +# * This file is part of FreeCAD. * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +""" +CAM Asset management package. + +This package contains modules for managing CAM assets including migration +functionality for handling asset directory versioning during FreeCAD upgrades. +""" diff --git a/src/Mod/CAM/Path/Tool/migration/migration.py b/src/Mod/CAM/Path/Tool/migration/migration.py new file mode 100644 index 0000000000..c7352ef8e0 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/migration/migration.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 sliptonic * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** +""" +CAM Asset Migration Module + +Handles migration of CAM assets during FreeCAD version upgrades. +""" + +import FreeCAD +import Path +import Path.Preferences +import pathlib + +# Logging setup - same pattern as Job.py +if True: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + +if FreeCAD.GuiUp: + import FreeCADGui + from PySide.QtWidgets import QMessageBox + + +class CAMAssetMigrator: + """ + Handles migration of CAM assets during FreeCAD version upgrades. + + This class provides functionality to: + - Check if migration is needed for custom CAM asset locations + - Offer migration to users through a dialog + - Perform the actual asset migration with versioned directories + """ + + def __init__(self): + self.pref_group_path = "User parameter:BaseApp/Preferences/Mod/CAM/Migration" + + def check_migration_needed(self): + """ + Public method to check and handle CAM asset migration. + """ + self._check_migration_needed() + + def _check_migration_needed(self): + """ + Check if CAM asset migration is needed for version upgrade. + + This method determines if the current CAM assets are stored in a custom + location outside the default user data directory and if migration has + not been offered for the current FreeCAD version. + + Returns: + bool: True if migration should be offered, False otherwise + """ + Path.Log.info("Starting CAM asset migration check") + + try: + # Get current directories + user_app_data_dir = FreeCAD.getUserAppDataDir() + user_app_data_path = pathlib.Path(user_app_data_dir) + Path.Log.debug(f"User app data directory: {user_app_data_dir}") + + # Get the current CAM asset path (may be naked or versioned) + current_asset_path = Path.Preferences.getAssetPath() + current_asset_pathlib = pathlib.Path(current_asset_path) + Path.Log.debug(f"Current CAM asset path: {current_asset_path}") + + # Only migrate if CamAssets is outside the standard user data directory + if current_asset_pathlib.is_relative_to(user_app_data_path): + Path.Log.debug("CamAssets is in default location, no custom migration needed") + return + + # Check if migration has already been offered for this version + if self.has_migration_been_offered(): + Path.Log.debug("Migration has already been offered for this version, skipping") + return + + # Determine the base path (naked path without version) + if FreeCAD.ApplicationDirectories.isVersionedPath(str(current_asset_path)): + # Check if we're already using the current version + if FreeCAD.ApplicationDirectories.usingCurrentVersionConfig(str(current_asset_path)): + Path.Log.debug("Already using current version, no migration needed") + return + + Path.Log.info("Migration is needed and should be offered") + if self._offer_migration_to_user(): + self._migrate_assets(str(current_asset_path)) + return + + except Exception as e: + Path.Log.error(f"Error checking CAM asset migration: {e}") + import traceback + Path.Log.debug(f"Full traceback: {traceback.format_exc()}") + return False + + def _offer_migration_to_user(self): + """ + Present migration dialog to user. + + Returns: + bool: True if user accepted migration, False otherwise + """ + # Get current version info + major = int(FreeCAD.ConfigGet("BuildVersionMajor")) + minor = int(FreeCAD.ConfigGet("BuildVersionMinor")) + current_version = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) + + # Get current asset path for display + current_asset_path = Path.Preferences.getAssetPath() + + Path.Log.debug(f"Offering migration to user for version {current_version}") + + if not FreeCAD.GuiUp: + Path.Log.debug("GUI not available, skipping migration offer") + return False + + msg = ( + f"FreeCAD has been upgraded to version {current_version}.\n\n" + f"Your CAM assets are stored in a custom location:\n{current_asset_path}\n\n" + "Would you like to migrate your CAM assets to a versioned directory " + "to preserve them during future upgrades?\n\n" + "This will copy your assets to a new directory." + ) + + Path.Log.debug("Showing migration dialog to user") + + reply = QMessageBox.question( + None, + "CAM Asset Migration", + msg, + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes + ) + + # Record that we offered migration for this version + pref_group = FreeCAD.ParamGet(self.pref_group_path) + offered_versions = pref_group.GetString("OfferedToMigrateCAMAssets", "") + known_versions = set(offered_versions.split(',')) if offered_versions else set() + known_versions.add(current_version) + pref_group.SetString("OfferedToMigrateCAMAssets", ",".join(known_versions)) + Path.Log.debug(f"Updated offered versions: {known_versions}") + + if reply == QMessageBox.Yes: + Path.Log.info("User accepted migration, starting asset migration") + return True + else: + Path.Log.info("User declined migration") + return False + + def _migrate_assets(self, source_path): + """ + Perform actual directory copying and preference updates. + + Args: + source_path: Current CAM asset directory path + """ + Path.Log.info(f"Starting asset migration from {source_path}") + + try: + FreeCAD.ApplicationDirectories.migrateAllPaths([source_path]) + Path.Log.info("Migration complete - preferences will be handled automatically by the system") + + if FreeCAD.GuiUp: + QMessageBox.information( + None, + "Migration Complete", + f"CAM assets have been migrated from:\n{source_path}\n\n" + "The system will automatically handle preference updates." + ) + + except Exception as e: + error_msg = f"Failed to migrate CAM assets: {e}" + Path.Log.error(error_msg) + import traceback + Path.Log.debug(f"Migration error traceback: {traceback.format_exc()}") + if FreeCAD.GuiUp: + QMessageBox.critical( + None, + "Migration Failed", + error_msg + ) + + def has_migration_been_offered(self): + """ + Check if migration has been offered for current version. + + Returns: + bool: True if migration was offered for this version + """ + + pref_group = FreeCAD.ParamGet(self.pref_group_path) + major = int(FreeCAD.ConfigGet("BuildVersionMajor")) + minor = int(FreeCAD.ConfigGet("BuildVersionMinor")) + + current_version_string = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) + offered_versions = pref_group.GetString("OfferedToMigrateCAMAssets", "") + known_versions = set(offered_versions.split(',')) if offered_versions else set() + return current_version_string in known_versions \ No newline at end of file From 9c81c71728a4a6f222e33588853e10adbbd9c83a Mon Sep 17 00:00:00 2001 From: sliptonic Date: Sun, 21 Sep 2025 13:32:31 -0500 Subject: [PATCH 5/7] fix default library name on import --- src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 8 ++++---- src/Mod/CAM/Path/Tool/library/serializers/fctl.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index 22a6f2cb98..9717963fc1 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -139,12 +139,12 @@ class AssetOpenDialog(QFileDialog): # Load and return the asset. try: - # Choose deserialization method based on whether toolbits were imported - if use_context_deserialize and hasattr(serializer_class, 'deep_deserialize_with_context'): - # Pass file path context for external dependency resolution + # Always use context-aware deserialization for libraries to get meaningful names + if hasattr(serializer_class, 'deep_deserialize_with_context'): + # Pass file path context for meaningful library names and external dependency resolution asset = serializer_class.deep_deserialize_with_context(raw_data, file_path) else: - # Use regular deserialization - toolbits should be in stores now + # Fallback to regular deserialization asset = serializer_class.deep_deserialize(raw_data) if not isinstance(asset, self.asset_class): diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index 37c8cd0d7d..e19843176b 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -73,9 +73,9 @@ class FCTLSerializer(AssetSerializer): # instance, overriding any 'id' that might be in the data_dict (which # is from an older version of the format). - # For the label, prefer data_dict["label"], then "name", then fallback to "Unnamed Library" - # Avoid using the UUID as the library name - label = data_dict.get("label") or data_dict.get("name") or "Unnamed Library" + # For the label, prefer data_dict["label"], then "name", then fallback to using the id as filename + # The id parameter often contains the filename stem when importing from files + label = data_dict.get("label") or data_dict.get("name") or id or "Unnamed Library" library = Library(label, id=id) if dependencies is None: @@ -194,8 +194,8 @@ class FCTLSerializer(AssetSerializer): from ...camassets import cam_assets from ...toolbit.serializers import all_serializers as toolbit_serializers - # Generate a unique ID for this library instance - library_id = str(uuid.uuid4()) + # Use filename stem as library ID for meaningful names + library_id = file_path.stem Path.Log.info( f"FCTL DEEP_DESERIALIZE_WITH_CONTEXT: Starting deep deserialization for library from {file_path}" From 21fa5facbf99ff47f1c2dee290b4625efd532aab Mon Sep 17 00:00:00 2001 From: sliptonic Date: Mon, 22 Sep 2025 08:47:48 -0500 Subject: [PATCH 6/7] codeQl and suggestions from discussion. --- src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 6 ++---- src/Mod/CAM/Path/Tool/assets/ui/preferences.py | 2 +- src/Mod/CAM/Path/Tool/camassets.py | 2 +- src/Mod/CAM/Path/Tool/migration/migration.py | 13 ++++--------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index 9717963fc1..b2a40963ff 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -126,13 +126,12 @@ class AssetOpenDialog(QFileDialog): # Import the external toolbits into local store self._import_external_toolbits(external_toolbits) # After importing, use regular deserialization since toolbits are now in local store - use_context_deserialize = False else: # User declined import, use context-aware deserialization for external loading - use_context_deserialize = True + pass else: # No external toolbits found, use regular deserialization - use_context_deserialize = False + pass except Exception as e: QMessageBox.critical(self, "Error", f"{file_path}: Failed to check dependencies: {e}") return None @@ -211,7 +210,6 @@ class AssetOpenDialog(QFileDialog): toolbit.id = dependency_uri.asset_id # Import the toolbit into local store - imported_uri = self.asset_manager.add(toolbit, store="local") imported_count += 1 except Exception as e: diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index 2e322c6004..dac0b8c8df 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -58,7 +58,7 @@ class AssetPreferencesPage: translate( "CAM_PreferencesAssets", "Note: Select the directory that will contain the " - "Bit/, Shape/, and Library/ subfolders.", + "Tool folder with Bit/, Shape/, and Library/ subfolders.", ) ) self.asset_path_note_label.setWordWrap(True) diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index a6695e2261..c3ee5c88ba 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -27,7 +27,7 @@ from Path import Preferences from Path.Preferences import addToolPreferenceObserver from .assets import AssetManager, AssetUri, Asset, FileStore -if True: +if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: diff --git a/src/Mod/CAM/Path/Tool/migration/migration.py b/src/Mod/CAM/Path/Tool/migration/migration.py index c7352ef8e0..2dc9ecbf28 100644 --- a/src/Mod/CAM/Path/Tool/migration/migration.py +++ b/src/Mod/CAM/Path/Tool/migration/migration.py @@ -33,7 +33,7 @@ import Path.Preferences import pathlib # Logging setup - same pattern as Job.py -if True: +if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: @@ -41,8 +41,8 @@ else: if FreeCAD.GuiUp: import FreeCADGui - from PySide.QtWidgets import QMessageBox - + from PySide.QtWidgets import QApplication, QMessageBox + from PySide.QtCore import Qt class CAMAssetMigrator: """ @@ -57,13 +57,8 @@ class CAMAssetMigrator: def __init__(self): self.pref_group_path = "User parameter:BaseApp/Preferences/Mod/CAM/Migration" - def check_migration_needed(self): - """ - Public method to check and handle CAM asset migration. - """ - self._check_migration_needed() - def _check_migration_needed(self): + def check_migration_needed(self): """ Check if CAM asset migration is needed for version upgrade. From af592924b651b7840698f24b92cb554846675d50 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:50:52 +0000 Subject: [PATCH 7/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/Mod/CAM/InitGui.py | 3 +- src/Mod/CAM/Path/Preferences.py | 4 +- src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 57 +++++++------ .../CAM/Path/Tool/assets/ui/preferences.py | 2 +- src/Mod/CAM/Path/Tool/camassets.py | 1 + .../CAM/Path/Tool/library/serializers/fctl.py | 11 +-- src/Mod/CAM/Path/Tool/migration/migration.py | 80 +++++++++---------- src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 4 +- 8 files changed, 86 insertions(+), 76 deletions(-) diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index 38b10015f5..ec2ef11ebc 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -31,6 +31,7 @@ else: class Workbench: pass + FreeCAD.__unit_test__ += ["TestCAMGui"] @@ -65,7 +66,6 @@ class CAMWorkbench(Workbench): self.__class__.MenuText = "CAM" self.__class__.ToolTip = "CAM workbench" - def Initialize(self): global PathCommandGroup @@ -97,6 +97,7 @@ class CAMWorkbench(Workbench): # Check if CAM asset migration is needed for version upgrade from Path.Tool.migration.migration import CAMAssetMigrator + migrator = CAMAssetMigrator() migrator.check_migration_needed() diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 83e475863d..d37f4a99a8 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -140,7 +140,9 @@ def getAssetPath() -> pathlib.Path: # Migrate: Set the legacy path as the new CamAssets path setAssetPath(legacy_path_obj) # Return the most recent version of the legacy path - most_recent_legacy = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase(str(legacy_path_obj)) + most_recent_legacy = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase( + str(legacy_path_obj) + ) return pathlib.Path(most_recent_legacy) # Fallback to default if no legacy path found diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index b2a40963ff..3bf0c5a8a8 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -75,12 +75,12 @@ class AssetOpenDialog(QFileDialog): raw_data = file_path.read_bytes() dependencies = serializer_class.extract_dependencies(raw_data) external_toolbits = [] # Track toolbits found externally - + for dependency_uri in dependencies: # First check if dependency exists in asset manager stores if self.asset_manager.exists(dependency_uri, store=["local", "builtin"]): continue - + # If not in stores, check if it exists relative to the library file dependency_found = False if dependency_uri.asset_type == "toolbit": @@ -88,8 +88,8 @@ class AssetOpenDialog(QFileDialog): # Library is in Library/, toolbits are in parallel Bit/ library_dir = file_path.parent # e.g., /path/to/Library/ tools_dir = library_dir.parent # e.g., /path/to/ - bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ - + bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ + if bit_dir.exists(): possible_extensions = [".fctb", ".json", ".yaml", ".yml"] for ext in possible_extensions: @@ -98,7 +98,7 @@ class AssetOpenDialog(QFileDialog): dependency_found = True external_toolbits.append((dependency_uri, toolbit_file)) break - + if not dependency_found: QMessageBox.critical( self, @@ -106,22 +106,23 @@ class AssetOpenDialog(QFileDialog): f"Failed to import {file_path}: required dependency {dependency_uri} not found in stores or in parallel Bit directory", ) return None - + # If we found external toolbits, ask user if they want to import them if external_toolbits: from PySide.QtGui import QMessageBox + toolbit_names = [uri.asset_id for uri, _ in external_toolbits] reply = QMessageBox.question( self, "Import External Toolbits", - f"This library references {len(external_toolbits)} toolbit(s) that are not in your local store:\n\n" + - "\n".join(f"• {name}" for name in toolbit_names) + - f"\n\nWould you like to import these toolbits into your local store?\n" + - "This will make them permanently available for use in other libraries.", + f"This library references {len(external_toolbits)} toolbit(s) that are not in your local store:\n\n" + + "\n".join(f"• {name}" for name in toolbit_names) + + f"\n\nWould you like to import these toolbits into your local store?\n" + + "This will make them permanently available for use in other libraries.", QMessageBox.Yes | QMessageBox.No, - QMessageBox.Yes + QMessageBox.Yes, ) - + if reply == QMessageBox.Yes: # Import the external toolbits into local store self._import_external_toolbits(external_toolbits) @@ -139,13 +140,13 @@ class AssetOpenDialog(QFileDialog): # Load and return the asset. try: # Always use context-aware deserialization for libraries to get meaningful names - if hasattr(serializer_class, 'deep_deserialize_with_context'): + if hasattr(serializer_class, "deep_deserialize_with_context"): # Pass file path context for meaningful library names and external dependency resolution asset = serializer_class.deep_deserialize_with_context(raw_data, file_path) else: # Fallback to regular deserialization asset = serializer_class.deep_deserialize(raw_data) - + if not isinstance(asset, self.asset_class): raise TypeError(f"Deserialized asset is not of type {self.asset_class.__name__}") return asset @@ -185,10 +186,10 @@ class AssetOpenDialog(QFileDialog): """Import external toolbits into the local asset store.""" from ...toolbit.serializers import all_serializers as toolbit_serializers from .util import get_serializer_from_extension - + imported_count = 0 failed_imports = [] - + for dependency_uri, toolbit_file in external_toolbits: try: # Find appropriate serializer for the file @@ -196,33 +197,39 @@ class AssetOpenDialog(QFileDialog): serializer_class = get_serializer_from_extension( toolbit_serializers, file_extension, for_import=True ) - + if not serializer_class: - failed_imports.append(f"{dependency_uri.asset_id}: No serializer for {file_extension}") + failed_imports.append( + f"{dependency_uri.asset_id}: No serializer for {file_extension}" + ) continue - + # Load and deserialize the toolbit raw_toolbit_data = toolbit_file.read_bytes() toolbit = serializer_class.deep_deserialize(raw_toolbit_data) - + # Ensure the toolbit ID matches what the library expects if toolbit.id != dependency_uri.asset_id: toolbit.id = dependency_uri.asset_id - + # Import the toolbit into local store imported_count += 1 - + except Exception as e: failed_imports.append(f"{dependency_uri.asset_id}: {str(e)}") - + # Show results to user if imported_count > 0: message = f"Successfully imported {imported_count} toolbit(s) into your local store." if failed_imports: - message += f"\n\nFailed to import {len(failed_imports)} toolbit(s):\n" + "\n".join(failed_imports) + message += f"\n\nFailed to import {len(failed_imports)} toolbit(s):\n" + "\n".join( + failed_imports + ) QMessageBox.information(self, "Import Results", message) elif failed_imports: - message = f"Failed to import all {len(failed_imports)} toolbit(s):\n" + "\n".join(failed_imports) + message = f"Failed to import all {len(failed_imports)} toolbit(s):\n" + "\n".join( + failed_imports + ) QMessageBox.warning(self, "Import Failed", message) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index dac0b8c8df..ff46b2e606 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -129,4 +129,4 @@ class AssetPreferencesPage: asset_path = pref.GetString(Path.Preferences.ToolPath, "") if not asset_path: asset_path = str(Path.Preferences.getDefaultAssetPath()) - self.asset_path_edit.setText(asset_path) \ No newline at end of file + self.asset_path_edit.setText(asset_path) diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index c3ee5c88ba..75abad2ef9 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -33,6 +33,7 @@ if False: else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): """ Ensures the given store is initialized with built-in library diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index e19843176b..c04ce3874b 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -72,7 +72,7 @@ class FCTLSerializer(AssetSerializer): # for the asset being deserialized. We should use this ID for the library # instance, overriding any 'id' that might be in the data_dict (which # is from an older version of the format). - + # For the label, prefer data_dict["label"], then "name", then fallback to using the id as filename # The id parameter often contains the filename stem when importing from files label = data_dict.get("label") or data_dict.get("name") or id or "Unnamed Library" @@ -225,12 +225,12 @@ class FCTLSerializer(AssetSerializer): Path.Log.info( f"FCTL EXTERNAL: Toolbit '{dep_uri.asset_id}' not in stores, trying external file: {e}" ) - + # Look for toolbit files in parallel Bit directory library_dir = file_path.parent # e.g., /path/to/Library/ tools_dir = library_dir.parent # e.g., /path/to/ - bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ - + bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ + toolbit_loaded = False if bit_dir.exists(): possible_extensions = [".fctb", ".json", ".yaml", ".yml"] @@ -240,6 +240,7 @@ class FCTLSerializer(AssetSerializer): try: # Find appropriate serializer for the file from ...assets.ui.util import get_serializer_from_extension + serializer_class = get_serializer_from_extension( toolbit_serializers, ext, for_import=True ) @@ -258,7 +259,7 @@ class FCTLSerializer(AssetSerializer): f"FCTL EXTERNAL: Failed to load toolbit from {toolbit_file}: {load_error}" ) continue - + if not toolbit_loaded: Path.Log.warning( f"FCTL EXTERNAL: Could not load toolbit '{dep_uri.asset_id}' from external files" diff --git a/src/Mod/CAM/Path/Tool/migration/migration.py b/src/Mod/CAM/Path/Tool/migration/migration.py index 2dc9ecbf28..73eb441f64 100644 --- a/src/Mod/CAM/Path/Tool/migration/migration.py +++ b/src/Mod/CAM/Path/Tool/migration/migration.py @@ -44,44 +44,44 @@ if FreeCAD.GuiUp: from PySide.QtWidgets import QApplication, QMessageBox from PySide.QtCore import Qt + class CAMAssetMigrator: """ Handles migration of CAM assets during FreeCAD version upgrades. - + This class provides functionality to: - Check if migration is needed for custom CAM asset locations - Offer migration to users through a dialog - Perform the actual asset migration with versioned directories """ - + def __init__(self): self.pref_group_path = "User parameter:BaseApp/Preferences/Mod/CAM/Migration" - def check_migration_needed(self): """ Check if CAM asset migration is needed for version upgrade. - + This method determines if the current CAM assets are stored in a custom location outside the default user data directory and if migration has not been offered for the current FreeCAD version. - + Returns: bool: True if migration should be offered, False otherwise """ Path.Log.info("Starting CAM asset migration check") - + try: # Get current directories user_app_data_dir = FreeCAD.getUserAppDataDir() user_app_data_path = pathlib.Path(user_app_data_dir) Path.Log.debug(f"User app data directory: {user_app_data_dir}") - + # Get the current CAM asset path (may be naked or versioned) current_asset_path = Path.Preferences.getAssetPath() current_asset_pathlib = pathlib.Path(current_asset_path) Path.Log.debug(f"Current CAM asset path: {current_asset_path}") - + # Only migrate if CamAssets is outside the standard user data directory if current_asset_pathlib.is_relative_to(user_app_data_path): Path.Log.debug("CamAssets is in default location, no custom migration needed") @@ -95,7 +95,9 @@ class CAMAssetMigrator: # Determine the base path (naked path without version) if FreeCAD.ApplicationDirectories.isVersionedPath(str(current_asset_path)): # Check if we're already using the current version - if FreeCAD.ApplicationDirectories.usingCurrentVersionConfig(str(current_asset_path)): + if FreeCAD.ApplicationDirectories.usingCurrentVersionConfig( + str(current_asset_path) + ): Path.Log.debug("Already using current version, no migration needed") return @@ -107,13 +109,14 @@ class CAMAssetMigrator: except Exception as e: Path.Log.error(f"Error checking CAM asset migration: {e}") import traceback + Path.Log.debug(f"Full traceback: {traceback.format_exc()}") return False - + def _offer_migration_to_user(self): """ Present migration dialog to user. - + Returns: bool: True if user accepted migration, False otherwise """ @@ -121,16 +124,16 @@ class CAMAssetMigrator: major = int(FreeCAD.ConfigGet("BuildVersionMajor")) minor = int(FreeCAD.ConfigGet("BuildVersionMinor")) current_version = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) - + # Get current asset path for display current_asset_path = Path.Preferences.getAssetPath() - + Path.Log.debug(f"Offering migration to user for version {current_version}") - + if not FreeCAD.GuiUp: Path.Log.debug("GUI not available, skipping migration offer") return False - + msg = ( f"FreeCAD has been upgraded to version {current_version}.\n\n" f"Your CAM assets are stored in a custom location:\n{current_asset_path}\n\n" @@ -138,69 +141,64 @@ class CAMAssetMigrator: "to preserve them during future upgrades?\n\n" "This will copy your assets to a new directory." ) - + Path.Log.debug("Showing migration dialog to user") - + reply = QMessageBox.question( - None, - "CAM Asset Migration", - msg, - QMessageBox.Yes | QMessageBox.No, - QMessageBox.Yes + None, "CAM Asset Migration", msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes ) - + # Record that we offered migration for this version pref_group = FreeCAD.ParamGet(self.pref_group_path) offered_versions = pref_group.GetString("OfferedToMigrateCAMAssets", "") - known_versions = set(offered_versions.split(',')) if offered_versions else set() + known_versions = set(offered_versions.split(",")) if offered_versions else set() known_versions.add(current_version) pref_group.SetString("OfferedToMigrateCAMAssets", ",".join(known_versions)) Path.Log.debug(f"Updated offered versions: {known_versions}") - + if reply == QMessageBox.Yes: Path.Log.info("User accepted migration, starting asset migration") return True else: Path.Log.info("User declined migration") return False - + def _migrate_assets(self, source_path): """ Perform actual directory copying and preference updates. - + Args: source_path: Current CAM asset directory path """ Path.Log.info(f"Starting asset migration from {source_path}") - + try: FreeCAD.ApplicationDirectories.migrateAllPaths([source_path]) - Path.Log.info("Migration complete - preferences will be handled automatically by the system") - + Path.Log.info( + "Migration complete - preferences will be handled automatically by the system" + ) + if FreeCAD.GuiUp: QMessageBox.information( None, "Migration Complete", f"CAM assets have been migrated from:\n{source_path}\n\n" - "The system will automatically handle preference updates." + "The system will automatically handle preference updates.", ) - + except Exception as e: error_msg = f"Failed to migrate CAM assets: {e}" Path.Log.error(error_msg) import traceback + Path.Log.debug(f"Migration error traceback: {traceback.format_exc()}") if FreeCAD.GuiUp: - QMessageBox.critical( - None, - "Migration Failed", - error_msg - ) - + QMessageBox.critical(None, "Migration Failed", error_msg) + def has_migration_been_offered(self): """ Check if migration has been offered for current version. - + Returns: bool: True if migration was offered for this version """ @@ -211,5 +209,5 @@ class CAMAssetMigrator: current_version_string = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) offered_versions = pref_group.GetString("OfferedToMigrateCAMAssets", "") - known_versions = set(offered_versions.split(',')) if offered_versions else set() - return current_version_string in known_versions \ No newline at end of file + known_versions = set(offered_versions.split(",")) if offered_versions else set() + return current_version_string in known_versions diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index 2c5ea7f33f..a3b52c3757 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -277,8 +277,8 @@ class ToolBitBrowserWidget(QtGui.QWidget): self, FreeCAD.Qt.translate("CAM", "Missing Toolbit"), FreeCAD.Qt.translate( - "CAM", - "This toolbit is missing from your local store. It may be a placeholder for a toolbit that was not found during library import." + "CAM", + "This toolbit is missing from your local store. It may be a placeholder for a toolbit that was not found during library import.", ), ) except Exception as e: