FreeCAD
997 строк · 40.7 Кб
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 datetime
27import hashlib
28import json
29import os
30import queue
31import re
32import shutil
33import stat
34import threading
35import time
36from typing import List
37
38from PySide import QtCore
39
40import FreeCAD
41import addonmanager_utilities as utils
42from addonmanager_macro import Macro
43from Addon import Addon
44from AddonStats import AddonStats
45import NetworkManager
46from addonmanager_git import initialize_git, GitFailed
47from addonmanager_metadata import MetadataReader, get_branch_from_metadata
48
49translate = FreeCAD.Qt.translate
50
51# Workers only have one public method by design
52# pylint: disable=c-extension-no-member,too-few-public-methods,too-many-instance-attributes
53
54
55class CreateAddonListWorker(QtCore.QThread):
56"""This worker updates the list of available workbenches, emitting an "addon_repo"
57signal for each Addon as they are processed."""
58
59status_message = QtCore.Signal(str)
60addon_repo = QtCore.Signal(object)
61
62def __init__(self):
63QtCore.QThread.__init__(self)
64
65# reject_listed addons
66self.macros_reject_list = []
67self.mod_reject_list = []
68
69# These addons will print an additional message informing the user
70self.obsolete = []
71
72# These addons will print an additional message informing the user Python2 only
73self.py2only = []
74
75self.package_names = []
76self.moddir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod")
77self.current_thread = None
78
79self.git_manager = initialize_git()
80
81def run(self):
82"populates the list of addons"
83
84self.current_thread = QtCore.QThread.currentThread()
85try:
86self._get_freecad_addon_repo_data()
87except ConnectionError:
88return
89self._get_custom_addons()
90self._get_official_addons()
91self._retrieve_macros_from_git()
92self._retrieve_macros_from_wiki()
93
94def _get_freecad_addon_repo_data(self):
95# update info lists
96p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(
97"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json", 5000
98)
99if p:
100p = p.data().decode("utf8")
101j = json.loads(p)
102if "obsolete" in j and "Mod" in j["obsolete"]:
103self.obsolete = j["obsolete"]["Mod"]
104
105if "blacklisted" in j and "Macro" in j["blacklisted"]:
106self.macros_reject_list = j["blacklisted"]["Macro"]
107
108if "blacklisted" in j and "Mod" in j["blacklisted"]:
109self.mod_reject_list = j["blacklisted"]["Mod"]
110
111if "py2only" in j and "Mod" in j["py2only"]:
112self.py2only = j["py2only"]["Mod"]
113
114if "deprecated" in j:
115self._process_deprecated(j["deprecated"])
116
117else:
118message = translate(
119"AddonsInstaller",
120"Failed to connect to GitHub. Check your connection and proxy settings.",
121)
122FreeCAD.Console.PrintError(message + "\n")
123self.status_message.emit(message)
124raise ConnectionError
125
126def _process_deprecated(self, deprecated_addons):
127"""Parse the section on deprecated addons"""
128
129fc_major = int(FreeCAD.Version()[0])
130fc_minor = int(FreeCAD.Version()[1])
131for item in deprecated_addons:
132if "as_of" in item and "name" in item:
133try:
134version_components = item["as_of"].split(".")
135major = int(version_components[0])
136if len(version_components) > 1:
137minor = int(version_components[1])
138else:
139minor = 0
140if major < fc_major or (major == fc_major and minor <= fc_minor):
141if "kind" not in item or item["kind"] == "mod":
142self.obsolete.append(item["name"])
143elif item["kind"] == "macro":
144self.macros_reject_list.append(item["name"])
145else:
146FreeCAD.Console.PrintMessage(
147f'Unrecognized Addon kind {item["kind"]} in deprecation list.'
148)
149except ValueError:
150FreeCAD.Console.PrintMessage(
151f"Failed to parse version from {item['name']}, version {item['as_of']}"
152)
153
154def _get_custom_addons(self):
155
156# querying custom addons first
157addon_list = (
158FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
159.GetString("CustomRepositories", "")
160.split("\n")
161)
162custom_addons = []
163for addon in addon_list:
164if " " in addon:
165addon_and_branch = addon.split(" ")
166custom_addons.append({"url": addon_and_branch[0], "branch": addon_and_branch[1]})
167else:
168custom_addons.append({"url": addon, "branch": "master"})
169for addon in custom_addons:
170if self.current_thread.isInterruptionRequested():
171return
172if addon and addon["url"]:
173if addon["url"][-1] == "/":
174addon["url"] = addon["url"][0:-1] # Strip trailing slash
175addon["url"] = addon["url"].split(".git")[0] # Remove .git
176name = addon["url"].split("/")[-1]
177if name in self.package_names:
178# We already have something with this name, skip this one
179FreeCAD.Console.PrintWarning(
180translate("AddonsInstaller", "WARNING: Duplicate addon {} ignored").format(
181name
182)
183)
184continue
185FreeCAD.Console.PrintLog(
186f"Adding custom location {addon['url']} with branch {addon['branch']}\n"
187)
188self.package_names.append(name)
189addondir = os.path.join(self.moddir, name)
190if os.path.exists(addondir) and os.listdir(addondir):
191state = Addon.Status.UNCHECKED
192else:
193state = Addon.Status.NOT_INSTALLED
194repo = Addon(name, addon["url"], state, addon["branch"])
195md_file = os.path.join(addondir, "package.xml")
196if os.path.isfile(md_file):
197repo.installed_metadata = MetadataReader.from_file(md_file)
198repo.installed_version = repo.installed_metadata.version
199repo.updated_timestamp = os.path.getmtime(md_file)
200repo.verify_url_and_branch(addon["url"], addon["branch"])
201
202self.addon_repo.emit(repo)
203
204def _get_official_addons(self):
205# querying official addons
206p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(
207"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/.gitmodules", 5000
208)
209if not p:
210return
211p = p.data().decode("utf8")
212p = re.findall(
213(
214r'(?m)\[submodule\s*"(?P<name>.*)"\]\s*'
215r"path\s*=\s*(?P<path>.+)\s*"
216r"url\s*=\s*(?P<url>https?://.*)\s*"
217r"(branch\s*=\s*(?P<branch>[^\s]*)\s*)?"
218),
219p,
220)
221for name, _, url, _, branch in p:
222if self.current_thread.isInterruptionRequested():
223return
224if name in self.package_names:
225# We already have something with this name, skip this one
226continue
227self.package_names.append(name)
228if branch is None or len(branch) == 0:
229branch = "master"
230url = url.split(".git")[0]
231addondir = os.path.join(self.moddir, name)
232if os.path.exists(addondir) and os.listdir(addondir):
233# make sure the folder exists and it contains files!
234state = Addon.Status.UNCHECKED
235else:
236state = Addon.Status.NOT_INSTALLED
237repo = Addon(name, url, state, branch)
238md_file = os.path.join(addondir, "package.xml")
239if os.path.isfile(md_file):
240repo.installed_metadata = MetadataReader.from_file(md_file)
241repo.installed_version = repo.installed_metadata.version
242repo.updated_timestamp = os.path.getmtime(md_file)
243repo.verify_url_and_branch(url, branch)
244
245if name in self.py2only:
246repo.python2 = True
247if name in self.mod_reject_list:
248repo.rejected = True
249if name in self.obsolete:
250repo.obsolete = True
251self.addon_repo.emit(repo)
252
253self.status_message.emit(translate("AddonsInstaller", "Workbenches list was updated."))
254
255def _retrieve_macros_from_git(self):
256"""Retrieve macros from FreeCAD-macros.git
257
258Emits a signal for each macro in
259https://github.com/FreeCAD/FreeCAD-macros.git
260"""
261
262macro_cache_location = utils.get_cache_file_name("Macros")
263
264if not self.git_manager:
265message = translate(
266"AddonsInstaller",
267"Git is disabled, skipping git macros",
268)
269self.status_message.emit(message)
270FreeCAD.Console.PrintWarning(message + "\n")
271return
272
273update_succeeded = self._update_local_git_repo()
274if not update_succeeded:
275return
276
277n_files = 0
278for _, _, filenames in os.walk(macro_cache_location):
279n_files += len(filenames)
280counter = 0
281for dirpath, _, filenames in os.walk(macro_cache_location):
282counter += 1
283if self.current_thread.isInterruptionRequested():
284return
285if ".git" in dirpath:
286continue
287for filename in filenames:
288if self.current_thread.isInterruptionRequested():
289return
290if filename.lower().endswith(".fcmacro"):
291macro = Macro(filename[:-8]) # Remove ".FCMacro".
292if macro.name in self.package_names:
293FreeCAD.Console.PrintLog(
294f"Ignoring second macro named {macro.name} (found on git)\n"
295)
296continue # We already have a macro with this name
297self.package_names.append(macro.name)
298macro.on_git = True
299macro.src_filename = os.path.join(dirpath, filename)
300macro.fill_details_from_file(macro.src_filename)
301repo = Addon.from_macro(macro)
302FreeCAD.Console.PrintLog(f"Found macro {repo.name}\n")
303repo.url = "https://github.com/FreeCAD/FreeCAD-macros.git"
304utils.update_macro_installation_details(repo)
305self.addon_repo.emit(repo)
306
307def _update_local_git_repo(self) -> bool:
308macro_cache_location = utils.get_cache_file_name("Macros")
309try:
310if os.path.exists(macro_cache_location):
311if not os.path.exists(os.path.join(macro_cache_location, ".git")):
312FreeCAD.Console.PrintWarning(
313translate(
314"AddonsInstaller",
315"Attempting to change non-git Macro setup to use git\n",
316)
317)
318self.git_manager.repair(
319"https://github.com/FreeCAD/FreeCAD-macros.git",
320macro_cache_location,
321)
322self.git_manager.update(macro_cache_location)
323else:
324self.git_manager.clone(
325"https://github.com/FreeCAD/FreeCAD-macros.git",
326macro_cache_location,
327)
328except GitFailed as e:
329FreeCAD.Console.PrintMessage(
330translate(
331"AddonsInstaller",
332"An error occurred updating macros from GitHub, trying clean checkout...",
333)
334+ f":\n{e}\n"
335)
336FreeCAD.Console.PrintMessage(f"{macro_cache_location}\n")
337FreeCAD.Console.PrintMessage(
338translate("AddonsInstaller", "Attempting to do a clean checkout...") + "\n"
339)
340try:
341os.chdir(
342os.path.join(macro_cache_location, "..")
343) # Make sure we are not IN this directory
344shutil.rmtree(macro_cache_location, onerror=self._remove_readonly)
345self.git_manager.clone(
346"https://github.com/FreeCAD/FreeCAD-macros.git",
347macro_cache_location,
348)
349FreeCAD.Console.PrintMessage(
350translate("AddonsInstaller", "Clean checkout succeeded") + "\n"
351)
352except GitFailed as e2:
353# The Qt Python translation extractor doesn't support splitting this string (yet)
354# pylint: disable=line-too-long
355FreeCAD.Console.PrintWarning(
356translate(
357"AddonsInstaller",
358"Failed to update macros from GitHub -- try clearing the Addon Manager's cache.",
359)
360+ f":\n{str(e2)}\n"
361)
362return False
363return True
364
365def _retrieve_macros_from_wiki(self):
366"""Retrieve macros from the wiki
367
368Read the wiki and emit a signal for each found macro.
369Reads only the page https://wiki.freecad.org/Macros_recipes
370"""
371
372p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(
373"https://wiki.freecad.org/Macros_recipes", 5000
374)
375if not p:
376# The Qt Python translation extractor doesn't support splitting this string (yet)
377# pylint: disable=line-too-long
378FreeCAD.Console.PrintWarning(
379translate(
380"AddonsInstaller",
381"Error connecting to the Wiki, FreeCAD cannot retrieve the Wiki macro list at this time",
382)
383+ "\n"
384)
385return
386p = p.data().decode("utf8")
387macros = re.findall('title="(Macro.*?)"', p)
388macros = [mac for mac in macros if "translated" not in mac]
389macro_names = []
390for _, mac in enumerate(macros):
391if self.current_thread.isInterruptionRequested():
392return
393macname = mac[6:] # Remove "Macro ".
394macname = macname.replace("&", "&")
395if not macname:
396continue
397if (
398(macname not in self.macros_reject_list)
399and ("recipes" not in macname.lower())
400and (macname not in macro_names)
401):
402macro_names.append(macname)
403macro = Macro(macname)
404if macro.name in self.package_names:
405FreeCAD.Console.PrintLog(
406f"Ignoring second macro named {macro.name} (found on wiki)\n"
407)
408continue # We already have a macro with this name
409self.package_names.append(macro.name)
410macro.on_wiki = True
411macro.parsed = False
412repo = Addon.from_macro(macro)
413repo.url = "https://wiki.freecad.org/Macros_recipes"
414utils.update_macro_installation_details(repo)
415self.addon_repo.emit(repo)
416
417def _remove_readonly(self, func, path, _) -> None:
418"""Remove a read-only file."""
419
420os.chmod(path, stat.S_IWRITE)
421func(path)
422
423
424class LoadPackagesFromCacheWorker(QtCore.QThread):
425"""A subthread worker that loads package information from its cache file."""
426
427addon_repo = QtCore.Signal(object)
428
429def __init__(self, cache_file: str):
430QtCore.QThread.__init__(self)
431self.cache_file = cache_file
432self.metadata_cache_path = os.path.join(
433FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata"
434)
435
436def override_metadata_cache_path(self, path):
437"""For testing purposes, override the location to fetch the package metadata from."""
438self.metadata_cache_path = path
439
440def run(self):
441"""Rarely called directly: create an instance and call start() on it instead to
442launch in a new thread"""
443with open(self.cache_file, encoding="utf-8") as f:
444data = f.read()
445if data:
446dict_data = json.loads(data)
447for item in dict_data.values():
448if QtCore.QThread.currentThread().isInterruptionRequested():
449return
450repo = Addon.from_cache(item)
451repo_metadata_cache_path = os.path.join(
452self.metadata_cache_path, repo.name, "package.xml"
453)
454if os.path.isfile(repo_metadata_cache_path):
455try:
456repo.load_metadata_file(repo_metadata_cache_path)
457except Exception as e:
458FreeCAD.Console.PrintLog(f"Failed loading {repo_metadata_cache_path}\n")
459FreeCAD.Console.PrintLog(str(e) + "\n")
460self.addon_repo.emit(repo)
461
462
463class LoadMacrosFromCacheWorker(QtCore.QThread):
464"""A worker object to load macros from a cache file"""
465
466add_macro_signal = QtCore.Signal(object)
467
468def __init__(self, cache_file: str):
469QtCore.QThread.__init__(self)
470self.cache_file = cache_file
471
472def run(self):
473"""Rarely called directly: create an instance and call start() on it instead to
474launch in a new thread"""
475
476with open(self.cache_file, encoding="utf-8") as f:
477data = f.read()
478dict_data = json.loads(data)
479for item in dict_data:
480if QtCore.QThread.currentThread().isInterruptionRequested():
481return
482new_macro = Macro.from_cache(item)
483repo = Addon.from_macro(new_macro)
484utils.update_macro_installation_details(repo)
485self.add_macro_signal.emit(repo)
486
487
488class CheckSingleUpdateWorker(QtCore.QObject):
489"""This worker is a little different from the others: the actual recommended way of
490running in a QThread is to make a worker object that gets moved into the thread."""
491
492update_status = QtCore.Signal(int)
493
494def __init__(self, repo: Addon, parent: QtCore.QObject = None):
495super().__init__(parent)
496self.repo = repo
497
498def do_work(self):
499"""Use the UpdateChecker class to do the work of this function, depending on the
500type of Addon"""
501
502checker = UpdateChecker()
503if self.repo.repo_type == Addon.Kind.WORKBENCH:
504checker.check_workbench(self.repo)
505elif self.repo.repo_type == Addon.Kind.MACRO:
506checker.check_macro(self.repo)
507elif self.repo.repo_type == Addon.Kind.PACKAGE:
508checker.check_package(self.repo)
509
510self.update_status.emit(self.repo.update_status)
511
512
513class CheckWorkbenchesForUpdatesWorker(QtCore.QThread):
514"""This worker checks for available updates for all workbenches"""
515
516update_status = QtCore.Signal(Addon)
517progress_made = QtCore.Signal(int, int)
518
519def __init__(self, repos: List[Addon]):
520
521QtCore.QThread.__init__(self)
522self.repos = repos
523self.current_thread = None
524self.basedir = FreeCAD.getUserAppDataDir()
525self.moddir = os.path.join(self.basedir, "Mod")
526
527def run(self):
528"""Rarely called directly: create an instance and call start() on it instead to
529launch in a new thread"""
530
531self.current_thread = QtCore.QThread.currentThread()
532checker = UpdateChecker()
533count = 1
534for repo in self.repos:
535if self.current_thread.isInterruptionRequested():
536return
537self.progress_made.emit(count, len(self.repos))
538count += 1
539if repo.status() == Addon.Status.UNCHECKED:
540if repo.repo_type == Addon.Kind.WORKBENCH:
541checker.check_workbench(repo)
542self.update_status.emit(repo)
543elif repo.repo_type == Addon.Kind.MACRO:
544checker.check_macro(repo)
545self.update_status.emit(repo)
546elif repo.repo_type == Addon.Kind.PACKAGE:
547checker.check_package(repo)
548self.update_status.emit(repo)
549
550
551class UpdateChecker:
552"""A utility class used by the CheckWorkbenchesForUpdatesWorker class. Each function is
553designed for a specific Addon type, and modifies the passed-in Addon with the determined
554update status."""
555
556def __init__(self):
557self.basedir = FreeCAD.getUserAppDataDir()
558self.moddir = os.path.join(self.basedir, "Mod")
559self.git_manager = initialize_git()
560
561def override_mod_directory(self, moddir):
562"""Primarily for use when testing, sets an alternate directory to use for mods"""
563self.moddir = moddir
564
565def check_workbench(self, wb):
566"""Given a workbench Addon wb, check it for updates using git. If git is not
567available, does nothing."""
568if not self.git_manager:
569wb.set_status(Addon.Status.CANNOT_CHECK)
570return
571clonedir = os.path.join(self.moddir, wb.name)
572if os.path.exists(clonedir):
573# mark as already installed AND already checked for updates
574if not os.path.exists(os.path.join(clonedir, ".git")):
575with wb.git_lock:
576self.git_manager.repair(wb.url, clonedir)
577with wb.git_lock:
578try:
579status = self.git_manager.status(clonedir)
580if "(no branch)" in self.git_manager.status(clonedir):
581# By definition, in a detached-head state we cannot
582# update, so don't even bother checking.
583wb.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
584wb.branch = self.git_manager.current_branch(clonedir)
585return
586except GitFailed as e:
587FreeCAD.Console.PrintWarning(
588"AddonManager: "
589+ translate(
590"AddonsInstaller",
591"Unable to fetch git updates for workbench {}",
592).format(wb.name)
593+ "\n"
594)
595FreeCAD.Console.PrintWarning(str(e) + "\n")
596wb.set_status(Addon.Status.CANNOT_CHECK)
597else:
598try:
599if self.git_manager.update_available(clonedir):
600wb.set_status(Addon.Status.UPDATE_AVAILABLE)
601else:
602wb.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
603except GitFailed:
604FreeCAD.Console.PrintWarning(
605translate("AddonsInstaller", "git status failed for {}").format(wb.name)
606+ "\n"
607)
608wb.set_status(Addon.Status.CANNOT_CHECK)
609
610def _branch_name_changed(self, package: Addon) -> bool:
611clone_dir = os.path.join(self.moddir, package.name)
612installed_metadata_file = os.path.join(clone_dir, "package.xml")
613if not os.path.isfile(installed_metadata_file):
614return False
615try:
616installed_metadata = MetadataReader.from_file(installed_metadata_file)
617installed_default_branch = get_branch_from_metadata(installed_metadata)
618remote_default_branch = get_branch_from_metadata(package.metadata)
619if installed_default_branch != remote_default_branch:
620return True
621except Exception:
622return False
623return False
624
625def check_package(self, package: Addon) -> None:
626"""Given a packaged Addon package, check it for updates. If git is available that is
627used. If not, the package's metadata is examined, and if the metadata file has changed
628compared to the installed copy, an update is flagged. In addition, a change to the
629default branch name triggers an update."""
630
631clone_dir = self.moddir + os.sep + package.name
632if os.path.exists(clone_dir):
633
634# First, see if the branch name changed, which automatically triggers an update
635if self._branch_name_changed(package):
636package.set_status(Addon.Status.UPDATE_AVAILABLE)
637return
638
639# Next, try to just do a git-based update, which will give the most accurate results:
640if self.git_manager:
641self.check_workbench(package)
642if package.status() != Addon.Status.CANNOT_CHECK:
643# It worked, just exit now
644return
645
646# If we were unable to do a git-based update, try using the package.xml file instead:
647installed_metadata_file = os.path.join(clone_dir, "package.xml")
648if not os.path.isfile(installed_metadata_file):
649# If there is no package.xml file, then it's because the package author added it
650# after the last time the local installation was updated. By definition, then,
651# there is an update available, if only to download the new XML file.
652package.set_status(Addon.Status.UPDATE_AVAILABLE)
653package.installed_version = None
654return
655package.updated_timestamp = os.path.getmtime(installed_metadata_file)
656try:
657installed_metadata = MetadataReader.from_file(installed_metadata_file)
658package.installed_version = installed_metadata.version
659# Packages are considered up-to-date if the metadata version matches.
660# Authors should update their version string when they want the addon
661# manager to alert users of a new version.
662if package.metadata.version != installed_metadata.version:
663package.set_status(Addon.Status.UPDATE_AVAILABLE)
664else:
665package.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
666except Exception:
667FreeCAD.Console.PrintWarning(
668translate(
669"AddonsInstaller",
670"Failed to read metadata from {name}",
671).format(name=installed_metadata_file)
672+ "\n"
673)
674package.set_status(Addon.Status.CANNOT_CHECK)
675
676def check_macro(self, macro_wrapper: Addon) -> None:
677"""Check to see if the online copy of the macro's code differs from the local copy."""
678
679# Make sure this macro has its code downloaded:
680try:
681if not macro_wrapper.macro.parsed and macro_wrapper.macro.on_git:
682macro_wrapper.macro.fill_details_from_file(macro_wrapper.macro.src_filename)
683elif not macro_wrapper.macro.parsed and macro_wrapper.macro.on_wiki:
684mac = macro_wrapper.macro.name.replace(" ", "_")
685mac = mac.replace("&", "%26")
686mac = mac.replace("+", "%2B")
687url = "https://wiki.freecad.org/Macro_" + mac
688macro_wrapper.macro.fill_details_from_wiki(url)
689except Exception:
690FreeCAD.Console.PrintWarning(
691translate(
692"AddonsInstaller",
693"Failed to fetch code for macro '{name}'",
694).format(name=macro_wrapper.macro.name)
695+ "\n"
696)
697macro_wrapper.set_status(Addon.Status.CANNOT_CHECK)
698return
699
700hasher1 = hashlib.sha1()
701hasher2 = hashlib.sha1()
702hasher1.update(macro_wrapper.macro.code.encode("utf-8"))
703new_sha1 = hasher1.hexdigest()
704test_file_one = os.path.join(FreeCAD.getUserMacroDir(True), macro_wrapper.macro.filename)
705test_file_two = os.path.join(
706FreeCAD.getUserMacroDir(True), "Macro_" + macro_wrapper.macro.filename
707)
708if os.path.exists(test_file_one):
709with open(test_file_one, "rb") as f:
710contents = f.read()
711hasher2.update(contents)
712old_sha1 = hasher2.hexdigest()
713elif os.path.exists(test_file_two):
714with open(test_file_two, "rb") as f:
715contents = f.read()
716hasher2.update(contents)
717old_sha1 = hasher2.hexdigest()
718else:
719return
720if new_sha1 == old_sha1:
721macro_wrapper.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
722else:
723macro_wrapper.set_status(Addon.Status.UPDATE_AVAILABLE)
724
725
726class CacheMacroCodeWorker(QtCore.QThread):
727"""Download and cache the macro code, and parse its internal metadata"""
728
729status_message = QtCore.Signal(str)
730update_macro = QtCore.Signal(Addon)
731progress_made = QtCore.Signal(int, int)
732
733def __init__(self, repos: List[Addon]) -> None:
734QtCore.QThread.__init__(self)
735self.repos = repos
736self.workers = []
737self.terminators = []
738self.lock = threading.Lock()
739self.failed = []
740self.counter = 0
741self.repo_queue = None
742
743def run(self):
744"""Rarely called directly: create an instance and call start() on it instead to
745launch in a new thread"""
746
747self.status_message.emit(translate("AddonsInstaller", "Caching macro code..."))
748
749self.repo_queue = queue.Queue()
750num_macros = 0
751for repo in self.repos:
752if repo.macro is not None:
753self.repo_queue.put(repo)
754num_macros += 1
755
756interrupted = self._process_queue(num_macros)
757if interrupted:
758return
759
760# Make sure all of our child threads have fully exited:
761for worker in self.workers:
762worker.wait(50)
763if not worker.isFinished():
764# The Qt Python translation extractor doesn't support splitting this string (yet)
765# pylint: disable=line-too-long
766FreeCAD.Console.PrintError(
767translate(
768"AddonsInstaller",
769"Addon Manager: a worker process failed to complete while fetching {name}",
770).format(name=worker.macro.name)
771+ "\n"
772)
773
774self.repo_queue.join()
775for terminator in self.terminators:
776if terminator and terminator.isActive():
777terminator.stop()
778
779if len(self.failed) > 0:
780num_failed = len(self.failed)
781FreeCAD.Console.PrintWarning(
782translate(
783"AddonsInstaller",
784"Out of {num_macros} macros, {num_failed} timed out while processing",
785).format(num_macros=num_macros, num_failed=num_failed)
786)
787
788def _process_queue(self, num_macros) -> bool:
789"""Spools up six network connections and downloads the macro code. Returns True if
790it was interrupted by user request, or False if it ran to completion."""
791
792# Emulate QNetworkAccessManager and spool up six connections:
793for _ in range(6):
794self.update_and_advance(None)
795
796current_thread = QtCore.QThread.currentThread()
797while True:
798if current_thread.isInterruptionRequested():
799for worker in self.workers:
800worker.blockSignals(True)
801worker.requestInterruption()
802if not worker.wait(100):
803FreeCAD.Console.PrintWarning(
804translate(
805"AddonsInstaller",
806"Addon Manager: a worker process failed to halt ({name})",
807).format(name=worker.macro.name)
808+ "\n"
809)
810return True
811# Ensure our signals propagate out by running an internal thread-local event loop
812QtCore.QCoreApplication.processEvents()
813with self.lock:
814if self.counter >= num_macros:
815break
816time.sleep(0.1)
817return False
818
819def update_and_advance(self, repo: Addon) -> None:
820"""Emit the updated signal and launch the next item from the queue."""
821if repo is not None:
822if repo.macro.name not in self.failed:
823self.update_macro.emit(repo)
824self.repo_queue.task_done()
825with self.lock:
826self.counter += 1
827
828if QtCore.QThread.currentThread().isInterruptionRequested():
829return
830
831self.progress_made.emit(len(self.repos) - self.repo_queue.qsize(), len(self.repos))
832
833try:
834next_repo = self.repo_queue.get_nowait()
835worker = GetMacroDetailsWorker(next_repo)
836worker.finished.connect(lambda: self.update_and_advance(next_repo))
837with self.lock:
838self.workers.append(worker)
839self.terminators.append(
840QtCore.QTimer.singleShot(10000, lambda: self.terminate(worker))
841)
842self.status_message.emit(
843translate(
844"AddonsInstaller",
845"Getting metadata from macro {}",
846).format(next_repo.macro.name)
847)
848worker.start()
849except queue.Empty:
850pass
851
852def terminate(self, worker) -> None:
853"""Shut down all running workers and exit the thread"""
854if not worker.isFinished():
855macro_name = worker.macro.name
856FreeCAD.Console.PrintWarning(
857translate(
858"AddonsInstaller",
859"Timeout while fetching metadata for macro {}",
860).format(macro_name)
861+ "\n"
862)
863# worker.blockSignals(True)
864worker.requestInterruption()
865worker.wait(100)
866if worker.isRunning():
867FreeCAD.Console.PrintError(
868translate(
869"AddonsInstaller",
870"Failed to kill process for macro {}!\n",
871).format(macro_name)
872)
873with self.lock:
874self.failed.append(macro_name)
875
876
877class GetMacroDetailsWorker(QtCore.QThread):
878"""Retrieve the macro details for a macro"""
879
880status_message = QtCore.Signal(str)
881readme_updated = QtCore.Signal(str)
882
883def __init__(self, repo):
884
885QtCore.QThread.__init__(self)
886self.macro = repo.macro
887
888def run(self):
889"""Rarely called directly: create an instance and call start() on it instead to
890launch in a new thread"""
891
892self.status_message.emit(translate("AddonsInstaller", "Retrieving macro description..."))
893if not self.macro.parsed and self.macro.on_git:
894self.status_message.emit(translate("AddonsInstaller", "Retrieving info from git"))
895self.macro.fill_details_from_file(self.macro.src_filename)
896if not self.macro.parsed and self.macro.on_wiki:
897self.status_message.emit(translate("AddonsInstaller", "Retrieving info from wiki"))
898mac = self.macro.name.replace(" ", "_")
899mac = mac.replace("&", "%26")
900mac = mac.replace("+", "%2B")
901url = "https://wiki.freecad.org/Macro_" + mac
902self.macro.fill_details_from_wiki(url)
903message = (
904"<h1>"
905+ self.macro.name
906+ "</h1>"
907+ self.macro.desc
908+ '<br/><br/>Macro location: <a href="'
909+ self.macro.url
910+ '">'
911+ self.macro.url
912+ "</a>"
913)
914if QtCore.QThread.currentThread().isInterruptionRequested():
915return
916self.readme_updated.emit(message)
917
918
919class GetBasicAddonStatsWorker(QtCore.QThread):
920"""Fetch data from an addon stats repository."""
921
922update_addon_stats = QtCore.Signal(Addon)
923
924def __init__(self, url: str, addons: List[Addon], parent: QtCore.QObject = None):
925super().__init__(parent)
926self.url = url
927self.addons = addons
928
929def run(self):
930"""Fetch the remote data and load it into the addons"""
931
932fetch_result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.url, 5000)
933if fetch_result is None:
934FreeCAD.Console.PrintError(
935translate(
936"AddonsInstaller",
937"Failed to get Addon statistics from {} -- only sorting alphabetically will be accurate\n",
938).format(self.url)
939)
940return
941text_result = fetch_result.data().decode("utf8")
942json_result = json.loads(text_result)
943
944for addon in self.addons:
945if addon.url in json_result:
946addon.stats = AddonStats.from_json(json_result[addon.url])
947self.update_addon_stats.emit(addon)
948
949
950class GetAddonScoreWorker(QtCore.QThread):
951"""Fetch data from an addon score file."""
952
953update_addon_score = QtCore.Signal(Addon)
954
955def __init__(self, url: str, addons: List[Addon], parent: QtCore.QObject = None):
956super().__init__(parent)
957self.url = url
958self.addons = addons
959
960def run(self):
961"""Fetch the remote data and load it into the addons"""
962
963if self.url != "TEST":
964fetch_result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.url, 5000)
965if fetch_result is None:
966FreeCAD.Console.PrintError(
967translate(
968"AddonsInstaller",
969"Failed to get Addon score from '{}' -- sorting by score will fail\n",
970).format(self.url)
971)
972return
973text_result = fetch_result.data().decode("utf8")
974json_result = json.loads(text_result)
975else:
976FreeCAD.Console.PrintWarning("Running score generation in TEST mode...\n")
977json_result = {}
978for addon in self.addons:
979if addon.macro:
980json_result[addon.name] = len(addon.macro.comment) if addon.macro.comment else 0
981else:
982json_result[addon.url] = len(addon.description) if addon.description else 0
983
984for addon in self.addons:
985score = None
986if addon.url in json_result:
987score = json_result[addon.url]
988elif addon.name in json_result:
989score = json_result[addon.name]
990if score is not None:
991try:
992addon.score = int(score)
993self.update_addon_score.emit(addon)
994except (ValueError, OverflowError):
995FreeCAD.Console.PrintLog(
996f"Failed to convert score value '{score}' to an integer for addon {addon.name}"
997)
998