diff --git a/src/App/FreeCADInit.py b/src/App/FreeCADInit.py index e6949134af..b56e18c56a 100644 --- a/src/App/FreeCADInit.py +++ b/src/App/FreeCADInit.py @@ -162,9 +162,15 @@ def InitApplications(): # also add these directories to the sys.path to # not change the old behaviour. once we have moved to - # proper python modules this can eventuelly be removed. + # proper python modules this can eventually be removed. sys.path = [ModDir] + libpaths + [ExtDir] + sys.path + # The AddonManager may install additional Python packages in + # this path: + additional_packages_path = os.path.join(FreeCAD.getUserAppDataDir(),"AdditionalPythonPackages") + if os.path.isdir(additional_packages_path): + sys.path.append(additional_packages_path) + def RunInitPy(Dir): InstallFile = os.path.join(Dir,"Init.py") if (os.path.exists(InstallFile)): diff --git a/src/Mod/AddonManager/ALLOWED_PYTHON_PACKAGES.txt b/src/Mod/AddonManager/ALLOWED_PYTHON_PACKAGES.txt new file mode 100644 index 0000000000..2619cbcb52 --- /dev/null +++ b/src/Mod/AddonManager/ALLOWED_PYTHON_PACKAGES.txt @@ -0,0 +1,29 @@ +# This file lists the Python packages that the Addon Manager allows to be installed +# automatically via pip. To request that a package be added to this list, submit a +# pull request to the FreeCAD git repository with the requested package added. Only +# packages in this list will be processed from the metadata.txt and requirements.txt +# files specified by an Addon. Note that this is NOT a requirements.txt-format file, +# no version information may be specified, and no wildcards are supported. + +# Allow these packages to be installed: +ezdxf +gmsh +lxml +markdown +matplotlib +numpy +olefile +openpyxl +pandas +pillow +ply +pycollada +pygit2 +pynastran +requests +rhino3dm +scipy +xlrd +xlutils +xlwt +PyYAML diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index d984fa7c43..a87b5a6fc2 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -97,6 +97,25 @@ class CommandAddonManager: "Addon Manager", ) + self.allowed_packages = set() + allow_file = os.path.join( + os.path.dirname(__file__), "ALLOWED_PYTHON_PACKAGES.txt" + ) + if os.path.exists(allow_file): + with open(allow_file, "r", encoding="utf8") as f: + lines = f.readlines() + for line in lines: + if line and len(line) > 0 and line[0] != "#": + self.allowed_packages.add(line.strip()) + else: + FreeCAD.PrintWarning( + translate( + "AddonsInstaller", + "Addon Manager installation problem: could not locate ALLOWED_PYTHON_PACKAGES.txt", + ) + + "\n" + ) + def GetResources(self) -> Dict[str, str]: return { "Pixmap": "AddonManager", @@ -262,9 +281,13 @@ class CommandAddonManager: last_cache_update = date.fromisoformat(last_cache_update_string) else: # Python 3.6 and earlier don't have date.fromisoformat - date_re = re.compile("([0-9]{4})-?(1[0-2]|0[1-9])-?(3[01]|0[1-9]|[12][0-9])") - matches = date_re.match (last_cache_update_string) - last_cache_update = date(int(matches.group(1)),int(matches.group(2)),int(matches.group(3))) + date_re = re.compile( + "([0-9]{4})-?(1[0-2]|0[1-9])-?(3[01]|0[1-9]|[12][0-9])" + ) + matches = date_re.match(last_cache_update_string) + last_cache_update = date( + int(matches.group(1)), int(matches.group(2)), int(matches.group(3)) + ) delta_update = timedelta(days=days_between_updates) if date.today() >= last_cache_update + delta_update: self.update_cache = True @@ -859,10 +882,42 @@ class CommandAddonManager: # Check the Python dependencies: missing_python_requirements = [] for py_dep in deps.python_required: - try: - __import__(py_dep) - except ImportError: - missing_python_requirements.append(py_dep) + if py_dep not in missing_python_requirements: + try: + __import__(py_dep) + except ImportError: + missing_python_requirements.append(py_dep) + + bad_packages = [] + for dep in missing_python_requirements: + if dep not in self.allowed_packages: + bad_packages.append(dep) + + if bad_packages: + message = translate( + "AddonsInstaller", + "The Addon {repo.name} requires Python packages that are not installed, and cannot be installed automatically. To use this workbench you must install the following Python packages manually:", + ) + if len(bad_packages) < 15: + for dep in bad_packages: + message += f"\n * {dep}" + else: + message += ( + "\n * (" + translate("AddonsInstaller", "Too many to list") + ")" + ) + QtWidgets.QMessageBox.critical( + None, translate("AddonsInstaller", "Connection failed"), message + ) + FreeCAD.Console.PrintMessage( + translate( + "AddonsInstaller", + "The following Python packages are allowed to be automatically installed", + ) + + ":\n" + ) + for package in self.allowed_packages: + FreeCAD.Console.PrintMessage(f" * {package}\n") + return missing_python_optionals = [] for py_dep in deps.python_optional: @@ -925,16 +980,19 @@ class CommandAddonManager: self.dependency_dialog.listWidgetPythonRequired.addItem(mod) for mod in missing_python_optionals: item = QtWidgets.QListWidgetItem(mod) - item.setFlags(Qt.ItemIsUserCheckable) + item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable) + item.setCheckState(QtCore.Qt.Unchecked) self.dependency_dialog.listWidgetPythonOptional.addItem(item) - # For now, we don't offer to automatically install the dependencies - # self.dependency_dialog.buttonBox.button( - # QtWidgets.QDialogButtonBox.Yes - # ).clicked.connect(lambda: self.dependency_dialog_yes_clicked(repo)) + self.dependency_dialog.buttonBox.button( + QtWidgets.QDialogButtonBox.Yes + ).clicked.connect(lambda: self.dependency_dialog_yes_clicked(repo)) self.dependency_dialog.buttonBox.button( QtWidgets.QDialogButtonBox.Ignore ).clicked.connect(lambda: self.dependency_dialog_ignore_clicked(repo)) + self.dependency_dialog.buttonBox.button( + QtWidgets.QDialogButtonBox.Cancel + ).setDefault(True) self.dependency_dialog.exec() else: self.install(repo) @@ -957,13 +1015,22 @@ class CommandAddonManager: python_optional = [] for row in range(self.dependency_dialog.listWidgetPythonOptional.count()): item = self.dependency_dialog.listWidgetPythonOptional.item(row) - if item.checked(): + if item.checkState() == QtCore.Qt.Checked: python_optional.append(item.text()) self.dependency_installation_worker = DependencyInstallationWorker( addons, python_required, python_optional ) - self.dependency_installation_worker.finished.connect(lambda: self.install(repo)) + self.dependency_installation_worker.no_python_exe.connect( + lambda: self.no_python_exe(repo) + ) + self.dependency_installation_worker.no_pip.connect( + lambda command: self.no_pip(command, repo) + ) + self.dependency_installation_worker.failure.connect( + self.dependency_installation_failure + ) + self.dependency_installation_worker.success.connect(lambda: self.install(repo)) self.dependency_installation_dialog = QtWidgets.QMessageBox( QtWidgets.QMessageBox.Information, translate("AddonsInstaller", "Installing dependencies"), @@ -977,6 +1044,59 @@ class CommandAddonManager: self.dependency_installation_dialog.show() self.dependency_installation_worker.start() + def no_python_exe(self, repo: AddonManagerRepo) -> None: + if hasattr(self, "dependency_installation_dialog"): + self.dependency_installation_dialog.hide() + result = QtWidgets.QMessageBox.critical( + self.dialog, + translate("AddonsInstaller", "Cannot execute Python"), + translate( + "AddonsInstaller", + "Failed to automatically locate your Python executable, or the path is set incorrectly. Please check the Addon Manager preferences setting for the path to Python.", + ) + + "\n\n" + + translate( + "AddonsInstaller", + f"Dependencies could not be installed. Continue with installation of {repo.name} anyway?", + ), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + ) + if result == QtWidgets.QMessageBox.Yes: + self.install(repo) + + def no_pip(self, command: str, repo: AddonManagerRepo) -> None: + if hasattr(self, "dependency_installation_dialog"): + self.dependency_installation_dialog.hide() + result = QtWidgets.QMessageBox.critical( + self.dialog, + translate("AddonsInstaller", "Cannot execute pip"), + translate( + "AddonsInstaller", + "Failed to execute pip, which may be missing from your Python installation. Please ensure your system has pip installed and try again. The failed command was: ", + ) + + f"\n\n{command}\n\n" + + translate( + "AddonsInstaller", + f"Continue with installation of {repo.name} anyway?", + ), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + ) + if result == QtWidgets.QMessageBox.Yes: + self.install(repo) + + def dependency_installation_failure(self, short_message: str, details: str) -> None: + if hasattr(self, "dependency_installation_dialog"): + self.dependency_installation_dialog.hide() + FreeCAD.Console.PrintError(details) + QtWidgets.QMessageBox.critical( + self.dialog, + translate("AddonsInstaller", "Package installation failed"), + short_message + + "\n\n" + + translate("AddonsInstaller", "See Report View for detailed failure log."), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + ) + def dependency_dialog_ignore_clicked(self, repo: AddonManagerRepo) -> None: self.install(repo) @@ -1034,8 +1154,7 @@ class CommandAddonManager: if not failed: message = translate( "AddonsInstaller", - "Macro successfully installed. The macro is " - "now available from the Macros dialog.", + "Macro successfully installed. The macro is now available from the Macros dialog.", ) self.on_package_installed(repo, message) else: diff --git a/src/Mod/AddonManager/AddonManagerOptions.ui b/src/Mod/AddonManager/AddonManagerOptions.ui index 33970bb76c..5a37e33911 100644 --- a/src/Mod/AddonManager/AddonManagerOptions.ui +++ b/src/Mod/AddonManager/AddonManagerOptions.ui @@ -6,7 +6,7 @@ 0 0 - 388 + 757 621 @@ -232,6 +232,42 @@ of the line after a space (e.g. https://github.com/FreeCAD/FreeCAD master). + + + + + + Python executable (optional): + + + + + + + + 0 + 0 + + + + + 300 + 0 + + + + The path to the Python executable for package installation with pip. Autodetected if needed and not specified. + + + PythonExecutableForPip + + + Addons + + + + + @@ -273,6 +309,11 @@ of the line after a space (e.g. https://github.com/FreeCAD/FreeCAD master).QLineEdit
Gui/PrefWidgets.h
+ + Gui::PrefFileChooser + QWidget +
Gui/PrefWidgets.h
+
diff --git a/src/Mod/AddonManager/AddonManagerRepo.py b/src/Mod/AddonManager/AddonManagerRepo.py index ac14006772..3c3cdb4bea 100644 --- a/src/Mod/AddonManager/AddonManagerRepo.py +++ b/src/Mod/AddonManager/AddonManagerRepo.py @@ -98,9 +98,10 @@ class AddonManagerRepo: self.description = None from addonmanager_utilities import construct_git_url - self.metadata_url = ( - "" if not self.url else construct_git_url(self, "package.xml") - ) + if "github" in self.url or "gitlab" in self.url or "salsa" in self.url: + self.metadata_url = construct_git_url(self, "package.xml") + else: + self.metadata_url = None self.metadata = None self.icon = None self.cached_icon_filename = "" @@ -113,7 +114,7 @@ class AddonManagerRepo: self.requires: Set[str] = set() self.blocks: Set[str] = set() - # And maintains a list of required and optional Python dependencies + # And maintains a list of required and optional Python dependencies from metadata.txt self.python_requires: Set[str] = set() self.python_optional: Set[str] = set() diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 1e5955f974..8b77d2846d 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -13,6 +13,7 @@ SET(AddonManager_SRCS addonmanager_workers.py AddonManager.ui AddonManagerOptions.ui + ALLOWED_PYTHON_PACKAGES.txt first_run.ui compact_view.py dependency_resolution_dialog.ui diff --git a/src/Mod/AddonManager/addonmanager_metadata.py b/src/Mod/AddonManager/addonmanager_metadata.py index b16149bbda..b6e8eebc46 100644 --- a/src/Mod/AddonManager/addonmanager_metadata.py +++ b/src/Mod/AddonManager/addonmanager_metadata.py @@ -25,7 +25,7 @@ import FreeCAD import os import io import hashlib -from typing import Dict, List +from typing import Dict, List, Set from PySide2 import QtCore, QtNetwork from PySide2.QtCore import QObject @@ -53,6 +53,16 @@ class DownloadWorker(QObject): QtNetwork.QNetworkRequest.RedirectPolicyAttribute, QtNetwork.QNetworkRequest.UserVerifiedRedirectPolicy, ) + self.request.setAttribute( + QtNetwork.QNetworkRequest.CacheSaveControlAttribute, True + ) + self.request.setAttribute( + QtNetwork.QNetworkRequest.CacheLoadControlAttribute, + QtNetwork.QNetworkRequest.PreferCache, + ) + self.request.setAttribute( + QtNetwork.QNetworkRequest.BackgroundRequestAttribute, True + ) self.fetch_task = network_manager.get(self.request) self.fetch_task.finished.connect(self.resolve_fetch) @@ -188,7 +198,7 @@ class MetadataDownloadWorker(DownloadWorker): self.updated.emit(self.repo) -class DependencyDownloadWorker(DownloadWorker): +class MetadataTxtDownloadWorker(DownloadWorker): """A worker for downloading metadata.txt""" def __init__(self, parent, repo: AddonManagerRepo): @@ -239,17 +249,67 @@ class DependencyDownloadWorker(DownloadWorker): elif line.startswith("pylibs="): depspy = line.split("=")[1].split(",") for pl in depspy: - if pl.strip(): - self.repo.python_requires.add(pl.strip()) + dep = pl.strip() + if dep: + self.repo.python_requires.add(dep) FreeCAD.Console.PrintLog( - f"{self.repo.display_name} requires python package '{pl.strip()}'\n" + f"{self.repo.display_name} requires python package '{dep}'\n" ) + elif line.startswith("optionalpylibs="): opspy = line.split("=")[1].split(",") for pl in opspy: - if pl.strip(): - self.repo.python_optional.add(pl.strip()) + dep = pl.strip() + if dep: + self.repo.python_optional.add(dep) FreeCAD.Console.PrintLog( f"{self.repo.display_name} optionally imports python package '{pl.strip()}'\n" ) self.updated.emit(self.repo) + + +class RequirementsTxtDownloadWorker(DownloadWorker): + """A worker for downloading requirements.txt""" + + def __init__(self, parent, repo: AddonManagerRepo): + super().__init__(parent, utils.construct_git_url(repo, "requirements.txt")) + self.repo = repo + + def resolve_fetch(self): + """Called when the data fetch completed, either with an error, or if it found the metadata file""" + + if self.fetch_task.error() == QtNetwork.QNetworkReply.NetworkError.NoError: + FreeCAD.Console.PrintLog( + f"Found a requirements.txt file for {self.repo.name}\n" + ) + new_deps = self.fetch_task.readAll() + self.parse_file(new_deps.data().decode("utf8")) + elif ( + self.fetch_task.error() + == QtNetwork.QNetworkReply.NetworkError.ContentNotFoundError + ): + pass + elif ( + self.fetch_task.error() + == QtNetwork.QNetworkReply.NetworkError.OperationCanceledError + ): + pass + else: + FreeCAD.Console.PrintWarning( + translate("AddonsInstaller", "Failed to connect to URL") + + f":\n{self.url}\n {self.fetch_task.error()}\n" + ) + + def parse_file(self, data: str) -> None: + f = io.StringIO(data) + 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: + self.repo.python_requires.add(package) + self.updated.emit(self.repo) diff --git a/src/Mod/AddonManager/addonmanager_utilities.py b/src/Mod/AddonManager/addonmanager_utilities.py index 48a87aa681..43c09dfc6b 100644 --- a/src/Mod/AddonManager/addonmanager_utilities.py +++ b/src/Mod/AddonManager/addonmanager_utilities.py @@ -197,7 +197,6 @@ def get_zip_url(repo): ): # https://framagit.org/freecad-france/mooc-workbench/-/archive/master/mooc-workbench-master.zip # https://salsa.debian.org/mess42/pyrate/-/archive/master/pyrate-master.zip - reponame = baseurl.strip("/").split("/")[-1] return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip" else: FreeCAD.Console.PrintLog( @@ -237,7 +236,7 @@ def get_readme_url(repo): def get_metadata_url(url): "Returns the location of a package.xml metadata file" - return construct_git_url(repo, "package.xml") + return construct_git_url(url, "package.xml") def get_desc_regex(repo): diff --git a/src/Mod/AddonManager/addonmanager_workers.py b/src/Mod/AddonManager/addonmanager_workers.py index d768be959c..16cc2cd0fc 100644 --- a/src/Mod/AddonManager/addonmanager_workers.py +++ b/src/Mod/AddonManager/addonmanager_workers.py @@ -34,6 +34,7 @@ import io import time import subprocess import sys +import platform from datetime import datetime from typing import Union, List @@ -46,7 +47,11 @@ if FreeCAD.GuiUp: import addonmanager_utilities as utils from addonmanager_macro import Macro -from addonmanager_metadata import MetadataDownloadWorker, DependencyDownloadWorker +from addonmanager_metadata import ( + MetadataDownloadWorker, + MetadataTxtDownloadWorker, + RequirementsTxtDownloadWorker, +) from AddonManagerRepo import AddonManagerRepo translate = FreeCAD.Qt.translate @@ -642,8 +647,7 @@ class FillMacroListWorker(QtCore.QThread): FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", - "There appears to be an issue connecting to the Wiki, " - "therefore FreeCAD cannot retrieve the Wiki macro list at this time", + "Error connecting to the Wiki, FreeCAD cannot retrieve the Wiki macro list at this time", ) + "\n" ) @@ -894,7 +898,7 @@ class ShowWorker(QtCore.QThread): return message = desc if self.repo.update_status == AddonManagerRepo.UpdateStatus.UNCHECKED: - # Addon is installed but we haven't checked it yet, so lets check if it has an update + # Addon is installed but we haven't checked it yet, so let's check if it has an update upd = False # checking for updates if not NOGIT and have_git: @@ -916,49 +920,7 @@ class ShowWorker(QtCore.QThread): AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE ) self.update_status.emit(self.repo) - - if QtCore.QThread.currentThread().isInterruptionRequested(): - return - - # If the Addon is obsolete, let the user know through the Addon UI - if self.repo.name 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.repo.name 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 - + if QtCore.QThread.currentThread().isInterruptionRequested(): return self.readme_updated.emit(message) @@ -1117,8 +1079,7 @@ class InstallWorkbenchWorker(QtCore.QThread): FreeCAD.Console.PrintError( translate( "AddonsInstaller", - "Your version of Python doesn't appear to support ZIP " - "files. Unable to proceed.", + "Your version of Python doesn't appear to support ZIP files. Unable to proceed.", ) + "\n" ) @@ -1166,8 +1127,7 @@ class InstallWorkbenchWorker(QtCore.QThread): FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", - "You are installing a Python 2 workbench on " - "a system running Python 3 - ", + "You are installing a Python 2 workbench on a system running Python 3 - ", ) + str(self.repo.name) + "\n" @@ -1176,11 +1136,10 @@ class InstallWorkbenchWorker(QtCore.QThread): utils.repair_git_repo(self.repo.url, clonedir) repo = git.Git(clonedir) try: - repo.pull() # Refuses to take a progress object? + repo.pull() # Refuses to take a progress object? answer = translate( "AddonsInstaller", - "Workbench successfully updated. " - "Please restart FreeCAD to apply the changes.", + "Workbench successfully updated. Please restart FreeCAD to apply the changes.", ) except Exception as e: answer = ( @@ -1207,8 +1166,7 @@ class InstallWorkbenchWorker(QtCore.QThread): FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", - "You are installing a Python 2 workbench on " - "a system running Python 3 - ", + "You are installing a Python 2 workbench on a system running Python 3 - ", ) + str(self.repo.name) + "\n" @@ -1234,8 +1192,7 @@ class InstallWorkbenchWorker(QtCore.QThread): answer = translate( "AddonsInstaller", - "Workbench successfully installed. Please restart " - "FreeCAD to apply the changes.", + "Workbench successfully installed. Please restart FreeCAD to apply the changes.", ) if self.repo.repo_type == AddonManagerRepo.RepoType.WORKBENCH: @@ -1260,8 +1217,7 @@ class InstallWorkbenchWorker(QtCore.QThread): ).SetString("destination", clonedir) answer += "\n\n" + translate( "AddonsInstaller", - "A macro has been installed and is available " - "under Macro -> Macros menu", + "A macro has been installed and is available under Macro -> Macros menu", ) answer += ":\n" + f + "" self.update_metadata() @@ -1383,7 +1339,12 @@ class InstallWorkbenchWorker(QtCore.QThread): class DependencyInstallationWorker(QtCore.QThread): - """Install dependencies: not yet implemented, DO NOT CALL""" + """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, python_required, python_optional): QtCore.QThread.__init__(self) @@ -1397,50 +1358,113 @@ class DependencyInstallationWorker(QtCore.QThread): if QtCore.QThread.currentThread().isInterruptionRequested(): return worker = InstallWorkbenchWorker(repo) - # Don't bother with a separate thread for this right now, just run it here: - FreeCAD.Console.PrintMessage(f"Pretending to install {repo.name}") - time.sleep(3) - continue - # worker.run() + 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) if self.python_required or self.python_optional: - # See if we have pip available: - try: - subprocess.check_call(["pip", "--version"]) - except subprocess.CalledProcessError as e: - FreeCAD.Console.PrintError( - translate( - "AddonsInstaller", "Failed to execute pip. Returned error was:" - ) - + f"\n{e.output}" - ) + + # Find Python. In preference order + # A) The value of the PythonExecutableForPip user preference + # B) The executable located in the same bin directory as FreeCAD and called "python3" + # C) The executable located in the same bin directory as FreeCAD and called "python" + # D) The result of an shutil search for your system's "python3" executable + # E) The result of an shutil search for your system's "python" executable + prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + python_exe = prefs.GetString("PythonExecutableForPip", "Not set") + if ( + not python_exe + or python_exe == "Not set" + or not os.path.exists(python_exe) + ): + fc_dir = FreeCAD.getHomePath() + python_exe = os.path.join(fc_dir, "bin", "python3") + if "Windows" in platform.system(): + python_exe += ".exe" + + if not python_exe or not os.path.exists(python_exe): + python_exe = os.path.join(fc_dir, "bin", "python") + if "Windows" in platform.system(): + python_exe += ".exe" + + if not python_exe or not os.path.exists(python_exe): + python_exe = shutil.which("python3") + + if not python_exe or not os.path.exists(python_exe): + python_exe = shutil.which("python") + + if not python_exe or not os.path.exists(python_exe): + self.no_python_exe.emit() return + prefs.SetString("PythonExecutableForPip", python_exe) + + pip_failed = False + try: + proc = subprocess.run( + [python_exe, "-m", "pip", "--version"], stdout=subprocess.PIPE + ) + except subprocess.CalledProcessError as e: + pip_failed = True + if proc.returncode != 0: + pip_failed = True + if pip_failed: + self.no_pip.emit(f"{python_exe} -m pip --version") + return + FreeCAD.Console.PrintMessage(proc.stdout) + FreeCAD.Console.PrintWarning(proc.stderr) + result = proc.stdout + FreeCAD.Console.PrintMessage(result.decode()) + vendor_path = os.path.join( + FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages" + ) + if not os.path.exists(vendor_path): + os.makedirs(vendor_path) + for pymod in self.python_required: if QtCore.QThread.currentThread().isInterruptionRequested(): return - FreeCAD.Console.PrintMessage(f"Pretending to install {pymod}") - time.sleep(3) - continue - # subprocess.check_call(["pip", "install", pymod]) + proc = subprocess.run( + [python_exe, "-m", "pip", "install", "--target", vendor_path, pymod], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + FreeCAD.Console.PrintMessage(proc.stdout.decode()) + if proc.returncode != 0: + self.emit.failure( + translate( + "AddonsInstaller", + f"Installation of Python package {pymod} failed", + ), + proc.stderr, + ) + return for pymod in self.python_optional: if QtCore.QThread.currentThread().isInterruptionRequested(): return - try: - FreeCAD.Console.PrintMessage(f"Pretending to install {pymod}") - time.sleep(3) - continue - # subprocess.check_call([sys.executable, "-m", "pip", "install", pymod]) - except subprocess.CalledProcessError as e: - FreeCAD.Console.PrintError( + proc = subprocess.run( + [python_exe, "-m", "pip", "install", "--target", vendor_path, pymod], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + FreeCAD.Console.PrintMessage(proc.stdout.decode()) + if proc.returncode != 0: + self.emit.failure( translate( "AddonsInstaller", - "Failed to install option dependency {pymod}. Returned error was:", - ) - + f"\n{e.output}" + f"Installation of Python package {pymod} failed", + ), + proc.stderr, ) - # This is not fatal, we can just continue without it + return + + self.success.emit() class CheckSingleWorker(QtCore.QThread): @@ -1531,6 +1555,16 @@ class UpdateMetadataCacheWorker(QtCore.QThread): ) # Must be created on this thread download_queue.finished.connect(self.on_finished) + # Prevent strange internal Qt errors about cache setup by pre-emptively setting + # up a cache. The error this fixes is: + # "caching was enabled after some bytes had been written" + qnam_cache = os.path.join( + FreeCAD.getUserCachePath(), "AddonManager", "QNAM_CACHE" + ) + diskCache = QtNetwork.QNetworkDiskCache() + diskCache.setCacheDirectory(qnam_cache) + download_queue.setCache(diskCache) + self.downloaders = [] for repo in self.repos: if repo.metadata_url: @@ -1541,7 +1575,13 @@ class UpdateMetadataCacheWorker(QtCore.QThread): self.downloaders.append(downloader) # metadata.txt - downloader = DependencyDownloadWorker(None, repo) + downloader = MetadataTxtDownloadWorker(None, repo) + downloader.start_fetch(download_queue) + downloader.updated.connect(self.on_updated) + self.downloaders.append(downloader) + + # requirements.txt + downloader = RequirementsTxtDownloadWorker(None, repo) downloader.start_fetch(download_queue) downloader.updated.connect(self.on_updated) self.downloaders.append(downloader) @@ -1593,8 +1633,13 @@ class UpdateMetadataCacheWorker(QtCore.QThread): self.package_updated.emit(repo) def send_ui_update(self): - self.progress_made.emit( - self.num_downloads_completed.get(), self.num_downloads_required + completed = self.num_downloads_completed.get() + required = self.num_downloads_required + percentage = int(100 * completed / required) + self.progress_made.emit(completed, required) + self.status_message.emit( + translate("AddonsInstaller", "Retrieving package metadata...") + + f" {completed} / {required} ({percentage}%)" ) def terminate_all(self): diff --git a/src/Mod/AddonManager/dependency_resolution_dialog.ui b/src/Mod/AddonManager/dependency_resolution_dialog.ui index 45803721c0..28cd16b365 100644 --- a/src/Mod/AddonManager/dependency_resolution_dialog.ui +++ b/src/Mod/AddonManager/dependency_resolution_dialog.ui @@ -10,7 +10,7 @@ 0 0 455 - 200 + 260
@@ -20,7 +20,9 @@ - This Addon has the following required and optional dependencies. You must install them before this Addon can be used. + This Addon has the following required and optional dependencies. You must install them before this Addon can be used. + +Do you want the Addon Manager to install them automatically? Choose "Ignore" to install the Addon without installing the dependencies. true @@ -73,7 +75,7 @@ Qt::Horizontal - QDialogButtonBox::Cancel|QDialogButtonBox::Ignore + QDialogButtonBox::Cancel|QDialogButtonBox::Ignore|QDialogButtonBox::Yes