From b2619f339c398869aad2017f2bb05d42ab33df9d Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 8 Dec 2024 22:32:45 -0600 Subject: [PATCH] Addon Manager: PythonDeps Cleanup and Testing --- src/Mod/AddonManager/AddonManager.py | 2 +- .../AddonManagerTest/app/test_utilities.py | 31 ++- .../AddonManagerTest/gui/gui_mocks.py | 12 +- .../gui/test_python_deps_gui.py | 139 +++++++++++ src/Mod/AddonManager/CMakeLists.txt | 28 +-- .../addonmanager_freecad_interface.py | 15 +- ...ies.py => addonmanager_python_deps_gui.py} | 224 ++++++++++-------- .../AddonManager/addonmanager_utilities.py | 11 +- 8 files changed, 323 insertions(+), 139 deletions(-) create mode 100644 src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py rename src/Mod/AddonManager/{manage_python_dependencies.py => addonmanager_python_deps_gui.py} (75%) diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index 7d8b30cf8a3c..e84cb1a77099 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -59,7 +59,7 @@ 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 diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py b/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py index 688571cbf3f2..cc00fd8dfd87 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py @@ -194,26 +194,33 @@ def raises_first_five_times(timeout): 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_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 - raises_first_ten_times.counter = 0 + 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__": diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py b/src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py index 7d08e7739a26..fde8d9e896b9 100644 --- a/src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py +++ b/src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py @@ -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 diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py b/src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py new file mode 100644 index 000000000000..be08fa37e66c --- /dev/null +++ b/src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py @@ -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() diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index a311537cbf20..d376834d1c6a 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -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) diff --git a/src/Mod/AddonManager/addonmanager_freecad_interface.py b/src/Mod/AddonManager/addonmanager_freecad_interface.py index 34d5a7d2b6fc..ac66a100caa2 100644 --- a/src/Mod/AddonManager/addonmanager_freecad_interface.py +++ b/src/Mod/AddonManager/addonmanager_freecad_interface.py @@ -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 @@ def __init__(self): 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 @@ def __init__(self): 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 diff --git a/src/Mod/AddonManager/manage_python_dependencies.py b/src/Mod/AddonManager/addonmanager_python_deps_gui.py similarity index 75% rename from src/Mod/AddonManager/manage_python_dependencies.py rename to src/Mod/AddonManager/addonmanager_python_deps_gui.py index cf4786a78b2a..4419b1635c07 100644 --- a/src/Mod/AddonManager/manage_python_dependencies.py +++ b/src/Mod/AddonManager/addonmanager_python_deps_gui.py @@ -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 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 @@ def run(self): 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 @@ def process(self): 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 @@ def show(self): "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 @@ def _create_list_from_pip(self): 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 @@ def _worker_finished(self): 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 @@ def _worker_finished(self): 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 @@ def _worker_finished(self): 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 @@ def _worker_finished(self): 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 @@ def _get_dependent_addons(self, package): 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 @@ def _update_package(self, package_name) -> None: 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 @@ def _update_all_packages(self, package_list) -> None: ): 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 @@ def migrate_old_am_installations(cls) -> bool: 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 @@ def migrate_old_am_installations(cls) -> bool: 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 @@ def migrate_old_am_installations(cls) -> bool: 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 @@ def _add_current_python_version(cls) -> None: 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 @@ def _current_python_version_is_new(cls) -> bool: 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 @@ def _load_old_package_list(self) -> List[str]: 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 @@ def _reinstall_all_packages(self) -> None: try: call_pip(args) except PipFailed as e: - FreeCAD.Console.PrintError(str(e) + "\n") + fci.Console.PrintError(str(e) + "\n") return diff --git a/src/Mod/AddonManager/addonmanager_utilities.py b/src/Mod/AddonManager/addonmanager_utilities.py index 5e9a2d497819..517d0fbbd668 100644 --- a/src/Mod/AddonManager/addonmanager_utilities.py +++ b/src/Mod/AddonManager/addonmanager_utilities.py @@ -29,6 +29,7 @@ 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