From 68a1ef20038ad1d89f057d1492fcacc86e6902fa Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 6 Aug 2025 18:08:56 -0500 Subject: [PATCH 1/3] Add global installation manifest Required so that the AM knows what branch is installed, since zip files don't have that info. --- Addon.py | 35 ++ AddonCatalog.py | 13 +- AddonManager.py | 51 ++- .../app/test_installation_manifest.py | 246 ++++++++++++++ AddonManagerTest/app/test_installer.py | 17 +- AddonManagerTest/app/test_uninstaller.py | 10 +- AddonManagerTest/app/test_workers_startup.py | 26 +- AddonManagerTest/gui/test_change_branch.py | 231 ------------- CMakeLists.txt | 2 - NetworkManager.py | 6 +- Widgets/addonmanager_widget_addon_buttons.py | 86 ++++- ...ddonmanager_widget_package_details_view.py | 22 -- addonmanager_installation_manifest.py | 177 ++++++++++ addonmanager_installer.py | 29 +- addonmanager_package_details_controller.py | 131 +++----- addonmanager_readme_controller.py | 23 ++ addonmanager_uninstaller.py | 3 + addonmanager_update_all_gui.py | 1 + addonmanager_workers_startup.py | 72 +++- change_branch.py | 316 ------------------ change_branch.ui | 105 ------ composite_view.py | 2 - package.xml | 4 +- package_list.py | 11 + 24 files changed, 817 insertions(+), 802 deletions(-) create mode 100644 AddonManagerTest/app/test_installation_manifest.py delete mode 100644 AddonManagerTest/gui/test_change_branch.py create mode 100644 addonmanager_installation_manifest.py delete mode 100644 change_branch.py delete mode 100644 change_branch.ui diff --git a/Addon.py b/Addon.py index 621edfec..1ce21a3c 100644 --- a/Addon.py +++ b/Addon.py @@ -169,6 +169,7 @@ def __init__( self.url = url.strip() self.relative_cache_path = "" self.branch = branch.strip() + self.branch_display_name = branch.strip() self.repo_type = Addon.Kind.WORKBENCH self.description = None self.tags = set() # Just a cache, loaded from Metadata @@ -176,6 +177,11 @@ def __init__( self.stats = AddonStats() self.score = 0 + # In cases where there are multiple versions/branches/installations available for an addon, + # this dictionary is the mapping from the displayed name in the UI (as given in the + # catalog) to the Addon object. + self.sub_addons = {} + # To prevent multiple threads from running git actions on this repo at the # same time self.git_lock = Lock() @@ -208,6 +214,14 @@ def __init__( self._cached_license: str = "" self._cached_update_date = None + def __eq__(self, other): + if not isinstance(other, Addon): + return NotImplemented + return self.name == other.name and self.branch_display_name == other.branch_display_name + + def __hash__(self): + return hash((self.name, self.branch_display_name)) + def _clean_url(self): # The url should never end in ".git", so strip it if it's there parsed_url = urlparse(self.url) @@ -759,3 +773,24 @@ def import_from_addon(self, repo: Addon, all_repos: List[Addon]): self.python_optional = [ option for option in self.python_optional if option not in self.python_requires ] + + +def cycle_to_sub_addon(original: Addon, sub_addon: Addon, addon_model): + """Given an addon with sub-addons, cycle the sub-addon to be the primary. After this call, + the addon_list will contain the sub_addon, which will itself have a list of sub-addons that + now includes the original addon (and *not* itself). + :param original: The addon that is currently the primary + :param sub_addon: The sub-addon that should be the primary + :param addon_model: The PackageListItemModel containing all addons""" + + addon_model.remove_item(original) + + new_sub_dict = {original.branch_display_name: original} + for key, value in original.sub_addons.items(): + if key != sub_addon.branch_display_name: + new_sub_dict[key] = value + + sub_addon.sub_addons = new_sub_dict + original.sub_addons = {} + + addon_model.append_item(sub_addon) diff --git a/AddonCatalog.py b/AddonCatalog.py index 20aa833f..5b63191d 100644 --- a/AddonCatalog.py +++ b/AddonCatalog.py @@ -178,6 +178,10 @@ def instantiate_addon(self, addon_id: str) -> Addon: else: addon.set_status(Addon.Status.NO_UPDATE_AVAILABLE) + addon.branch_display_name = ( + self.branch_display_name if self.branch_display_name else self.git_ref + ) + return addon @staticmethod @@ -335,16 +339,19 @@ def add_metadata_to_entry( raise RuntimeError(f"Addon {addon_id} index out of range") self._dictionary[addon_id][index].metadata = metadata - def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]: + def get_available_branches(self, addon_id: str) -> List[str]: """For a given ID, get the list of available branches compatible with this version of FreeCAD along with the branch display name. Either field may be empty, but not both. The - first entry in the list is expected to be the "primary".""" + first entry in the list is expected to be the "primary". + :return: A list of branch display names (or git refs, if no display name is available)""" if addon_id not in self._dictionary: return [] result = [] for entry in self._dictionary[addon_id]: if entry.is_compatible(): - result.append((entry.git_ref, entry.branch_display_name)) + result.append( + entry.branch_display_name if entry.branch_display_name else entry.git_ref + ) return result def get_catalog(self) -> Dict[str, List[AddonCatalogEntry]]: diff --git a/AddonManager.py b/AddonManager.py index c62d8240..9a69e9fc 100644 --- a/AddonManager.py +++ b/AddonManager.py @@ -24,6 +24,7 @@ import os import functools +import shutil import tempfile import threading from typing import Dict @@ -45,7 +46,7 @@ from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar from Widgets.addonmanager_widget_progress_bar import Progress from package_list import PackageListItemModel -from Addon import Addon +from Addon import Addon, cycle_to_sub_addon from addonmanager_python_deps_gui import ( PythonPackageManagerGui, ) @@ -298,7 +299,7 @@ def launch(self) -> None: self.composite_view.package_list.stop_loading.connect(self.stop_update) self.composite_view.package_list.setEnabled(False) self.composite_view.execute.connect(self.execute_macro) - self.composite_view.install.connect(self.launch_installer_gui) + self.composite_view.install.connect(self.update) self.composite_view.uninstall.connect(self.remove) self.composite_view.update.connect(self.update) self.composite_view.update_status.connect(self.status_updated) @@ -600,6 +601,12 @@ def append_to_repos_list(self, repo: Addon) -> None: self.item_model.append_item(repo) def update(self, repo: Addon) -> None: + try: + self.prep_for_install(repo) + except OSError as e: + fci.Console.PrintError(e) + fci.Console.PrintError("\n\nInstallation cancelled: out of disk space?\n") + return self.launch_installer_gui(repo) def mark_repo_update_available(self, repo: Addon, available: bool) -> None: @@ -610,6 +617,19 @@ def mark_repo_update_available(self, repo: Addon, available: bool) -> None: self.item_model.reload_item(repo) self.composite_view.package_details_controller.show_repo(repo) + def prep_for_install(self, installing_addon: Addon): + """To prepare for installing an addon, we need to see if this is the current active branch: + if it is not, then we need to cycle the addon's attached to this addon ID, making this one + active. We'll also remove any existing addon installed with this ID, making a backup + so that we can recover from a failed update/branch switch.""" + + for catalog_addon in self.item_model.repos: + if catalog_addon.name == installing_addon.name: + if catalog_addon != installing_addon: + cycle_to_sub_addon(catalog_addon, installing_addon, self.item_model) + break + make_backup(installing_addon) + def launch_installer_gui(self, addon: Addon) -> None: if self.installer_gui is not None: fci.Console.PrintError( @@ -624,6 +644,7 @@ def launch_installer_gui(self, addon: Addon) -> None: else: self.installer_gui = AddonInstallerGUI(addon, self.item_model.repos) self.installer_gui.success.connect(self.on_package_status_changed) + self.installer_gui.success.connect(cleanup_pre_installation_backup) self.installer_gui.finished.connect(self.cleanup_installer) self.installer_gui.run() # Does not block @@ -744,4 +765,30 @@ def open_addons_folder(): return +# Some utility functions + + +def make_backup(addon: Addon) -> None: + """Make a backup of the addon's current installation directory, so that we can recover + from a failed update/branch switch.""" + original = str(os.path.join(fci.DataPaths().mod_dir, addon.name)) + if os.path.exists(original): + shutil.copytree(original, original + ".pre_update_backup", dirs_exist_ok=True) + + +def cleanup_pre_installation_backup(addon: Addon) -> None: + """Remove the backup of the addon's current installation directory""" + original = str(os.path.join(fci.DataPaths().mod_dir, addon.name)) + if os.path.exists(original): + shutil.rmtree(original + ".pre_update_backup", ignore_errors=True) + + +def revert_to_backup(addon: Addon) -> None: + """Revert to the backup of the addon's current installation directory""" + original = str(os.path.join(fci.DataPaths().mod_dir, addon.name)) + if os.path.exists(original + ".pre_update_backup"): + shutil.rmtree(original, ignore_errors=True) + shutil.copytree(original + ".pre_update_backup", original, dirs_exist_ok=True) + + # @} diff --git a/AddonManagerTest/app/test_installation_manifest.py b/AddonManagerTest/app/test_installation_manifest.py new file mode 100644 index 00000000..2e154c77 --- /dev/null +++ b/AddonManagerTest/app/test_installation_manifest.py @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 The FreeCAD project association AISBL * +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +import datetime +import json +import os +from unittest.mock import patch + +from pyfakefs.fake_filesystem_unittest import TestCase as PyFakeFSTestCase + +from addonmanager_installation_manifest import InstallationManifest, most_recent_update + + +class MockCatalog: + def get_available_branches(self, addon_id): + return ["Main"] + + +class MockAddon: + branch_display_name = "Main" + branch = "abc123" + + +class TestMostRecentUpdate(PyFakeFSTestCase): + def setUp(self): + self.setUpPyfakefs() + + def test_latest_mtime_file_is_returned(self): + self.fs.create_dir("/test/dir") + now = datetime.datetime.now().timestamp() + earlier = now - 100 + later = now + 100 + + file1 = "/test/dir/file1.txt" + file2 = "/test/dir/file2.txt" + + self.fs.create_file(file1) + self.fs.create_file(file2) + + os.utime(file1, (earlier, earlier)) + os.utime(file2, (later, later)) + + result = most_recent_update("/test/dir") + expected = datetime.datetime.fromtimestamp(later).astimezone() + self.assertEqual(result, expected) + + def test_empty_directory_returns_epoch(self): + self.fs.create_dir("/test/empty") + result = most_recent_update("/test/empty") + expected = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc) + self.assertEqual(result, expected) + + def test_nonexistent_directory_raises(self): + with self.assertRaises(FileNotFoundError): + most_recent_update("/does/not/exist") + + def test_path_is_not_directory(self): + self.fs.create_file("/test/file.txt") + with self.assertRaises(NotADirectoryError): + most_recent_update("/test/file.txt") + + +class TestInstallationManifest(PyFakeFSTestCase): + + def setUp(self): + self.setUpPyfakefs() + + def tearDown(self): + pass + + @patch("addonmanager_installation_manifest.fci.DataPaths") + def test_manifest_created_when_missing(self, mock_data_paths): + # Arrange + self.mod_dir = "/fake/mod" + self.manifest_path = os.path.join(self.mod_dir, "manifest.json") + + mock_data_paths.return_value.mod_dir = self.mod_dir + + self.fs.create_dir(self.mod_dir) + self.fs.create_dir(os.path.join(self.mod_dir, "SomeAddon")) + + # File does NOT exist before Act + self.assertFalse(os.path.exists(self.manifest_path)) + + catalog = MockCatalog() + + # Act + InstallationManifest.path_to_manifest_file = "" # clear any previous state + manifest = InstallationManifest(catalog) + + # Assert + + # File should now exist + self.assertTrue(os.path.exists(self.manifest_path)) + + # File contents should match the expected manifest structure + with open(self.manifest_path, "r") as f: + data = json.load(f) + + expected = { + "SomeAddon": { + "addon_id": "SomeAddon", + "migrated": True, + "first_installed": datetime.datetime.fromtimestamp( + 0, tz=datetime.timezone.utc + ).isoformat(), + "last_updated": manifest.get_addon_info("SomeAddon")[ + "last_updated" + ], # can't hardcode + "branch_display_name": "Main", + "extra_files": [], + "freecad_version": "", + } + } + + self.assertEqual(data["SomeAddon"]["addon_id"], expected["SomeAddon"]["addon_id"]) + self.assertEqual(data["SomeAddon"]["migrated"], expected["SomeAddon"]["migrated"]) + self.assertEqual( + data["SomeAddon"]["branch_display_name"], expected["SomeAddon"]["branch_display_name"] + ) + self.assertEqual(data["SomeAddon"]["extra_files"], expected["SomeAddon"]["extra_files"]) + self.assertEqual( + data["SomeAddon"]["freecad_version"], expected["SomeAddon"]["freecad_version"] + ) + # Validate that last_updated is a valid ISO8601 datetime string + self.assertTrue(datetime.datetime.fromisoformat(data["SomeAddon"]["last_updated"])) + + @patch("addonmanager_installation_manifest.fci.DataPaths") + def test_manifest_created_without_catalog(self, mock_data_paths): + mod_dir = "/fake/mod" + manifest_path = os.path.join(mod_dir, "manifest.json") + + mock_data_paths.return_value.mod_dir = mod_dir + + self.fs.create_dir(mod_dir) + self.fs.create_dir(os.path.join(mod_dir, "OrphanAddon")) + + InstallationManifest.path_to_manifest_file = "" # Reset shared class var + manifest = InstallationManifest(catalog=None) + + self.assertTrue(os.path.exists(manifest_path)) + + with open(manifest_path, "r") as f: + data = json.load(f) + self.assertEqual(data, {}) + + @patch("addonmanager_installation_manifest.fci.DataPaths") + def test_manifest_loaded_if_exists(self, mock_data_paths): + mod_dir = "/fake/mod" + manifest_path = os.path.join(mod_dir, "manifest.json") + mock_data_paths.return_value.mod_dir = mod_dir + + self.fs.create_dir(mod_dir) + known_addon_id = "KnownAddon" + backup_addon_id = "SomeAddon_backup" + self.fs.create_dir(os.path.join(mod_dir, known_addon_id)) + self.fs.create_dir(os.path.join(mod_dir, backup_addon_id)) + + fake_manifest_data = { + known_addon_id: { + "addon_id": known_addon_id, + "migrated": True, + "first_installed": "2024-01-01T00:00:00+00:00", + "last_updated": "2024-01-01T00:00:00+00:00", + "branch_display_name": "Main", + "extra_files": [], + "freecad_version": "0.20", + } + } + with open(manifest_path, "w") as f: + json.dump(fake_manifest_data, f) + + InstallationManifest.path_to_manifest_file = "" # Reset class var + manifest = InstallationManifest(catalog=None) + + self.assertIn(known_addon_id, manifest._manifest) + self.assertEqual(manifest._manifest[known_addon_id]["branch_display_name"], "Main") + + # Manifest file should still exist and be unmodified (load only) + with open(manifest_path, "r") as f: + reloaded = json.load(f) + self.assertEqual(reloaded, fake_manifest_data) + + @patch("addonmanager_installation_manifest.fci.DataPaths") + def test_unrecognized_addons_are_detected(self, mock_data_paths): + mod_dir = "/fake/mod" + mock_data_paths.return_value.mod_dir = mod_dir + self.fs.create_dir(mod_dir) + + self.fs.create_dir(os.path.join(mod_dir, "UnknownAddon")) + + manifest_path = os.path.join(mod_dir, "manifest.json") + with open(manifest_path, "w") as f: + f.write("{}") + + class MinimalMockCatalog: + def get_available_branches(self, addon_id): + return [] + + catalog = MinimalMockCatalog() + InstallationManifest.path_to_manifest_file = "" + manifest = InstallationManifest(catalog) + + self.assertIn("UnknownAddon", manifest.unrecognized_directories) + + @patch("addonmanager_installation_manifest.fci.DataPaths") + def test_backups_are_detected(self, mock_data_paths): + mod_dir = "/fake/mod" + mock_data_paths.return_value.mod_dir = mod_dir + self.fs.create_dir(mod_dir) + + self.fs.create_dir(os.path.join(mod_dir, "SomeAddon-backup-1-2-3")) + + manifest_path = os.path.join(mod_dir, "manifest.json") + with open(manifest_path, "w") as f: + f.write("{}") + + class MinimalMockCatalog: + def get_available_branches(self, addon_id): + return [] + + catalog = MinimalMockCatalog() + InstallationManifest.path_to_manifest_file = "" + manifest = InstallationManifest(catalog) + + self.assertIn("SomeAddon-backup-1-2-3", manifest.old_backups) diff --git a/AddonManagerTest/app/test_installer.py b/AddonManagerTest/app/test_installer.py index 5b9c9dae..750c020f 100644 --- a/AddonManagerTest/app/test_installer.py +++ b/AddonManagerTest/app/test_installer.py @@ -24,7 +24,7 @@ """Contains the unit test class for addonmanager_installer.py non-GUI functionality.""" import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch import os import shutil import tempfile @@ -114,7 +114,8 @@ def test_update_metadata(self): installer._update_metadata() self.assertEqual(self.real_addon.installed_version, good_metadata.version) - def test_finalize_zip_installation_non_github(self): + @patch("addonmanager_installer.InstallationManifest") + def test_finalize_zip_installation_non_github(self, mock_manifest): """Ensure that zip files are correctly extracted.""" with tempfile.TemporaryDirectory() as temp_dir: test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip") @@ -127,7 +128,8 @@ def test_finalize_zip_installation_non_github(self): expected_location = os.path.join(temp_dir, non_gh_mock.name, "README") self.assertTrue(os.path.isfile(expected_location), "Non-GitHub zip extraction failed") - def test_finalize_zip_installation_github(self): + @patch("addonmanager_installer.InstallationManifest") + def test_finalize_zip_installation_github(self, mock_manifest): with tempfile.TemporaryDirectory() as temp_dir: test_github_style_repo = os.path.join(self.test_data_dir, "test_github_style_repo.zip") self.mock_addon.url = "https://github.com/something/test_github_style_repo" @@ -189,7 +191,8 @@ def test_move_code_out_of_subdirectory(self): self.assertTrue(os.path.isfile(os.path.join(temp_dir, "AnotherFile.txt"))) self.assertFalse(os.path.isdir(subdir)) - def test_install_by_git(self): + @patch("addonmanager_installer.InstallationManifest") + def test_install_by_git(self, mock_manifest): """Test using git to install. Depends on there being a local git installation: the test is skipped if there is no local git.""" git_manager = initialize_git() @@ -218,7 +221,8 @@ def test_install_by_git(self): readme = os.path.join(addon_name_dir, "README.md") self.assertTrue(os.path.exists(readme)) - def test_install_by_copy(self): + @patch("addonmanager_installer.InstallationManifest") + def test_install_by_copy(self, manifest): """Test using a simple filesystem copy to install an addon.""" with tempfile.TemporaryDirectory() as temp_dir: git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip") @@ -404,7 +408,8 @@ def setUp(self): self.mock = MockAddon() self.mock.macro = MockMacro() - def test_installation(self): + @patch("addonmanager_installer.InstallationManifest") + def test_installation(self, mock_installation_manifest): """Test the wrapper around the macro installer""" # Note that this doesn't test the underlying Macro object's install function, diff --git a/AddonManagerTest/app/test_uninstaller.py b/AddonManagerTest/app/test_uninstaller.py index 4396da0a..9ccfef5c 100644 --- a/AddonManagerTest/app/test_uninstaller.py +++ b/AddonManagerTest/app/test_uninstaller.py @@ -28,6 +28,7 @@ from stat import S_IREAD, S_IRGRP, S_IROTH, S_IWUSR import tempfile import unittest +from unittest.mock import patch from addonmanager_uninstaller import AddonUninstaller, MacroUninstaller @@ -76,7 +77,8 @@ def create_fake_macro(self, macro_directory, fake_macro_name, digest): with open(fake_file_installed, "w", encoding="utf-8") as f: f.write("# Fake macro data for unit testing") - def test_uninstall_normal(self): + @patch("addonmanager_uninstaller.InstallationManifest") + def test_uninstall_normal(self, mock_installation_manifest): """Test the integrated uninstall function under normal circumstances""" with tempfile.TemporaryDirectory() as temp_dir: @@ -124,7 +126,8 @@ def test_uninstall_unmatching_name(self): self.assertNotIn("success", self.signals_caught) self.assertIn("finished", self.signals_caught) - def test_uninstall_addon_with_macros(self): + @patch("addonmanager_uninstaller.InstallationManifest") + def test_uninstall_addon_with_macros(self, mock_manifest): """Tests that the uninstaller removes the macro files""" with tempfile.TemporaryDirectory() as temp_dir: toplevel_path = self.setup_dummy_installation(temp_dir) @@ -141,7 +144,8 @@ def test_uninstall_addon_with_macros(self): self.assertFalse(os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro"))) self.assertTrue(os.path.exists(macro_directory)) - def test_uninstall_calls_script(self): + @patch("addonmanager_uninstaller.InstallationManifest") + def test_uninstall_calls_script(self, mock_install_manifest): """Tests that the main uninstaller run function calls the uninstall.py script""" class Interceptor: diff --git a/AddonManagerTest/app/test_workers_startup.py b/AddonManagerTest/app/test_workers_startup.py index 2a0737b3..cad78a56 100644 --- a/AddonManagerTest/app/test_workers_startup.py +++ b/AddonManagerTest/app/test_workers_startup.py @@ -101,10 +101,13 @@ def create_fake_addon_catalog_json(num_entries: int): ] return json.dumps(catalog_dict) + @patch("addonmanager_workers_startup.InstallationManifest") @patch("addonmanager_workers_startup.CreateAddonListWorker.addon_repo") - def test_process_addon_catalog_single(self, mock_addon_repo_signal): + def test_process_addon_catalog_single(self, mock_addon_repo_signal, mock_manifest_class): # Arrange catalog_text = TestCreateAddonListWorker.create_fake_addon_catalog_json(1) + mock_manifest_instance = self.MockManifest() + mock_manifest_class.return_value = mock_manifest_instance # Act addonmanager_workers_startup.CreateAddonListWorker().process_addon_cache(catalog_text) @@ -112,25 +115,42 @@ def test_process_addon_catalog_single(self, mock_addon_repo_signal): # Assert mock_addon_repo_signal.emit.assert_called_once() + class MockManifest: + def __init__(self): + self.old_backups = [] + + def contains(self, _): + return False + + @patch("addonmanager_workers_startup.InstallationManifest") @patch("addonmanager_workers_startup.CreateAddonListWorker.addon_repo") - def test_process_addon_catalog_multiple(self, mock_addon_repo_signal): + def test_process_addon_catalog_multiple(self, mock_addon_repo_signal, mock_manifest_class): # Arrange catalog_text = TestCreateAddonListWorker.create_fake_addon_catalog_json(10) + mock_manifest_instance = self.MockManifest() + mock_manifest_class.return_value = mock_manifest_instance + # Act addonmanager_workers_startup.CreateAddonListWorker().process_addon_cache(catalog_text) # Assert self.assertEqual(mock_addon_repo_signal.emit.call_count, 10) + @patch("addonmanager_workers_startup.InstallationManifest") @patch("addonmanager_workers_startup.CreateAddonListWorker.addon_repo") @patch("addonmanager_workers_startup.fci.Console") - def test_process_addon_catalog_with_user_override(self, _, mock_addon_repo_signal): + def test_process_addon_catalog_with_user_override( + self, _, mock_addon_repo_signal, mock_manifest_class + ): # Arrange catalog_text = TestCreateAddonListWorker.create_fake_addon_catalog_json(10) worker = addonmanager_workers_startup.CreateAddonListWorker() worker.package_names = ["FakeAddon1", "FakeAddon2"] + mock_manifest_instance = self.MockManifest() + mock_manifest_class.return_value = mock_manifest_instance + # Act worker.process_addon_cache(catalog_text) diff --git a/AddonManagerTest/gui/test_change_branch.py b/AddonManagerTest/gui/test_change_branch.py deleted file mode 100644 index 6dd44fe9..00000000 --- a/AddonManagerTest/gui/test_change_branch.py +++ /dev/null @@ -1,231 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later -# *************************************************************************** -# * * -# * Copyright (c) 2025 The FreeCAD Project Association AISBL * -# * * -# * 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 * -# * . * -# * * -# *************************************************************************** - -"""Test the Change Branch GUI code""" - -# pylint: disable=wrong-import-position, deprecated-module, too-many-return-statements - -import sys -import unittest -from unittest.mock import patch, Mock, MagicMock - -from AddonManagerTest.gui.gui_mocks import DialogWatcher, AsynchronousMonitor - -from change_branch import ChangeBranchDialog - -from addonmanager_freecad_interface import translate -from addonmanager_git import GitFailed - -try: - from PySide import QtCore, QtWidgets -except ImportError: - try: - from PySide6 import QtCore, QtWidgets - except ImportError: - from PySide2 import QtCore, QtWidgets - - -class MockFilter(QtCore.QSortFilterProxyModel): - """Replaces a filter with a non-filter that simply always returns whatever it's given""" - - def mapToSource(self, something): - return something - - -class MockChangeBranchDialogModel(QtCore.QAbstractTableModel): - """Replace a data-connected model with a static one for testing""" - - branches = [ - {"ref_name": "ref1", "upstream": "us1"}, - {"ref_name": "ref2", "upstream": "us2"}, - {"ref_name": "ref3", "upstream": "us3"}, - ] - current_branch = "ref1" - DataSortRole = QtCore.Qt.UserRole - RefAccessRole = QtCore.Qt.UserRole + 1 - - def __init__(self, _: str, parent=None) -> None: - super().__init__(parent) - - def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: - """Number of rows: should always return 3""" - if parent.isValid(): - return 0 - return len(self.branches) - - def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: - """Number of columns (identical to non-mocked version)""" - if parent.isValid(): - return 0 - return 3 # Local name, remote name, date - - def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole): - """Mock returns static untranslated strings for DisplayRole, no tooltips at all, and - otherwise matches the non-mock version""" - if not index.isValid(): - return None - row = index.row() - column = index.column() - if role == QtCore.Qt.DisplayRole: - if column == 2: - return "date" - if column == 0: - return "ref_name" - if column == 1: - return "upstream" - return None - if role == MockChangeBranchDialogModel.DataSortRole: - return None - if role == MockChangeBranchDialogModel.RefAccessRole: - return self.branches[row] - return None - - def headerData( - self, - section: int, - orientation: QtCore.Qt.Orientation, - role: int = QtCore.Qt.DisplayRole, - ): - """Mock returns untranslated strings for DisplayRole, and no tooltips at all""" - if orientation == QtCore.Qt.Vertical: - return None - if role != QtCore.Qt.DisplayRole: - return None - if section == 0: - return "Local" - if section == 1: - return "Remote tracking" - if section == 2: - return "Last Updated" - return None - - def currentBranch(self) -> str: - """Mock returns a static string stored in the class: that string could be modified to - return something else by tests that require it.""" - return self.current_branch - - -class TestChangeBranchGui(unittest.TestCase): - """Tests for the ChangeBranch GUI code""" - - MODULE = "test_change_branch" # file name without extension - - def setUp(self): - pass - - def tearDown(self): - pass - - @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel) - @patch("change_branch.initialize_git", new=Mock(return_value=None)) - def test_no_git(self): - """If git is not present, a dialog saying so is presented""" - # Arrange - gui = ChangeBranchDialog("/some/path") - ref = {"ref_name": "foo/bar", "upstream": "us1"} - dialog_watcher = DialogWatcher( - "AddonManager_CannotFindGitDialog", - QtWidgets.QDialogButtonBox.Ok, - ) - - # Act - gui.change_branch("/foo/bar/baz", ref) - - # Assert - self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box") - - @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel) - @patch("change_branch.initialize_git") - def test_git_failed(self, init_git: MagicMock): - """If git fails when attempting to change branches, a dialog saying so is presented""" - # Arrange - git_manager = MagicMock() - git_manager.checkout = MagicMock() - git_manager.checkout.side_effect = GitFailed() - init_git.return_value = git_manager - gui = ChangeBranchDialog("/some/path") - ref = {"ref_name": "foo/bar", "upstream": "us1"} - dialog_watcher = DialogWatcher( - "AddonManager_GitOperationFailedDialog", - QtWidgets.QDialogButtonBox.Ok, - ) - - # Act - gui.change_branch("/foo/bar/baz", ref) - - # Assert - self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box") - - @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel) - @patch("change_branch.initialize_git", new=MagicMock) - def test_branch_change_succeeded(self): - """If nothing gets thrown, then the process is assumed to have worked, and the appropriate - signal is emitted.""" - - # Arrange - gui = ChangeBranchDialog("/some/path") - ref = {"ref_name": "foo/bar", "upstream": "us1"} - monitor = AsynchronousMonitor(gui.branch_changed) - - # Act - gui.change_branch("/foo/bar/baz", ref) - - # Assert - monitor.wait_for_at_most(10) # Should be effectively instantaneous - self.assertTrue(monitor.good()) - - @patch("change_branch.ChangeBranchDialogFilter", new=MockFilter) - @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel) - @patch("change_branch.initialize_git", new=MagicMock) - def test_warning_is_shown_when_dialog_is_accepted(self): - """If the dialog is accepted (e.g. a branch change is requested) then a warning dialog is - displayed, and gives the opportunity to cancel. If cancelled, no signal is emitted.""" - # Arrange - gui = ChangeBranchDialog("/some/path") - gui.ui.exec = MagicMock() - gui.ui.exec.return_value = QtWidgets.QDialog.Accepted - gui.ui.tableView.selectedIndexes = MagicMock() - gui.ui.tableView.selectedIndexes.return_value = [MagicMock()] - gui.ui.tableView.selectedIndexes.return_value[0].isValid = MagicMock() - gui.ui.tableView.selectedIndexes.return_value[0].isValid.return_value = True - dialog_watcher = DialogWatcher( - "AddonManager_DeveloperFeatureDialog", - QtWidgets.QDialogButtonBox.Cancel, - ) - monitor = AsynchronousMonitor(gui.branch_changed) - - # Act - gui.exec() - - # Assert - self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box") - self.assertFalse(monitor.good()) # The watcher cancelled the op, so no signal is emitted - - -if __name__ == "__main__": - app = QtWidgets.QApplication(sys.argv) - QtCore.QTimer.singleShot(0, unittest.main) - if hasattr(app, "exec"): - app.exec() # PySide6 - else: - app.exec_() # PySide2 diff --git a/CMakeLists.txt b/CMakeLists.txt index 788b4f3a..7a03999f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,8 +44,6 @@ SET(AddonManager_SRCS addonmanager_utilities.py addonmanager_workers_startup.py addonmanager_workers_utility.py - change_branch.py - change_branch.ui compact_view.py composite_view.py dependency_resolution_dialog.ui diff --git a/NetworkManager.py b/NetworkManager.py index 56d256f0..30d6212e 100644 --- a/NetworkManager.py +++ b/NetworkManager.py @@ -317,9 +317,9 @@ def __launch_request( if operation == QtNetwork.QNetworkAccessManager.GetOperation: reply = self.QNAM.get(request) elif operation == QtNetwork.QNetworkAccessManager.HeadOperation: - reply = self.QNAM.sendCustomRequest(request, b"HEAD") + reply = self.QNAM.head(request) else: - raise NotImplementedError(f"Unknown operation {operation}") + raise NotImplementedError(f"Unknown operation {operation.name}") self.replies[index] = reply self.__last_started_index = index @@ -619,6 +619,8 @@ def __reply_finished(self) -> None: else: if index in self.monitored_connections: self.progress_complete.emit(index, response_code, "") + elif reply.operation() == QtNetwork.QNetworkAccessManager.HeadOperation: + self.content_length.emit(index, response_code, 0) else: self.completed.emit(index, response_code, None) self.replies.pop(index) diff --git a/Widgets/addonmanager_widget_addon_buttons.py b/Widgets/addonmanager_widget_addon_buttons.py index 8ad8b1b6..dc487b6e 100644 --- a/Widgets/addonmanager_widget_addon_buttons.py +++ b/Widgets/addonmanager_widget_addon_buttons.py @@ -25,10 +25,11 @@ from enum import Enum, auto import os +from typing import List from addonmanager_freecad_interface import translate -from PySideWrapper import QtGui, QtWidgets +from PySideWrapper import QtCore, QtGui, QtWidgets class ButtonBarDisplayMode(Enum): @@ -38,8 +39,15 @@ class ButtonBarDisplayMode(Enum): class WidgetAddonButtons(QtWidgets.QWidget): + + install_branch = QtCore.Signal(str) + def __init__(self, parent: QtWidgets.QWidget = None): super().__init__(parent) + self.setup_to_change_branch = False + self.is_addon_manager = False + self.actions = [] + self.branch_menu = None self.display_mode = ButtonBarDisplayMode.TextAndIcons self._setup_ui() self._set_icons() @@ -53,6 +61,67 @@ def set_display_mode(self, mode: ButtonBarDisplayMode): self._set_icons() self.retranslateUi(None) + def set_can_check_for_updates(self, can_check_for_updates: bool): + """Only non-catalog addons have a separate update checker -- addons in the catalog don't + query their update status individually.""" + self.update.setVisible(can_check_for_updates) + + def set_installation_status( + self, installed: bool, available_branches: List[str], disabled: bool + ): + """Set up the buttons for a given installation status. + :param installed: Whether the addon is currently installed or not. + :param available_branches: The list of branches available -- cna be empty in which case it is + not presented to the user as an option to change. + :param disabled: Whether the addon is currently disabled.""" + self.is_addon_manager = False + self.setup_to_change_branch = False + self.uninstall.setVisible(installed) + if not available_branches or len(available_branches) == 1 and not installed: + self.install.setVisible(not installed) + self.install.setMenu(None) + self.branch_menu = None + else: + self.install.setVisible(True) + self.branch_menu = QtWidgets.QMenu() + self.install.setMenu(self.branch_menu) + self.actions.clear() + if installed: + self.setup_to_change_branch = True + for branch in available_branches: + if hasattr(QtGui, "QAction"): + # Qt6 + new_action = QtGui.QAction() + else: + # Qt5 + new_action = QtWidgets.QAction() + new_action.setText(branch) + new_action.triggered.connect(self.action_activated) + self.actions.append(new_action) + self.branch_menu.addAction(new_action) + + self.enable.setVisible(installed and disabled) + self.disable.setVisible(installed and not disabled) + self.retranslateUi(None) + + def action_activated(self, _): + sender = self.sender() + if not sender: + return + if hasattr(sender, "text"): + self.install_branch.emit(sender.text()) + + def set_can_run(self, can_run: bool): + self.run_macro.setVisible(can_run) + + def setup_for_addon_manager(self): + """If the addon in question is the Addon Manager itself, then we tweak some things: there + is no "disable" option, and "uninstall" becomes "revert to built-in".""" + self.disable.setVisible(False) + self.enable.setVisible(False) + self.is_addon_manager = True + self.retranslateUi(None) + def _setup_ui(self): if self.layout(): self.setLayout(None) # TODO: Check this @@ -65,7 +134,6 @@ def _setup_ui(self): self.disable = QtWidgets.QPushButton(self) self.update = QtWidgets.QPushButton(self) self.run_macro = QtWidgets.QPushButton(self) - self.change_branch = QtWidgets.QPushButton(self) self.check_for_update = QtWidgets.QPushButton(self) self.horizontal_layout.addWidget(self.back) self.horizontal_layout.addStretch() @@ -76,7 +144,6 @@ def _setup_ui(self): self.horizontal_layout.addWidget(self.disable) self.horizontal_layout.addWidget(self.update) self.horizontal_layout.addWidget(self.run_macro) - self.horizontal_layout.addWidget(self.change_branch) self.setLayout(self.horizontal_layout) def set_show_back_button(self, show: bool) -> None: @@ -90,11 +157,18 @@ def _set_icons(self): def retranslateUi(self, _): self.check_for_update.setText(translate("AddonsInstaller", "Check for Update")) - self.install.setText(translate("AddonsInstaller", "Install")) - self.uninstall.setText(translate("AddonsInstaller", "Uninstall")) + if self.setup_to_change_branch: + self.install.setText(translate("AddonsInstaller", "Switch to branch")) + elif self.is_addon_manager: + self.install.setText(translate("AddonsInstaller", "Override built-in")) + else: + self.install.setText(translate("AddonsInstaller", "Install")) self.disable.setText(translate("AddonsInstaller", "Disable")) self.enable.setText(translate("AddonsInstaller", "Enable")) self.update.setText(translate("AddonsInstaller", "Update")) self.run_macro.setText(translate("AddonsInstaller", "Run")) - self.change_branch.setText(translate("AddonsInstaller", "Change Branch…")) self.back.setToolTip(translate("AddonsInstaller", "Return to Package List")) + if self.is_addon_manager: + self.uninstall.setText(translate("AddonsInstaller", "Revert to built-in")) + else: + self.uninstall.setText(translate("AddonsInstaller", "Uninstall")) diff --git a/Widgets/addonmanager_widget_package_details_view.py b/Widgets/addonmanager_widget_package_details_view.py index 105ba01b..d87f5007 100644 --- a/Widgets/addonmanager_widget_package_details_view.py +++ b/Widgets/addonmanager_widget_package_details_view.py @@ -202,30 +202,8 @@ def set_updated(self): self.message_label.setStyleSheet("color:" + attention_color_string()) def _sync_ui_state(self): - self._sync_button_state() self._create_status_label_text() - def _sync_button_state(self): - self.button_bar.install.setVisible(not self.installed) - self.button_bar.uninstall.setVisible(self.installed) - if not self.installed: - self.button_bar.disable.hide() - self.button_bar.enable.hide() - self.button_bar.update.hide() - self.button_bar.check_for_update.hide() - else: - self.button_bar.update.setVisible(self.update_info.update_available) - if self.update_info.detached_head: - self.button_bar.check_for_update.hide() - else: - self.button_bar.check_for_update.setVisible(not self.update_info.update_available) - if self.can_disable: - self.button_bar.enable.setVisible(self.disabled) - self.button_bar.disable.setVisible(not self.disabled) - else: - self.button_bar.enable.hide() - self.button_bar.disable.hide() - def _create_status_label_text(self): if self.installed: installation_details = self._get_installation_details_string() diff --git a/addonmanager_installation_manifest.py b/addonmanager_installation_manifest.py new file mode 100644 index 00000000..f3097493 --- /dev/null +++ b/addonmanager_installation_manifest.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 The FreeCAD project association AISBL * +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** +import json +import os +import threading +import datetime +from pathlib import Path +from typing import Dict + +import addonmanager_freecad_interface as fci +from Addon import Addon +from AddonCatalog import AddonCatalog + + +def most_recent_update(directory_path: str) -> datetime.datetime: + """Walk through a path and return the most recent update time.""" + path = Path(directory_path) + + if not path.exists(): + raise FileNotFoundError(f"Directory not found: {path}") + if not path.is_dir(): + raise NotADirectoryError(f"Path is not a directory: {path}") + + latest_time = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc) + for file_path in path.rglob("*"): + if file_path.is_file(): + try: + mtime = datetime.datetime.fromtimestamp(file_path.stat().st_mtime).astimezone() + if mtime > latest_time: + latest_time = mtime + except (PermissionError, OSError): + continue + return latest_time + + +class InstallationManifest: + """The installation manifest tracks installation and updates of addons so that the Addon Manager + knows what branch is currently installed (this gets reflected in the change-branch UI for addons + that list multiple branches in the catalog). It also tracks the original installation time and + last updated time, which may eventually get used in the interface.""" + + lock = threading.Lock() + + path_to_manifest_file = "" + + def __init__(self, catalog: AddonCatalog = None): + self._manifest = {} + self.old_backups = [] + self.unrecognized_directories = [] + if not InstallationManifest.path_to_manifest_file: + InstallationManifest.path_to_manifest_file = os.path.join( + fci.DataPaths().mod_dir, "manifest.json" + ) + if not os.path.exists(InstallationManifest.path_to_manifest_file): + self._migrate_to_manifest_file(catalog) + self.write_manifest() + else: + self.load_manifest() + if catalog: + self._scan_mod_path_for_extras(catalog) + + def _migrate_to_manifest_file(self, catalog: AddonCatalog): + dirs_in_mod = os.listdir(fci.DataPaths().mod_dir) + for addon_id in dirs_in_mod: + branches = [] + if catalog: + branches = catalog.get_available_branches(addon_id) + if branches: + branch_display_name = branches[0] + self._manifest[addon_id] = { + "addon_id": addon_id, + "migrated": True, + "first_installed": datetime.datetime.fromtimestamp( + 0, tz=datetime.timezone.utc + ).isoformat(), + "last_updated": most_recent_update( + os.path.join(fci.DataPaths().mod_dir, addon_id) + ).isoformat(), + "branch_display_name": branch_display_name, + "extra_files": [], + "freecad_version": "", + } + + def load_manifest(self): + """Load the manifest from the disk""" + with InstallationManifest.lock: + with open(self.path_to_manifest_file, "r") as f: + self._manifest = json.load(f) + + def write_manifest(self): + """Write the manifest to the disk""" + with InstallationManifest.lock: + with open(self.path_to_manifest_file, "w") as f: + json.dump(self._manifest, f, indent=2) + + def _scan_mod_path_for_extras(self, catalog: AddonCatalog): + dirs_in_mod = os.listdir(fci.DataPaths().mod_dir) + for addon_id in dirs_in_mod: + if not os.path.isdir(os.path.join(fci.DataPaths().mod_dir, addon_id)): + continue + branches = catalog.get_available_branches(addon_id) + if not branches: + if "backup" in addon_id: + fci.Console.PrintMessage("Found old backup directory: " + addon_id + "\n") + self.old_backups.append(addon_id) + else: + fci.Console.PrintMessage( + "Found addon not in main AM catalog: " + addon_id + "\n" + ) + self.unrecognized_directories.append(addon_id) + + def record_new_installation(self, addon_id: str, addon: Addon, extra_files: list = None): + """Record the first installation of an addon. + :param addon_id: The addon ID + :param addon: The Addon object + :param extra_files: A list of extra files to record as installed + """ + self._manifest[addon_id] = { + "addon_id": addon_id, + "migrated": False, + "first_installed": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "last_updated": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "branch_display_name": addon.branch_display_name, + "extra_files": [] if extra_files is None else extra_files, + "freecad_version": fci.Version()[0:3], + } + self.write_manifest() + + def record_update(self, addon_id: str, addon: Addon, extra_files: list = None): + """Record the update of an addon that was already installed. Will raise an exception if the + addon is not already in the manifest. + :param addon_id: The addon ID + :param addon: The Addon object + :param extra_files: A list of extra files to record as installed (typically copied FCmacro files) + """ + self._manifest[addon_id]["last_updated"] = datetime.datetime.now( + datetime.timezone.utc + ).isoformat() + self._manifest[addon_id]["branch_display_name"] = addon.branch_display_name + self._manifest[addon_id]["freecad_version"] = fci.Version()[0:3] + if extra_files: + all_extra_files = set(self._manifest[addon_id]["extra_files"]) + all_extra_files.update(extra_files) + self._manifest[addon_id]["extra_files"] = list(all_extra_files) + self.write_manifest() + + def remove(self, addon_id: str): + """Remove an addon from the manifest (that is, it was uninstalled -- we don't record the + uninstallation, it just gets dropped).""" + self._manifest.pop(addon_id, None) + self.write_manifest() + + def contains(self, addon_id: str) -> bool: + return addon_id in self._manifest + + def get_addon_info(self, addon_id: str) -> Dict: + return self._manifest[addon_id] diff --git a/addonmanager_installer.py b/addonmanager_installer.py index 95710b94..6d432ca1 100644 --- a/addonmanager_installer.py +++ b/addonmanager_installer.py @@ -40,6 +40,7 @@ from Addon import Addon import addonmanager_utilities as utils +from addonmanager_installation_manifest import InstallationManifest from addonmanager_metadata import get_branch_from_metadata from addonmanager_git import initialize_git, GitFailed @@ -449,7 +450,17 @@ def _move_code_out_of_subdirectory(self, destination): def _finalize_successful_installation(self): """Perform any necessary additional steps after installing the addon.""" self._update_metadata() - self._install_macros() + extra_files = self._install_macros() + manifest = InstallationManifest() + if manifest.contains(self.addon_to_install.name): + manifest.record_update( + self.addon_to_install.name, self.addon_to_install, extra_files=extra_files + ) + else: + manifest.record_new_installation( + self.addon_to_install.name, self.addon_to_install, extra_files=extra_files + ) + self.success.emit(self.addon_to_install) def _update_metadata(self): @@ -463,7 +474,7 @@ def _update_metadata(self): self.addon_to_install.installed_version = self.addon_to_install.metadata.version self.addon_to_install.updated_timestamp = os.path.getmtime(package_xml) - def _install_macros(self): + def _install_macros(self) -> List[str]: """For any workbenches, copy FCMacro files into the macro directory. Exclude packages that have preference packs, otherwise we will litter the macro directory with the pre and post scripts.""" @@ -471,7 +482,7 @@ def _install_macros(self): isinstance(self.addon_to_install, Addon) and self.addon_to_install.contains_preference_pack() ): - return + return [] if not os.path.exists(self.macro_installation_path): os.makedirs(self.macro_installation_path) @@ -503,6 +514,7 @@ def _install_macros(self): ) for fcmacro_file in installed_macro_files: f.write(fcmacro_file + "\n") + return installed_macro_files @classmethod def _validate_object(cls, addon: object): @@ -585,6 +597,17 @@ def _write_installation_manifest(self, manifest): ) fci.Console.PrintWarning(manifest_file) + # Update the primary manifest as well + manifest = InstallationManifest() + if manifest.contains(self.addon_to_install.name): + manifest.record_update( + self.addon_to_install.name, self.addon_to_install, extra_files=[] + ) + else: + manifest.record_new_installation( + self.addon_to_install.name, self.addon_to_install, extra_files=[] + ) + @classmethod def _validate_object(cls, addon): """Make sure this object provides an attribute called "macro" with a method called diff --git a/addonmanager_package_details_controller.py b/addonmanager_package_details_controller.py index 104d2d23..58ec2718 100644 --- a/addonmanager_package_details_controller.py +++ b/addonmanager_package_details_controller.py @@ -39,7 +39,6 @@ from addonmanager_workers_startup import CheckSingleUpdateWorker from addonmanager_git import GitManager, NoGitFound from Addon import Addon -from change_branch import ChangeBranchDialog from addonmanager_readme_controller import ReadmeController from Widgets.addonmanager_widget_package_details_view import UpdateInformation, WarningFlags @@ -76,18 +75,32 @@ def __init__(self, widget=None): self.ui.button_bar.install.clicked.connect(lambda: self.install.emit(self.addon)) self.ui.button_bar.uninstall.clicked.connect(lambda: self.uninstall.emit(self.addon)) self.ui.button_bar.update.clicked.connect(lambda: self.update.emit(self.addon)) - self.ui.button_bar.change_branch.clicked.connect(self.change_branch_clicked) self.ui.button_bar.enable.clicked.connect(self.enable_clicked) self.ui.button_bar.disable.clicked.connect(self.disable_clicked) + self.ui.button_bar.install_branch.connect(self.install_branch) - def show_repo(self, repo: Addon) -> None: + def show_repo(self, addon: Addon) -> None: """The main entry point for this class shows the package details and related buttons for the provided repo.""" - self.addon = repo - self.readme_controller.set_addon(repo) + self.addon = addon + self.readme_controller.set_addon(addon) self.original_disabled_state = self.addon.is_disabled() - if repo is not None: + if addon is not None: self.ui.button_bar.show() + branches = [] + if addon.status() == Addon.Status.NOT_INSTALLED: + branches.append(addon.branch_display_name) + if addon.sub_addons: + branches.extend(addon.sub_addons.keys()) + self.ui.button_bar.set_installation_status( + installed=addon.status() != Addon.Status.NOT_INSTALLED, + available_branches=branches, + disabled=addon.is_disabled(), + ) + self.ui.button_bar.set_can_run(addon.contains_macro()) + self.ui.button_bar.set_can_check_for_updates(True if addon.cache_directory else False) + if addon.name == "AddonManager": + self.ui.button_bar.setup_for_addon_manager() # Must happen AFTER other config steps else: self.ui.button_bar.hide() @@ -98,8 +111,8 @@ def show_repo(self, repo: Addon) -> None: installed = self.addon.status() != Addon.Status.NOT_INSTALLED self.ui.set_installed(installed) - if repo.metadata is not None: - self.ui.set_url(get_repo_url_from_metadata(repo.metadata)) + if addon.metadata is not None: + self.ui.set_url(get_repo_url_from_metadata(addon.metadata)) else: self.ui.set_url(None) # to reset it and hide it update_info = UpdateInformation() @@ -107,33 +120,28 @@ def show_repo(self, repo: Addon) -> None: update_info.unchecked = self.addon.status() == Addon.Status.UNCHECKED update_info.update_available = self.addon.status() == Addon.Status.UPDATE_AVAILABLE update_info.check_in_progress = False # TODO: Implement the "check in progress" status - if repo.metadata: - update_info.branch = get_branch_from_metadata(repo.metadata) - update_info.version = str(repo.metadata.version) - elif repo.macro: - update_info.version = str(repo.macro.version) + if addon.metadata: + update_info.branch = get_branch_from_metadata(addon.metadata) + update_info.version = str(addon.metadata.version) + elif addon.macro: + update_info.version = str(addon.macro.version) self.ui.set_update_available(update_info) self.ui.set_location( self.addon.macro_directory - if repo.repo_type == Addon.Kind.MACRO + if addon.repo_type == Addon.Kind.MACRO else os.path.join(self.addon.mod_directory, self.addon.name) ) self.ui.set_disabled(self.addon.is_disabled()) - self.ui.allow_running(repo.repo_type == Addon.Kind.MACRO) - self.ui.allow_disabling(repo.repo_type != Addon.Kind.MACRO) + self.ui.allow_running(addon.repo_type == Addon.Kind.MACRO) + self.ui.allow_disabling(addon.repo_type != Addon.Kind.MACRO) - if repo.status() == Addon.Status.UNCHECKED: - self.ui.button_bar.check_for_update.show() - self.ui.button_bar.check_for_update.setText( - translate("AddonsInstaller", "Check for Update") - ) - self.ui.button_bar.check_for_update.setEnabled(True) + if addon.status() == Addon.Status.UNCHECKED: if not self.update_check_thread: self.update_check_thread = QtCore.QThread() self.update_check_thread.setObjectName( "PackageDetailsController update check thread" ) - self.check_for_update_worker = CheckSingleUpdateWorker(repo) + self.check_for_update_worker = CheckSingleUpdateWorker(addon) self.check_for_update_worker.moveToThread(self.update_check_thread) self.update_check_thread.finished.connect(self.check_for_update_worker.deleteLater) self.ui.button_bar.check_for_update.clicked.connect( @@ -145,69 +153,7 @@ def show_repo(self, repo: Addon) -> None: self.ui.button_bar.check_for_update.hide() flags = WarningFlags() - flags.required_freecad_version = self.requires_newer_freecad() self.ui.set_warning_flags(flags) - self.set_change_branch_button_state() - - def requires_newer_freecad(self) -> Optional[Version]: - """If the current package is not installed, returns the first supported version of - FreeCAD, if one is set, or None if no information is available (or if the package is - already installed).""" - - # If it's not installed, check to see if it's for a newer version of FreeCAD - if self.addon.status() == Addon.Status.NOT_INSTALLED and self.addon.metadata: - # Only hide if ALL content items require a newer version, otherwise - # it's possible that this package actually provides versions of itself - # for newer and older versions - - first_supported_version = get_first_supported_freecad_version(self.addon.metadata) - if first_supported_version is not None: - fc_version = Version(from_list=fci.Version()) - if first_supported_version > fc_version: - return first_supported_version - return None - - def set_change_branch_button_state(self): - """The change branch button is only available for installed Addons that have a .git directory - and in runs where the git is available.""" - - self.ui.button_bar.change_branch.hide() - - pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons") - show_switcher = pref.GetBool("ShowBranchSwitcher", False) - if not show_switcher: - return - - # Is this repo installed? If not, return. - if self.addon.status() == Addon.Status.NOT_INSTALLED: - return - - # Is it a Macro? If so, return: - if self.addon.repo_type == Addon.Kind.MACRO: - return - - # Can we actually switch branches? If not, return. - if not self.git_manager: - return - - # Is there a .git subdirectory? If not, return. - basedir = fci.getUserAppDataDir() - path_to_git = os.path.join(basedir, "Mod", self.addon.name, ".git") - if not os.path.isdir(path_to_git): - return - - # If all four above checks passed, then it's possible for us to switch - # branches, if there are any besides the one we are on: show the button - self.ui.button_bar.change_branch.show() - self.ui.button_bar.change_branch.show() - - def change_branch_clicked(self) -> None: - """Loads the branch-switching dialog""" - basedir: str = fci.getUserAppDataDir() - path_to_repo = os.path.join(basedir, "Mod", self.addon.name) - change_branch_dialog = ChangeBranchDialog(path_to_repo, self.ui) - change_branch_dialog.branch_changed.connect(self.branch_changed) - change_branch_dialog.exec() def enable_clicked(self) -> None: """Called by the Enable button, enables this Addon and updates GUI to reflect @@ -235,6 +181,21 @@ def disable_clicked(self) -> None: self.addon.set_status(self.original_status) self.update_status.emit(self.addon) + def install_branch(self, branch: str): + if self.addon.branch_display_name == branch: + fci.Console.PrintMessage( + f"Installing active branch {branch} for {self.addon.display_name}\n" + ) + self.install.emit(self.addon) + return + if branch not in self.addon.sub_addons: + fci.Console.PrintError( + f"Internal error: branch {branch} not found in sub_addons list for addon {self.addon.display_name}.\n" + ) + return + fci.Console.PrintMessage(f"Installing sub-branch {branch} for {self.addon.display_name}\n") + self.install.emit(self.addon.sub_addons[branch]) + def branch_changed(self, old_branch: str, name: str) -> None: """Displays a dialog confirming the branch changed, and tries to access the metadata file from that branch.""" diff --git a/addonmanager_readme_controller.py b/addonmanager_readme_controller.py index d7f43260..fe90623e 100644 --- a/addonmanager_readme_controller.py +++ b/addonmanager_readme_controller.py @@ -32,6 +32,7 @@ from typing import Optional import NetworkManager +from addonmanager_metadata import UrlType translate = fci.translate @@ -84,6 +85,28 @@ def set_addon(self, repo: Addon): return else: self.url = utils.get_readme_url(repo) + if self.addon.metadata and self.addon.metadata.url: + for url in self.addon.metadata.url: + if url.type == UrlType.readme: + if self.url != url.location: + fci.Console.PrintLog("README url does not match expected location\n") + fci.Console.PrintLog(f"Expected: {self.url}\n") + fci.Console.PrintLog(f"package.xml contents: {url.location}\n") + fci.Console.PrintLog( + "Note to addon devs: package.xml now expects a" + " url to the raw MD data, now that Qt can render" + " it without having it transformed to HTML.\n" + ) + self.url = url.location + if "/blob/" in self.url: + fci.Console.PrintLog("Attempting to replace 'blob' with 'raw'...\n") + self.url = self.url.replace("/blob/", "/raw/") + elif "/src/" in self.url and "codeberg" in self.url: + fci.Console.PrintLog( + "Attempting to replace 'src' with 'raw' in codeberg URL..." + ) + self.url = self.url.replace("/src/", "/raw/") + self.widget.setUrl(self.url) self.widget.setText( diff --git a/addonmanager_uninstaller.py b/addonmanager_uninstaller.py index 9066511b..c788b016 100644 --- a/addonmanager_uninstaller.py +++ b/addonmanager_uninstaller.py @@ -31,6 +31,7 @@ import addonmanager_freecad_interface as fci import addonmanager_utilities as utils from Addon import Addon +from addonmanager_installation_manifest import InstallationManifest try: from PySide import QtCore # Use the FreeCAD wrapper @@ -132,6 +133,8 @@ def run(self) -> bool: "Could not find addon {} to remove it.", ).format(self.addon_to_remove.name) if success: + manifest = InstallationManifest() + manifest.remove(self.addon_to_remove.name) self.success.emit(self.addon_to_remove) else: self.failure.emit(self.addon_to_remove, error_message) diff --git a/addonmanager_update_all_gui.py b/addonmanager_update_all_gui.py index cacb2100..02059d8b 100644 --- a/addonmanager_update_all_gui.py +++ b/addonmanager_update_all_gui.py @@ -208,6 +208,7 @@ def _setup_main_dialog(self): self.dialog.setObjectName("AddonManager_UpdateAllDialog") self.dialog.table_view.setModel(self.model) self.dialog.update_button.clicked.connect(self.update_button_clicked) + self.dialog.button_box.rejected.connect(self.finished.emit) self.dialog.table_view.horizontalHeader().setStretchLastSection(False) self.dialog.table_view.horizontalHeader().setSectionResizeMode( diff --git a/addonmanager_workers_startup.py b/addonmanager_workers_startup.py index 815e6746..0a6d5741 100644 --- a/addonmanager_workers_startup.py +++ b/addonmanager_workers_startup.py @@ -33,6 +33,7 @@ import zipfile from PySideWrapper import QtCore +from addonmanager_installation_manifest import InstallationManifest from addonmanager_macro import Macro from Addon import Addon @@ -55,6 +56,7 @@ class CreateAddonListWorker(QtCore.QThread): addon_repo = QtCore.Signal(object) progress_made = QtCore.Signal(str, int, int) + old_backups_found = QtCore.Signal(object) MAX_ATTEMPTS = 3 RETRY_DELAY_SECONDS = 3 @@ -226,13 +228,14 @@ def new_cache_available(cache_name: str) -> bool: def process_addon_cache(self, catalog_text_data): catalog = AddonCatalog(json.loads(catalog_text_data)) + manifest = InstallationManifest(catalog) for addon_id in catalog.get_available_addon_ids(): if addon_id in self.package_names: # We already have something with this name, skip this one fci.Console.PrintWarning( translate( "AddonsInstaller", - "WARNING: User-provided custom addon {} is overriding the one in the official addon catalog\n", + "WARNING: Custom addon '{}' is overriding the one in the official addon catalog\n", ).format(addon_id) ) continue @@ -240,19 +243,70 @@ def process_addon_cache(self, catalog_text_data): branches = catalog.get_available_branches(addon_id) if not branches: fci.Console.PrintWarning( - f"Failed to find any compatible branches for {addon_id}. This is an internal error, please report it to the developers.\n" + f"Failed to find any compatible branches for '{addon_id}'. This is an internal error, please report it to the developers.\n" ) continue - main = branches[0] - # TODO: add multiple branch information to the Addon class - try: - addon = catalog.get_addon_from_id(addon_id, main[0]) - self.addon_repo.emit(addon) - except Exception as e: + + primary_addon = None + installed_branch = None + if manifest.contains(addon_id): + # Then this addon is currently installed: make sure we use the correct branch + installed_branch = manifest.get_addon_info(addon_id)["branch_display_name"] + fci.Console.PrintLog( + f"Found installed addon '{addon_id}' with branch '{installed_branch}'\n" + ) + for branch_display_name in branches: + if branch_display_name == installed_branch: + primary_addon = branch_display_name + break + if primary_addon is None: + fci.Console.PrintError( + f"Failed to find the installed branch '{installed_branch}' for addon '{addon_id}', skipping it.\n" + ) + continue + addon_instances = {} + name_of_first_entry = None + for branch_display_name in branches: + try: + addon = catalog.get_addon_from_id(addon_id, branch_display_name) + addon_instances[branch_display_name] = addon + if name_of_first_entry is None: + name_of_first_entry = branch_display_name + else: + fci.Console.PrintMessage( + f"Found additional branch '{branch_display_name}' for addon {addon_id}\n" + ) + except Exception as e: + # Any exception that occurs gets absorbed here in an attempt to find a working + # branch. Only if all proposed branches fail does this become an error that + # causes us to skip the addon + fci.Console.PrintWarning( + f"Could not load branch '{branch_display_name}' for addon {addon_id}: {str(e)}\n" + ) + continue + if name_of_first_entry is None: fci.Console.PrintError( f"Failed to load the addon {addon_id} from the addon catalog, skipping it.\n" ) - fci.Console.PrintError(str(e) + "\n") + continue + primary_branch_name = installed_branch if installed_branch else name_of_first_entry + for branch_display_name in branches: + if branch_display_name != primary_branch_name: + # Only add non-primary addons to the sub_addons list so that the primary addon + # doesn't list *itself* as a sub-addon + addon_instances[primary_branch_name].sub_addons[branch_display_name] = ( + addon_instances[branch_display_name] + ) + + if name_of_first_entry is None: + fci.Console.PrintError( + f"Failed to load the addon {addon_id} from the addon catalog, skipping it.\n" + ) + continue + self.addon_repo.emit(addon_instances[primary_branch_name]) + + if manifest.old_backups: + self.old_backups_found.emit(manifest.old_backups) def process_macro_cache(self, catalog_text_data): cache_object: dict = json.loads(catalog_text_data) diff --git a/change_branch.py b/change_branch.py deleted file mode 100644 index 3d2c6e5f..00000000 --- a/change_branch.py +++ /dev/null @@ -1,316 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later -# *************************************************************************** -# * * -# * Copyright (c) 2022-2025 The FreeCAD Project Association AISBL * -# * * -# * 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 * -# * . * -# * * -# *************************************************************************** - -"""The Change Branch dialog and utility classes and methods""" - -import os -from typing import Dict - -import addonmanager_freecad_interface as fci -import addonmanager_utilities as utils -from Widgets.addonmanager_utility_dialogs import MessageDialog - -from addonmanager_git import initialize_git, GitFailed - -try: - from PySide import QtWidgets, QtCore -except ImportError: - try: - from PySide6 import QtWidgets, QtCore - except ImportError: - from PySide2 import QtWidgets, QtCore # pylint: disable=deprecated-module - -translate = fci.translate - - -class ChangeBranchDialog(QtWidgets.QWidget): - """A dialog that displays available git branches and allows the user to select one to change - to. Includes code that does that change, as well as some modal dialogs to warn them of the - possible consequences and display various error messages.""" - - branch_changed = QtCore.Signal(str, str) - - def __init__(self, path: str, parent=None): - super().__init__(parent) - - self.ui = utils.loadUi(os.path.join(os.path.dirname(__file__), "change_branch.ui")) - self.ui.setObjectName("AddonManager_ChangeBranchDialog") - - self.item_filter = ChangeBranchDialogFilter() - self.ui.tableView.setModel(self.item_filter) - - self.item_model = ChangeBranchDialogModel(path, self) - self.item_filter.setSourceModel(self.item_model) - self.ui.tableView.sortByColumn( - 2, QtCore.Qt.DescendingOrder - ) # Default to sorting by remote last-changed date - - # Figure out what row gets selected: - git_manager = initialize_git() - if git_manager is None: - return - - row = 0 - self.current_ref = git_manager.current_branch(path) - selection_model = self.ui.tableView.selectionModel() - for ref in self.item_model.branches: - if ref["ref_name"] == self.current_ref: - index = self.item_filter.mapFromSource(self.item_model.index(row, 0)) - selection_model.select(index, QtCore.QItemSelectionModel.ClearAndSelect) - selection_model.select(index.siblingAtColumn(1), QtCore.QItemSelectionModel.Select) - selection_model.select(index.siblingAtColumn(2), QtCore.QItemSelectionModel.Select) - break - row += 1 - - # Make sure the column widths are OK: - header = self.ui.tableView.horizontalHeader() - header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) - - def exec(self): - """Run the Change Branch dialog and its various sub-dialogs. May result in the branch - being changed. Code that cares if that happens should connect to the branch_changed - signal.""" - if self.ui.exec() == QtWidgets.QDialog.Accepted: - - selection = self.ui.tableView.selectedIndexes() - index = self.item_filter.mapToSource(selection[0]) - ref = self.item_model.data(index, ChangeBranchDialogModel.RefAccessRole) - - if ref["ref_name"] == self.item_model.current_branch: - # This is the one we are already on... just return - return - - result = MessageDialog.show_modal( - MessageDialog.DialogType.ERROR, - "AddonManager_DeveloperFeatureDialog", - translate("AddonsInstaller", "DANGER: Developer feature"), - translate( - "AddonsInstaller", - "DANGER: Switching branches is intended for developers and beta testers, " - "and may result in broken, non-backwards compatible documents, instability, " - "crashes, and/or the premature heat death of the universe. Are you sure you " - "want to continue?", - ), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, - parent=self, - ) - if result == QtWidgets.QMessageBox.Cancel: - return - if self.item_model.dirty: - result = MessageDialog.show_modal( - MessageDialog.DialogType.ERROR, - "AddonManager_LocalChangesDialog", - translate("AddonsInstaller", "There are local changes"), - translate( - "AddonsInstaller", - "WARNING: This repo has uncommitted local changes. Are you sure you want " - "to change branches (bringing the changes with you)?", - ), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, - parent=self, - ) - if result == QtWidgets.QMessageBox.Cancel: - return - - self.change_branch(self.item_model.path, ref) - - def change_branch(self, path: str, ref: Dict[str, str]) -> None: - """Change the git clone in `path` to git ref `ref`. Emits the branch_changed signal - on success.""" - remote_name = ref["ref_name"] - _, _, local_name = ref["ref_name"].rpartition("/") - gm = initialize_git() - if gm is None: - self._show_no_git_dialog() - return - - try: - if ref["upstream"]: - gm.checkout(path, remote_name) - else: - gm.checkout(path, remote_name, args=["-b", local_name]) - self.branch_changed.emit(self.current_ref, local_name) - except GitFailed: - self._show_git_failed_dialog() - - def _show_no_git_dialog(self): - MessageDialog.show_modal( - MessageDialog.DialogType.ERROR, - "AddonManager_CannotFindGitDialog", - translate("AddonsInstaller", "Cannot find git"), - translate( - "AddonsInstaller", - "Could not find git executable: cannot change branch", - ), - QtWidgets.QMessageBox.Ok, - parent=self, - ) - - def _show_git_failed_dialog(self): - MessageDialog.show_modal( - MessageDialog.DialogType.ERROR, - "AddonManager_GitOperationFailedDialog", - translate("AddonsInstaller", "git operation failed"), - translate( - "AddonsInstaller", - "Git returned an error code when attempting to change branch. There may be " - "more details in the Report View.", - ), - QtWidgets.QMessageBox.Ok, - parent=self, - ) - - -class ChangeBranchDialogModel(QtCore.QAbstractTableModel): - """The data for the dialog comes from git: this model handles the git interactions and - returns branch information as its rows. Use user data in the RefAccessRole to get information - about the git refs. RefAccessRole data is a dictionary defined by the GitManager class as the - results of a `get_branches_with_info()` call.""" - - branches = [] - DataSortRole = QtCore.Qt.UserRole - RefAccessRole = QtCore.Qt.UserRole + 1 - - def __init__(self, path: str, parent=None) -> None: - super().__init__(parent) - - gm = initialize_git() - self.path = path - self.branches = gm.get_branches_with_info(path) - self.current_branch = gm.current_branch(path) - self.dirty = gm.dirty(path) - self._remove_tracking_duplicates() - - def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: - """Returns the number of rows in the model, e.g. the number of branches.""" - if parent.isValid(): - return 0 - return len(self.branches) - - def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: - """Returns the number of columns in the model, e.g. the number of entries in the git ref - structure (currently 3, 'ref_name', 'upstream', and 'date').""" - if parent.isValid(): - return 0 - return 3 # Local name, remote name, date - - def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole): - """The data access method for this model. Supports four roles: ToolTipRole, DisplayRole, - DataSortRole, and RefAccessRole.""" - if not index.isValid(): - return None - row = index.row() - column = index.column() - if role == QtCore.Qt.ToolTipRole: - return self.branches[row]["author"] + ": " + self.branches[row]["subject"] - if role == QtCore.Qt.DisplayRole: - return self._data_display_role(column, row) - if role == ChangeBranchDialogModel.DataSortRole: - return self._data_sort_role(column, row) - if role == ChangeBranchDialogModel.RefAccessRole: - return self.branches[row] - return None - - def _data_display_role(self, column, row): - dd = self.branches[row] - if column == 2: - if dd["date"] is not None: - q_date = QtCore.QDateTime.fromString(dd["date"], QtCore.Qt.DateFormat.RFC2822Date) - return QtCore.QLocale().toString(q_date, QtCore.QLocale.ShortFormat) - return None - if column == 0: - return dd["ref_name"] - if column == 1: - return dd["upstream"] - return None - - def _data_sort_role(self, column, row): - if column == 2: - if self.branches[row]["date"] is not None: - q_date = QtCore.QDateTime.fromString( - self.branches[row]["date"], QtCore.Qt.DateFormat.RFC2822Date - ) - return q_date - return None - if column == 0: - return self.branches[row]["ref_name"] - if column == 1: - return self.branches[row]["upstream"] - return None - - def headerData( - self, - section: int, - orientation: QtCore.Qt.Orientation, - role: int = QtCore.Qt.DisplayRole, - ): - """Returns the header information for the data in this model.""" - if orientation == QtCore.Qt.Vertical: - return None - if role != QtCore.Qt.DisplayRole: - return None - if section == 0: - return translate( - "AddonsInstaller", - "Local", - "Table header for local git ref name", - ) - if section == 1: - return translate( - "AddonsInstaller", - "Remote tracking", - "Table header for git remote tracking branch name", - ) - if section == 2: - return translate( - "AddonsInstaller", - "Last Updated", - "Table header for git update date", - ) - return None - - def _remove_tracking_duplicates(self): - remote_tracking_branches = [] - branches_to_keep = [] - for branch in self.branches: - if branch["upstream"]: - remote_tracking_branches.append(branch["upstream"]) - for branch in self.branches: - if ( - "HEAD" not in branch["ref_name"] - and branch["ref_name"] not in remote_tracking_branches - ): - branches_to_keep.append(branch) - self.branches = branches_to_keep - - -class ChangeBranchDialogFilter(QtCore.QSortFilterProxyModel): - """Uses the DataSortRole in the model to provide a comparison method to sort the data.""" - - def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex): - """Compare two git refs according to the DataSortRole in the model.""" - left_data = self.sourceModel().data(left, ChangeBranchDialogModel.DataSortRole) - right_data = self.sourceModel().data(right, ChangeBranchDialogModel.DataSortRole) - if left_data is None or right_data is None: - return right_data is not None - return left_data < right_data diff --git a/change_branch.ui b/change_branch.ui deleted file mode 100644 index 8a79b38a..00000000 --- a/change_branch.ui +++ /dev/null @@ -1,105 +0,0 @@ - - - change_branch - - - - 0 - 0 - 550 - 300 - - - - Change Branch - - - true - - - - - - Change to branch - - - true - - - - - - - QAbstractItemView::NoEditTriggers - - - false - - - true - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - true - - - true - - - true - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - buttonBox - accepted() - change_branch - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - change_branch - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/composite_view.py b/composite_view.py index d0f99955..4d80c522 100644 --- a/composite_view.py +++ b/composite_view.py @@ -139,14 +139,12 @@ def addon_selected(self, addon): self.scroll_position = ( self.package_list.ui.listPackages.verticalScrollBar().sliderPosition() ) - print(f"Saved slider position at {self.scroll_position}") self.package_list.hide() self.package_details.show() self.package_details.button_bar.set_show_back_button(True) def _back_button_clicked(self): if self.display_style != AddonManagerDisplayStyle.COMPOSITE: - print(f"Set slider position to {self.scroll_position}") self.package_list.show() self.package_details.hide() # The following must be done *after* a cycle through the event loop diff --git a/package.xml b/package.xml index 1a697323..e2ab19e6 100644 --- a/package.xml +++ b/package.xml @@ -5,8 +5,8 @@ Addon Manager Tool to install workbenches, macros, themes, etc. Resources/icons/addon_manager.svg - 2025.08.05 - 2025-08-05 + 2025.08.08 + 2025-08-08 Chris Hennes Yorik van Havre Jonathan Wiedemann diff --git a/package_list.py b/package_list.py index ec5d615d..35a6ca41 100644 --- a/package_list.py +++ b/package_list.py @@ -242,6 +242,17 @@ def append_item(self, repo: Addon) -> None: self.repos.append(repo) self.endInsertRows() + def remove_item(self, repo: Addon) -> None: + if repo not in self.repos: + # Nothing to remove, don't care + return + with self.write_lock: + self.beginRemoveRows( + QtCore.QModelIndex(), self.repos.index(repo), self.repos.index(repo) + ) + self.repos.remove(repo) + self.endRemoveRows() + def clear(self) -> None: """Clear the model, removing all rows. Thread safe.""" if self.rowCount() > 0: From 1a2eecd6c7ede30fe87fca21e5454ace3235e638 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sat, 9 Aug 2025 08:28:51 -0500 Subject: [PATCH 2/3] Offer to delete old backups --- AddonManager.py | 58 +++++++++++++++++++++++++- addonmanager_preferences_defaults.json | 1 + addonmanager_workers_startup.py | 1 - 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/AddonManager.py b/AddonManager.py index 9a69e9fc..94c41560 100644 --- a/AddonManager.py +++ b/AddonManager.py @@ -27,9 +27,10 @@ import shutil import tempfile import threading -from typing import Dict +from typing import Dict, List from PySideWrapper import QtGui, QtCore, QtWidgets, QtSvg +from Widgets.addonmanager_utility_dialogs import MessageDialog from addonmanager_workers_startup import ( CreateAddonListWorker, @@ -441,6 +442,7 @@ def populate_packages_table(self) -> None: self.create_addon_list_worker = CreateAddonListWorker() self.create_addon_list_worker.addon_repo.connect(self.add_addon_repo) self.update_progress_bar(translate("AddonsInstaller", "Creating addon list"), 10, 100) + self.create_addon_list_worker.old_backups_found.connect(self.found_old_backups) self.create_addon_list_worker.finished.connect(self.do_next_startup_phase) # Link to step 2 self.create_addon_list_worker.start() @@ -764,6 +766,52 @@ def open_addons_folder(): QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(addons_folder)) return + @staticmethod + def found_old_backups(backups: List[str]): + handling = fci.Preferences().get("old_backup_handling") + if handling.lower().strip() == "never": + return + if handling.lower().strip() == "always": + delete_old_backups(backups) + return + + backup_string = ( + translate( + "AddonsInstaller", + f"The following auto-generated backups were found in your Mod directory:", + ) + + "\n" + ) + for backup in backups: + backup_string += "• " + str(backup) + "\n" + backup_string += translate("AddonsInstaller", "Do you want to delete them now?") + dlg = QtWidgets.QMessageBox() + dlg.setObjectName("AddonManager_FoundOldBackups") + dlg.setText(backup_string) + dlg.setStandardButtons( + QtWidgets.QMessageBox.YesToAll + | QtWidgets.QMessageBox.Yes + | QtWidgets.QMessageBox.No + | QtWidgets.QMessageBox.NoToAll + ) + dlg.setDefaultButton(QtWidgets.QMessageBox.No) + dlg.button(QtWidgets.QMessageBox.YesToAll).setText( + translate("AddonsInstaller", "Always", "'Always' delete old backups") + ) + dlg.button(QtWidgets.QMessageBox.NoToAll).setText( + translate("AddonsInstaller", "Never", "'Never' delete old backups") + ) + result = dlg.exec_() + + if result == QtWidgets.QMessageBox.NoToAll: + fci.Preferences().set("old_backup_handling", "never") + return + if result == QtWidgets.QMessageBox.No: + return + if result == QtWidgets.QMessageBox.YesToAll: + fci.Preferences().set("old_backup_handling", "always") + delete_old_backups(backups) + # Some utility functions @@ -791,4 +839,12 @@ def revert_to_backup(addon: Addon) -> None: shutil.copytree(original + ".pre_update_backup", original, dirs_exist_ok=True) +def delete_old_backups(backups) -> None: + """Delete old backups found in the Mod directory.""" + for backup in backups: + full_path = os.path.join(fci.DataPaths().mod_dir, backup) + if os.path.exists(full_path): + shutil.rmtree(full_path, ignore_errors=True) + + # @} diff --git a/addonmanager_preferences_defaults.json b/addonmanager_preferences_defaults.json index ea3ad661..94ccd498 100644 --- a/addonmanager_preferences_defaults.json +++ b/addonmanager_preferences_defaults.json @@ -31,5 +31,6 @@ "last_fetched_addon_catalog_cache_hash": "Cache never fetched, no hash available", "last_fetched_macro_cache_hash": "Cache never fetched, no hash available", "macro_cache_url": "https://addons.freecad.org/macro_cache.zip", + "old_backup_handling": "ask", "readWarning2022": false } diff --git a/addonmanager_workers_startup.py b/addonmanager_workers_startup.py index 0a6d5741..e5c573a0 100644 --- a/addonmanager_workers_startup.py +++ b/addonmanager_workers_startup.py @@ -46,7 +46,6 @@ translate = fci.translate -# Workers only have one public method by design # pylint: disable=c-extension-no-member, too-few-public-methods, too-many-instance-attributes From 0102f674b1f3432d791da0ae0fcc6bb06ae7d383 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sat, 9 Aug 2025 08:38:14 -0500 Subject: [PATCH 3/3] Cleanup branch processing code --- AddonCatalog.py | 3 +-- AddonManager.py | 1 - addonmanager_workers_startup.py | 26 ++++++-------------------- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/AddonCatalog.py b/AddonCatalog.py index 5b63191d..50a5dcc0 100644 --- a/AddonCatalog.py +++ b/AddonCatalog.py @@ -341,8 +341,7 @@ def add_metadata_to_entry( def get_available_branches(self, addon_id: str) -> List[str]: """For a given ID, get the list of available branches compatible with this version of - FreeCAD along with the branch display name. Either field may be empty, but not both. The - first entry in the list is expected to be the "primary". + FreeCAD. :return: A list of branch display names (or git refs, if no display name is available)""" if addon_id not in self._dictionary: return [] diff --git a/AddonManager.py b/AddonManager.py index 94c41560..3fcd4a05 100644 --- a/AddonManager.py +++ b/AddonManager.py @@ -30,7 +30,6 @@ from typing import Dict, List from PySideWrapper import QtGui, QtCore, QtWidgets, QtSvg -from Widgets.addonmanager_utility_dialogs import MessageDialog from addonmanager_workers_startup import ( CreateAddonListWorker, diff --git a/addonmanager_workers_startup.py b/addonmanager_workers_startup.py index e5c573a0..e3479b9b 100644 --- a/addonmanager_workers_startup.py +++ b/addonmanager_workers_startup.py @@ -246,23 +246,13 @@ def process_addon_cache(self, catalog_text_data): ) continue - primary_addon = None - installed_branch = None + installed_branch_name = None if manifest.contains(addon_id): # Then this addon is currently installed: make sure we use the correct branch - installed_branch = manifest.get_addon_info(addon_id)["branch_display_name"] + installed_branch_name = manifest.get_addon_info(addon_id)["branch_display_name"] fci.Console.PrintLog( - f"Found installed addon '{addon_id}' with branch '{installed_branch}'\n" + f"Found installed addon '{addon_id}' with branch '{installed_branch_name}'\n" ) - for branch_display_name in branches: - if branch_display_name == installed_branch: - primary_addon = branch_display_name - break - if primary_addon is None: - fci.Console.PrintError( - f"Failed to find the installed branch '{installed_branch}' for addon '{addon_id}', skipping it.\n" - ) - continue addon_instances = {} name_of_first_entry = None for branch_display_name in branches: @@ -288,7 +278,9 @@ def process_addon_cache(self, catalog_text_data): f"Failed to load the addon {addon_id} from the addon catalog, skipping it.\n" ) continue - primary_branch_name = installed_branch if installed_branch else name_of_first_entry + primary_branch_name = ( + installed_branch_name if installed_branch_name else name_of_first_entry + ) for branch_display_name in branches: if branch_display_name != primary_branch_name: # Only add non-primary addons to the sub_addons list so that the primary addon @@ -296,12 +288,6 @@ def process_addon_cache(self, catalog_text_data): addon_instances[primary_branch_name].sub_addons[branch_display_name] = ( addon_instances[branch_display_name] ) - - if name_of_first_entry is None: - fci.Console.PrintError( - f"Failed to load the addon {addon_id} from the addon catalog, skipping it.\n" - ) - continue self.addon_repo.emit(addon_instances[primary_branch_name]) if manifest.old_backups: