# *************************************************************************** # * * # * Copyright (c) 2022 FreeCAD Project Association * # * Copyright (c) 2019 Yorik van Havre * # * * # * This library 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. * # * * # * This library 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 this library; if not, write to the Free Software * # * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * # * 02110-1301 USA * # * * # *************************************************************************** """ Worker thread classes for Addon Manager installation and removal """ # pylint: disable=c-extension-no-member,too-few-public-methods import io import os import queue import shutil import subprocess import time import zipfile from typing import Dict, List from enum import Enum, auto from PySide2 import QtCore import FreeCAD import addonmanager_utilities as utils from Addon import Addon import NetworkManager from addonmanager_git import initialize_git translate = FreeCAD.Qt.translate # @package AddonManager_workers # \ingroup ADDONMANAGER # \brief Multithread workers for the addon manager # @{ class InstallWorkbenchWorker(QtCore.QThread): "This worker installs a workbench" status_message = QtCore.Signal(str) progress_made = QtCore.Signal(int, int) success = QtCore.Signal(Addon, str) failure = QtCore.Signal(Addon, str) def __init__(self, repo: Addon, location=None): QtCore.QThread.__init__(self) self.repo = repo self.update_timer = QtCore.QTimer() self.update_timer.setInterval(100) self.update_timer.timeout.connect(self.update_status) self.update_timer.start() if location: self.clone_directory = location else: basedir = FreeCAD.getUserAppDataDir() self.clone_directory = os.path.join(basedir, "Mod", repo.name) if not os.path.exists(self.clone_directory): os.makedirs(self.clone_directory) self.git_manager = initialize_git() # Some stored data for the ZIP processing self.zip_complete = False self.zipdir = None self.bakdir = None self.zip_download_index = None def run(self): """Normally not called directly: instead, create an instance of this worker class and call start() on it to launch in a new thread. Installs or updates the selected addon""" if not self.repo: return if not self.git_manager: FreeCAD.Console.PrintLog( translate( "AddonsInstaller", "Git disabled - using ZIP file download instead.", ) + "\n" ) target_dir = self.clone_directory if self.git_manager: # Do the git process... self.run_git(target_dir) else: # The zip process uses an event loop, since the download can potentially be quite large self.launch_zip(target_dir) self.zip_complete = False current_thread = QtCore.QThread.currentThread() while not self.zip_complete: if current_thread.isInterruptionRequested(): return QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50) self.repo.set_status(Addon.Status.PENDING_RESTART) def update_status(self) -> None: """Periodically emit the progress of the git download, for asynchronous operations""" if hasattr(self, "git_progress") and self.isRunning(): self.progress_made.emit(self.git_progress.current, self.git_progress.total) self.status_message.emit(self.git_progress.message) def run_git(self, clonedir: str) -> None: """Clone or update the addon using git. Exits if git is disabled.""" if not self.git_manager: FreeCAD.Console.PrintLog( translate( "AddonsInstaller", "Git disabled, skipping git operations", ) + "\n" ) return if os.path.exists(clonedir): self.run_git_update(clonedir) else: self.run_git_clone(clonedir) def run_git_update(self, clonedir: str) -> None: """Runs git update operation: normally a fetch and pull, but if something goew wrong it will revert to a clean clone.""" self.status_message.emit("Updating module...") with self.repo.git_lock: if not os.path.exists(os.path.join(clonedir, ".git")): self.git_manager.repair(self.repo.url, clonedir) try: self.git_manager.update(clonedir) if self.repo.contains_workbench(): answer = translate( "AddonsInstaller", "Addon successfully updated. Please restart FreeCAD to apply the changes.", ) else: answer = translate( "AddonsInstaller", "Addon successfully updated.", ) except GitFailed as e: answer = ( translate("AddonsInstaller", "Error updating module") + " " + self.repo.name + " - " + translate("AddonsInstaller", "Please fix manually") + " -- \n" ) answer += str(e) self.failure.emit(self.repo, answer) self.update_metadata() self.success.emit(self.repo, answer) def run_git_clone(self, clonedir: str) -> None: """Clones a repo using git""" self.status_message.emit("Cloning module...") current_thread = QtCore.QThread.currentThread() FreeCAD.Console.PrintMessage("Cloning repo...\n") if self.repo.git_lock.locked(): FreeCAD.Console.PrintMessage("Waiting for lock to be released to us...\n") if not self.repo.git_lock.acquire(timeout=2): FreeCAD.Console.PrintError( "Timeout waiting for a lock on the git process, failed to clone repo\n" ) return self.repo.git_lock.release() with self.repo.git_lock: FreeCAD.Console.PrintMessage("Lock acquired...\n") self.git_manager.clone(self.repo.url, clonedir) FreeCAD.Console.PrintMessage("Initial clone complete...\n") if current_thread.isInterruptionRequested(): return if current_thread.isInterruptionRequested(): return FreeCAD.Console.PrintMessage("Clone complete\n") if self.repo.contains_workbench(): answer = translate( "AddonsInstaller", "Addon successfully installed. Please restart FreeCAD to apply the changes.", ) else: answer = translate( "AddonsInstaller", "Addon successfully installed.", ) if self.repo.repo_type == Addon.Kind.WORKBENCH: # symlink any macro contained in the module to the macros folder macro_dir = FreeCAD.getUserMacroDir(True) if not os.path.exists(macro_dir): os.makedirs(macro_dir) if os.path.exists(clonedir): for f in os.listdir(clonedir): if f.lower().endswith(".fcmacro"): try: utils.symlink( os.path.join(clonedir, f), os.path.join(macro_dir, f) ) except OSError: # If the symlink failed (e.g. for a non-admin user on Windows), copy # the macro instead shutil.copy( os.path.join(clonedir, f), os.path.join(macro_dir, f) ) FreeCAD.ParamGet( "User parameter:Plugins/" + self.repo.name ).SetString("destination", clonedir) # pylint: disable=line-too-long answer += "\n\n" + translate( "AddonsInstaller", "A macro has been installed and is available under Macro -> Macros menu", ) answer += ":\n" + f + "" self.update_metadata() self.success.emit(self.repo, answer) def launch_zip(self, zipdir: str) -> None: """Downloads and unzip a zip version from a git repo""" bakdir = None if os.path.exists(zipdir): bakdir = zipdir + ".bak" if os.path.exists(bakdir): shutil.rmtree(bakdir) os.rename(zipdir, bakdir) os.makedirs(zipdir) zipurl = utils.get_zip_url(self.repo) if not zipurl: self.failure.emit( self.repo, translate("AddonsInstaller", "Error: Unable to locate ZIP from") + " " + self.repo.name, ) return self.zipdir = zipdir self.bakdir = bakdir NetworkManager.AM_NETWORK_MANAGER.progress_made.connect(self.update_zip_status) NetworkManager.AM_NETWORK_MANAGER.progress_complete.connect(self.finish_zip) self.zip_download_index = ( NetworkManager.AM_NETWORK_MANAGER.submit_monitored_get(zipurl) ) def update_zip_status(self, index: int, bytes_read: int, data_size: int): """Called periodically when downloading a zip file, emits a signal to display the download progress.""" if index == self.zip_download_index: locale = QtCore.QLocale() if data_size > 10 * 1024 * 1024: # To avoid overflows, show MB instead MB_read = bytes_read / 1024 / 1024 MB_total = data_size / 1024 / 1024 self.progress_made.emit(MB_read, MB_total) mbytes_str = locale.toString(MB_read) mbytes_total_str = locale.toString(MB_total) percent = int(100 * float(MB_read / MB_total)) self.status_message.emit( translate( "AddonsInstaller", "Downloading: {mbytes_str}MB of {mbytes_total_str}MB ({percent}%)", ).format( mbytes_str=mbytes_str, mbytes_total_str=mbytes_total_str, percent=percent, ) ) elif data_size > 0: self.progress_made.emit(bytes_read, data_size) bytes_str = locale.toString(bytes_read) bytes_total_str = locale.toString(data_size) percent = int(100 * float(bytes_read / data_size)) self.status_message.emit( translate( "AddonsInstaller", "Downloading: {bytes_str} of {bytes_total_str} bytes ({percent}%)", ).format( bytes_str=bytes_str, bytes_total_str=bytes_total_str, percent=percent, ) ) else: MB_read = bytes_read / 1024 / 1024 bytes_str = locale.toString(MB_read) self.status_message.emit( translate( "AddonsInstaller", "Downloading: {bytes_str}MB of unknown total", ).format(bytes_str=bytes_str) ) def finish_zip(self, _index: int, response_code: int, filename: os.PathLike): """Once the zip download is finished, unzip it into the correct location.""" self.zip_complete = True if response_code != 200: self.failure.emit( self.repo, translate( "AddonsInstaller", "Error: Error while downloading ZIP file for {}", ).format(self.repo.display_name), ) return with zipfile.ZipFile(filename, "r") as zfile: master = zfile.namelist()[0] # github will put everything in a subfolder self.status_message.emit( translate("AddonsInstaller", "Download complete. Unzipping file...") ) QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents) zfile.extractall(self.zipdir) for extracted_filename in os.listdir(self.zipdir + os.sep + master): shutil.move( self.zipdir + os.sep + master + os.sep + extracted_filename, self.zipdir + os.sep + extracted_filename, ) os.rmdir(self.zipdir + os.sep + master) if self.bakdir: shutil.rmtree(self.bakdir) self.update_metadata() self.success.emit( self.repo, translate( "AddonsInstaller", "Successfully installed {} from ZIP file", ).format(self.repo.display_name), ) def update_metadata(self): """Loads the package metadata from the Addon's downloaded package.xml file.""" basedir = FreeCAD.getUserAppDataDir() package_xml = os.path.join(basedir, "Mod", self.repo.name, "package.xml") if os.path.isfile(package_xml): self.repo.load_metadata_file(package_xml) self.repo.installed_version = self.repo.metadata.Version self.repo.updated_timestamp = os.path.getmtime(package_xml) class DependencyInstallationWorker(QtCore.QThread): """Install dependencies using Addonmanager for FreeCAD, and pip for python""" no_python_exe = QtCore.Signal() no_pip = QtCore.Signal(str) # Attempted command failure = QtCore.Signal(str, str) # Short message, detailed message success = QtCore.Signal() def __init__( self, addons: List[Addon], python_required: List[str], python_optional: List[str], location: os.PathLike = None, ): """Install the various types of dependencies that might be specified. If an optional dependency fails this is non-fatal, but other failures are considered fatal. If location is specified it overrides the FreeCAD user base directory setting: this is used mostly for testing purposes and shouldn't be set by normal code in most circumstances.""" QtCore.QThread.__init__(self) self.addons = addons self.python_required = python_required self.python_optional = python_optional self.location = location def run(self): """Normally not called directly: create the object and call start() to launch it in its own thread. Installs dependencies for the Addon.""" self._install_required_addons() if self.python_required or self.python_optional: self._install_python_packages() self.success.emit() def _install_required_addons(self): """Install whatever FreeCAD Addons were set as required.""" for repo in self.addons: if QtCore.QThread.currentThread().isInterruptionRequested(): return location = self.location if location: location = os.path.join(location, "Mod") worker = InstallWorkbenchWorker(repo, location=location) worker.start() while worker.isRunning(): if QtCore.QThread.currentThread().isInterruptionRequested(): worker.requestInterruption() worker.wait() return time.sleep(0.1) QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50) def _install_python_packages(self): """Install required and optional Python dependencies using pip.""" if not self._verify_pip(): return if self.location: vendor_path = os.path.join(self.location, "AdditionalPythonPackages") else: vendor_path = os.path.join( FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages" ) if not os.path.exists(vendor_path): os.makedirs(vendor_path) self._install_required(vendor_path) self._install_optional(vendor_path) def _verify_pip(self) -> bool: """Ensure that pip is working -- returns True if it is, or False if not. Also emits the no_pip signal if pip cannot execute.""" python_exe = utils.get_python_exe() pip_failed = False if python_exe: try: proc = subprocess.run( [python_exe, "-m", "pip", "--version"], stdout=subprocess.PIPE, check=True, ) except subprocess.CalledProcessError: pip_failed = True if proc.returncode != 0: pip_failed = True else: pip_failed = True if pip_failed: self.no_pip.emit(f"{python_exe} -m pip --version") FreeCAD.Console.PrintMessage(proc.stdout) FreeCAD.Console.PrintWarning(proc.stderr) result = proc.stdout FreeCAD.Console.PrintMessage(result.decode()) return not pip_failed def _install_required(self, vendor_path: os.PathLike): """Install the required Python package dependencies. If any fail a failure signal is emitted and the function exits without proceeding with any additional installs.""" python_exe = utils.get_python_exe() for pymod in self.python_required: if QtCore.QThread.currentThread().isInterruptionRequested(): return proc = subprocess.run( [ python_exe, "-m", "pip", "install", "--disable-pip-version-check", "--target", vendor_path, pymod, ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ) FreeCAD.Console.PrintMessage(proc.stdout.decode()) if proc.returncode != 0: self.failure.emit( translate( "AddonsInstaller", "Installation of Python package {} failed", ).format(pymod), proc.stderr, ) return def _install_optional(self, vendor_path: os.PathLike): """Install the optional Python package dependencies. If any fail a message is printed to the console, but installation of the others continues.""" python_exe = utils.get_python_exe() for pymod in self.python_optional: if QtCore.QThread.currentThread().isInterruptionRequested(): return try: proc = subprocess.run( [ python_exe, "-m", "pip", "install", "--target", vendor_path, pymod, ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ) except subprocess.CalledProcessError as e: FreeCAD.Console.PrintError(str(e)) continue FreeCAD.Console.PrintMessage(proc.stdout.decode()) if proc.returncode != 0: FreeCAD.Console.PrintError(proc.stderr.decode()) class UpdateMetadataCacheWorker(QtCore.QThread): """Scan through all available packages and see if our local copy of package.xml needs to be updated""" status_message = QtCore.Signal(str) progress_made = QtCore.Signal(int, int) package_updated = QtCore.Signal(Addon) class RequestType(Enum): """The type of item being downloaded.""" PACKAGE_XML = auto() METADATA_TXT = auto() REQUIREMENTS_TXT = auto() ICON = auto() def __init__(self, repos): QtCore.QThread.__init__(self) self.repos = repos self.requests: Dict[int, (Addon, UpdateMetadataCacheWorker.RequestType)] = {} NetworkManager.AM_NETWORK_MANAGER.completed.connect(self.download_completed) self.requests_completed = 0 self.total_requests = 0 self.store = os.path.join( FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata" ) self.updated_repos = set() def run(self): current_thread = QtCore.QThread.currentThread() for repo in self.repos: if repo.url and utils.recognized_git_location(repo): # package.xml index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get( utils.construct_git_url(repo, "package.xml") ) self.requests[index] = ( repo, UpdateMetadataCacheWorker.RequestType.PACKAGE_XML, ) self.total_requests += 1 # metadata.txt index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get( utils.construct_git_url(repo, "metadata.txt") ) self.requests[index] = ( repo, UpdateMetadataCacheWorker.RequestType.METADATA_TXT, ) self.total_requests += 1 # requirements.txt index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get( utils.construct_git_url(repo, "requirements.txt") ) self.requests[index] = ( repo, UpdateMetadataCacheWorker.RequestType.REQUIREMENTS_TXT, ) self.total_requests += 1 while self.requests: if current_thread.isInterruptionRequested(): NetworkManager.AM_NETWORK_MANAGER.completed.disconnect( self.download_completed ) for request in self.requests.keys(): NetworkManager.AM_NETWORK_MANAGER.abort(request) return # 50 ms maximum between checks for interruption QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50) # This set contains one copy of each of the repos that got some kind of data in # this process. For those repos, tell the main Addon Manager code that it needs # to update its copy of the repo, and redraw its information. for repo in self.updated_repos: self.package_updated.emit(repo) def download_completed( self, index: int, code: int, data: QtCore.QByteArray ) -> None: """Callback for handling a completed metadata file download.""" if index in self.requests: self.requests_completed += 1 self.progress_made.emit(self.requests_completed, self.total_requests) request = self.requests.pop(index) if code == 200: # HTTP success self.updated_repos.add(request[0]) # mark this repo as updated if request[1] == UpdateMetadataCacheWorker.RequestType.PACKAGE_XML: self.process_package_xml(request[0], data) elif request[1] == UpdateMetadataCacheWorker.RequestType.METADATA_TXT: self.process_metadata_txt(request[0], data) elif ( request[1] == UpdateMetadataCacheWorker.RequestType.REQUIREMENTS_TXT ): self.process_requirements_txt(request[0], data) elif request[1] == UpdateMetadataCacheWorker.RequestType.ICON: self.process_icon(request[0], data) def process_package_xml(self, repo: Addon, data: QtCore.QByteArray): """Process the package.xml metadata file""" repo.repo_type = Addon.Kind.PACKAGE # By definition package_cache_directory = os.path.join(self.store, repo.name) if not os.path.exists(package_cache_directory): os.makedirs(package_cache_directory) new_xml_file = os.path.join(package_cache_directory, "package.xml") with open(new_xml_file, "wb") as f: f.write(data.data()) metadata = FreeCAD.Metadata(new_xml_file) repo.metadata = metadata self.status_message.emit( translate("AddonsInstaller", "Downloaded package.xml for {}").format( repo.name ) ) # Grab a new copy of the icon as well: we couldn't enqueue this earlier because # we didn't know the path to it, which is stored in the package.xml file. icon = metadata.Icon 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: content = repo.metadata.Content if "workbench" in content: wb = content["workbench"][0] if wb.Icon: if wb.Subdirectory: subdir = wb.Subdirectory else: subdir = wb.Name repo.Icon = subdir + wb.Icon icon = repo.Icon icon_url = utils.construct_git_url(repo, icon) index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(icon_url) self.requests[index] = (repo, UpdateMetadataCacheWorker.RequestType.ICON) self.total_requests += 1 def process_metadata_txt(self, repo: Addon, data: QtCore.QByteArray): """Process the metadata.txt metadata file""" self.status_message.emit( translate("AddonsInstaller", "Downloaded metadata.txt for {}").format( repo.display_name ) ) f = io.StringIO(data.data().decode("utf8")) while True: line = f.readline() if not line: break if line.startswith("workbenches="): depswb = line.split("=")[1].split(",") for wb in depswb: wb_name = wb.strip() if wb_name: repo.requires.add(wb_name) FreeCAD.Console.PrintLog( f"{repo.display_name} requires FreeCAD Addon '{wb_name}'\n" ) elif line.startswith("pylibs="): depspy = line.split("=")[1].split(",") for pl in depspy: dep = pl.strip() if dep: repo.python_requires.add(dep) FreeCAD.Console.PrintLog( f"{repo.display_name} requires python package '{dep}'\n" ) elif line.startswith("optionalpylibs="): opspy = line.split("=")[1].split(",") for pl in opspy: dep = pl.strip() if dep: repo.python_optional.add(dep) FreeCAD.Console.PrintLog( f"{repo.display_name} optionally imports python package" + f" '{pl.strip()}'\n" ) # For review and debugging purposes, store the file locally package_cache_directory = os.path.join(self.store, repo.name) if not os.path.exists(package_cache_directory): os.makedirs(package_cache_directory) new_xml_file = os.path.join(package_cache_directory, "metadata.txt") with open(new_xml_file, "wb") as f: f.write(data.data()) def process_requirements_txt(self, repo: Addon, data: QtCore.QByteArray): """Process the requirements.txt metadata file""" self.status_message.emit( translate( "AddonsInstaller", "Downloaded requirements.txt for {}", ).format(repo.display_name) ) f = io.StringIO(data.data().decode("utf8")) lines = f.readlines() for line in lines: break_chars = " <>=~!+#" package = line for n, c in enumerate(line): if c in break_chars: package = line[:n].strip() break if package: repo.python_requires.add(package) # For review and debugging purposes, store the file locally package_cache_directory = os.path.join(self.store, repo.name) if not os.path.exists(package_cache_directory): os.makedirs(package_cache_directory) new_xml_file = os.path.join(package_cache_directory, "requirements.txt") with open(new_xml_file, "wb") as f: f.write(data.data()) def process_icon(self, repo: Addon, data: QtCore.QByteArray): """Convert icon data into a valid icon file and store it""" self.status_message.emit( translate("AddonsInstaller", "Downloaded icon for {}").format( repo.display_name ) ) cache_file = repo.get_cached_icon_filename() with open(cache_file, "wb") as icon_file: icon_file.write(data.data()) repo.cached_icon_filename = cache_file class UpdateAllWorker(QtCore.QThread): """Update all listed packages, of any kind""" progress_made = QtCore.Signal(int, int) status_message = QtCore.Signal(str) success = QtCore.Signal(Addon) failure = QtCore.Signal(Addon) # TODO: This should be re-written to be solidly single-threaded, some of the called code is # not re-entrant def __init__(self, repos): super().__init__() self.repos = repos def run(self): self.progress_made.emit(0, len(self.repos)) self.repo_queue = queue.Queue() current_thread = QtCore.QThread.currentThread() for repo in self.repos: self.repo_queue.put(repo) FreeCAD.Console.PrintLog( f" UPDATER: Adding '{repo.name}' to update queue\n" ) # The original design called for multiple update threads at the same time, but the updater # itself is not thread-safe, so for the time being only spawn one update thread. workers = [] for _ in range(1): FreeCAD.Console.PrintLog(f" UPDATER: Starting worker\n") worker = UpdateSingleWorker(self.repo_queue) worker.success.connect(self.on_success) worker.failure.connect(self.on_failure) worker.start() workers.append(worker) while not self.repo_queue.empty(): if current_thread.isInterruptionRequested(): for worker in workers: worker.blockSignals(True) worker.requestInterruption() worker.wait() return # Ensure our signals propagate out by running an internal thread-local event loop QtCore.QCoreApplication.processEvents() self.repo_queue.join() # Make sure all of our child threads have fully exited: for worker in workers: worker.wait() def on_success(self, repo: Addon) -> None: FreeCAD.Console.PrintLog( f" UPDATER: Main thread received notice that worker successfully updated {repo.name}\n" ) self.progress_made.emit( len(self.repos) - self.repo_queue.qsize(), len(self.repos) ) self.success.emit(repo) def on_failure(self, repo: Addon) -> None: FreeCAD.Console.PrintLog( f" UPDATER: Main thread received notice that worker failed to update {repo.name}\n" ) self.progress_made.emit( len(self.repos) - self.repo_queue.qsize(), len(self.repos) ) self.failure.emit(repo) class UpdateSingleWorker(QtCore.QThread): success = QtCore.Signal(Addon) failure = QtCore.Signal(Addon) def __init__(self, repo_queue: queue.Queue, location=None): super().__init__() self.repo_queue = repo_queue self.location = location def run(self): current_thread = QtCore.QThread.currentThread() while True: if current_thread.isInterruptionRequested(): FreeCAD.Console.PrintLog( f" UPDATER: Interruption requested, stopping all updates\n" ) return try: repo = self.repo_queue.get_nowait() FreeCAD.Console.PrintLog( f" UPDATER: Pulling {repo.name} from the update queue\n" ) except queue.Empty: FreeCAD.Console.PrintLog( f" UPDATER: Worker thread queue is empty, exiting thread\n" ) return if repo.repo_type == Addon.Kind.MACRO: FreeCAD.Console.PrintLog(f" UPDATER: Updating macro '{repo.name}'\n") self.update_macro(repo) else: FreeCAD.Console.PrintLog(f" UPDATER: Updating addon '{repo.name}'\n") self.update_package(repo) self.repo_queue.task_done() FreeCAD.Console.PrintLog( f" UPDATER: Worker thread completed action for '{repo.name}' and reported result " + "to main thread\n" ) def update_macro(self, repo: Addon): """Updating a macro happens in this function, in the current thread""" cache_path = os.path.join( FreeCAD.getUserCachePath(), "AddonManager", "MacroCache" ) os.makedirs(cache_path, exist_ok=True) install_succeeded, _ = repo.macro.install(cache_path) if install_succeeded: install_succeeded, _ = repo.macro.install(FreeCAD.getUserMacroDir(True)) utils.update_macro_installation_details(repo) if install_succeeded: self.success.emit(repo) else: self.failure.emit(repo) def update_package(self, repo: Addon): """Updating a package re-uses the package installation worker, so actually spawns another thread that we block on""" worker = InstallWorkbenchWorker(repo, location=self.location) worker.success.connect(lambda repo, _: self.success.emit(repo)) worker.failure.connect(lambda repo, _: self.failure.emit(repo)) worker.start() while True: # Ensure our signals propagate out by running an internal thread-local event loop QtCore.QCoreApplication.processEvents() if not worker.isRunning(): break time.sleep(0.1) # Give the signal a moment to propagate to the other threads QtCore.QCoreApplication.processEvents() # @}