FreeCAD
1000 строк · 40.8 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022-2023 FreeCAD Project Association *
5# * Copyright (c) 2019 Yorik van Havre <yorik@uncreated.net> *
6# * *
7# * This file is part of FreeCAD. *
8# * *
9# * FreeCAD is free software: you can redistribute it and/or modify it *
10# * under the terms of the GNU Lesser General Public License as *
11# * published by the Free Software Foundation, either version 2.1 of the *
12# * License, or (at your option) any later version. *
13# * *
14# * FreeCAD is distributed in the hope that it will be useful, but *
15# * WITHOUT ANY WARRANTY; without even the implied warranty of *
16# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
17# * Lesser General Public License for more details. *
18# * *
19# * You should have received a copy of the GNU Lesser General Public *
20# * License along with FreeCAD. If not, see *
21# * <https://www.gnu.org/licenses/>. *
22# * *
23# ***************************************************************************
24
25""" Worker thread classes for Addon Manager startup """
26import hashlib27import json28import os29import queue30import re31import shutil32import stat33import threading34import time35from typing import List36
37from PySide import QtCore38
39import FreeCAD40import addonmanager_utilities as utils41from addonmanager_macro import Macro42from Addon import Addon43from AddonStats import AddonStats44import NetworkManager45from addonmanager_git import initialize_git, GitFailed46from addonmanager_metadata import MetadataReader, get_branch_from_metadata47
48translate = FreeCAD.Qt.translate49
50# Workers only have one public method by design
51# pylint: disable=c-extension-no-member,too-few-public-methods,too-many-instance-attributes
52
53
54class CreateAddonListWorker(QtCore.QThread):55"""This worker updates the list of available workbenches, emitting an "addon_repo"56signal for each Addon as they are processed."""
57
58status_message = QtCore.Signal(str)59addon_repo = QtCore.Signal(object)60
61def __init__(self):62QtCore.QThread.__init__(self)63
64# reject_listed addons65self.macros_reject_list = []66self.mod_reject_list = []67
68# These addons will print an additional message informing the user69self.obsolete = []70
71# These addons will print an additional message informing the user Python2 only72self.py2only = []73
74self.package_names = []75self.moddir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod")76self.current_thread = None77
78self.git_manager = initialize_git()79
80def run(self):81"populates the list of addons"82
83self.current_thread = QtCore.QThread.currentThread()84try:85self._get_freecad_addon_repo_data()86except ConnectionError:87return88self._get_custom_addons()89self._get_official_addons()90self._retrieve_macros_from_git()91self._retrieve_macros_from_wiki()92
93def _get_freecad_addon_repo_data(self):94# update info lists95p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(96"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json", 500097)98if p:99p = p.data().decode("utf8")100j = json.loads(p)101if "obsolete" in j and "Mod" in j["obsolete"]:102self.obsolete = j["obsolete"]["Mod"]103
104if "blacklisted" in j and "Macro" in j["blacklisted"]:105self.macros_reject_list = j["blacklisted"]["Macro"]106
107if "blacklisted" in j and "Mod" in j["blacklisted"]:108self.mod_reject_list = j["blacklisted"]["Mod"]109
110if "py2only" in j and "Mod" in j["py2only"]:111self.py2only = j["py2only"]["Mod"]112
113if "deprecated" in j:114self._process_deprecated(j["deprecated"])115
116else:117message = translate(118"AddonsInstaller",119"Failed to connect to GitHub. Check your connection and proxy settings.",120)121FreeCAD.Console.PrintError(message + "\n")122self.status_message.emit(message)123raise ConnectionError124
125def _process_deprecated(self, deprecated_addons):126"""Parse the section on deprecated addons"""127
128fc_major = int(FreeCAD.Version()[0])129fc_minor = int(FreeCAD.Version()[1])130for item in deprecated_addons:131if "as_of" in item and "name" in item:132try:133version_components = item["as_of"].split(".")134major = int(version_components[0])135if len(version_components) > 1:136minor = int(version_components[1])137else:138minor = 0139if major < fc_major or (major == fc_major and minor <= fc_minor):140if "kind" not in item or item["kind"] == "mod":141self.obsolete.append(item["name"])142elif item["kind"] == "macro":143self.macros_reject_list.append(item["name"])144else:145FreeCAD.Console.PrintMessage(146f'Unrecognized Addon kind {item["kind"]} in deprecation list.'147)148except ValueError:149FreeCAD.Console.PrintMessage(150f"Failed to parse version from {item['name']}, version {item['as_of']}"151)152
153def _get_custom_addons(self):154
155# querying custom addons first156addon_list = (157FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")158.GetString("CustomRepositories", "")159.split("\n")160)161custom_addons = []162for addon in addon_list:163if " " in addon:164addon_and_branch = addon.split(" ")165custom_addons.append({"url": addon_and_branch[0], "branch": addon_and_branch[1]})166else:167custom_addons.append({"url": addon, "branch": "master"})168for addon in custom_addons:169if self.current_thread.isInterruptionRequested():170return171if addon and addon["url"]:172if addon["url"][-1] == "/":173addon["url"] = addon["url"][0:-1] # Strip trailing slash174addon["url"] = addon["url"].split(".git")[0] # Remove .git175name = addon["url"].split("/")[-1]176if name in self.package_names:177# We already have something with this name, skip this one178FreeCAD.Console.PrintWarning(179translate("AddonsInstaller", "WARNING: Duplicate addon {} ignored").format(180name
181)182)183continue184FreeCAD.Console.PrintLog(185f"Adding custom location {addon['url']} with branch {addon['branch']}\n"186)187self.package_names.append(name)188addondir = os.path.join(self.moddir, name)189if os.path.exists(addondir) and os.listdir(addondir):190state = Addon.Status.UNCHECKED191else:192state = Addon.Status.NOT_INSTALLED193repo = Addon(name, addon["url"], state, addon["branch"])194md_file = os.path.join(addondir, "package.xml")195if os.path.isfile(md_file):196repo.installed_metadata = MetadataReader.from_file(md_file)197repo.installed_version = repo.installed_metadata.version198repo.updated_timestamp = os.path.getmtime(md_file)199repo.verify_url_and_branch(addon["url"], addon["branch"])200
201self.addon_repo.emit(repo)202
203def _get_official_addons(self):204# querying official addons205p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(206"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/.gitmodules", 5000207)208if not p:209return210p = p.data().decode("utf8")211p = re.findall(212(213r'(?m)\[submodule\s*"(?P<name>.*)"\]\s*'214r"path\s*=\s*(?P<path>.+)\s*"215r"url\s*=\s*(?P<url>https?://.*)\s*"216r"(branch\s*=\s*(?P<branch>[^\s]*)\s*)?"217),218p,219)220for name, _, url, _, branch in p:221if self.current_thread.isInterruptionRequested():222return223if name in self.package_names:224# We already have something with this name, skip this one225continue226self.package_names.append(name)227if branch is None or len(branch) == 0:228branch = "master"229url = url.split(".git")[0]230addondir = os.path.join(self.moddir, name)231if os.path.exists(addondir) and os.listdir(addondir):232# make sure the folder exists and it contains files!233state = Addon.Status.UNCHECKED234else:235state = Addon.Status.NOT_INSTALLED236repo = Addon(name, url, state, branch)237md_file = os.path.join(addondir, "package.xml")238if os.path.isfile(md_file):239repo.installed_metadata = MetadataReader.from_file(md_file)240repo.installed_version = repo.installed_metadata.version241repo.updated_timestamp = os.path.getmtime(md_file)242repo.verify_url_and_branch(url, branch)243
244if name in self.py2only:245repo.python2 = True246if name in self.mod_reject_list:247repo.rejected = True248if name in self.obsolete:249repo.obsolete = True250self.addon_repo.emit(repo)251
252self.status_message.emit(translate("AddonsInstaller", "Workbenches list was updated."))253
254def _retrieve_macros_from_git(self):255"""Retrieve macros from FreeCAD-macros.git256
257Emits a signal for each macro in
258https://github.com/FreeCAD/FreeCAD-macros.git
259"""
260
261macro_cache_location = utils.get_cache_file_name("Macros")262
263if not self.git_manager:264message = translate(265"AddonsInstaller",266"Git is disabled, skipping git macros",267)268self.status_message.emit(message)269FreeCAD.Console.PrintWarning(message + "\n")270return271
272update_succeeded = self._update_local_git_repo()273if not update_succeeded:274return275
276n_files = 0277for _, _, filenames in os.walk(macro_cache_location):278n_files += len(filenames)279counter = 0280for dirpath, _, filenames in os.walk(macro_cache_location):281counter += 1282if self.current_thread.isInterruptionRequested():283return284if ".git" in dirpath:285continue286for filename in filenames:287if self.current_thread.isInterruptionRequested():288return289if filename.lower().endswith(".fcmacro"):290macro = Macro(filename[:-8]) # Remove ".FCMacro".291if macro.name in self.package_names:292FreeCAD.Console.PrintLog(293f"Ignoring second macro named {macro.name} (found on git)\n"294)295continue # We already have a macro with this name296self.package_names.append(macro.name)297macro.on_git = True298macro.src_filename = os.path.join(dirpath, filename)299macro.fill_details_from_file(macro.src_filename)300repo = Addon.from_macro(macro)301FreeCAD.Console.PrintLog(f"Found macro {repo.name}\n")302repo.url = "https://github.com/FreeCAD/FreeCAD-macros.git"303utils.update_macro_installation_details(repo)304self.addon_repo.emit(repo)305
306def _update_local_git_repo(self) -> bool:307macro_cache_location = utils.get_cache_file_name("Macros")308try:309if os.path.exists(macro_cache_location):310if not os.path.exists(os.path.join(macro_cache_location, ".git")):311FreeCAD.Console.PrintWarning(312translate(313"AddonsInstaller",314"Attempting to change non-git Macro setup to use git\n",315)316)317self.git_manager.repair(318"https://github.com/FreeCAD/FreeCAD-macros.git",319macro_cache_location,320)321self.git_manager.update(macro_cache_location)322else:323self.git_manager.clone(324"https://github.com/FreeCAD/FreeCAD-macros.git",325macro_cache_location,326)327except GitFailed as e:328FreeCAD.Console.PrintMessage(329translate(330"AddonsInstaller",331"An error occurred updating macros from GitHub, trying clean checkout...",332)333+ f":\n{e}\n"334)335FreeCAD.Console.PrintMessage(f"{macro_cache_location}\n")336FreeCAD.Console.PrintMessage(337translate("AddonsInstaller", "Attempting to do a clean checkout...") + "\n"338)339try:340os.chdir(341os.path.join(macro_cache_location, "..")342) # Make sure we are not IN this directory343shutil.rmtree(macro_cache_location, onerror=self._remove_readonly)344self.git_manager.clone(345"https://github.com/FreeCAD/FreeCAD-macros.git",346macro_cache_location,347)348FreeCAD.Console.PrintMessage(349translate("AddonsInstaller", "Clean checkout succeeded") + "\n"350)351except GitFailed as e2:352# The Qt Python translation extractor doesn't support splitting this string (yet)353# pylint: disable=line-too-long354FreeCAD.Console.PrintWarning(355translate(356"AddonsInstaller",357"Failed to update macros from GitHub -- try clearing the Addon Manager's cache.",358)359+ f":\n{str(e2)}\n"360)361return False362return True363
364def _retrieve_macros_from_wiki(self):365"""Retrieve macros from the wiki366
367Read the wiki and emit a signal for each found macro.
368Reads only the page https://wiki.freecad.org/Macros_recipes
369"""
370
371p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(372"https://wiki.freecad.org/Macros_recipes", 5000373)374if not p:375# The Qt Python translation extractor doesn't support splitting this string (yet)376# pylint: disable=line-too-long377FreeCAD.Console.PrintWarning(378translate(379"AddonsInstaller",380"Error connecting to the Wiki, FreeCAD cannot retrieve the Wiki macro list at this time",381)382+ "\n"383)384return385p = p.data().decode("utf8")386macros = re.findall('title="(Macro.*?)"', p)387macros = [mac for mac in macros if "translated" not in mac]388macro_names = []389for _, mac in enumerate(macros):390if self.current_thread.isInterruptionRequested():391return392macname = mac[6:] # Remove "Macro ".393macname = macname.replace("&", "&")394if not macname:395continue396if (397(macname not in self.macros_reject_list)398and ("recipes" not in macname.lower())399and (macname not in macro_names)400):401macro_names.append(macname)402macro = Macro(macname)403if macro.name in self.package_names:404FreeCAD.Console.PrintLog(405f"Ignoring second macro named {macro.name} (found on wiki)\n"406)407continue # We already have a macro with this name408self.package_names.append(macro.name)409macro.on_wiki = True410macro.parsed = False411repo = Addon.from_macro(macro)412repo.url = "https://wiki.freecad.org/Macros_recipes"413utils.update_macro_installation_details(repo)414self.addon_repo.emit(repo)415
416def _remove_readonly(self, func, path, _) -> None:417"""Remove a read-only file."""418
419os.chmod(path, stat.S_IWRITE)420func(path)421
422
423class LoadPackagesFromCacheWorker(QtCore.QThread):424"""A subthread worker that loads package information from its cache file."""425
426addon_repo = QtCore.Signal(object)427
428def __init__(self, cache_file: str):429QtCore.QThread.__init__(self)430self.cache_file = cache_file431self.metadata_cache_path = os.path.join(432FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata"433)434
435def override_metadata_cache_path(self, path):436"""For testing purposes, override the location to fetch the package metadata from."""437self.metadata_cache_path = path438
439def run(self):440"""Rarely called directly: create an instance and call start() on it instead to441launch in a new thread"""
442with open(self.cache_file, encoding="utf-8") as f:443data = f.read()444if data:445dict_data = json.loads(data)446for item in dict_data.values():447if QtCore.QThread.currentThread().isInterruptionRequested():448return449repo = Addon.from_cache(item)450repo_metadata_cache_path = os.path.join(451self.metadata_cache_path, repo.name, "package.xml"452)453if os.path.isfile(repo_metadata_cache_path):454try:455repo.load_metadata_file(repo_metadata_cache_path)456except RuntimeError as e:457FreeCAD.Console.PrintLog(f"Failed loading {repo_metadata_cache_path}\n")458FreeCAD.Console.PrintLog(str(e) + "\n")459self.addon_repo.emit(repo)460
461
462class LoadMacrosFromCacheWorker(QtCore.QThread):463"""A worker object to load macros from a cache file"""464
465add_macro_signal = QtCore.Signal(object)466
467def __init__(self, cache_file: str):468QtCore.QThread.__init__(self)469self.cache_file = cache_file470
471def run(self):472"""Rarely called directly: create an instance and call start() on it instead to473launch in a new thread"""
474
475with open(self.cache_file, encoding="utf-8") as f:476data = f.read()477dict_data = json.loads(data)478for item in dict_data:479if QtCore.QThread.currentThread().isInterruptionRequested():480return481new_macro = Macro.from_cache(item)482repo = Addon.from_macro(new_macro)483utils.update_macro_installation_details(repo)484self.add_macro_signal.emit(repo)485
486
487class CheckSingleUpdateWorker(QtCore.QObject):488"""This worker is a little different from the others: the actual recommended way of489running in a QThread is to make a worker object that gets moved into the thread."""
490
491update_status = QtCore.Signal(int)492
493def __init__(self, repo: Addon, parent: QtCore.QObject = None):494super().__init__(parent)495self.repo = repo496
497def do_work(self):498"""Use the UpdateChecker class to do the work of this function, depending on the499type of Addon"""
500
501checker = UpdateChecker()502if self.repo.repo_type == Addon.Kind.WORKBENCH:503checker.check_workbench(self.repo)504elif self.repo.repo_type == Addon.Kind.MACRO:505checker.check_macro(self.repo)506elif self.repo.repo_type == Addon.Kind.PACKAGE:507checker.check_package(self.repo)508
509self.update_status.emit(self.repo.update_status)510
511
512class CheckWorkbenchesForUpdatesWorker(QtCore.QThread):513"""This worker checks for available updates for all workbenches"""514
515update_status = QtCore.Signal(Addon)516progress_made = QtCore.Signal(int, int)517
518def __init__(self, repos: List[Addon]):519
520QtCore.QThread.__init__(self)521self.repos = repos522self.current_thread = None523self.basedir = FreeCAD.getUserAppDataDir()524self.moddir = os.path.join(self.basedir, "Mod")525
526def run(self):527"""Rarely called directly: create an instance and call start() on it instead to528launch in a new thread"""
529
530self.current_thread = QtCore.QThread.currentThread()531checker = UpdateChecker()532count = 1533for repo in self.repos:534if self.current_thread.isInterruptionRequested():535return536self.progress_made.emit(count, len(self.repos))537count += 1538if repo.status() == Addon.Status.UNCHECKED:539if repo.repo_type == Addon.Kind.WORKBENCH:540checker.check_workbench(repo)541self.update_status.emit(repo)542elif repo.repo_type == Addon.Kind.MACRO:543checker.check_macro(repo)544self.update_status.emit(repo)545elif repo.repo_type == Addon.Kind.PACKAGE:546checker.check_package(repo)547self.update_status.emit(repo)548
549
550class UpdateChecker:551"""A utility class used by the CheckWorkbenchesForUpdatesWorker class. Each function is552designed for a specific Addon type, and modifies the passed-in Addon with the determined
553update status."""
554
555def __init__(self):556self.basedir = FreeCAD.getUserAppDataDir()557self.moddir = os.path.join(self.basedir, "Mod")558self.git_manager = initialize_git()559
560def override_mod_directory(self, moddir):561"""Primarily for use when testing, sets an alternate directory to use for mods"""562self.moddir = moddir563
564def check_workbench(self, wb):565"""Given a workbench Addon wb, check it for updates using git. If git is not566available, does nothing."""
567if not self.git_manager:568wb.set_status(Addon.Status.CANNOT_CHECK)569return570clonedir = os.path.join(self.moddir, wb.name)571if os.path.exists(clonedir):572# mark as already installed AND already checked for updates573if not os.path.exists(os.path.join(clonedir, ".git")):574with wb.git_lock:575self.git_manager.repair(wb.url, clonedir)576with wb.git_lock:577try:578status = self.git_manager.status(clonedir)579if "(no branch)" in status:580# By definition, in a detached-head state we cannot581# update, so don't even bother checking.582wb.set_status(Addon.Status.NO_UPDATE_AVAILABLE)583wb.branch = self.git_manager.current_branch(clonedir)584return585except GitFailed as e:586FreeCAD.Console.PrintWarning(587"AddonManager: "588+ translate(589"AddonsInstaller",590"Unable to fetch git updates for workbench {}",591).format(wb.name)592+ "\n"593)594FreeCAD.Console.PrintWarning(str(e) + "\n")595wb.set_status(Addon.Status.CANNOT_CHECK)596else:597try:598if self.git_manager.update_available(clonedir):599wb.set_status(Addon.Status.UPDATE_AVAILABLE)600else:601wb.set_status(Addon.Status.NO_UPDATE_AVAILABLE)602except GitFailed:603FreeCAD.Console.PrintWarning(604translate("AddonsInstaller", "git status failed for {}").format(wb.name)605+ "\n"606)607wb.set_status(Addon.Status.CANNOT_CHECK)608
609def _branch_name_changed(self, package: Addon) -> bool:610clone_dir = os.path.join(self.moddir, package.name)611installed_metadata_file = os.path.join(clone_dir, "package.xml")612if not os.path.isfile(installed_metadata_file):613return False614if not hasattr(package, "metadata") or package.metadata is None:615return False616try:617installed_metadata = MetadataReader.from_file(installed_metadata_file)618installed_default_branch = get_branch_from_metadata(installed_metadata)619remote_default_branch = get_branch_from_metadata(package.metadata)620if installed_default_branch != remote_default_branch:621return True622except RuntimeError:623return False624return False625
626def check_package(self, package: Addon) -> None:627"""Given a packaged Addon package, check it for updates. If git is available that is628used. If not, the package's metadata is examined, and if the metadata file has changed
629compared to the installed copy, an update is flagged. In addition, a change to the
630default branch name triggers an update."""
631
632clone_dir = self.moddir + os.sep + package.name633if os.path.exists(clone_dir):634
635# First, see if the branch name changed, which automatically triggers an update636if self._branch_name_changed(package):637package.set_status(Addon.Status.UPDATE_AVAILABLE)638return639
640# Next, try to just do a git-based update, which will give the most accurate results:641if self.git_manager:642self.check_workbench(package)643if package.status() != Addon.Status.CANNOT_CHECK:644# It worked, just exit now645return646
647# If we were unable to do a git-based update, try using the package.xml file instead:648installed_metadata_file = os.path.join(clone_dir, "package.xml")649if not os.path.isfile(installed_metadata_file):650# If there is no package.xml file, then it's because the package author added it651# after the last time the local installation was updated. By definition, then,652# there is an update available, if only to download the new XML file.653package.set_status(Addon.Status.UPDATE_AVAILABLE)654package.installed_version = None655return656package.updated_timestamp = os.path.getmtime(installed_metadata_file)657try:658installed_metadata = MetadataReader.from_file(installed_metadata_file)659package.installed_version = installed_metadata.version660# Packages are considered up-to-date if the metadata version matches.661# Authors should update their version string when they want the addon662# manager to alert users of a new version.663if package.metadata.version != installed_metadata.version:664package.set_status(Addon.Status.UPDATE_AVAILABLE)665else:666package.set_status(Addon.Status.NO_UPDATE_AVAILABLE)667except RuntimeError:668FreeCAD.Console.PrintWarning(669translate(670"AddonsInstaller",671"Failed to read metadata from {name}",672).format(name=installed_metadata_file)673+ "\n"674)675package.set_status(Addon.Status.CANNOT_CHECK)676
677def check_macro(self, macro_wrapper: Addon) -> None:678"""Check to see if the online copy of the macro's code differs from the local copy."""679
680# Make sure this macro has its code downloaded:681try:682if not macro_wrapper.macro.parsed and macro_wrapper.macro.on_git:683macro_wrapper.macro.fill_details_from_file(macro_wrapper.macro.src_filename)684elif not macro_wrapper.macro.parsed and macro_wrapper.macro.on_wiki:685mac = macro_wrapper.macro.name.replace(" ", "_")686mac = mac.replace("&", "%26")687mac = mac.replace("+", "%2B")688url = "https://wiki.freecad.org/Macro_" + mac689macro_wrapper.macro.fill_details_from_wiki(url)690except RuntimeError:691FreeCAD.Console.PrintWarning(692translate(693"AddonsInstaller",694"Failed to fetch code for macro '{name}'",695).format(name=macro_wrapper.macro.name)696+ "\n"697)698macro_wrapper.set_status(Addon.Status.CANNOT_CHECK)699return700
701hasher1 = hashlib.sha1()702hasher2 = hashlib.sha1()703hasher1.update(macro_wrapper.macro.code.encode("utf-8"))704new_sha1 = hasher1.hexdigest()705test_file_one = os.path.join(FreeCAD.getUserMacroDir(True), macro_wrapper.macro.filename)706test_file_two = os.path.join(707FreeCAD.getUserMacroDir(True), "Macro_" + macro_wrapper.macro.filename708)709if os.path.exists(test_file_one):710with open(test_file_one, "rb") as f:711contents = f.read()712hasher2.update(contents)713old_sha1 = hasher2.hexdigest()714elif os.path.exists(test_file_two):715with open(test_file_two, "rb") as f:716contents = f.read()717hasher2.update(contents)718old_sha1 = hasher2.hexdigest()719else:720return721if new_sha1 == old_sha1:722macro_wrapper.set_status(Addon.Status.NO_UPDATE_AVAILABLE)723else:724macro_wrapper.set_status(Addon.Status.UPDATE_AVAILABLE)725
726
727class CacheMacroCodeWorker(QtCore.QThread):728"""Download and cache the macro code, and parse its internal metadata"""729
730status_message = QtCore.Signal(str)731update_macro = QtCore.Signal(Addon)732progress_made = QtCore.Signal(int, int)733
734def __init__(self, repos: List[Addon]) -> None:735QtCore.QThread.__init__(self)736self.repos = repos737self.workers = []738self.terminators = []739self.lock = threading.Lock()740self.failed = []741self.counter = 0742self.repo_queue = None743
744def run(self):745"""Rarely called directly: create an instance and call start() on it instead to746launch in a new thread"""
747
748self.status_message.emit(translate("AddonsInstaller", "Caching macro code..."))749
750self.repo_queue = queue.Queue()751num_macros = 0752for repo in self.repos:753if repo.macro is not None:754self.repo_queue.put(repo)755num_macros += 1756
757interrupted = self._process_queue(num_macros)758if interrupted:759return760
761# Make sure all of our child threads have fully exited:762for worker in self.workers:763worker.wait(50)764if not worker.isFinished():765# The Qt Python translation extractor doesn't support splitting this string (yet)766# pylint: disable=line-too-long767FreeCAD.Console.PrintError(768translate(769"AddonsInstaller",770"Addon Manager: a worker process failed to complete while fetching {name}",771).format(name=worker.macro.name)772+ "\n"773)774
775self.repo_queue.join()776for terminator in self.terminators:777if terminator and terminator.isActive():778terminator.stop()779
780if len(self.failed) > 0:781num_failed = len(self.failed)782FreeCAD.Console.PrintWarning(783translate(784"AddonsInstaller",785"Out of {num_macros} macros, {num_failed} timed out while processing",786).format(num_macros=num_macros, num_failed=num_failed)787+ "\n"788)789
790def _process_queue(self, num_macros) -> bool:791"""Spools up six network connections and downloads the macro code. Returns True if792it was interrupted by user request, or False if it ran to completion."""
793
794# Emulate QNetworkAccessManager and spool up six connections:795for _ in range(6):796self.update_and_advance(None)797
798current_thread = QtCore.QThread.currentThread()799while True:800if current_thread.isInterruptionRequested():801for worker in self.workers:802worker.blockSignals(True)803worker.requestInterruption()804if not worker.wait(100):805FreeCAD.Console.PrintWarning(806translate(807"AddonsInstaller",808"Addon Manager: a worker process failed to halt ({name})",809).format(name=worker.macro.name)810+ "\n"811)812return True813# Ensure our signals propagate out by running an internal thread-local event loop814QtCore.QCoreApplication.processEvents()815with self.lock:816if self.counter >= num_macros:817break818time.sleep(0.1)819return False820
821def update_and_advance(self, repo: Addon) -> None:822"""Emit the updated signal and launch the next item from the queue."""823if repo is not None:824if repo.macro.name not in self.failed:825self.update_macro.emit(repo)826self.repo_queue.task_done()827with self.lock:828self.counter += 1829
830if QtCore.QThread.currentThread().isInterruptionRequested():831return832
833self.progress_made.emit(len(self.repos) - self.repo_queue.qsize(), len(self.repos))834
835try:836next_repo = self.repo_queue.get_nowait()837worker = GetMacroDetailsWorker(next_repo)838worker.finished.connect(lambda: self.update_and_advance(next_repo))839with self.lock:840self.workers.append(worker)841self.terminators.append(842QtCore.QTimer.singleShot(10000, lambda: self.terminate(worker))843)844self.status_message.emit(845translate(846"AddonsInstaller",847"Getting metadata from macro {}",848).format(next_repo.macro.name)849)850worker.start()851except queue.Empty:852pass853
854def terminate(self, worker) -> None:855"""Shut down all running workers and exit the thread"""856if not worker.isFinished():857macro_name = worker.macro.name858FreeCAD.Console.PrintWarning(859translate(860"AddonsInstaller",861"Timeout while fetching metadata for macro {}",862).format(macro_name)863+ "\n"864)865# worker.blockSignals(True)866worker.requestInterruption()867worker.wait(100)868if worker.isRunning():869FreeCAD.Console.PrintError(870translate(871"AddonsInstaller",872"Failed to kill process for macro {}!\n",873).format(macro_name)874)875with self.lock:876self.failed.append(macro_name)877
878
879class GetMacroDetailsWorker(QtCore.QThread):880"""Retrieve the macro details for a macro"""881
882status_message = QtCore.Signal(str)883readme_updated = QtCore.Signal(str)884
885def __init__(self, repo):886
887QtCore.QThread.__init__(self)888self.macro = repo.macro889
890def run(self):891"""Rarely called directly: create an instance and call start() on it instead to892launch in a new thread"""
893
894self.status_message.emit(translate("AddonsInstaller", "Retrieving macro description..."))895if not self.macro.parsed and self.macro.on_git:896self.status_message.emit(translate("AddonsInstaller", "Retrieving info from git"))897self.macro.fill_details_from_file(self.macro.src_filename)898if not self.macro.parsed and self.macro.on_wiki:899self.status_message.emit(translate("AddonsInstaller", "Retrieving info from wiki"))900mac = self.macro.name.replace(" ", "_")901mac = mac.replace("&", "%26")902mac = mac.replace("+", "%2B")903url = "https://wiki.freecad.org/Macro_" + mac904self.macro.fill_details_from_wiki(url)905message = (906"<h1>"907+ self.macro.name908+ "</h1>"909+ self.macro.desc910+ '<br/><br/>Macro location: <a href="'911+ self.macro.url912+ '">'913+ self.macro.url914+ "</a>"915)916if QtCore.QThread.currentThread().isInterruptionRequested():917return918self.readme_updated.emit(message)919
920
921class GetBasicAddonStatsWorker(QtCore.QThread):922"""Fetch data from an addon stats repository."""923
924update_addon_stats = QtCore.Signal(Addon)925
926def __init__(self, url: str, addons: List[Addon], parent: QtCore.QObject = None):927super().__init__(parent)928self.url = url929self.addons = addons930
931def run(self):932"""Fetch the remote data and load it into the addons"""933
934fetch_result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.url, 5000)935if fetch_result is None:936FreeCAD.Console.PrintError(937translate(938"AddonsInstaller",939"Failed to get Addon statistics from {} -- only sorting alphabetically will"940" be accurate\n",941).format(self.url)942)943return944text_result = fetch_result.data().decode("utf8")945json_result = json.loads(text_result)946
947for addon in self.addons:948if addon.url in json_result:949addon.stats = AddonStats.from_json(json_result[addon.url])950self.update_addon_stats.emit(addon)951
952
953class GetAddonScoreWorker(QtCore.QThread):954"""Fetch data from an addon score file."""955
956update_addon_score = QtCore.Signal(Addon)957
958def __init__(self, url: str, addons: List[Addon], parent: QtCore.QObject = None):959super().__init__(parent)960self.url = url961self.addons = addons962
963def run(self):964"""Fetch the remote data and load it into the addons"""965
966if self.url != "TEST":967fetch_result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.url, 5000)968if fetch_result is None:969FreeCAD.Console.PrintError(970translate(971"AddonsInstaller",972"Failed to get Addon score from '{}' -- sorting by score will fail\n",973).format(self.url)974)975return976text_result = fetch_result.data().decode("utf8")977json_result = json.loads(text_result)978else:979FreeCAD.Console.PrintWarning("Running score generation in TEST mode...\n")980json_result = {}981for addon in self.addons:982if addon.macro:983json_result[addon.name] = len(addon.macro.comment) if addon.macro.comment else 0984else:985json_result[addon.url] = len(addon.description) if addon.description else 0986
987for addon in self.addons:988score = None989if addon.url in json_result:990score = json_result[addon.url]991elif addon.name in json_result:992score = json_result[addon.name]993if score is not None:994try:995addon.score = int(score)996self.update_addon_score.emit(addon)997except (ValueError, OverflowError):998FreeCAD.Console.PrintLog(999f"Failed to convert score value '{score}' to an integer for {addon.name}"1000)1001