# -*- coding: utf-8 -*- #*************************************************************************** #* * #* Copyright (c) 2019 Yorik van Havre * #* * #* This program is free software; you can redistribute it and/or modify * #* it under the terms of the GNU Lesser General Public License (LGPL) * #* as published by the Free Software Foundation; either version 2 of * #* the License, or (at your option) any later version. * #* for detail see the LICENCE text file. * #* * #* This program 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 Library General Public License for more details. * #* * #* You should have received a copy of the GNU Library General Public * #* License along with this program; if not, write to the Free Software * #* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * #* USA * #* * #*************************************************************************** import os import re import shutil import stat import sys import tempfile from PySide import QtCore, QtGui import FreeCAD if FreeCAD.GuiUp: import FreeCADGui import addonmanager_utilities as utils from addonmanager_utilities import translate # this needs to be as is for pylupdate from addonmanager_macro import Macro have_git = False try: import git have_git = True except ImportError: pass have_zip = False try: import zipfile have_zip = True except ImportError: pass have_markdown = False try: import markdown have_markdown = True except ImportError: pass try: import StringIO as io _stringio = io.StringIO except ImportError: # StringIO is not available with python3 import io _stringio = io.BytesIO # @package AddonManager_workers # \ingroup ADDONMANAGER # \brief Multithread workers for the addon manager # @{ # Blacklisted addons macros_blacklist = [] # These addons will print an additional message informing the user obsolete = [] # These addons will print an additional message informing the user Python2 only py2only = [] NOGIT = False # for debugging purposes, set this to True to always use http downloads NOMARKDOWN = False # for debugging purposes, set this to True to disable Markdown lib """Multithread workers for the Addon Manager""" class UpdateWorker(QtCore.QThread): """This worker updates the list of available workbenches""" info_label = QtCore.Signal(str) addon_repo = QtCore.Signal(object) progressbar_show = QtCore.Signal(bool) done = QtCore.Signal() def __init__(self): QtCore.QThread.__init__(self) def run(self): "populates the list of addons" self.progressbar_show.emit(True) # update info lists global obsolete, macros_blacklist, py2only u = utils.urlopen("https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json") if u: p = u.read() if sys.version_info.major >= 3 and isinstance(p, bytes): p = p.decode("utf-8") u.close() hit = re.findall(r'"obsolete"[^\{]*?{[^\{]*?"Mod":\[(?P[^\[\]]+?)\]}', p.replace("\n", "").replace(" ", "")) if hit: obsolete = hit[0].replace('"', "").split(",") hit = re.findall(r'"blacklisted"[^\{]*?{[^\{]*?"Macro":\[(?P[^\[\]]+?)\]}', p.replace("\n", "").replace(" ", "")) if hit: macros_blacklist = hit[0].replace('"', "").split(",") hit = re.findall(r'"py2only"[^\{]*?{[^\{]*?"Mod":\[(?P[^\[\]]+?)\]}', p.replace("\n", "").replace(" ", "")) if hit: py2only = hit[0].replace('"', "").split(",") else: print("Debug: addon_flags.json not found") u = utils.urlopen("https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/.gitmodules") if not u: self.progressbar_show.emit(False) self.done.emit() self.stop = True return p = u.read() if sys.version_info.major >= 3 and isinstance(p, bytes): p = p.decode("utf-8") u.close() p = re.findall((r'(?m)\[submodule\s*"(?P.*)"\]\s*' r"path\s*=\s*(?P.+)\s*" r"url\s*=\s*(?Phttps?://.*)"), p) basedir = FreeCAD.getUserAppDataDir() moddir = basedir + os.sep + "Mod" repos = [] # querying official addons for name, path, url in p: self.info_label.emit(name) url = url.split(".git")[0] addondir = moddir + os.sep + name if os.path.exists(addondir) and os.listdir(addondir): # make sure the folder exists and it contains files! state = 1 else: state = 0 repos.append([name, url, state]) # querying custom addons customaddons = (FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") .GetString("CustomRepositories", "").split("\n")) for url in customaddons: if url: name = url.split("/")[-1] if name.lower().endswith(".git"): name = name[:-4] addondir = moddir + os.sep + name if not os.path.exists(addondir): state = 0 else: state = 1 repos.append([name, url, state]) if not repos: self.info_label.emit(translate("AddonsInstaller", "Unable to download addon list.")) else: repos = sorted(repos, key=lambda s: s[0].lower()) for repo in repos: self.addon_repo.emit(repo) self.info_label.emit(translate("AddonsInstaller", "Workbenches list was updated.")) self.progressbar_show.emit(False) self.done.emit() self.stop = True class InfoWorker(QtCore.QThread): """This worker retrieves the description text of a workbench""" addon_repos = QtCore.Signal(object) def __init__(self): QtCore.QThread.__init__(self) def run(self): i = 0 for repo in self.repos: url = repo[1] u = utils.urlopen(url) if not u: self.stop = True return p = u.read() if sys.version_info.major >= 3 and isinstance(p, bytes): p = p.decode("utf-8") u.close() desc = re.findall('= 3 and isinstance(p, bytes): p = p.decode("utf-8") macros = re.findall('title="(Macro.*?)"', p) macros = [mac for mac in macros if ("translated" not in mac)] for mac in macros: macname = mac[6:] # Remove "Macro ". macname = macname.replace("&", "&") if (macname not in macros_blacklist) and ("recipes" not in macname.lower()): macro = Macro(macname) macro.on_wiki = True self.macros.append(macro) class ShowWorker(QtCore.QThread): """This worker retrieves info of a given workbench""" info_label = QtCore.Signal(str) addon_repos = QtCore.Signal(object) progressbar_show = QtCore.Signal(bool) def __init__(self, repos, idx): # repos is a list of [name, url, installbit, descr] # name : Addon name # url : Addon repository location # installbit: 0 = Addon is not installed # 1 = Addon is installed # 2 = Addon is installed and checked for available updates (none pending) # 3 = Addon is installed and has a pending update # descr : Addon description QtCore.QThread.__init__(self) self.repos = repos self.idx = idx def run(self): self.progressbar_show.emit(True) self.info_label.emit(translate("AddonsInstaller", "Retrieving description...")) if len(self.repos[self.idx]) == 4: desc = self.repos[self.idx][3] else: u = None url = self.repos[self.idx][1] self.info_label.emit(translate("AddonsInstaller", "Retrieving info from") + " " + str(url)) desc = "" regex = utils.get_readme_regex(url) if regex: # extract readme from html via regex readmeurl = utils.get_readme_html_url(url) if not readmeurl: print("Debug: README not found for", url) u = utils.urlopen(readmeurl) if not u: print("Debug: README not found at", readmeurl) u = utils.urlopen(readmeurl) if u: p = u.read() if sys.version_info.major >= 3 and isinstance(p, bytes): p = p.decode("utf-8") u.close() readme = re.findall(regex, p, flags=re.MULTILINE | re.DOTALL) if readme: desc = readme[0] else: print("Debug: README not found at", readmeurl) else: # convert raw markdown using lib readmeurl = utils.get_readme_url(url) if not readmeurl: print("Debug: README not found for", url) u = utils.urlopen(readmeurl) if u: p = u.read() if sys.version_info.major >= 3 and isinstance(p, bytes): p = p.decode("utf-8") u.close() desc = utils.fix_relative_links(p, readmeurl.rsplit("/README.md")[0]) if NOMARKDOWN or not have_markdown: desc = markdown.markdown(desc, extensions=["md_in_html"]) else: message = """
""" message += translate("AddonsInstaller", "Raw markdown displayed") message += "

" message += translate("AddonsInstaller", "Python Markdown library is missing.") message += "

" + desc + "
" desc = message else: print("Debug: README not found at", readmeurl) if desc == "": # fall back to the description text u = utils.urlopen(url) if not u: self.progressbar_show.emit(False) self.stop = True return p = u.read() if sys.version_info.major >= 3 and isinstance(p, bytes): p = p.decode("utf-8") u.close() descregex = utils.get_desc_regex(url) if descregex: desc = re.findall(descregex, p) if desc: desc = desc[0] if not desc: desc = "Unable to retrieve addon description" self.repos[self.idx].append(desc) self.addon_repos.emit(self.repos) # Addon is installed so lets check if it has an update if self.repos[self.idx][2] == 1: upd = False # checking for updates if not NOGIT and have_git: repo = self.repos[self.idx] clonedir = FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + repo[0] if os.path.exists(clonedir): if not os.path.exists(clonedir + os.sep + ".git"): # Repair addon installed with raw download bare_repo = git.Repo.clone_from(repo[1], clonedir + os.sep + ".git", bare=True) try: with bare_repo.config_writer() as cw: cw.set("core", "bare", False) except AttributeError: FreeCAD.Console.PrintWarning(translate("AddonsInstaller", "Outdated GitPython detected, " "consider upgrading with pip.") + "\n") cw = bare_repo.config_writer() cw.set("core", "bare", False) del cw repo = git.Repo(clonedir) repo.head.reset("--hard") gitrepo = git.Git(clonedir) gitrepo.fetch() if "git pull" in gitrepo.status(): upd = True # If there is an update pending, lets user know via the UI if upd: message = """

""" message += translate("AddonsInstaller", "An update is available for this addon.") message += "

" + desc + '

Addon repository: ' + self.repos[self.idx][1] + "" # mark as already installed AND already checked for updates AND update is available self.repos[self.idx][2] = 3 # If there isn't, indicate that this addon is already installed else: message = """

""" message += translate("AddonsInstaller", "This addon is already installed.") message += "

" + desc message += '

Addon repository: ' + self.repos[self.idx][1] + "" self.repos[self.idx][2] = 2 # mark as already installed AND already checked for updates # Let the user know the install path for this addon message += "
" + translate("AddonInstaller", "Installed location") + ": " message += FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + self.repos[self.idx][0] self.addon_repos.emit(self.repos) elif self.repos[self.idx][2] == 2: message = """

""" message += translate("AddonsInstaller", "This addon is already installed.") message += "

" + desc message += '

Addon repository: ' + self.repos[self.idx][1] + "" message += "
" + translate("AddonInstaller", "Installed location") + ": " message += FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + self.repos[self.idx][0] elif self.repos[self.idx][2] == 3: message = """

""" message += translate("AddonsInstaller", "An update is available for this addon.") message += "

" + desc + '

Addon repository: ' + self.repos[self.idx][1] + "" message += "
" + translate("AddonInstaller", "Installed location") + ": " message += FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + self.repos[self.idx][0] else: message = desc + '

Addon repository: ' + self.repos[self.idx][1] + '' # If the Addon is obsolete, let the user know through the Addon UI if self.repos[self.idx][0] in obsolete: message = """
""" message += translate("AddonsInstaller", "This addon is marked as obsolete") + "

" message += translate("AddonsInstaller", "This usually means it is no longer maintained, " "and some more advanced addon in this list " "provides the same functionality.") + "

" + desc # If the Addon is Python 2 only, let the user know through the Addon UI if self.repos[self.idx][0] in py2only: message = """
""" message += translate("AddonsInstaller", "This addon is marked as Python 2 Only") + "

" message += translate("AddonsInstaller", "This workbench may no longer be maintained and " "installing it on a Python 3 system will more than " "likely result in errors at startup or while in use.") message += "

" + desc self.info_label.emit(message) self.progressbar_show.emit(False) self.mustLoadImages = True label = self.loadImages(message, self.repos[self.idx][1], self.repos[self.idx][0]) if label: self.info_label.emit(label) self.stop = True def stopImageLoading(self): "this stops the image loading process and allow the thread to terminate earlier" self.mustLoadImages = False def loadImages(self, message, url, wbName): "checks if the given page contains images and downloads them" # QTextBrowser cannot display online images. So we download them # here, and replace the image link in the html code with the # downloaded version imagepaths = re.findall("= 260: remainChars = 259 - (len(store) + len(wbName) + 1) storename = os.path.join(store, wbName+name[-remainChars:]) if not os.path.exists(storename): try: u = utils.urlopen(path) imagedata = u.read() u.close() except Exception: print("AddonManager: Debug: Error retrieving image from", path) else: f = open(storename, "wb") f.write(imagedata) f.close() # resize the image to 300x300px if needed img = QtGui.QImage(storename) if (img.width() > 300) or (img.height() > 300): pix = QtGui.QPixmap() pix = pix.fromImage(img.scaled(300, 300, QtCore.Qt.KeepAspectRatio, QtCore.Qt.FastTransformation)) pix.save(storename, "jpeg", 100) message = message.replace("src=\""+origpath, "src=\"file:///"+storename.replace("\\", "/")) # print(message) return message return None class GetMacroDetailsWorker(QtCore.QThread): """Retrieve the macro details for a macro""" info_label = QtCore.Signal(str) progressbar_show = QtCore.Signal(bool) def __init__(self, macro): QtCore.QThread.__init__(self) self.macro = macro def run(self): self.progressbar_show.emit(True) self.info_label.emit(translate("AddonsInstaller", "Retrieving description...")) if not self.macro.parsed and self.macro.on_git: self.info_label.emit(translate("AddonsInstaller", "Retrieving info from git")) self.macro.fill_details_from_file(self.macro.src_filename) if not self.macro.parsed and self.macro.on_wiki: self.info_label.emit(translate("AddonsInstaller", "Retrieving info from wiki")) mac = self.macro.name.replace(" ", "_") mac = mac.replace("&", "%26") mac = mac.replace("+", "%2B") url = "https://www.freecadweb.org/wiki/Macro_" + mac self.macro.fill_details_from_wiki(url) if self.macro.is_installed(): already_installed_msg = ('' + translate("AddonsInstaller", "This macro is already installed.") + '
') else: already_installed_msg = "" message = (already_installed_msg + "

"+self.macro.name+"

" + self.macro.desc + "

Macro location: " + self.macro.url + "") self.info_label.emit(message) self.progressbar_show.emit(False) self.stop = True class InstallWorker(QtCore.QThread): "This worker installs a workbench" info_label = QtCore.Signal(str) progressbar_show = QtCore.Signal(bool) mark_recompute = QtCore.Signal(str) def __init__(self, repos, idx): QtCore.QThread.__init__(self) self.idx = idx self.repos = repos def run(self): "installs or updates the selected addon" if not have_git: self.info_label.emit("GitPython not found.") FreeCAD.Console.PrintWarning(translate("AddonsInstaller", "GitPython not found. Using standard download instead.") + "\n") if not have_zip: self.info_label.emit("no zip support.") FreeCAD.Console.PrintError(translate("AddonsInstaller", "Your version of python doesn't appear to support ZIP " "files. Unable to proceed.") + "\n") return if not isinstance(self.idx, list): self.idx = [self.idx] for idx in self.idx: if idx < 0: return if not self.repos: return basedir = FreeCAD.getUserAppDataDir() moddir = basedir + os.sep + "Mod" if not os.path.exists(moddir): os.makedirs(moddir) clonedir = moddir + os.sep + self.repos[idx][0] self.progressbar_show.emit(True) if os.path.exists(clonedir): self.info_label.emit("Updating module...") if sys.version_info.major > 2 and str(self.repos[idx][0]) in py2only: FreeCAD.Console.PrintWarning(translate("AddonsInstaller", "User requested updating a Python 2 workbench on " "a system running Python 3 - ") + str(self.repos[idx][0])+"\n") if have_git: if not os.path.exists(clonedir + os.sep + ".git"): # Repair addon installed with raw download bare_repo = git.Repo.clone_from(self.repos[idx][1], clonedir + os.sep + ".git", bare=True) try: with bare_repo.config_writer() as cw: cw.set("core", "bare", False) except AttributeError: FreeCAD.Console.PrintWarning(translate("AddonsInstaller", "Outdated GitPython detected, consider " "upgrading with pip.") + "\n") cw = bare_repo.config_writer() cw.set("core", "bare", False) del cw repo = git.Repo(clonedir) repo.head.reset("--hard") repo = git.Git(clonedir) try: answer = repo.pull() + "\n\n" + translate("AddonsInstaller", "Workbench successfully updated. " "Please restart FreeCAD to apply the changes.") except Exception: print("Error updating module", self.repos[idx][1], " - Please fix manually") answer = repo.status() print(answer) else: # Update the submodules for this repository repo_sms = git.Repo(clonedir) for submodule in repo_sms.submodules: submodule.update(init=True, recursive=True) else: answer = self.download(self.repos[idx][1], clonedir) + "\n\n" answer += translate("AddonsInstaller", "Workbench successfully updated. Please " "restart FreeCAD to apply the changes.") else: self.info_label.emit("Checking module dependencies...") depsok, answer = self.check_dependencies(self.repos[idx][1]) if depsok: if sys.version_info.major > 2 and str(self.repos[idx][0]) in py2only: FreeCAD.Console.PrintWarning(translate("AddonsInstaller", "User requested installing a Python 2 " "workbench on a system running Python 3 - ") + str(self.repos[idx][0]) + "\n") if have_git: self.info_label.emit("Cloning module...") repo = git.Repo.clone_from(self.repos[idx][1], clonedir, branch="master") # Make sure to clone all the submodules as well if repo.submodules: repo.submodule_update(recursive=True) else: self.info_label.emit("Downloading module...") self.download(self.repos[idx][1], clonedir) answer = translate("AddonsInstaller", "Workbench successfully installed. Please restart " "FreeCAD to apply the changes.") # 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"): print("copying macro:", f) utils.symlink(os.path.join(clonedir, f), os.path.join(macro_dir, f)) FreeCAD.ParamGet('User parameter:Plugins/' + self.repos[idx][0]).SetString("destination", clonedir) answer += "\n\n" + translate("AddonsInstaller", "A macro has been installed and is available " "under Macro -> Macros menu") answer += ":\n" + f + "" self.progressbar_show.emit(False) self.info_label.emit(answer) self.mark_recompute.emit(self.repos[idx][0]) self.stop = True def check_dependencies(self, baseurl): "checks if the repo contains a metadata.txt and check its contents" ok = True message = "" depsurl = baseurl.replace("github.com", "raw.githubusercontent.com") if not depsurl.endswith("/"): depsurl += "/" depsurl += "master/metadata.txt" mu = utils.urlopen(depsurl) if mu: # metadata.txt found depsfile = mu.read() mu.close() # urllib2 gives us a bytelike object instead of a string. Have to consider that try: depsfile = depsfile.decode("utf-8") except AttributeError: pass deps = depsfile.split("\n") for line in deps: if line.startswith("workbenches="): depswb = line.split("=")[1].split(",") for wb in depswb: if wb.strip(): if not wb.strip() in FreeCADGui.listWorkbenches().keys(): if not wb.strip()+"Workbench" in FreeCADGui.listWorkbenches().keys(): ok = False message += translate("AddonsInstaller", "Missing workbench") + ": " + wb + ", " elif line.startswith("pylibs="): depspy = line.split("=")[1].split(",") for pl in depspy: if pl.strip(): try: __import__(pl.strip()) except ImportError: ok = False message += translate("AddonsInstaller", "Missing python module") + ": " + pl + ", " elif line.startswith("optionalpylibs="): opspy = line.split("=")[1].split(",") for pl in opspy: if pl.strip(): try: __import__(pl.strip()) except ImportError: message += translate("AddonsInstaller", "Missing optional python module (doesn't prevent installing)") message += ": " + pl + ", " if message and (not ok): message = translate("AddonsInstaller", "Some errors were found that prevent to install this workbench") message += ": " + message + ". " message += translate("AddonsInstaller", "Please install the missing components first.") return ok, message def download(self, baseurl, clonedir): "downloads and unzip a zip version from a git repo" bakdir = None if os.path.exists(clonedir): bakdir = clonedir+".bak" if os.path.exists(bakdir): shutil.rmtree(bakdir) os.rename(clonedir, bakdir) os.makedirs(clonedir) zipurl = utils.get_zip_url(baseurl) if not zipurl: return translate("AddonsInstaller", "Error: Unable to locate zip from") + " " + baseurl try: print("Downloading "+zipurl) u = utils.urlopen(zipurl) except Exception: return translate("AddonsInstaller", "Error: Unable to download") + " " + zipurl if not u: return translate("AddonsInstaller", "Error: Unable to download") + " " + zipurl zfile = _stringio() zfile.write(u.read()) zfile = zipfile.ZipFile(zfile) master = zfile.namelist()[0] # github will put everything in a subfolder zfile.extractall(clonedir) u.close() zfile.close() for filename in os.listdir(clonedir+os.sep+master): shutil.move(clonedir+os.sep+master+os.sep+filename, clonedir+os.sep+filename) os.rmdir(clonedir+os.sep+master) if bakdir: shutil.rmtree(bakdir) return translate("AddonsInstaller", "Successfully installed") + " " + zipurl class CheckSingleWorker(QtCore.QThread): """Worker to check for updates for a single addon""" updateAvailable = QtCore.Signal(bool) def __init__(self, name): QtCore.QThread.__init__(self) self.name = name def run(self): if not have_git: return FreeCAD.Console.PrintLog("Checking for available updates of the "+self.name+" addon\n") addondir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", self.name) if os.path.exists(addondir): if os.path.exists(addondir + os.sep + ".git"): gitrepo = git.Git(addondir) try: gitrepo.fetch() if "git pull" in gitrepo.status(): self.updateAvailable.emit(True) return except Exception: # can fail for any number of reasons, ex. not being online pass self.updateAvailable.emit(False) # @}