FreeCAD
476 строк · 19.5 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022-2023 FreeCAD Project Association *
5# * *
6# * This file is part of FreeCAD. *
7# * *
8# * FreeCAD is free software: you can redistribute it and/or modify it *
9# * under the terms of the GNU Lesser General Public License as *
10# * published by the Free Software Foundation, either version 2.1 of the *
11# * License, or (at your option) any later version. *
12# * *
13# * FreeCAD is distributed in the hope that it will be useful, but *
14# * WITHOUT ANY WARRANTY; without even the implied warranty of *
15# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
16# * Lesser General Public License for more details. *
17# * *
18# * You should have received a copy of the GNU Lesser General Public *
19# * License along with FreeCAD. If not, see *
20# * <https://www.gnu.org/licenses/>. *
21# * *
22# ***************************************************************************
23
24""" Provides classes and support functions for managing the automatically-installed
25Python library dependencies. No support is provided for uninstalling those dependencies
26because pip's uninstall function does not support the target directory argument. """
27
28import json29import os30import platform31import shutil32import subprocess33import sys34from functools import partial35from typing import Dict, List, Tuple36
37import addonmanager_freecad_interface as fci38
39import FreeCAD40import FreeCADGui41from freecad.utils import get_python_exe42from PySide import QtCore, QtGui, QtWidgets43
44import addonmanager_utilities as utils45
46translate = FreeCAD.Qt.translate47
48# pylint: disable=too-few-public-methods
49
50
51class PipFailed(Exception):52"""Exception thrown when pip times out or otherwise fails to return valid results"""53
54
55class CheckForPythonPackageUpdatesWorker(QtCore.QThread):56"""Perform non-blocking Python library update availability checking"""57
58python_package_updates_available = QtCore.Signal()59
60def __init__(self):61QtCore.QThread.__init__(self)62
63def run(self):64"""Usually not called directly: instead, instantiate this class and call its start()65function in a parent thread. emits a python_package_updates_available signal if updates
66are available for any of the installed Python packages."""
67
68if check_for_python_package_updates():69self.python_package_updates_available.emit()70
71
72def check_for_python_package_updates() -> bool:73"""Returns True if any of the Python packages installed into the AdditionalPythonPackages74directory have updates available, or False if they are all up-to-date."""
75
76vendor_path = os.path.join(FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages")77package_counter = 078try:79outdated_packages_stdout = call_pip(["list", "-o", "--path", vendor_path])80except PipFailed as e:81FreeCAD.Console.PrintError(str(e) + "\n")82return False83FreeCAD.Console.PrintLog("Output from pip -o:\n")84for line in outdated_packages_stdout:85if len(line) > 0:86package_counter += 187FreeCAD.Console.PrintLog(f" {line}\n")88return package_counter > 089
90
91def call_pip(args) -> List[str]:92"""Tries to locate the appropriate Python executable and run pip with version checking93disabled. Fails if Python can't be found or if pip is not installed."""
94
95python_exe = get_python_exe()96pip_failed = False97if python_exe:98call_args = [python_exe, "-m", "pip", "--disable-pip-version-check"]99call_args.extend(args)100proc = None101try:102proc = utils.run_interruptable_subprocess(call_args)103except subprocess.CalledProcessError:104pip_failed = True105
106result = []107if not pip_failed:108data = proc.stdout109result = data.split("\n")110elif proc:111raise PipFailed(proc.stderr)112else:113raise PipFailed("pip timed out")114else:115raise PipFailed("Could not locate Python executable on this system")116return result117
118
119class PythonPackageManager:120"""A GUI-based pip interface allowing packages to be updated, either individually or all at121once."""
122
123class PipRunner(QtCore.QObject):124"""Run pip in a separate thread so the UI doesn't block while it runs"""125
126finished = QtCore.Signal()127error = QtCore.Signal(str)128
129def __init__(self, vendor_path, parent=None):130super().__init__(parent)131self.all_packages_stdout = []132self.outdated_packages_stdout = []133self.vendor_path = vendor_path134self.package_list = {}135
136def process(self):137"""Execute this object."""138try:139self.all_packages_stdout = call_pip(["list", "--path", self.vendor_path])140self.outdated_packages_stdout = call_pip(["list", "-o", "--path", self.vendor_path])141except PipFailed as e:142FreeCAD.Console.PrintError(str(e) + "\n")143self.error.emit(str(e))144self.finished.emit()145
146def __init__(self, addons):147self.dlg = FreeCADGui.PySideUic.loadUi(148os.path.join(os.path.dirname(__file__), "PythonDependencyUpdateDialog.ui")149)150self.addons = addons151self.vendor_path = utils.get_pip_target_directory()152self.worker_thread = None153self.worker_object = None154self.package_list = []155
156def show(self):157"""Run the modal dialog"""158
159known_python_versions = self.get_known_python_versions()160if self._current_python_version_is_new() and known_python_versions:161# pylint: disable=line-too-long162result = QtWidgets.QMessageBox.question(163None,164translate("AddonsInstaller", "New Python Version Detected"),165translate(166"AddonsInstaller",167"This appears to be the first time this version of Python has been used with the Addon Manager. "168"Would you like to install the same auto-installed dependencies for it?",169),170QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,171)172if result == QtWidgets.QMessageBox.Yes:173self._reinstall_all_packages()174
175self._add_current_python_version()176self._create_list_from_pip()177self.dlg.tableWidget.setSortingEnabled(False)178self.dlg.labelInstallationPath.setText(self.vendor_path)179self.dlg.exec()180
181def _create_list_from_pip(self):182"""Uses pip and pip -o to generate a list of installed packages, and creates the user183interface elements for those packages. Asynchronous, will complete AFTER the window is
184showing in most cases."""
185
186self.worker_thread = QtCore.QThread()187self.worker_object = PythonPackageManager.PipRunner(self.vendor_path)188self.worker_object.moveToThread(self.worker_thread)189self.worker_object.finished.connect(self._worker_finished)190self.worker_object.finished.connect(self.worker_thread.quit)191self.worker_thread.started.connect(self.worker_object.process)192self.worker_thread.start()193
194self.dlg.tableWidget.setRowCount(1)195self.dlg.tableWidget.setItem(1960,1970,198QtWidgets.QTableWidgetItem(translate("AddonsInstaller", "Processing, please wait...")),199)200self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(2010, QtWidgets.QHeaderView.ResizeToContents202)203
204def _worker_finished(self):205"""Callback for when the worker process has completed"""206all_packages_stdout = self.worker_object.all_packages_stdout207outdated_packages_stdout = self.worker_object.outdated_packages_stdout208
209self.package_list = self._parse_pip_list_output(210all_packages_stdout, outdated_packages_stdout211)212self.dlg.buttonUpdateAll.clicked.connect(213partial(self._update_all_packages, self.package_list)214)215
216self.dlg.tableWidget.setRowCount(len(self.package_list))217updateButtons = []218counter = 0219update_counter = 0220self.dlg.tableWidget.setSortingEnabled(False)221for package_name, package_details in self.package_list.items():222dependent_addons = self._get_dependent_addons(package_name)223dependencies = []224for addon in dependent_addons:225if addon["optional"]:226dependencies.append(addon["name"] + "*")227else:228dependencies.append(addon["name"])229self.dlg.tableWidget.setItem(counter, 0, QtWidgets.QTableWidgetItem(package_name))230self.dlg.tableWidget.setItem(231counter,2321,233QtWidgets.QTableWidgetItem(package_details["installed_version"]),234)235self.dlg.tableWidget.setItem(236counter,2372,238QtWidgets.QTableWidgetItem(package_details["available_version"]),239)240self.dlg.tableWidget.setItem(241counter,2423,243QtWidgets.QTableWidgetItem(", ".join(dependencies)),244)245if len(package_details["available_version"]) > 0:246updateButtons.append(QtWidgets.QPushButton(translate("AddonsInstaller", "Update")))247updateButtons[-1].setIcon(QtGui.QIcon(":/icons/button_up.svg"))248updateButtons[-1].clicked.connect(partial(self._update_package, package_name))249self.dlg.tableWidget.setCellWidget(counter, 4, updateButtons[-1])250update_counter += 1251else:252self.dlg.tableWidget.removeCellWidget(counter, 3)253counter += 1254self.dlg.tableWidget.setSortingEnabled(True)255
256self.dlg.tableWidget.horizontalHeader().setStretchLastSection(False)257self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(2580, QtWidgets.QHeaderView.Stretch259)260self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(2611, QtWidgets.QHeaderView.ResizeToContents262)263self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(2642, QtWidgets.QHeaderView.ResizeToContents265)266self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(2673, QtWidgets.QHeaderView.ResizeToContents268)269
270if update_counter > 0:271self.dlg.buttonUpdateAll.setEnabled(True)272else:273self.dlg.buttonUpdateAll.setEnabled(False)274
275def _get_dependent_addons(self, package):276dependent_addons = []277for addon in self.addons:278# if addon.installed_version is not None:279if package.lower() in addon.python_requires:280dependent_addons.append({"name": addon.name, "optional": False})281elif package.lower() in addon.python_optional:282dependent_addons.append({"name": addon.name, "optional": True})283return dependent_addons284
285def _parse_pip_list_output(self, all_packages, outdated_packages) -> Dict[str, Dict[str, str]]:286"""Parses the output from pip into a dictionary with update information in it. The pip287output should be an array of lines of text."""
288
289# All Packages output looks like this:290# Package Version291# ---------- -------292# gitdb 4.0.9293# setuptools 41.2.0294
295# Outdated Packages output looks like this:296# Package Version Latest Type297# ---------- ------- ------ -----298# pip 21.0.1 22.1.2 wheel299# setuptools 41.2.0 63.2.0 wheel300
301packages = {}302skip_counter = 0303for line in all_packages:304if skip_counter < 2:305skip_counter += 1306continue307entries = line.split()308if len(entries) > 1:309package_name = entries[0]310installed_version = entries[1]311packages[package_name] = {312"installed_version": installed_version,313"available_version": "",314}315
316skip_counter = 0317for line in outdated_packages:318if skip_counter < 2:319skip_counter += 1320continue321entries = line.split()322if len(entries) > 1:323package_name = entries[0]324installed_version = entries[1]325available_version = entries[2]326packages[package_name] = {327"installed_version": installed_version,328"available_version": available_version,329}330
331return packages332
333def _update_package(self, package_name) -> None:334"""Run pip --upgrade on the given package. Updates all dependent packages as well."""335for line in range(self.dlg.tableWidget.rowCount()):336if self.dlg.tableWidget.item(line, 0).text() == package_name:337self.dlg.tableWidget.setItem(338line,3392,340QtWidgets.QTableWidgetItem(translate("AddonsInstaller", "Updating...")),341)342break343QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)344
345try:346FreeCAD.Console.PrintLog(347f"Running 'pip install --upgrade --target {self.vendor_path} {package_name}'\n"348)349call_pip(["install", "--upgrade", package_name, "--target", self.vendor_path])350self._create_list_from_pip()351while self.worker_thread.isRunning():352QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)353except PipFailed as e:354FreeCAD.Console.PrintError(str(e) + "\n")355return356QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)357
358def _update_all_packages(self, package_list) -> None:359"""Updates all packages with available updates."""360updates = []361for package_name, package_details in package_list.items():362if (363len(package_details["available_version"]) > 0364and package_details["available_version"] != package_details["installed_version"]365):366updates.append(package_name)367
368FreeCAD.Console.PrintLog(f"Running update for {len(updates)} Python packages...\n")369for package_name in updates:370self._update_package(package_name)371
372@classmethod373def migrate_old_am_installations(cls) -> bool:374"""Move packages installed before the Addon Manager switched to a versioned directory375structure into the versioned structure. Returns True if a migration was done, or false
376if no migration was needed."""
377
378migrated = False379
380old_directory = os.path.join(FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages")381
382new_directory = utils.get_pip_target_directory()383new_directory_name = new_directory.rsplit(os.path.sep, 1)[1]384
385if not os.path.exists(old_directory) or os.path.exists(386os.path.join(old_directory, "MIGRATION_COMPLETE")387):388# Nothing to migrate389return False390
391os.makedirs(new_directory, mode=0o777, exist_ok=True)392
393for content_item in os.listdir(old_directory):394if content_item == new_directory_name:395continue396old_path = os.path.join(old_directory, content_item)397new_path = os.path.join(new_directory, content_item)398FreeCAD.Console.PrintLog(399f"Moving {content_item} into the new (versioned) directory structure\n"400)401FreeCAD.Console.PrintLog(f" {old_path} --> {new_path}\n")402shutil.move(old_path, new_path)403migrated = True404
405sys.path.append(new_directory)406cls._add_current_python_version()407
408with open(os.path.join(old_directory, "MIGRATION_COMPLETE"), "w", encoding="utf-8") as f:409f.write("Files originally installed in this directory have been migrated to:\n")410f.write(new_directory)411f.write(412"\nThe existence of this file prevents the Addon Manager from "413"attempting the migration again.\n"414)415return migrated416
417@classmethod418def get_known_python_versions(cls) -> List[Tuple[int, int, int]]:419"""Get the list of Python versions that the Addon Manager has seen before."""420pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")421known_python_versions_string = pref.GetString("KnownPythonVersions", "[]")422known_python_versions = json.loads(known_python_versions_string)423return known_python_versions424
425@classmethod426def _add_current_python_version(cls) -> None:427known_python_versions = cls.get_known_python_versions()428major, minor, _ = platform.python_version_tuple()429if not [major, minor] in known_python_versions:430known_python_versions.append((major, minor))431pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")432pref.SetString("KnownPythonVersions", json.dumps(known_python_versions))433
434@classmethod435def _current_python_version_is_new(cls) -> bool:436"""Returns True if this is the first time the Addon Manager has seen this version of437Python"""
438known_python_versions = cls.get_known_python_versions()439major, minor, _ = platform.python_version_tuple()440if not [major, minor] in known_python_versions:441return True442return False443
444def _load_old_package_list(self) -> List[str]:445"""Gets the list of packages from the package installation manifest"""446
447known_python_versions = self.get_known_python_versions()448if not known_python_versions:449return []450last_version = known_python_versions[-1]451expected_directory = f"py{last_version[0]}{last_version[1]}"452expected_directory = os.path.join(453FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages", expected_directory454)455# For now just do this synchronously456worker_object = PythonPackageManager.PipRunner(expected_directory)457worker_object.process()458packages = self._parse_pip_list_output(459worker_object.all_packages_stdout, worker_object.outdated_packages_stdout460)461return packages.keys()462
463def _reinstall_all_packages(self) -> None:464"""Loads the package manifest from another Python version, and installs the same packages465for the current (presumably new) version of Python."""
466
467packages = self._load_old_package_list()468args = ["install"]469args.extend(packages)470args.extend(["--target", self.vendor_path])471
472try:473call_pip(args)474except PipFailed as e:475FreeCAD.Console.PrintError(str(e) + "\n")476return477