Addon Manager: PythonDeps Cleanup and Testing
This commit is contained in:
committed by
Yorik van Havre
parent
795af3f8d0
commit
da473a54f0
@@ -59,7 +59,7 @@ from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar
|
||||
from Widgets.addonmanager_widget_progress_bar import Progress
|
||||
from package_list import PackageListItemModel
|
||||
from Addon import Addon
|
||||
from manage_python_dependencies import (
|
||||
from addonmanager_python_deps_gui import (
|
||||
PythonPackageManager,
|
||||
)
|
||||
from addonmanager_cache import local_cache_needs_update
|
||||
|
||||
@@ -194,26 +194,33 @@ class TestUtilities(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_run_interruptable_subprocess_timeout_ten_times(self, mock_popen):
|
||||
"""Ten times is the limit for an error to be raised (e.g. the real timeout is ten seconds)"""
|
||||
def test_run_interruptable_subprocess_timeout_exceeded(self, mock_popen):
|
||||
"""Exceeding the set timeout gives a CalledProcessError exception"""
|
||||
|
||||
def raises_first_ten_times(timeout=0):
|
||||
raises_first_ten_times.counter += 1
|
||||
if not raises_first_ten_times.mock_access.kill.called:
|
||||
if raises_first_ten_times.counter <= 10:
|
||||
raise subprocess.TimeoutExpired("Test", timeout)
|
||||
return "Mocked stdout", "Mocked stderr"
|
||||
def raises_one_time(timeout=0):
|
||||
if not raises_one_time.raised:
|
||||
raises_one_time.raised = True
|
||||
raise subprocess.TimeoutExpired("Test", timeout)
|
||||
return "Mocked stdout", None
|
||||
|
||||
raises_first_ten_times.counter = 0
|
||||
raises_one_time.raised = False
|
||||
|
||||
def fake_time():
|
||||
"""Time that advances by one second every time it is called"""
|
||||
fake_time.time += 1.0
|
||||
return fake_time.time
|
||||
|
||||
fake_time.time = 0.0
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.communicate = raises_first_ten_times
|
||||
raises_first_ten_times.mock_access = mock_process
|
||||
mock_process.communicate = raises_one_time
|
||||
raises_one_time.mock_access = mock_process
|
||||
mock_process.returncode = None
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
run_interruptable_subprocess(["arg0", "arg1"], 10)
|
||||
with patch("time.time", fake_time):
|
||||
run_interruptable_subprocess(["arg0", "arg1"], 0.1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -21,7 +21,17 @@
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
from PySide import QtCore, QtWidgets
|
||||
import sys
|
||||
|
||||
try:
|
||||
from PySide import QtCore, QtWidgets
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
except ImportError:
|
||||
from PySide2 import QtCore, QtWidgets
|
||||
|
||||
sys.path.append("../../") # For running in standalone mode during testing
|
||||
|
||||
from AddonManagerTest.app.mocks import SignalCatcher
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
try:
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
except ImportError:
|
||||
from PySide2 import QtCore, QtWidgets
|
||||
|
||||
sys.path.append(
|
||||
"../.."
|
||||
) # So that when run standalone, the Addon Manager classes imported below are available
|
||||
|
||||
from addonmanager_python_deps_gui import (
|
||||
PythonPackageManager,
|
||||
call_pip,
|
||||
PipFailed,
|
||||
python_package_updates_are_available,
|
||||
parse_pip_list_output,
|
||||
)
|
||||
from AddonManagerTest.gui.gui_mocks import DialogInteractor, DialogWatcher
|
||||
|
||||
|
||||
class TestPythonPackageManager(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.manager = PythonPackageManager([])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if self.manager.worker_thread:
|
||||
self.manager.worker_thread.terminate()
|
||||
self.manager.worker_thread.wait()
|
||||
|
||||
@patch("addonmanager_python_deps_gui.PythonPackageManager._create_list_from_pip")
|
||||
def test_show(self, patched_create_list_from_pip):
|
||||
dialog_watcher = DialogWatcher("Manage Python Dependencies")
|
||||
self.manager.show()
|
||||
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
|
||||
|
||||
|
||||
class TestPythonDepsStandaloneFunctions(unittest.TestCase):
|
||||
|
||||
@patch("addonmanager_utilities.run_interruptable_subprocess")
|
||||
def test_call_pip(self, mock_run_subprocess: MagicMock):
|
||||
call_pip(["arg1", "arg2", "arg3"])
|
||||
mock_run_subprocess.assert_called()
|
||||
args = mock_run_subprocess.call_args[0][0]
|
||||
self.assertTrue("pip" in args)
|
||||
|
||||
@patch("addonmanager_python_deps_gui.get_python_exe")
|
||||
def test_call_pip_no_python(self, mock_get_python_exe: MagicMock):
|
||||
mock_get_python_exe.return_value = None
|
||||
with self.assertRaises(PipFailed):
|
||||
call_pip(["arg1", "arg2", "arg3"])
|
||||
|
||||
@patch("addonmanager_utilities.run_interruptable_subprocess")
|
||||
def test_call_pip_exception_raised(self, mock_run_subprocess: MagicMock):
|
||||
mock_run_subprocess.side_effect = subprocess.CalledProcessError(
|
||||
-1, "dummy_command", "Fake contents of stdout", "Fake contents of stderr"
|
||||
)
|
||||
with self.assertRaises(PipFailed):
|
||||
call_pip(["arg1", "arg2", "arg3"])
|
||||
|
||||
@patch("addonmanager_utilities.run_interruptable_subprocess")
|
||||
def test_call_pip_splits_results(self, mock_run_subprocess: MagicMock):
|
||||
result_mock = MagicMock()
|
||||
result_mock.stdout = "\n".join(["Value 1", "Value 2", "Value 3"])
|
||||
mock_run_subprocess.return_value = result_mock
|
||||
result = call_pip(["arg1", "arg2", "arg3"])
|
||||
self.assertEqual(len(result), 3)
|
||||
|
||||
@patch("addonmanager_python_deps_gui.call_pip")
|
||||
def test_python_package_updates_are_available(self, mock_call_pip: MagicMock):
|
||||
mock_call_pip.return_value = "Some result"
|
||||
result = python_package_updates_are_available()
|
||||
self.assertEqual(result, True)
|
||||
|
||||
@patch("addonmanager_python_deps_gui.call_pip")
|
||||
def test_python_package_updates_are_available_no_results(self, mock_call_pip: MagicMock):
|
||||
"""An empty string is an indication that no updates are available"""
|
||||
mock_call_pip.return_value = ""
|
||||
result = python_package_updates_are_available()
|
||||
self.assertEqual(result, False)
|
||||
|
||||
@patch("addonmanager_python_deps_gui.call_pip")
|
||||
def test_python_package_updates_are_available_pip_failure(self, mock_call_pip: MagicMock):
|
||||
logging.disable()
|
||||
mock_call_pip.side_effect = PipFailed("Test error message")
|
||||
logging.disable() # A logging error message is expected here, but not desirable during test runs
|
||||
result = python_package_updates_are_available()
|
||||
self.assertEqual(result, False)
|
||||
logging.disable(logging.NOTSET)
|
||||
|
||||
def test_parse_pip_list_output_no_input(self):
|
||||
results_dict = parse_pip_list_output("", "")
|
||||
self.assertEqual(len(results_dict), 0)
|
||||
|
||||
def test_parse_pip_list_output_all_packages_no_updates(self):
|
||||
results_dict = parse_pip_list_output(
|
||||
["Package Version", "---------- -------", "gitdb 4.0.9", "setuptools 41.2.0"],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(results_dict), 2)
|
||||
self.assertTrue("gitdb" in results_dict)
|
||||
self.assertTrue("setuptools" in results_dict)
|
||||
self.assertEqual(results_dict["gitdb"]["installed_version"], "4.0.9")
|
||||
self.assertEqual(results_dict["gitdb"]["available_version"], "")
|
||||
self.assertEqual(results_dict["setuptools"]["installed_version"], "41.2.0")
|
||||
self.assertEqual(results_dict["setuptools"]["available_version"], "")
|
||||
|
||||
def test_parse_pip_list_output_all_packages_with_updates(self):
|
||||
results_dict = parse_pip_list_output(
|
||||
[],
|
||||
[
|
||||
"Package Version Latest Type",
|
||||
"---------- ------- ------ -----",
|
||||
"pip 21.0.1 22.1.2 wheel",
|
||||
"setuptools 41.2.0 63.2.0 wheel",
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(results_dict), 2)
|
||||
self.assertTrue("pip" in results_dict)
|
||||
self.assertTrue("setuptools" in results_dict)
|
||||
self.assertEqual(results_dict["pip"]["installed_version"], "21.0.1")
|
||||
self.assertEqual(results_dict["pip"]["available_version"], "22.1.2")
|
||||
self.assertEqual(results_dict["setuptools"]["installed_version"], "41.2.0")
|
||||
self.assertEqual(results_dict["setuptools"]["available_version"], "63.2.0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
QtCore.QTimer.singleShot(0, unittest.main)
|
||||
app.exec()
|
||||
@@ -4,11 +4,20 @@ IF (BUILD_GUI)
|
||||
ENDIF (BUILD_GUI)
|
||||
|
||||
SET(AddonManager_SRCS
|
||||
add_toolbar_button_dialog.ui
|
||||
ALLOWED_PYTHON_PACKAGES.txt
|
||||
Addon.py
|
||||
AddonStats.py
|
||||
AddonManager.py
|
||||
AddonManager.ui
|
||||
AddonManagerOptions.py
|
||||
AddonManagerOptions.ui
|
||||
AddonManagerOptions_AddCustomRepository.ui
|
||||
AddonStats.py
|
||||
Init.py
|
||||
InitGui.py
|
||||
NetworkManager.py
|
||||
PythonDependencyUpdateDialog.ui
|
||||
TestAddonManagerApp.py
|
||||
add_toolbar_button_dialog.ui
|
||||
addonmanager_cache.py
|
||||
addonmanager_connection_checker.py
|
||||
addonmanager_dependency_installer.py
|
||||
@@ -17,8 +26,8 @@ SET(AddonManager_SRCS
|
||||
addonmanager_devmode_license_selector.py
|
||||
addonmanager_devmode_licenses_table.py
|
||||
addonmanager_devmode_metadata_checker.py
|
||||
addonmanager_devmode_person_editor.py
|
||||
addonmanager_devmode_people_table.py
|
||||
addonmanager_devmode_person_editor.py
|
||||
addonmanager_devmode_predictor.py
|
||||
addonmanager_devmode_validators.py
|
||||
addonmanager_firstrun.py
|
||||
@@ -33,18 +42,15 @@ SET(AddonManager_SRCS
|
||||
addonmanager_package_details_controller.py
|
||||
addonmanager_preferences_defaults.json
|
||||
addonmanager_pyside_interface.py
|
||||
addonmanager_python_deps_gui.py
|
||||
addonmanager_readme_controller.py
|
||||
addonmanager_update_all_gui.py
|
||||
addonmanager_uninstaller.py
|
||||
addonmanager_uninstaller_gui.py
|
||||
addonmanager_update_all_gui.py
|
||||
addonmanager_utilities.py
|
||||
addonmanager_workers_installation.py
|
||||
addonmanager_workers_startup.py
|
||||
addonmanager_workers_utility.py
|
||||
AddonManagerOptions.ui
|
||||
AddonManagerOptions_AddCustomRepository.ui
|
||||
AddonManagerOptions.py
|
||||
ALLOWED_PYTHON_PACKAGES.txt
|
||||
change_branch.py
|
||||
change_branch.ui
|
||||
compact_view.py
|
||||
@@ -65,16 +71,10 @@ SET(AddonManager_SRCS
|
||||
developer_mode_tags.ui
|
||||
expanded_view.py
|
||||
first_run.ui
|
||||
Init.py
|
||||
InitGui.py
|
||||
install_to_toolbar.py
|
||||
loading.html
|
||||
manage_python_dependencies.py
|
||||
NetworkManager.py
|
||||
package_list.py
|
||||
PythonDependencyUpdateDialog.ui
|
||||
select_toolbar_dialog.ui
|
||||
TestAddonManagerApp.py
|
||||
update_all.ui
|
||||
)
|
||||
IF (BUILD_GUI)
|
||||
|
||||
@@ -143,6 +143,7 @@ class DataPaths:
|
||||
all paths are temp directories. If not run within FreeCAD, all directories are
|
||||
deleted when the last reference to this class is deleted."""
|
||||
|
||||
data_dir = None
|
||||
mod_dir = None
|
||||
macro_dir = None
|
||||
cache_dir = None
|
||||
@@ -152,6 +153,8 @@ class DataPaths:
|
||||
|
||||
def __init__(self):
|
||||
if FreeCAD:
|
||||
if self.data_dir is None:
|
||||
self.data_dir = getUserAppDataDir()
|
||||
if self.mod_dir is None:
|
||||
self.mod_dir = os.path.join(getUserAppDataDir(), "Mod")
|
||||
if self.cache_dir is None:
|
||||
@@ -162,6 +165,8 @@ class DataPaths:
|
||||
self.home_dir = FreeCAD.getHomePath()
|
||||
else:
|
||||
self.reference_count += 1
|
||||
if self.data_dir is None:
|
||||
self.data_dir = tempfile.mkdtemp()
|
||||
if self.mod_dir is None:
|
||||
self.mod_dir = tempfile.mkdtemp()
|
||||
if self.cache_dir is None:
|
||||
@@ -174,9 +179,13 @@ class DataPaths:
|
||||
def __del__(self):
|
||||
self.reference_count -= 1
|
||||
if not FreeCAD and self.reference_count <= 0:
|
||||
os.rmdir(self.mod_dir)
|
||||
os.rmdir(self.cache_dir)
|
||||
os.rmdir(self.macro_dir)
|
||||
paths = [self.data_dir, self.mod_dir, self.cache_dir, self.macro_dir, self.mod_dir]
|
||||
for path in paths:
|
||||
try:
|
||||
os.rmdir(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.data_dir = None
|
||||
self.mod_dir = None
|
||||
self.cache_dir = None
|
||||
self.macro_dir = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2022-2023 FreeCAD Project Association *
|
||||
# * Copyright (c) 2022-2024 FreeCAD Project Association AISBL *
|
||||
# * *
|
||||
# * This file is part of FreeCAD. *
|
||||
# * *
|
||||
@@ -32,18 +32,33 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import partial
|
||||
from typing import Dict, List, Tuple
|
||||
from typing import Dict, Iterable, List, Tuple, TypedDict
|
||||
|
||||
import addonmanager_freecad_interface as fci
|
||||
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
from freecad.utils import get_python_exe
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
try:
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
from PySide.QtUiTools import QUiLoader
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
except ImportError:
|
||||
from PySide2 import QtCore, QtGui, QtWidgets
|
||||
from PySide2.QtUiTools import QUiLoader
|
||||
|
||||
try:
|
||||
from freecad.utils import get_python_exe
|
||||
except ImportError:
|
||||
|
||||
def get_python_exe():
|
||||
return shutil.which("python")
|
||||
|
||||
|
||||
import addonmanager_utilities as utils
|
||||
|
||||
translate = FreeCAD.Qt.translate
|
||||
translate = fci.translate
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
@@ -65,30 +80,30 @@ class CheckForPythonPackageUpdatesWorker(QtCore.QThread):
|
||||
function in a parent thread. emits a python_package_updates_available signal if updates
|
||||
are available for any of the installed Python packages."""
|
||||
|
||||
if check_for_python_package_updates():
|
||||
if python_package_updates_are_available():
|
||||
self.python_package_updates_available.emit()
|
||||
|
||||
|
||||
def check_for_python_package_updates() -> bool:
|
||||
def python_package_updates_are_available() -> bool:
|
||||
"""Returns True if any of the Python packages installed into the AdditionalPythonPackages
|
||||
directory have updates available, or False if they are all up-to-date."""
|
||||
|
||||
vendor_path = os.path.join(FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages")
|
||||
vendor_path = os.path.join(fci.DataPaths().data_dir, "AdditionalPythonPackages")
|
||||
package_counter = 0
|
||||
try:
|
||||
outdated_packages_stdout = call_pip(["list", "-o", "--path", vendor_path])
|
||||
except PipFailed as e:
|
||||
FreeCAD.Console.PrintError(str(e) + "\n")
|
||||
fci.Console.PrintError(str(e) + "\n")
|
||||
return False
|
||||
FreeCAD.Console.PrintLog("Output from pip -o:\n")
|
||||
fci.Console.PrintLog("Output from pip -o:\n")
|
||||
for line in outdated_packages_stdout:
|
||||
if len(line) > 0:
|
||||
package_counter += 1
|
||||
FreeCAD.Console.PrintLog(f" {line}\n")
|
||||
fci.Console.PrintLog(f" {line}\n")
|
||||
return package_counter > 0
|
||||
|
||||
|
||||
def call_pip(args) -> List[str]:
|
||||
def call_pip(args: List[str]) -> List[str]:
|
||||
"""Tries to locate the appropriate Python executable and run pip with version checking
|
||||
disabled. Fails if Python can't be found or if pip is not installed."""
|
||||
|
||||
@@ -103,17 +118,64 @@ def call_pip(args) -> List[str]:
|
||||
except subprocess.CalledProcessError:
|
||||
pip_failed = True
|
||||
|
||||
result = []
|
||||
if not pip_failed:
|
||||
data = proc.stdout
|
||||
result = data.split("\n")
|
||||
return data.split("\n")
|
||||
elif proc:
|
||||
raise PipFailed(proc.stderr)
|
||||
else:
|
||||
raise PipFailed("pip timed out")
|
||||
else:
|
||||
raise PipFailed("Could not locate Python executable on this system")
|
||||
return result
|
||||
|
||||
|
||||
def parse_pip_list_output(all_packages, outdated_packages) -> Dict[str, Dict[str, str]]:
|
||||
"""Parses the output from pip into a dictionary with update information in it. The pip
|
||||
output should be an array of lines of text."""
|
||||
|
||||
# All Packages output looks like this:
|
||||
# Package Version
|
||||
# ---------- -------
|
||||
# gitdb 4.0.9
|
||||
# setuptools 41.2.0
|
||||
|
||||
# Outdated Packages output looks like this:
|
||||
# Package Version Latest Type
|
||||
# ---------- ------- ------ -----
|
||||
# pip 21.0.1 22.1.2 wheel
|
||||
# setuptools 41.2.0 63.2.0 wheel
|
||||
|
||||
packages = {}
|
||||
skip_counter = 0
|
||||
for line in all_packages:
|
||||
if skip_counter < 2:
|
||||
skip_counter += 1
|
||||
continue
|
||||
entries = line.split()
|
||||
if len(entries) > 1:
|
||||
package_name = entries[0]
|
||||
installed_version = entries[1]
|
||||
packages[package_name] = {
|
||||
"installed_version": installed_version,
|
||||
"available_version": "",
|
||||
}
|
||||
|
||||
skip_counter = 0
|
||||
for line in outdated_packages:
|
||||
if skip_counter < 2:
|
||||
skip_counter += 1
|
||||
continue
|
||||
entries = line.split()
|
||||
if len(entries) > 1:
|
||||
package_name = entries[0]
|
||||
installed_version = entries[1]
|
||||
available_version = entries[2]
|
||||
packages[package_name] = {
|
||||
"installed_version": installed_version,
|
||||
"available_version": available_version,
|
||||
}
|
||||
|
||||
return packages
|
||||
|
||||
|
||||
class PythonPackageManager:
|
||||
@@ -139,14 +201,22 @@ class PythonPackageManager:
|
||||
self.all_packages_stdout = call_pip(["list", "--path", self.vendor_path])
|
||||
self.outdated_packages_stdout = call_pip(["list", "-o", "--path", self.vendor_path])
|
||||
except PipFailed as e:
|
||||
FreeCAD.Console.PrintError(str(e) + "\n")
|
||||
fci.Console.PrintError(str(e) + "\n")
|
||||
self.error.emit(str(e))
|
||||
self.finished.emit()
|
||||
|
||||
class DependentAddon(TypedDict):
|
||||
name: str
|
||||
optional: bool
|
||||
|
||||
def __init__(self, addons):
|
||||
self.dlg = FreeCADGui.PySideUic.loadUi(
|
||||
ui_file = QtCore.QFile(
|
||||
os.path.join(os.path.dirname(__file__), "PythonDependencyUpdateDialog.ui")
|
||||
)
|
||||
ui_file.open(QtCore.QFile.OpenModeFlag.ReadOnly)
|
||||
loader = QUiLoader()
|
||||
self.dlg = loader.load(ui_file)
|
||||
|
||||
self.addons = addons
|
||||
self.vendor_path = utils.get_pip_target_directory()
|
||||
self.worker_thread = None
|
||||
@@ -167,9 +237,9 @@ class PythonPackageManager:
|
||||
"This appears to be the first time this version of Python has been used with the Addon Manager. "
|
||||
"Would you like to install the same auto-installed dependencies for it?",
|
||||
),
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
|
||||
)
|
||||
if result == QtWidgets.QMessageBox.Yes:
|
||||
if result == QtWidgets.QMessageBox.StandardButton.Yes:
|
||||
self._reinstall_all_packages()
|
||||
|
||||
self._add_current_python_version()
|
||||
@@ -198,7 +268,7 @@ class PythonPackageManager:
|
||||
QtWidgets.QTableWidgetItem(translate("AddonsInstaller", "Processing, please wait...")),
|
||||
)
|
||||
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
|
||||
0, QtWidgets.QHeaderView.ResizeToContents
|
||||
0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
|
||||
def _worker_finished(self):
|
||||
@@ -206,15 +276,13 @@ class PythonPackageManager:
|
||||
all_packages_stdout = self.worker_object.all_packages_stdout
|
||||
outdated_packages_stdout = self.worker_object.outdated_packages_stdout
|
||||
|
||||
self.package_list = self._parse_pip_list_output(
|
||||
all_packages_stdout, outdated_packages_stdout
|
||||
)
|
||||
self.package_list = parse_pip_list_output(all_packages_stdout, outdated_packages_stdout)
|
||||
self.dlg.buttonUpdateAll.clicked.connect(
|
||||
partial(self._update_all_packages, self.package_list)
|
||||
)
|
||||
|
||||
self.dlg.tableWidget.setRowCount(len(self.package_list))
|
||||
updateButtons = []
|
||||
update_buttons = []
|
||||
counter = 0
|
||||
update_counter = 0
|
||||
self.dlg.tableWidget.setSortingEnabled(False)
|
||||
@@ -243,10 +311,10 @@ class PythonPackageManager:
|
||||
QtWidgets.QTableWidgetItem(", ".join(dependencies)),
|
||||
)
|
||||
if len(package_details["available_version"]) > 0:
|
||||
updateButtons.append(QtWidgets.QPushButton(translate("AddonsInstaller", "Update")))
|
||||
updateButtons[-1].setIcon(QtGui.QIcon(":/icons/button_up.svg"))
|
||||
updateButtons[-1].clicked.connect(partial(self._update_package, package_name))
|
||||
self.dlg.tableWidget.setCellWidget(counter, 4, updateButtons[-1])
|
||||
update_buttons.append(QtWidgets.QPushButton(translate("AddonsInstaller", "Update")))
|
||||
update_buttons[-1].setIcon(QtGui.QIcon(":/icons/button_up.svg"))
|
||||
update_buttons[-1].clicked.connect(partial(self._update_package, package_name))
|
||||
self.dlg.tableWidget.setCellWidget(counter, 4, update_buttons[-1])
|
||||
update_counter += 1
|
||||
else:
|
||||
self.dlg.tableWidget.removeCellWidget(counter, 3)
|
||||
@@ -255,16 +323,16 @@ class PythonPackageManager:
|
||||
|
||||
self.dlg.tableWidget.horizontalHeader().setStretchLastSection(False)
|
||||
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
|
||||
0, QtWidgets.QHeaderView.Stretch
|
||||
0, QtWidgets.QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
|
||||
1, QtWidgets.QHeaderView.ResizeToContents
|
||||
1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
|
||||
2, QtWidgets.QHeaderView.ResizeToContents
|
||||
2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
|
||||
3, QtWidgets.QHeaderView.ResizeToContents
|
||||
3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
|
||||
if update_counter > 0:
|
||||
@@ -272,7 +340,7 @@ class PythonPackageManager:
|
||||
else:
|
||||
self.dlg.buttonUpdateAll.setEnabled(False)
|
||||
|
||||
def _get_dependent_addons(self, package):
|
||||
def _get_dependent_addons(self, package) -> List[DependentAddon]:
|
||||
dependent_addons = []
|
||||
for addon in self.addons:
|
||||
# if addon.installed_version is not None:
|
||||
@@ -282,54 +350,6 @@ class PythonPackageManager:
|
||||
dependent_addons.append({"name": addon.name, "optional": True})
|
||||
return dependent_addons
|
||||
|
||||
def _parse_pip_list_output(self, all_packages, outdated_packages) -> Dict[str, Dict[str, str]]:
|
||||
"""Parses the output from pip into a dictionary with update information in it. The pip
|
||||
output should be an array of lines of text."""
|
||||
|
||||
# All Packages output looks like this:
|
||||
# Package Version
|
||||
# ---------- -------
|
||||
# gitdb 4.0.9
|
||||
# setuptools 41.2.0
|
||||
|
||||
# Outdated Packages output looks like this:
|
||||
# Package Version Latest Type
|
||||
# ---------- ------- ------ -----
|
||||
# pip 21.0.1 22.1.2 wheel
|
||||
# setuptools 41.2.0 63.2.0 wheel
|
||||
|
||||
packages = {}
|
||||
skip_counter = 0
|
||||
for line in all_packages:
|
||||
if skip_counter < 2:
|
||||
skip_counter += 1
|
||||
continue
|
||||
entries = line.split()
|
||||
if len(entries) > 1:
|
||||
package_name = entries[0]
|
||||
installed_version = entries[1]
|
||||
packages[package_name] = {
|
||||
"installed_version": installed_version,
|
||||
"available_version": "",
|
||||
}
|
||||
|
||||
skip_counter = 0
|
||||
for line in outdated_packages:
|
||||
if skip_counter < 2:
|
||||
skip_counter += 1
|
||||
continue
|
||||
entries = line.split()
|
||||
if len(entries) > 1:
|
||||
package_name = entries[0]
|
||||
installed_version = entries[1]
|
||||
available_version = entries[2]
|
||||
packages[package_name] = {
|
||||
"installed_version": installed_version,
|
||||
"available_version": available_version,
|
||||
}
|
||||
|
||||
return packages
|
||||
|
||||
def _update_package(self, package_name) -> None:
|
||||
"""Run pip --upgrade on the given package. Updates all dependent packages as well."""
|
||||
for line in range(self.dlg.tableWidget.rowCount()):
|
||||
@@ -340,20 +360,22 @@ class PythonPackageManager:
|
||||
QtWidgets.QTableWidgetItem(translate("AddonsInstaller", "Updating...")),
|
||||
)
|
||||
break
|
||||
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
|
||||
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 50)
|
||||
|
||||
try:
|
||||
FreeCAD.Console.PrintLog(
|
||||
fci.Console.PrintLog(
|
||||
f"Running 'pip install --upgrade --target {self.vendor_path} {package_name}'\n"
|
||||
)
|
||||
call_pip(["install", "--upgrade", package_name, "--target", self.vendor_path])
|
||||
self._create_list_from_pip()
|
||||
while self.worker_thread.isRunning():
|
||||
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
|
||||
QtCore.QCoreApplication.processEvents(
|
||||
QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 50
|
||||
)
|
||||
except PipFailed as e:
|
||||
FreeCAD.Console.PrintError(str(e) + "\n")
|
||||
fci.Console.PrintError(str(e) + "\n")
|
||||
return
|
||||
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
|
||||
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 50)
|
||||
|
||||
def _update_all_packages(self, package_list) -> None:
|
||||
"""Updates all packages with available updates."""
|
||||
@@ -365,7 +387,7 @@ class PythonPackageManager:
|
||||
):
|
||||
updates.append(package_name)
|
||||
|
||||
FreeCAD.Console.PrintLog(f"Running update for {len(updates)} Python packages...\n")
|
||||
fci.Console.PrintLog(f"Running update for {len(updates)} Python packages...\n")
|
||||
for package_name in updates:
|
||||
self._update_package(package_name)
|
||||
|
||||
@@ -377,7 +399,7 @@ class PythonPackageManager:
|
||||
|
||||
migrated = False
|
||||
|
||||
old_directory = os.path.join(FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages")
|
||||
old_directory = os.path.join(fci.DataPaths().data_dir, "AdditionalPythonPackages")
|
||||
|
||||
new_directory = utils.get_pip_target_directory()
|
||||
new_directory_name = new_directory.rsplit(os.path.sep, 1)[1]
|
||||
@@ -395,10 +417,10 @@ class PythonPackageManager:
|
||||
continue
|
||||
old_path = os.path.join(old_directory, content_item)
|
||||
new_path = os.path.join(new_directory, content_item)
|
||||
FreeCAD.Console.PrintLog(
|
||||
fci.Console.PrintLog(
|
||||
f"Moving {content_item} into the new (versioned) directory structure\n"
|
||||
)
|
||||
FreeCAD.Console.PrintLog(f" {old_path} --> {new_path}\n")
|
||||
fci.Console.PrintLog(f" {old_path} --> {new_path}\n")
|
||||
shutil.move(old_path, new_path)
|
||||
migrated = True
|
||||
|
||||
@@ -415,10 +437,9 @@ class PythonPackageManager:
|
||||
return migrated
|
||||
|
||||
@classmethod
|
||||
def get_known_python_versions(cls) -> List[Tuple[int, int, int]]:
|
||||
def get_known_python_versions(cls) -> List[Tuple[int, int]]:
|
||||
"""Get the list of Python versions that the Addon Manager has seen before."""
|
||||
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
|
||||
known_python_versions_string = pref.GetString("KnownPythonVersions", "[]")
|
||||
known_python_versions_string = fci.Preferences().get("KnownPythonVersions")
|
||||
known_python_versions = json.loads(known_python_versions_string)
|
||||
return known_python_versions
|
||||
|
||||
@@ -428,8 +449,7 @@ class PythonPackageManager:
|
||||
major, minor, _ = platform.python_version_tuple()
|
||||
if not [major, minor] in known_python_versions:
|
||||
known_python_versions.append((major, minor))
|
||||
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
|
||||
pref.SetString("KnownPythonVersions", json.dumps(known_python_versions))
|
||||
fci.Preferences().set("KnownPythonVersions", json.dumps(known_python_versions))
|
||||
|
||||
@classmethod
|
||||
def _current_python_version_is_new(cls) -> bool:
|
||||
@@ -441,8 +461,8 @@ class PythonPackageManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_old_package_list(self) -> List[str]:
|
||||
"""Gets the list of packages from the package installation manifest"""
|
||||
def _load_old_package_list(self) -> Iterable[str]:
|
||||
"""Gets iterable of packages from the package installation manifest"""
|
||||
|
||||
known_python_versions = self.get_known_python_versions()
|
||||
if not known_python_versions:
|
||||
@@ -450,12 +470,12 @@ class PythonPackageManager:
|
||||
last_version = known_python_versions[-1]
|
||||
expected_directory = f"py{last_version[0]}{last_version[1]}"
|
||||
expected_directory = os.path.join(
|
||||
FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages", expected_directory
|
||||
fci.DataPaths().data_dir, "AdditionalPythonPackages", expected_directory
|
||||
)
|
||||
# For now just do this synchronously
|
||||
worker_object = PythonPackageManager.PipRunner(expected_directory)
|
||||
worker_object.process()
|
||||
packages = self._parse_pip_list_output(
|
||||
packages = parse_pip_list_output(
|
||||
worker_object.all_packages_stdout, worker_object.outdated_packages_stdout
|
||||
)
|
||||
return packages.keys()
|
||||
@@ -472,5 +492,5 @@ class PythonPackageManager:
|
||||
try:
|
||||
call_pip(args)
|
||||
except PipFailed as e:
|
||||
FreeCAD.Console.PrintError(str(e) + "\n")
|
||||
fci.Console.PrintError(str(e) + "\n")
|
||||
return
|
||||
@@ -29,6 +29,7 @@ import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
import ctypes
|
||||
from typing import Optional, Any
|
||||
@@ -418,13 +419,11 @@ def run_interruptable_subprocess(args, timeout_secs: int = 10) -> subprocess.Com
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return_code = None
|
||||
counter = 0
|
||||
start_time = time.time()
|
||||
while return_code is None:
|
||||
counter += 1
|
||||
try:
|
||||
stdout, stderr = p.communicate(
|
||||
timeout=1
|
||||
) # one second timeout allows interrupting the run once per second
|
||||
# one second timeout allows interrupting the run once per second
|
||||
stdout, stderr = p.communicate(timeout=1)
|
||||
return_code = p.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
if (
|
||||
@@ -433,7 +432,7 @@ def run_interruptable_subprocess(args, timeout_secs: int = 10) -> subprocess.Com
|
||||
):
|
||||
p.kill()
|
||||
raise ProcessInterrupted()
|
||||
if counter >= timeout_secs: # The real timeout
|
||||
if time.time() - start_time >= timeout_secs: # The real timeout
|
||||
p.kill()
|
||||
stdout, stderr = p.communicate()
|
||||
return_code = -1
|
||||
|
||||
Reference in New Issue
Block a user