@@ -273,6 +309,11 @@ of the line after a space (e.g. https://github.com/FreeCAD/FreeCAD master).QLineEdit
+
+ Gui::PrefFileChooser
+ QWidget
+
+
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