diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index fd1b8f94a1..ddf4fabf74 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -83,6 +83,7 @@ class CommandAddonManager: "install_worker", "update_metadata_cache_worker", "update_all_worker", + "update_check_single_worker" ] lock = threading.Lock() @@ -236,6 +237,7 @@ class CommandAddonManager: self.packageDetails.update.connect(self.update) self.packageDetails.back.connect(self.on_buttonBack_clicked) self.packageDetails.update_status.connect(self.status_updated) + self.packageDetails.check_for_update.connect(self.check_for_update) # center the dialog over the FreeCAD window mw = FreeCADGui.getMainWindow() @@ -440,14 +442,14 @@ class CommandAddonManager: def cache_package(self, repo: AddonManagerRepo): if not hasattr(self, "package_cache"): - self.package_cache = [] - self.package_cache.append(repo.to_cache()) + self.package_cache = {} + self.package_cache[repo.name] = repo.to_cache() def write_package_cache(self): - package_cache_path = self.get_cache_file_name("package_cache.json") - with open(package_cache_path, "w") as f: - f.write(json.dumps(self.package_cache)) - self.package_cache = [] + if hasattr(self, "package_cache"): + package_cache_path = self.get_cache_file_name("package_cache.json") + with open(package_cache_path, "w") as f: + f.write(json.dumps(self.package_cache)) def activate_table_widgets(self) -> None: self.packageList.setEnabled(True) @@ -514,9 +516,7 @@ class CommandAddonManager: """Called when the named package has either new metadata or a new icon (or both)""" with self.lock: - cache_path = os.path.join( - FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata", repo.name - ) + self.cache_package(repo) repo.icon = self.get_icon(repo, update=True) self.item_model.reload_item(repo) @@ -527,6 +527,10 @@ class CommandAddonManager: pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") autocheck = pref.GetBool("AutoCheck", False) if not autocheck: + FreeCAD.Console.PrintMessage(translate( + "AddonsInstaller", + "Addon Manager: Skipping update check because AutoCheck user preference is False" + ) + "\n") self.do_next_startup_phase() return if not self.packages_with_updates: @@ -703,6 +707,28 @@ class CommandAddonManager: def update(self, repo: AddonManagerRepo) -> None: self.install(repo) + def check_for_update(self, repo: AddonManagerRepo) -> None: + """ Check a single repo for available updates asynchronously """ + + if hasattr(self, "update_check_single_worker") and self.update_check_single_worker: + if self.update_check_single_worker.isRunning(): + self.update_check_single_worker.requestInterrupt() + self.update_check_single_worker.wait() + + self.update_check_single_worker = CheckSingleWorker(repo.name) + self.update_check_single_worker.updateAvailable.connect( + lambda update_available : self.mark_repo_update_available(repo, update_available) + ) + self.update_check_single_worker.start() + + def mark_repo_update_available(self, repo:AddonManagerRepo, available:bool) -> None: + if available: + repo.update_status = AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE + else: + repo.update_status = AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE + self.item_model.reload_item(repo) + self.packageDetails.show_repo(repo) + def update_all(self) -> None: """Asynchronously apply all available updates: individual failures are noted, but do not stop other updates""" diff --git a/src/Mod/AddonManager/AddonManagerRepo.py b/src/Mod/AddonManager/AddonManagerRepo.py index f931555319..56928356cd 100644 --- a/src/Mod/AddonManager/AddonManagerRepo.py +++ b/src/Mod/AddonManager/AddonManagerRepo.py @@ -72,6 +72,7 @@ class AddonManagerRepo: def __init__(self, name: str, url: str, status: UpdateStatus, branch: str): self.name = name.strip() + self.display_name = self.name self.url = url.strip() self.branch = branch.strip() self.update_status = status @@ -122,9 +123,17 @@ class AddonManagerRepo: else: status = AddonManagerRepo.UpdateStatus.NOT_INSTALLED instance = AddonManagerRepo(data["name"], data["url"], status, data["branch"]) + instance.display_name = data["display_name"] instance.repo_type = AddonManagerRepo.RepoType(data["repo_type"]) instance.description = data["description"] instance.cached_icon_filename = data["cached_icon_filename"] + if instance.repo_type == AddonManagerRepo.RepoType.PACKAGE: + # There must be a cached metadata file, too + cached_package_xml_file = os.path.join( + FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata", instance.name + ) + if os.path.isfile(cached_package_xml_file): + instance.load_metadata_file(cached_package_xml_file) return instance def to_cache(self) -> Dict: @@ -132,6 +141,7 @@ class AddonManagerRepo: return { "name": self.name, + "display_name": self.display_name, "url": self.url, "branch": self.branch, "repo_type": int(self.repo_type), @@ -139,6 +149,24 @@ class AddonManagerRepo: "cached_icon_filename": self.get_cached_icon_filename(), } + def load_metadata_file (self, file:str) -> None: + if os.path.isfile(file): + metadata = FreeCAD.Metadata(file) + self.set_metadata(metadata) + + def set_metadata (self, metadata:FreeCAD.Metadata) -> None: + self.metadata = metadata + self.display_name = metadata.Name + self.repo_type = AddonManagerRepo.RepoType.PACKAGE + self.description = metadata.Description + for url in metadata.Urls: + if "type" in url and url["type"] == "repository": + self.url = url["location"] + if "branch" in url: + self.branch = url["branch"] + else: + self.branch = "master" + def contains_workbench(self) -> bool: """Determine if this package contains (or is) a workbench""" diff --git a/src/Mod/AddonManager/addonmanager_metadata.py b/src/Mod/AddonManager/addonmanager_metadata.py index d1b1ec4c66..7cd87591cc 100644 --- a/src/Mod/AddonManager/addonmanager_metadata.py +++ b/src/Mod/AddonManager/addonmanager_metadata.py @@ -25,6 +25,7 @@ import FreeCAD import tempfile import os import hashlib +from typing import Dict, List from PySide2 import QtCore, QtNetwork from PySide2.QtCore import QObject @@ -47,8 +48,8 @@ class MetadataDownloadWorker(QObject): updated = QtCore.Signal(AddonManagerRepo) - def __init__(self, parent, repo, index): - "repo is an AddonManagerRepo object, and index is a dictionary of SHA1 hashes of the package.xml files in the cache" + def __init__(self, parent, repo:AddonManagerRepo, index:Dict[str,str]): + """ repo is an AddonManagerRepo object, and index is a dictionary of SHA1 hashes of the package.xml files in the cache """ super().__init__(parent) self.repo = repo @@ -59,8 +60,9 @@ class MetadataDownloadWorker(QObject): self.last_sha1 = "" self.url = self.repo.metadata_url - def start_fetch(self, network_manager): - "Asynchronously begin the network access. Intended as a set-and-forget black box for downloading metadata." + def start_fetch(self, network_manager:QtNetwork.QNetworkAccessManager): + """Asynchronously begin the network access. Intended as a set-and-forget black box for downloading metadata.""" + self.request = QtNetwork.QNetworkRequest(QtCore.QUrl(self.url)) self.request.setAttribute( QtNetwork.QNetworkRequest.RedirectPolicyAttribute, @@ -76,20 +78,21 @@ class MetadataDownloadWorker(QObject): if not self.fetch_task.isFinished(): self.fetch_task.abort() - def on_redirect(self, url): + def on_redirect(self, _): # For now just blindly follow all redirects self.fetch_task.redirectAllowed.emit() - def on_ssl_error(self, reply, errors): + def on_ssl_error(self, reply:str, errors:List[str]): FreeCAD.Console.PrintWarning(f"Error with encrypted connection:\n") FreeCAD.Console.PrintWarning(reply) for error in errors: FreeCAD.Console.PrintWarning(error) def resolve_fetch(self): - "Called when the data fetch completed, either with an error, or if it found the metadata file" + """ Called when the data fetch completed, either with an error, or if it found the metadata file """ + if self.fetch_task.error() == QtNetwork.QNetworkReply.NetworkError.NoError: - FreeCAD.Console.PrintMessage( + FreeCAD.Console.PrintLog( f"Found a metadata file for {self.repo.name}\n" ) self.repo.repo_type = AddonManagerRepo.RepoType.PACKAGE @@ -128,8 +131,8 @@ class MetadataDownloadWorker(QObject): ): pass else: - FreeCAD.Console.PrintWarning( - f"Failed to connect to {self.url}:\n {self.fetch_task.error()}\n" + FreeCAD.Console.PrintWarning( + translate("AddonsInstaller", "Failed to connect to") + f" {self.url}:\n {self.fetch_task.error()}\n" ) def update_local_copy(self, new_xml): @@ -151,8 +154,8 @@ class MetadataDownloadWorker(QObject): if not icon: # If there is no icon set for the entire package, see if there are - # any workbenches, which - # are required to have icons, and grab the first one we find: + # any workbenches, which are required to have icons, and grab the first + # one we find: content = self.repo.metadata.Content if "workbench" in content: wb = content["workbench"][0] diff --git a/src/Mod/AddonManager/addonmanager_workers.py b/src/Mod/AddonManager/addonmanager_workers.py index 2d03fcef7a..0042fc798d 100644 --- a/src/Mod/AddonManager/addonmanager_workers.py +++ b/src/Mod/AddonManager/addonmanager_workers.py @@ -120,8 +120,8 @@ class UpdateWorker(QtCore.QThread): if "obsolete" in j and "Mod" in j["obsolete"]: obsolete = j["obsolete"]["Mod"] - if "reject_listed" in j and "Macro" in j["reject_listed"]: - macros_reject_list = j["reject_listed"]["Macro"] + if "blacklisted" in j and "Macro" in j["blacklisted"]: + macros_reject_list = j["blacklisted"]["Macro"] if "py2only" in j and "Mod" in j["py2only"]: py2only = j["py2only"]["Mod"] @@ -157,16 +157,21 @@ class UpdateWorker(QtCore.QThread): if name.lower().endswith(".git"): name = name[:-4] if name in package_names: - # We already have this one cached since it's a package + # We already have something with this name, skip this one continue + package_names.append(name) addondir = moddir + os.sep + name if os.path.exists(addondir) and os.listdir(addondir): state = AddonManagerRepo.UpdateStatus.UNCHECKED else: state = AddonManagerRepo.UpdateStatus.NOT_INSTALLED - self.addon_repo.emit( - AddonManagerRepo(name, addon["url"], state, addon["branch"]) - ) + repo = AddonManagerRepo(name, addon["url"], state, addon["branch"]) + md_file = os.path.join(addondir,"package.xml") + if os.path.isfile(md_file): + repo.load_metadata_file(md_file) + repo.installed_version = repo.metadata.Version + repo.updated_timestamp = os.path.getmtime(md_file) + self.addon_repo.emit(repo) # querying official addons u = utils.urlopen( @@ -193,8 +198,9 @@ class UpdateWorker(QtCore.QThread): if self.current_thread.isInterruptionRequested(): return if name in package_names: - # We've already got this info since it's a package or a custom repo + # We already have something with this name, skip this one continue + package_names.append(name) if branch is None or len(branch) == 0: branch = "master" url = url.split(".git")[0] @@ -204,7 +210,13 @@ class UpdateWorker(QtCore.QThread): state = AddonManagerRepo.UpdateStatus.UNCHECKED else: state = AddonManagerRepo.UpdateStatus.NOT_INSTALLED - self.addon_repo.emit(AddonManagerRepo(name, url, state, branch)) + repo = AddonManagerRepo(name, url, state, branch) + md_file = os.path.join(addondir,"package.xml") + if os.path.isfile(md_file): + repo.load_metadata_file(md_file) + repo.installed_version = repo.metadata.Version + repo.updated_timestamp = os.path.getmtime(md_file) + self.addon_repo.emit(repo) self.status_message.emit( translate("AddonsInstaller", "Workbenches list was updated.") @@ -231,7 +243,7 @@ class LoadPackagesFromCacheWorker(QtCore.QThread): data = f.read() if data: dict_data = json.loads(data) - for item in dict_data: + for item in dict_data.values(): if QtCore.QThread.currentThread().isInterruptionRequested(): return repo = AddonManagerRepo.from_cache(item) @@ -240,8 +252,13 @@ class LoadPackagesFromCacheWorker(QtCore.QThread): ) if os.path.isfile(repo_metadata_cache_path): try: - repo.metadata = FreeCAD.Metadata(repo_metadata_cache_path) + repo.load_metadata_file(repo_metadata_cache_path) + repo.installed_version = repo.metadata.Version + repo.updated_timestamp = os.path.getmtime(repo_metadata_cache_path) except Exception: + FreeCAD.Console.PrintWarning( + translate("AddonsInstaller","Failed loading") + f"{repo_metadata_cache_path}\n" + ) pass self.addon_repo.emit(repo) self.done.emit() @@ -382,6 +399,7 @@ class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): package.updated_timestamp = os.path.getmtime(installed_metadata_file) try: installed_metadata = FreeCAD.Metadata(installed_metadata_file) + package.set_metadata(installed_metadata) package.installed_version = installed_metadata.Version # Packages are considered up-to-date if the metadata version matches. Authors should update # their version string when they want the addon manager to alert users of a new version. @@ -1096,7 +1114,7 @@ class InstallWorkbenchWorker(QtCore.QThread): "FreeCAD to apply the changes.", ) else: - self.emit.failure(self.repo, answer) + self.failure.emit(self.repo, answer) return if self.repo.repo_type == AddonManagerRepo.RepoType.WORKBENCH: @@ -1199,14 +1217,15 @@ class InstallWorkbenchWorker(QtCore.QThread): ) message += ": " + pl + ", " if message and (not ok): - message = translate( + final_message = translate( "AddonsInstaller", "Some errors were found that prevent installation of this workbench", ) - message += ": " + message + ". " - message += translate( + final_message += ": " + message + ". " + final_message += translate( "AddonsInstaller", "Please install the missing components first." ) + message = final_message return ok, message def check_package_dependencies(self): @@ -1295,7 +1314,7 @@ class InstallWorkbenchWorker(QtCore.QThread): basedir = FreeCAD.getUserAppDataDir() package_xml = os.path.join(basedir, "Mod", self.repo.name, "package.xml") if os.path.isfile(package_xml): - self.repo.metadata = FreeCAD.Metadata(package_xml) + self.repo.load_metadata_file(package_xml) self.repo.installed_version = self.repo.metadata.Version self.repo.updated_timestamp = datetime.now().timestamp() diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index a62ed16ef7..86cdb97667 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -46,6 +46,7 @@ class PackageDetails(QWidget): update = Signal(AddonManagerRepo) execute = Signal(AddonManagerRepo) update_status = Signal(AddonManagerRepo) + check_for_update = Signal(AddonManagerRepo) def __init__(self, parent=None): super().__init__(parent) @@ -61,6 +62,7 @@ class PackageDetails(QWidget): self.ui.buttonInstall.clicked.connect(lambda: self.install.emit(self.repo)) self.ui.buttonUninstall.clicked.connect(lambda: self.uninstall.emit(self.repo)) self.ui.buttonUpdate.clicked.connect(lambda: self.update.emit(self.repo)) + self.ui.buttonCheckForUpdate.clicked.connect(lambda: self.check_for_update.emit(self.repo)) def show_repo(self, repo: AddonManagerRepo, reload: bool = False) -> None: @@ -149,9 +151,18 @@ class PackageDetails(QWidget): + "." ) elif repo.update_status == AddonManagerRepo.UpdateStatus.UNCHECKED: - installed_version_string += ( - translate("AddonsInstaller", "Update check in progress") + "." - ) + + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + autocheck = pref.GetBool("AutoCheck", False) + if autocheck: + installed_version_string += ( + translate("AddonsInstaller", "Update check in progress") + "." + ) + else: + installed_version_string += ( + translate("AddonsInstaller", "Automatic update checks disabled") + "." + ) + basedir = FreeCAD.getUserAppDataDir() moddir = os.path.join(basedir, "Mod", repo.name) @@ -171,22 +182,27 @@ class PackageDetails(QWidget): self.ui.buttonInstall.show() self.ui.buttonUninstall.hide() self.ui.buttonUpdate.hide() + self.ui.buttonCheckForUpdate.hide() elif repo.update_status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE: self.ui.buttonInstall.hide() self.ui.buttonUninstall.show() self.ui.buttonUpdate.hide() + self.ui.buttonCheckForUpdate.hide() elif repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: self.ui.buttonInstall.hide() self.ui.buttonUninstall.show() self.ui.buttonUpdate.show() + self.ui.buttonCheckForUpdate.hide() elif repo.update_status == AddonManagerRepo.UpdateStatus.UNCHECKED: self.ui.buttonInstall.hide() self.ui.buttonUninstall.show() self.ui.buttonUpdate.hide() + self.ui.buttonCheckForUpdate.show() elif repo.update_status == AddonManagerRepo.UpdateStatus.PENDING_RESTART: self.ui.buttonInstall.hide() self.ui.buttonUninstall.show() self.ui.buttonUpdate.hide() + self.ui.buttonCheckForUpdate.hide() @classmethod def cache_path(self, repo: AddonManagerRepo) -> str: @@ -354,6 +370,11 @@ class Ui_PackageDetails(object): self.layoutDetailsBackButton.addWidget(self.buttonUpdate) + self.buttonCheckForUpdate = QPushButton(PackageDetails) + self.buttonCheckForUpdate.setObjectName("buttonCheckForUpdate") + + self.layoutDetailsBackButton.addWidget(self.buttonCheckForUpdate) + self.buttonExecute = QPushButton(PackageDetails) self.buttonExecute.setObjectName("buttonExecute") @@ -390,6 +411,9 @@ class Ui_PackageDetails(object): self.buttonUpdate.setText( QCoreApplication.translate("AddonsInstaller", "Update", None) ) + self.buttonCheckForUpdate.setText( + QCoreApplication.translate("AddonsInstaller", "Check for Update", None) + ) self.buttonExecute.setText( QCoreApplication.translate("AddonsInstaller", "Run Macro", None) ) diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py index 14f7258c3c..6028552c45 100644 --- a/src/Mod/AddonManager/package_list.py +++ b/src/Mod/AddonManager/package_list.py @@ -28,8 +28,6 @@ from PySide2.QtCore import * from PySide2.QtGui import * from PySide2.QtWidgets import * -import datetime -from typing import Dict, Union from enum import IntEnum import threading @@ -130,6 +128,7 @@ class PackageList(QWidget): self.item_filter.setFilterRegularExpression(text_filter) def set_view_style(self, style: ListDisplayStyle) -> None: + self.item_model.layoutAboutToBeChanged.emit() self.item_delegate.set_view(style) if style == ListDisplayStyle.COMPACT: self.ui.listPackages.setSpacing(2) @@ -172,17 +171,17 @@ class PackageListItemModel(QAbstractListModel): if self.repos[row].repo_type == AddonManagerRepo.RepoType.PACKAGE: tooltip = ( translate("AddonsInstaller", "Click for details about package") - + f" '{self.repos[row].name}'" + + f" '{self.repos[row].display_name}'" ) elif self.repos[row].repo_type == AddonManagerRepo.RepoType.WORKBENCH: tooltip = ( translate("AddonsInstaller", "Click for details about workbench") - + f" '{self.repos[row].name}'" + + f" '{self.repos[row].display_name}'" ) elif self.repos[row].repo_type == AddonManagerRepo.RepoType.MACRO: tooltip = ( translate("AddonsInstaller", "Click for details about macro") - + f" '{self.repos[row].name}'" + + f" '{self.repos[row].display_name}'" ) return tooltip elif role == PackageListItemModel.DataAccessRole: @@ -301,11 +300,11 @@ class PackageListItemDelegate(QStyledItemDelegate): repo = index.data(PackageListItemModel.DataAccessRole) if self.displayStyle == ListDisplayStyle.EXPANDED: self.widget = self.expanded - self.widget.ui.labelPackageName.setText(f"