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::PrefComboBox
- QComboBox
-
-
Gui::PrefRadioButton
QRadioButton
@@ -399,11 +259,6 @@
QLineEdit
-
- Gui::PrefFileChooser
- QWidget
-
-
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
+
+
+
+
+