Skip to content

Commit 2c2f120

Browse files
committed
Addon Manager: Fix pip on Snap and AppImage
Also fixes various issues with dependency updater
1 parent 7e08681 commit 2c2f120

File tree

3 files changed

+186
-62
lines changed

3 files changed

+186
-62
lines changed

src/Mod/AddonManager/addonmanager_dependency_installer.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@
2727
import subprocess
2828
from typing import List
2929

30-
from freecad.utils import get_python_exe
31-
3230
import addonmanager_freecad_interface as fci
3331
from addonmanager_pyside_interface import QObject, Signal, is_interruption_requested
3432

@@ -46,7 +44,7 @@ class DependencyInstaller(QObject):
4644
no_python_exe = Signal()
4745
no_pip = Signal(str) # Attempted command
4846
failure = Signal(str, str) # Short message, detailed message
49-
finished = Signal()
47+
finished = Signal(bool) # True if everything completed normally, otherwise false
5048

5149
def __init__(
5250
self,
@@ -65,17 +63,25 @@ def __init__(
6563
self.python_requires = python_requires
6664
self.python_optional = python_optional
6765
self.location = location
66+
self.required_succeeded = False
67+
self.finished_successfully = False
6868

6969
def run(self):
7070
"""Normally not called directly, but rather connected to the worker thread's started
7171
signal."""
72-
if self._verify_pip():
72+
try:
7373
if self.python_requires or self.python_optional:
74-
if not is_interruption_requested():
75-
self._install_python_packages()
76-
if not is_interruption_requested():
77-
self._install_addons()
78-
self.finished.emit()
74+
if self._verify_pip():
75+
if not is_interruption_requested():
76+
self._install_python_packages()
77+
else:
78+
self.required_succeeded = True
79+
if not is_interruption_requested():
80+
self._install_addons()
81+
self.finished_successfully = self.required_succeeded
82+
except RuntimeError:
83+
pass
84+
self.finished.emit(self.finished_successfully)
7985

8086
def _install_python_packages(self):
8187
"""Install required and optional Python dependencies using pip."""
@@ -87,20 +93,20 @@ def _install_python_packages(self):
8793
if not os.path.exists(vendor_path):
8894
os.makedirs(vendor_path)
8995

90-
self._install_required(vendor_path)
96+
self.required_succeeded = self._install_required(vendor_path)
9197
self._install_optional(vendor_path)
9298

9399
def _verify_pip(self) -> bool:
94100
"""Ensure that pip is working -- returns True if it is, or False if not. Also emits the
95101
no_pip signal if pip cannot execute."""
96-
python_exe = self._get_python()
97-
if not python_exe:
98-
return False
99102
try:
100103
proc = self._run_pip(["--version"])
101104
fci.Console.PrintMessage(proc.stdout + "\n")
105+
if proc.returncode != 0:
106+
return False
102107
except subprocess.CalledProcessError:
103-
self.no_pip.emit(f"{python_exe} -m pip --version")
108+
call = utils.create_pip_call([])
109+
self.no_pip.emit(" ".join(call))
104110
return False
105111
return True
106112

@@ -115,7 +121,6 @@ def _install_required(self, vendor_path: str) -> bool:
115121
proc = self._run_pip(
116122
[
117123
"install",
118-
"--disable-pip-version-check",
119124
"--target",
120125
vendor_path,
121126
pymod,
@@ -144,7 +149,6 @@ def _install_optional(self, vendor_path: str):
144149
proc = self._run_pip(
145150
[
146151
"install",
147-
"--disable-pip-version-check",
148152
"--target",
149153
vendor_path,
150154
pymod,
@@ -160,22 +164,13 @@ def _install_optional(self, vendor_path: str):
160164
)
161165

162166
def _run_pip(self, args):
163-
python_exe = self._get_python()
164-
final_args = [python_exe, "-m", "pip"]
165-
final_args.extend(args)
167+
final_args = utils.create_pip_call(args)
166168
return self._subprocess_wrapper(final_args)
167169

168170
@staticmethod
169171
def _subprocess_wrapper(args) -> subprocess.CompletedProcess:
170172
"""Wrap subprocess call so test code can mock it."""
171-
return utils.run_interruptable_subprocess(args)
172-
173-
def _get_python(self) -> str:
174-
"""Wrap Python access so test code can mock it."""
175-
python_exe = get_python_exe()
176-
if not python_exe:
177-
self.no_python_exe.emit()
178-
return python_exe
173+
return utils.run_interruptable_subprocess(args, timeout_secs=120)
179174

180175
def _install_addons(self):
181176
for addon in self.addons:

src/Mod/AddonManager/addonmanager_utilities.py

Lines changed: 148 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,42 +22,94 @@
2222
# * *
2323
# ***************************************************************************
2424

25-
""" Utilities to work across different platforms, providers and python versions """
25+
"""Utilities to work across different platforms, providers and python versions"""
2626

27+
# pylint: disable=deprecated-module, ungrouped-imports
28+
29+
from datetime import datetime
30+
from typing import Optional, Any, List
2731
import os
2832
import platform
2933
import shutil
3034
import stat
3135
import subprocess
36+
import time
3237
import re
3338
import ctypes
34-
from typing import Optional, Any
3539

3640
from urllib.parse import urlparse
3741

3842
try:
3943
from PySide import QtCore, QtGui, QtWidgets
4044
except ImportError:
41-
QtCore = None
42-
QtWidgets = None
43-
QtGui = None
45+
try:
46+
from PySide6 import QtCore, QtGui, QtWidgets
47+
except ImportError:
48+
from PySide2 import QtCore, QtGui, QtWidgets
4449

4550
import addonmanager_freecad_interface as fci
4651

52+
try:
53+
from freecad.utils import get_python_exe
54+
except ImportError:
55+
56+
def get_python_exe():
57+
"""Use shutil.which to find python executable"""
58+
return shutil.which("python")
59+
60+
4761
if fci.FreeCADGui:
4862

4963
# If the GUI is up, we can use the NetworkManager to handle our downloads. If there is no event
5064
# loop running this is not possible, so fall back to requests (if available), or the native
5165
# Python urllib.request (if requests is not available).
5266
import NetworkManager # Requires an event loop, so is only available with the GUI
67+
68+
requests = None
69+
ssl = None
70+
urllib = None
5371
else:
72+
NetworkManager = None
5473
try:
5574
import requests
75+
76+
ssl = None
77+
urllib = None
5678
except ImportError:
5779
requests = None
5880
import urllib.request
5981
import ssl
6082

83+
if fci.FreeCADGui:
84+
loadUi = fci.loadUi
85+
else:
86+
has_loader = False
87+
try:
88+
from PySide6.QtUiTools import QUiLoader
89+
90+
has_loader = True
91+
except ImportError:
92+
try:
93+
from PySide2.QtUiTools import QUiLoader
94+
95+
has_loader = True
96+
except ImportError:
97+
98+
def loadUi(ui_file: str):
99+
"""If there are no available versions of QtUiTools, then raise an error if this
100+
method is used."""
101+
raise RuntimeError("Cannot use QUiLoader without PySide or FreeCAD")
102+
103+
if has_loader:
104+
105+
def loadUi(ui_file: str) -> QtWidgets.QWidget:
106+
"""Load a Qt UI from an on-disk file."""
107+
q_ui_file = QtCore.QFile(ui_file)
108+
q_ui_file.open(QtCore.QFile.OpenModeFlag.ReadOnly)
109+
loader = QUiLoader()
110+
return loader.load(ui_file)
111+
112+
61113
# @package AddonManager_utilities
62114
# \ingroup ADDONMANAGER
63115
# \brief Utilities to work across different platforms, providers and python versions
@@ -97,10 +149,13 @@ def symlink(source, link_name):
97149

98150

99151
def rmdir(path: str) -> bool:
152+
"""Remove a directory or symlink, even if it is read-only."""
100153
try:
101154
if os.path.islink(path):
102155
os.unlink(path) # Remove symlink
103156
else:
157+
# NOTE: the onerror argument was deprecated in Python 3.12, replaced by onexc -- replace
158+
# when earlier versions are no longer supported.
104159
shutil.rmtree(path, onerror=remove_readonly)
105160
except (WindowsError, PermissionError, OSError):
106161
return False
@@ -175,7 +230,7 @@ def get_zip_url(repo):
175230

176231

177232
def recognized_git_location(repo) -> bool:
178-
"""Returns whether this repo is based at a known git repo location: works with github, gitlab,
233+
"""Returns whether this repo is based at a known git repo location: works with GitHub, gitlab,
179234
framagit, and salsa.debian.org"""
180235

181236
parsed_url = urlparse(repo.url)
@@ -357,7 +412,7 @@ def is_float(element: Any) -> bool:
357412

358413

359414
def get_pip_target_directory():
360-
# Get the default location to install new pip packages
415+
"""Get the default location to install new pip packages"""
361416
major, minor, _ = platform.python_version_tuple()
362417
vendor_path = os.path.join(
363418
fci.DataPaths().mod_dir, "..", "AdditionalPythonPackages", f"py{major}{minor}"
@@ -379,7 +434,12 @@ def blocking_get(url: str, method=None) -> bytes:
379434
succeeded, or an empty string if it failed, or returned no data. The method argument is
380435
provided mainly for testing purposes."""
381436
p = b""
382-
if fci.FreeCADGui and method is None or method == "networkmanager":
437+
if (
438+
fci.FreeCADGui
439+
and method is None
440+
or method == "networkmanager"
441+
and NetworkManager is not None
442+
):
383443
NetworkManager.InitializeNetworkManager()
384444
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url, 10000) # 10 second timeout
385445
if p:
@@ -398,7 +458,7 @@ def blocking_get(url: str, method=None) -> bytes:
398458
return p
399459

400460

401-
def run_interruptable_subprocess(args) -> subprocess.CompletedProcess:
461+
def run_interruptable_subprocess(args, timeout_secs: int = 10) -> subprocess.CompletedProcess:
402462
"""Wrap subprocess call so it can be interrupted gracefully."""
403463
creation_flags = 0
404464
if hasattr(subprocess, "CREATE_NO_WINDOW"):
@@ -418,22 +478,63 @@ def run_interruptable_subprocess(args) -> subprocess.CompletedProcess:
418478
stdout = ""
419479
stderr = ""
420480
return_code = None
481+
start_time = time.time()
421482
while return_code is None:
422483
try:
423-
stdout, stderr = p.communicate(timeout=10)
484+
# one second timeout allows interrupting the run once per second
485+
stdout, stderr = p.communicate(timeout=1)
424486
return_code = p.returncode
425-
except subprocess.TimeoutExpired:
426-
if QtCore.QThread.currentThread().isInterruptionRequested():
487+
except subprocess.TimeoutExpired as timeout_exception:
488+
if (
489+
hasattr(QtCore, "QThread")
490+
and QtCore.QThread.currentThread().isInterruptionRequested()
491+
):
492+
p.kill()
493+
raise ProcessInterrupted() from timeout_exception
494+
if time.time() - start_time >= timeout_secs: # The real timeout
427495
p.kill()
428-
raise ProcessInterrupted()
496+
stdout, stderr = p.communicate()
497+
return_code = -1
429498
if return_code is None or return_code != 0:
430499
raise subprocess.CalledProcessError(
431500
return_code if return_code is not None else -1, args, stdout, stderr
432501
)
433502
return subprocess.CompletedProcess(args, return_code, stdout, stderr)
434503

435504

505+
def process_date_string_to_python_datetime(date_string: str) -> datetime:
506+
"""For modern macros the expected date format is ISO 8601, YYYY-MM-DD. For older macros this
507+
standard was not always used, and various orderings and separators were used. This function
508+
tries to match the majority of those older macros. Commonly-used separators are periods,
509+
slashes, and dashes."""
510+
511+
def raise_error(bad_string: str, root_cause: Exception = None):
512+
raise ValueError(
513+
f"Unrecognized date string '{bad_string}' (expected YYYY-MM-DD)"
514+
) from root_cause
515+
516+
split_result = re.split(r"[ ./-]+", date_string.strip())
517+
if len(split_result) != 3:
518+
raise_error(date_string)
519+
520+
try:
521+
split_result = [int(x) for x in split_result]
522+
# The earliest possible year an addon can be created or edited is 2001:
523+
if split_result[0] > 2000:
524+
return datetime(split_result[0], split_result[1], split_result[2])
525+
if split_result[2] > 2000:
526+
# Generally speaking it's not possible to distinguish between DD-MM and MM-DD, so try
527+
# the first, and only if that fails try the second
528+
if split_result[1] <= 12:
529+
return datetime(split_result[2], split_result[1], split_result[0])
530+
return datetime(split_result[2], split_result[0], split_result[1])
531+
raise ValueError(f"Invalid year in date string '{date_string}'")
532+
except ValueError as exception:
533+
raise_error(date_string, exception)
534+
535+
436536
def get_main_am_window():
537+
"""Find the Addon Manager's main window in the Qt widget hierarchy."""
437538
windows = QtWidgets.QApplication.topLevelWidgets()
438539
for widget in windows:
439540
if widget.objectName() == "AddonManager_Main_Window":
@@ -449,3 +550,37 @@ def get_main_am_window():
449550
return widget.centralWidget()
450551
# Why is this code even getting called?
451552
return None
553+
554+
555+
def remove_target_option(args: List[str]) -> List[str]:
556+
# The Snap pip automatically adds the --user option, which is not compatible with the
557+
# --target option, so we have to remove --target and its argument, if present
558+
try:
559+
index = args.index("--target")
560+
del args[index : index + 2] # The --target option and its argument
561+
except ValueError:
562+
pass
563+
return args
564+
565+
566+
def create_pip_call(args: List[str]) -> List[str]:
567+
"""Choose the correct mechanism for calling pip on each platform. It currently supports
568+
either `python -m pip` (most environments) or `pip` (Snap packages). Returns a list
569+
of arguments suitable for passing directly to subprocess.Popen and related functions."""
570+
snap_package = os.getenv("SNAP_REVISION")
571+
appimage = os.getenv("APPIMAGE")
572+
if snap_package:
573+
args = remove_target_option(args)
574+
call_args = ["pip", "--disable-pip-version-check"]
575+
call_args.extend(args)
576+
elif appimage:
577+
python_exe = fci.DataPaths.home_dir + "bin/python"
578+
call_args = [python_exe, "-m", "pip", "--disable-pip-version-check"]
579+
call_args.extend(args)
580+
else:
581+
python_exe = get_python_exe()
582+
if not python_exe:
583+
raise RuntimeError("Could not locate Python executable on this system")
584+
call_args = [python_exe, "-m", "pip", "--disable-pip-version-check"]
585+
call_args.extend(args)
586+
return call_args

0 commit comments

Comments
 (0)