diff --git a/Addon.py b/Addon.py index df8a0b79..dc082e6b 100644 --- a/Addon.py +++ b/Addon.py @@ -26,7 +26,7 @@ import datetime import os import re -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from typing import Set, List, Optional from threading import Lock from enum import IntEnum, auto @@ -314,7 +314,13 @@ def set_metadata(self, metadata: Metadata) -> None: """ self.metadata = metadata - self.display_name = metadata.name + self.display_name = ( + str(metadata.name) + .replace("\n", " ") + .replace("\r", " ") + .replace("{", "") + .replace("}", "") + ) self.repo_type = Addon.Kind.PACKAGE self.description = metadata.description for url in metadata.url: @@ -633,6 +639,30 @@ def _find_classname_in_file(current_file) -> str: pass return "" + def get_zip_url(self) -> str: + if self.url.endswith(".zip"): + zip_url = self.url + else: + # The ZIP url is based on the location of the main cache file: + if self.relative_cache_path: + cache_file_url = fci.Preferences().get("addon_catalog_cache_url") + parsed_url = urlparse(cache_file_url) + path_parts = parsed_url.path.rpartition("/") + new_path = path_parts[0] + "/" + self.relative_cache_path + zip_url = urlunparse( + ( + parsed_url.scheme, + parsed_url.netloc, + new_path, + parsed_url.params, + parsed_url.query, + parsed_url.fragment, + ) + ) + else: + zip_url = utils.get_zip_url(self) + return zip_url + # @dataclass(frozen) class MissingDependencies: diff --git a/AddonManager.py b/AddonManager.py index dd62eade..c62d8240 100644 --- a/AddonManager.py +++ b/AddonManager.py @@ -269,12 +269,7 @@ def launch(self) -> None: self.composite_view = CompositeView(self.dialog) self.button_bar = WidgetGlobalButtonBar(self.dialog) - # If we are checking for updates automatically, hide the Check for updates button: - autocheck = fci.Preferences().get("AutoCheck") - if autocheck: - self.button_bar.check_for_updates.hide() - else: - self.button_bar.update_all_addons.hide() + self.button_bar.check_for_updates.hide() # Set up the listing of packages using the model-view-controller architecture self.item_model = PackageListItemModel() @@ -471,13 +466,6 @@ def select_addon(self) -> None: def check_updates(self) -> None: """checks every installed addon for available updates""" - autocheck = fci.Preferences().get("AutoCheck") - if not autocheck: - fci.Console.PrintLog( - "Addon Manager: Skipping update check because AutoCheck user preference is False\n" - ) - self.do_next_startup_phase() - return if not self.packages_with_updates: self.force_check_updates(standalone=False) else: @@ -538,6 +526,18 @@ def update_check_complete(self) -> None: def check_python_updates(self) -> None: # TODO: Run the checker to see if we need to do any Python updates as well + + # Really, there are two different things to check here: first, run our normal dependency + # checker and display the dependency resolution dialog. This will handle addons that have + # disappeared/been uninstalled (but were required by other addons) as well as Python required + # and optional dependencies. The only catch is, if we ONLY have optional Python dependencies + # missing, we should ignore them. + + # Second, if this is a version of Python we've used before, do any of our Python libraries + # installed into the custom directory, or the venv, need to be updated? + + # To the user these are two quite different things, so their interface should reflect that. + self.do_next_startup_phase() def show_python_updates_dialog(self) -> None: diff --git a/AddonManagerOptions.ui b/AddonManagerOptions.ui index c0b69d26..253549f2 100644 --- a/AddonManagerOptions.ui +++ b/AddonManagerOptions.ui @@ -14,28 +14,18 @@ Addon Manager Options - + + 0 + + + 0 + + + 0 + + 0 - - - - Checks for updates of installed addons when launching the Addon Manager - - - Automatically check for updates at start (requires Git) - - - false - - - AutoCheck - - - Addons - - - @@ -84,54 +74,6 @@ - - - - Hide addons marked Python 2 only - - - true - - - HidePy2 - - - Addons - - - - - - - Hide addons marked obsolete - - - true - - - HideObsolete - - - Addons - - - - - - - Hide addons that require a newer version of FreeCAD - - - true - - - Addons - - - HideNewerFreeCADRequired - - - @@ -286,83 +228,6 @@ - - - - - - Path to Git executable (optional) - - - - - - - - 0 - 0 - - - - - 300 - 0 - - - - The path to the Git executable. Autodetected if needed and not specified. - - - GitExecutable - - - Addons - - - - - - - - - - 0 - 0 - - - - Advanced Options - - - - - - Show option to change branches (requires Git) - - - ShowBranchSwitcher - - - Addons - - - - - - - Disable Git (fall back to ZIP downloads only) - - - disableGit - - - Addons - - - - - - @@ -384,11 +249,6 @@ QCheckBox
Gui/PrefWidgets.h
- - Gui::PrefComboBox - QComboBox -
Gui/PrefWidgets.h
-
Gui::PrefRadioButton QRadioButton @@ -399,11 +259,6 @@ QLineEdit
Gui/PrefWidgets.h
- - Gui::PrefFileChooser - QWidget -
Gui/PrefWidgets.h
-
diff --git a/AddonManagerTest/app/mocks.py b/AddonManagerTest/app/mocks.py index 2b25385e..c3f8b45b 100644 --- a/AddonManagerTest/app/mocks.py +++ b/AddonManagerTest/app/mocks.py @@ -113,6 +113,9 @@ def set_status(self, status): def get_best_icon_relative_path(): return "" + def get_zip_url(self): + return self.url + class MockMacro: """Minimal Macro class""" diff --git a/AddonManagerTest/app/test_installer.py b/AddonManagerTest/app/test_installer.py index f9fce26a..5b9c9dae 100644 --- a/AddonManagerTest/app/test_installer.py +++ b/AddonManagerTest/app/test_installer.py @@ -208,6 +208,7 @@ def test_install_by_git(self): mock_addon.url = os.path.join(temp_dir, "test_repo") mock_addon.branch = "main" installer = AddonInstaller(mock_addon, []) + installer.git_manager = git_manager # Make sure it's been created installer.installation_path = os.path.join(temp_dir, "installed_addon") installer._install_by_git() @@ -247,6 +248,7 @@ def test_determine_install_method_local_path(self): self.assertEqual(method, InstallationMethod.COPY) git_manager = initialize_git() if git_manager: + installer.git_manager = git_manager method = installer._determine_install_method(temp_dir, InstallationMethod.GIT) self.assertEqual(method, InstallationMethod.GIT) method = installer._determine_install_method(temp_dir, InstallationMethod.ZIP) @@ -264,6 +266,7 @@ def test_determine_install_method_file_url(self): self.assertEqual(method, InstallationMethod.COPY) git_manager = initialize_git() if git_manager: + installer.git_manager = git_manager method = installer._determine_install_method(temp_dir, InstallationMethod.GIT) self.assertEqual(method, InstallationMethod.GIT) method = installer._determine_install_method(temp_dir, InstallationMethod.ZIP) @@ -358,7 +361,7 @@ def test_determine_install_method_https_known_sites_any_gm(self): method = installer._determine_install_method(temp_file, InstallationMethod.ANY) self.assertEqual( method, - InstallationMethod.GIT, + InstallationMethod.ZIP, f"Failed to allow git access to {site} URL", ) diff --git a/AddonManagerTest/gui/gui_mocks.py b/AddonManagerTest/gui/gui_mocks.py index 7b97f40a..3edd8c7e 100644 --- a/AddonManagerTest/gui/gui_mocks.py +++ b/AddonManagerTest/gui/gui_mocks.py @@ -22,6 +22,7 @@ # *************************************************************************** import sys +from typing import Optional try: from PySide import QtCore, QtWidgets @@ -156,3 +157,49 @@ def wait_for_at_most(self, max_wait_millis) -> None: def good(self) -> bool: return self.signal_catcher.caught and not self.signal_catcher.killed + + +class MockNetworkManagerGuiUp: + """A mock network manager that behaves roughly like the real thing but never does any network + or filesystem access and can simulate various failure scenarios. Uses real Qt signals and can + be used across threads. Requires a running event loop. Designed to allow UI evaluation by + taking real wall-clock time to complete events, or to speed up testing by using very short + timers to simulate network requests.""" + + completed = QtCore.Signal(int, int, QtCore.QByteArray) + content_length = QtCore.Signal(int, int, int) + progress_made = QtCore.Signal(int, int, int) + progress_complete = QtCore.Signal(int, int, str) + + def __init__(self, wall_clock_ms: int = 1, simulate_failure: bool = False): + pass + + def query_download_size(self, url: str, timeout_ms: int = 30000): + pass + + def submit_unmonitored_get( + self, + url: str, + timeout_ms: int = 30000, + ) -> int: + pass + + def submit_monitored_get( + self, + url: str, + timeout_ms: int = 30000, + ) -> int: + pass + + def blocking_get( + self, + url: str, + timeout_ms: int = 30000, + ) -> Optional[QtCore.QByteArray]: + pass + + def abort_all(self): + pass + + def abort(self, index: int): + pass diff --git a/NetworkManager.py b/NetworkManager.py index 618974ab..56d256f0 100644 --- a/NetworkManager.py +++ b/NetworkManager.py @@ -92,11 +92,18 @@ class QueueItem: """A container for information about an item in the network queue.""" - def __init__(self, index: int, request: QtNetwork.QNetworkRequest, track_progress: bool): + def __init__( + self, + index: int, + request: QtNetwork.QNetworkRequest, + track_progress: bool, + operation: QtNetwork.QNetworkAccessManager.Operation = QtNetwork.QNetworkAccessManager.GetOperation, + ): self.index = index self.request = request self.original_url = request.url() self.track_progress = track_progress + self.operation = operation class NetworkManager(QtCore.QObject): @@ -110,6 +117,8 @@ class NetworkManager(QtCore.QObject): int, int, QtCore.QByteArray ) # Index, http response code, received data (if any) + content_length = QtCore.Signal(int, int, int) + # Connect to progress_made and progress_complete for large amounts of data, which get buffered into a temp file # That temp file should be deleted when your code is done with it progress_made = QtCore.Signal(int, int, int) # Index, bytes read, total bytes (may be None) @@ -133,6 +142,9 @@ def __init__(self): self.synchronous_complete: Dict[int, bool] = {} self.synchronous_result_data: Dict[int, QtCore.QByteArray] = {} + # Only one size request at a time: + self.download_size_lock = threading.Lock() + # Make sure we exit nicely on quit if QtCore.QCoreApplication.instance() is not None: QtCore.QCoreApplication.instance().aboutToQuit.connect(self.__aboutToQuit) @@ -290,14 +302,24 @@ def __setup_network_request(self): return # Do not do anything with this item, it's been aborted... if item.track_progress: self.monitored_connections.append(item.index) - self.__launch_request(item.index, item.request) + self.__launch_request(item.index, item.request, item.operation) except queue.Empty: # Once the queue is empty, there's nothing left to do for now pass - def __launch_request(self, index: int, request: QtNetwork.QNetworkRequest) -> None: + def __launch_request( + self, + index: int, + request: QtNetwork.QNetworkRequest, + operation: QtNetwork.QNetworkAccessManager.Operation, + ) -> None: """Given a network request, ask the QNetworkAccessManager to begin processing it.""" - reply = self.QNAM.get(request) + if operation == QtNetwork.QNetworkAccessManager.GetOperation: + reply = self.QNAM.get(request) + elif operation == QtNetwork.QNetworkAccessManager.HeadOperation: + reply = self.QNAM.sendCustomRequest(request, b"HEAD") + else: + raise NotImplementedError(f"Unknown operation {operation}") self.replies[index] = reply self.__last_started_index = index @@ -307,6 +329,22 @@ def __launch_request(self, index: int, request: QtNetwork.QNetworkRequest) -> No reply.readyRead.connect(self.__ready_to_read) reply.downloadProgress.connect(self.__download_progress) + def query_download_size(self, url: str, timeout_ms: int = default_timeout): + """A query to get the download size in the 'Content-Length' header, or zero if the server + doesn't support it. Connect to the download_size signal to get the result when it arrives. + """ + with self.download_size_lock: + current_index = next(self.counting_iterator) # A thread-safe counter + item = QueueItem( + current_index, + self.__create_get_request(url, timeout_ms), + track_progress=False, + operation=QtNetwork.QNetworkAccessManager.HeadOperation, + ) + self.queue.put(item) + self.__request_queued.emit() + return current_index + def submit_unmonitored_get( self, url: str, @@ -554,7 +592,9 @@ def __reply_finished(self) -> None: if hasattr(request, "transferTimeout"): timeout_ms = request.transferTimeout() new_url = reply.attribute(QtNetwork.QNetworkRequest.RedirectionTargetAttribute) - self.__launch_request(index, self.__create_get_request(new_url, timeout_ms)) + self.__launch_request( + index, self.__create_get_request(new_url, timeout_ms), reply.operation() + ) return # The task is not done, so get out of this method now if reply.error() != QtNetwork.QNetworkReply.NetworkError.OperationCanceledError: # It this was not a timeout, make sure we mark the queue task done @@ -567,6 +607,12 @@ def __reply_finished(self) -> None: f = self.file_buffers[index] f.close() self.progress_complete.emit(index, response_code, f.name) + elif reply.operation() == QtNetwork.QNetworkAccessManager.HeadOperation: + # The data we were trying to read was the ContentLengthHeader, so make sure that's what we emit + data = reply.header(QtNetwork.QNetworkRequest.ContentLengthHeader) + if data is None: + data = 0 + self.content_length.emit(index, response_code, data) else: data = reply.readAll() self.completed.emit(index, response_code, data) diff --git a/addonmanager_git.py b/addonmanager_git.py index fd10c5c2..c48b85ab 100644 --- a/addonmanager_git.py +++ b/addonmanager_git.py @@ -412,17 +412,11 @@ def migrate_branch(self, local_path: str, old_branch: str, new_branch: str) -> N os.chdir(old_dir) def _find_git(self): - # Find git. In preference order - # A) The value of the GitExecutable user preference - # B) The executable located in the same directory as FreeCAD and called "git" - # C) The result of a shutil search for your system's "git" executable - prefs = fci.ParamGet("User parameter:BaseApp/Preferences/Addons") - git_exe = prefs.GetString("GitExecutable", "Not set") - if not git_exe or git_exe == "Not set" or not os.path.exists(git_exe): - fc_dir = fci.DataPaths().home_dir - git_exe = os.path.join(fc_dir, "bin", "git") - if "Windows" in platform.system(): - git_exe += ".exe" + + fc_dir = fci.DataPaths().home_dir + git_exe = os.path.join(fc_dir, "bin", "git") + if "Windows" in platform.system(): + git_exe += ".exe" if platform.system() == "Darwin" and not self._git_is_real(): return @@ -433,7 +427,6 @@ def _find_git(self): if not git_exe or not os.path.exists(git_exe): return - prefs.SetString("GitExecutable", git_exe) self.git_exe = git_exe @staticmethod diff --git a/addonmanager_installer.py b/addonmanager_installer.py index ed3a7d88..95710b94 100644 --- a/addonmanager_installer.py +++ b/addonmanager_installer.py @@ -61,6 +61,17 @@ class InstallationMethod(IntEnum): ZIP = auto() ANY = auto() + def __str__(self): + if self.value == self.GIT: + return "Git" + if self.value == self.COPY: + return "Copy" + if self.value == self.ZIP: + return "Zip" + if self.value == self.ANY: + return "Any" + return "Unknown" + class AddonInstaller(QtCore.QObject): """The core, non-GUI installer class. Usually instantiated and moved to its own thread, @@ -112,7 +123,7 @@ class AddonInstaller(QtCore.QObject): failure = QtCore.Signal(object, str) # Finished: regardless of the outcome, this is emitted when all work that is going to be done - # is done (i.e. whatever thread this is running in can quit). + # is done (i.e., whatever thread this is running in can quit). finished = QtCore.Signal() allowed_packages = set() @@ -126,7 +137,11 @@ def __init__(self, addon: Addon, allow_list: List[str] = None): super().__init__() self.addon_to_install = addon - self.git_manager = initialize_git() + forced_repos = fci.Preferences().get("force_git_in_repos").split(",") + if addon and self.addon_to_install.name in forced_repos: + self.git_manager = initialize_git() + else: + self.git_manager = None if allow_list is not None: AddonInstaller.allowed_packages = set(allow_list if allow_list is not None else []) @@ -145,6 +160,9 @@ def run(self, install_method: InstallationMethod = InstallationMethod.ANY) -> bo try: addon_url = self.addon_to_install.url.replace(os.path.sep, "/") method_to_use = self._determine_install_method(addon_url, install_method) + fci.Console.PrintMessage( + f"Installing addon {self.addon_to_install.name} using {method_to_use}\n" + ) if method_to_use == InstallationMethod.ZIP: success = self._install_by_zip() elif method_to_use == InstallationMethod.GIT: @@ -255,12 +273,13 @@ def _determine_install_method( if not is_remote: return InstallationMethod.COPY - # Prefer git if we have git - if self.git_manager: + # Use git only if the user specifically requests it, and we have git + forced_repos = fci.Preferences().get("force_git_in_repos").split(",") + if self.git_manager and self.addon_to_install.name in forced_repos: return InstallationMethod.GIT - # Fall back to ZIP in other cases, though this relies on remote hosts falling - # into one of a few particular patterns + # Normal case: we aren't locked into any particular method, so use zip downloads from the + # addons cache return InstallationMethod.ZIP def _install_by_copy(self) -> bool: @@ -311,13 +330,9 @@ def _install_by_git(self) -> bool: def _install_by_zip(self) -> bool: """Installs the specified url by downloading the file (if it is remote) and unzipping it - into the appropriate installation location. If the GUI is running the download is - asynchronous, and issues periodic updates about how much data has been downloaded.""" - if self.addon_to_install.url.endswith(".zip"): - zip_url = self.addon_to_install.url - else: - zip_url = utils.get_zip_url(self.addon_to_install) - + into the appropriate installation location. If the GUI is running, the download is + asynchronous and issues periodic updates about how much data has been downloaded.""" + zip_url = self.addon_to_install.get_zip_url() fci.Console.PrintLog(f"Downloading ZIP file from {zip_url}...\n") parse_result = urlparse(zip_url) is_remote = parse_result.scheme in ["http", "https"] diff --git a/addonmanager_installer_gui.py b/addonmanager_installer_gui.py index 8414ff53..b3435f9d 100644 --- a/addonmanager_installer_gui.py +++ b/addonmanager_installer_gui.py @@ -129,23 +129,26 @@ def install(self) -> None: self.worker_thread.setObjectName("Addon Installer worker thread") self.installer.moveToThread(self.worker_thread) self.installer.finished.connect(self.worker_thread.quit) + self.installer.progress_update.connect(self._progress_update) self.worker_thread.started.connect(self.installer.run) - self.installing_dialog = QtWidgets.QMessageBox( - QtWidgets.QMessageBox.NoIcon, - translate("AddonsInstaller", "Installing Addon"), - translate("AddonsInstaller", "Installing FreeCAD addon '{}'").format( + self.installing_dialog = fci.loadUi(os.path.join(os.path.dirname(__file__), "progress.ui")) + self.installing_dialog.setObjectName("AddonManager_InstallingDialog") + self.installing_dialog.label.setText( + translate("AddonsInstaller", "Installing '{}'").format( self.addon_to_install.display_name - ), - QtWidgets.QMessageBox.Cancel, - parent=utils.get_main_am_window(), + ) ) - self.installing_dialog.setObjectName("AddonManager_InstallingDialog") + self.installing_dialog.rejected.connect(self._cancel_addon_installation) self.installer.finished.connect(self.installing_dialog.hide) self.installing_dialog.show() self.worker_thread.start() # Returns immediately + def _progress_update(self, bytes_read: int, data_size: int) -> None: + self.installing_dialog.progressBar.setMaximum(data_size) + self.installing_dialog.progressBar.setValue(bytes_read) + def _cancel_addon_installation(self): dlg = QtWidgets.QMessageBox( QtWidgets.QMessageBox.NoIcon, diff --git a/addonmanager_preferences_defaults.json b/addonmanager_preferences_defaults.json index 4d886430..ea3ad661 100644 --- a/addonmanager_preferences_defaults.json +++ b/addonmanager_preferences_defaults.json @@ -1,43 +1,35 @@ { - "addon_catalog_cache_url": "https://addons.freecad.org/addon_catalog_cache.zip", - "AddonsStatsURL": "https://freecad.org/addon_stats.json", "AddonsScoreURL": "NONE", - "AutoCheck": false, + "AddonsStatsURL": "https://freecad.org/addon_stats.json", "BlockedMacros": "BOLTS,WorkFeatures,how to install,documentation,PartsLibrary,FCGear", "CompositeSplitterState": "", "CustomRepoHash": "", "CustomRepositories": "", "CustomToolbarName": "Auto-Created Macro Toolbar", - "DaysBetweenUpdates": -1, "DownloadMacros": false, "FirstTimeAskingForToolbar": true, - "GitExecutable": "Not set", - "HideNewerFreeCADRequired": true, - "HideObsolete": true, - "HidePy2": true, - "HideNonOSIApproved": false, "HideNonFSFFreeLibre": false, + "HideNonOSIApproved": false, "HideUnlicensed": false, "KnownPythonVersions": "[]", - "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", "MacroUpdateStatsURL": "https://addons.freecad.org/macro_update_stats.json", "NoProxyCheck": true, "PackageTypeSelection": 0, "ProxyUrl": "", "SearchString": "", "SelectedAddon": "", - "ShowBranchSwitcher": false, "StatusSelection": 0, "SystemProxyCheck": false, - "UpdateFrequencyComboEntry": 0, "UserProxyCheck": false, "ViewStyle": 1, "WindowHeight": 600, "WindowWidth": 800, + "addon_catalog_cache_url": "https://addons.freecad.org/addon_catalog_cache.zip", "alwaysAskForToolbar": true, - "disableGit": false, "dontShowAddMacroButtonDialog": false, + "force_git_in_repos": "parts_library", + "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", "readWarning2022": false } diff --git a/addonmanager_update_all_gui.py b/addonmanager_update_all_gui.py index 1f5bc244..cacb2100 100644 --- a/addonmanager_update_all_gui.py +++ b/addonmanager_update_all_gui.py @@ -22,11 +22,12 @@ # *************************************************************************** """Class to manage the display of an Update All dialog.""" - +import threading from enum import IntEnum, auto import os from typing import List +import NetworkManager from PySideWrapper import QtCore, QtWidgets import addonmanager_freecad_interface as fci @@ -83,6 +84,7 @@ class UpdateAllWorker(QtCore.QObject): finished = QtCore.Signal() addon_updated = QtCore.Signal(object) + progress_update = QtCore.Signal(int, int) def __init__(self, addons: List[Addon]): super().__init__() @@ -92,12 +94,35 @@ def __init__(self, addons: List[Addon]): self.running = False self.cancelled = False self.currentIndex = 0 + self.download_size_lock = threading.Lock() + self.total_size = 0 + self.sizes_received = 0 + self.downloaded_sizes = [] + NetworkManager.AM_NETWORK_MANAGER.content_length.connect(self._update_download_size) def run(self): """Run the Update All process. Blocks until updates are complete or cancelled.""" self.running = True self.currentIndex = 0 - self._process_next_update() + self.query_sizes() + + def query_sizes(self): + """In the background, builds a list of the download sizes for all the addons being updated""" + forced_repos = fci.Preferences().get("force_git_in_repos").split(",") + for addon in self.addons: + if addon.name in forced_repos: + self.sizes_received += 1 + continue + zip_url = addon.get_zip_url() + NetworkManager.AM_NETWORK_MANAGER.query_download_size(zip_url) + + def _update_download_size(self, _index: int, _response_code: int, content_length: int) -> None: + with self.download_size_lock: + self.sizes_received += 1 + self.total_size += content_length + self.downloaded_sizes.append(0) + if self.sizes_received == len(self.addons): + self._process_next_update() def cancel(self): self.cancelled = True @@ -118,8 +143,18 @@ def _launch_active_installer(self): self.active_installer.success.connect(self._update_succeeded) self.active_installer.failure.connect(self._update_failed) self.active_installer.finished.connect(self._update_finished) + if hasattr(self.active_installer, "progress_update"): + self.active_installer.progress_update.connect(self._update_progress_update) self.active_installer.run() + def _update_progress_update(self, progress: int, total: int) -> None: + """Calculate total progress and emit the signal""" + size_array_index = self.currentIndex - 1 + if 0 <= size_array_index < len(self.downloaded_sizes): + self.downloaded_sizes[size_array_index] = progress + total_so_far = sum(self.downloaded_sizes) + self.progress_update.emit(total_so_far, self.total_size) + def _update_succeeded(self, addon): """Callback for a successful update""" self.addon_updated.emit(addon) @@ -193,9 +228,7 @@ def _setup_main_dialog(self): self.dialog.table_view.hideColumn(4) def _setup_progress_dialog(self): - self.progress_dialog = fci.loadUi( - os.path.join(os.path.dirname(__file__), "update_all_progress.ui") - ) + self.progress_dialog = fci.loadUi(os.path.join(os.path.dirname(__file__), "progress.ui")) self.progress_dialog.setObjectName("AddonManager_UpdateAllProgressDialog") self.progress_dialog.buttonBox.rejected.connect(self.cancel) @@ -244,7 +277,7 @@ def update_button_clicked(self): ) else: fci.Console.PrintMessage("No unsatisfied dependencies found, continuing with update\n") - self.proceed() + self.check_for_git_migration() def handle_missing_dependencies( self, @@ -261,9 +294,44 @@ def handle_missing_dependencies( missing_dependencies.python_optional = optional_python_modules self.dependency_installer = AddonDependencyInstallerGUI(addons, missing_dependencies) self.dependency_installer.cancel.connect(self.cancel) - self.dependency_installer.proceed.connect(self.proceed) + self.dependency_installer.proceed.connect(self.check_for_git_migration) self.dependency_installer.run() + def check_for_git_migration(self): + """The Addon Manager used to use git as the preferred installation mechanism and only + fell back to Zip if git was unavailable (or specifically deactivated). That changed in + mid-2025, and Zip was made the preferred download mechanism. This function checks to see if + there is a .git directory present that would be deleted by doing this update and offers + to let the user turn on git installation (if they are a developer, for example).""" + + addons_to_update = [ + addon + for addon, checked in zip(self.model.addons_with_update, self.model.update_is_checked) + if checked + ] + custom_repos_lines = fci.Preferences().get("CustomRepositories").split("\n") + custom_repos = [line.split(" ")[0] for line in custom_repos_lines] + forced_repos = fci.Preferences().get("force_git_in_repos").split(",") + for addon in addons_to_update: + if addon.name in custom_repos or addon.name in forced_repos: + continue + path_to_addon = str(os.path.join(fci.DataPaths().mod_dir, addon.name)) + path_to_git_directory = str(os.path.join(path_to_addon, ".git")) + if os.path.exists(path_to_git_directory): + backup_path = path_to_addon + "-backup-before-zip-migration" + os.rename(path_to_addon, backup_path) + fci.Console.PrintMessage( + f"Found .git directory for Addon {addon.display_name}" + " - backup created before migration to zip\n" + ) + with open(os.path.join(backup_path, "ADDON_DISABLED"), "w", encoding="utf-8") as f: + f.write( + "This directory is a backup made before migrating from Git to Zip." + " If you don't care about retaining any git information or you are" + " not a developer and/or don't use git, it can be deleted safely." + ) + self.proceed() + def proceed(self): """Does the updates""" @@ -287,6 +355,7 @@ def proceed(self): self.addon_installer.finished.connect(self.worker_thread.quit) self.addon_installer.finished.connect(self.update_complete) self.addon_installer.addon_updated.connect(self.update_progress) + self.addon_installer.progress_update.connect(self.update_progress_bar) self.worker_thread.start() def cancel(self): @@ -306,8 +375,10 @@ def update_progress(self, addon_installed: Addon): """Updates the progress bar and check the state of the rows""" self.model.rescan_addon(addon_installed) self.addon_updated.emit(addon_installed) - if self.progress_dialog.progressBar.value() < self.progress_dialog.progressBar.maximum(): - self.progress_dialog.progressBar.setValue(self.progress_dialog.progressBar.value() + 1) + + def update_progress_bar(self, so_far: int, total: int): + self.progress_dialog.progressBar.setMaximum(total) + self.progress_dialog.progressBar.setValue(so_far) def update_complete(self): self.progress_dialog.hide() diff --git a/addonmanager_utilities.py b/addonmanager_utilities.py index 80f564d3..c30265a6 100644 --- a/addonmanager_utilities.py +++ b/addonmanager_utilities.py @@ -260,12 +260,6 @@ def get_readme_url(repo): return construct_git_url(repo, "README.md") -def get_metadata_url(url): - """Returns the location of a package.xml metadata file""" - - return construct_git_url(url, "package.xml") - - def get_desc_regex(repo): """Returns a regex string that extracts a WB description to be displayed in the description panel of the Addon manager, if the README could not be found""" diff --git a/package.xml b/package.xml index 94ed66e1..65f5b746 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.07.17 - 2025-07-17 + 2025.08.04 + 2025-08-04 Chris Hennes Yorik van Havre Jonathan Wiedemann diff --git a/package_list.py b/package_list.py index 44fce5e7..5b082769 100644 --- a/package_list.py +++ b/package_list.py @@ -97,13 +97,8 @@ def setModel(self, model): self.set_view_style(style) self.ui.view_bar.view_selector.set_current_view(style) - self.item_filter.setHidePy2(fci.Preferences().get("HidePy2")) - self.item_filter.setHideObsolete(fci.Preferences().get("HideObsolete")) self.item_filter.setHideNonOSIApproved(fci.Preferences().get("HideNonOSIApproved")) self.item_filter.setHideNonFSFLibre(fci.Preferences().get("HideNonFSFFreeLibre")) - self.item_filter.setHideNewerFreeCADRequired( - fci.Preferences().get("HideNewerFreeCADRequired") - ) self.item_filter.setHideUnlicensed(fci.Preferences().get("HideUnlicensed")) def select_addon(self, addon_name: str): @@ -586,16 +581,6 @@ def setStatusFilter( self.status = status self.invalidateFilter() - def setHidePy2(self, hide_py2: bool) -> None: - """Sets whether to hide Python 2-only Addons""" - self.hide_py2 = hide_py2 - self.invalidateFilter() - - def setHideObsolete(self, hide_obsolete: bool) -> None: - """Sets whether to hide Addons marked obsolete""" - self.hide_obsolete = hide_obsolete - self.invalidateFilter() - def setHideNonOSIApproved(self, hide: bool) -> None: """Sets whether to hide Addons with non-OSI-approved licenses""" self.hide_non_OSI_approved = hide @@ -611,12 +596,6 @@ def setHideUnlicensed(self, hide: bool) -> None: self.hide_unlicensed = hide self.invalidateFilter() - def setHideNewerFreeCADRequired(self, hide_nfr: bool) -> None: - """Sets whether to hide packages that have indicated they need a newer version - of FreeCAD than the one currently running.""" - self.hide_newer_freecad_required = hide_nfr - self.invalidateFilter() - def filterAcceptsRow(self, row, _parent=QtCore.QModelIndex()): """Do the actual filtering (called automatically by Qt when drawing the list)""" @@ -693,22 +672,6 @@ def filterAcceptsRow(self, row, _parent=QtCore.QModelIndex()): # ) return False - # If it's not installed, check to see if it's for a newer version of FreeCAD - if ( - data.status() == Addon.Status.NOT_INSTALLED - and self.hide_newer_freecad_required - and data.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(data.metadata) - if first_supported_version is not None: - current_fc_version = Version(from_list=fci.Version()) - if first_supported_version > current_fc_version: - return False - name = data.display_name desc = data.description if hasattr(self, "filterRegularExpression"): # Added in Qt 5.12 diff --git a/progress.ui b/progress.ui new file mode 100644 index 00000000..4d2cb190 --- /dev/null +++ b/progress.ui @@ -0,0 +1,81 @@ + + + Dialog + + + + 0 + 0 + 400 + 104 + + + + Updating Addons + + + true + + + + + + Updating Addons… + + + + + + + 24 + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +