Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions InitGui.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@

import os
import AddonManager
from addonmanager_python_deps_commands import Std_AddonMgrPip

cwd = os.path.dirname(AddonManager.__file__)
FreeCADGui.addLanguagePath(os.path.join(cwd, "Resources", "translations"))
FreeCADGui.addIconPath(os.path.join(cwd, "Resources", "icons"))
FreeCADGui.addCommand("Std_AddonMgr", AddonManager.CommandAddonManager())

Std_AddonMgrPip.install()
29 changes: 21 additions & 8 deletions PythonDependencyUpdateDialog.ui
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@
</widget>
</item>
<item>
<widget class="QLabel" name="labelInstallationPath">
<widget class="QLineEdit" name="labelInstallationPath">
<property name="text">
<string notr="true">placeholder for path</string>
</property>
<property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::TextSelectableByMouse</set>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
Expand All @@ -48,12 +48,18 @@
</widget>
</item>
<item>
<widget class="QLabel" name="updateInProgressLabel">
<property name="text">
<string>Update in progress…</string>
<widget class="QProgressBar" name="updateInProgressLabel">
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>0</number>
</property>
<property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::NoTextInteraction</set>
<property name="value">
<number>-1</number>
</property>
<property name="textVisible">
<bool>true</bool>
</property>
</widget>
</item>
Expand All @@ -76,6 +82,13 @@
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonInstallPkgs">
<property name="text">
<string>Add Package</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
Expand Down
103 changes: 58 additions & 45 deletions addonmanager_python_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import shutil
import subprocess
from typing import Dict, Iterable, List, TypedDict, Optional, Set
from enum import Enum
from addonmanager_metadata import Version
from addonmanager_utilities import (
create_pip_call,
Expand Down Expand Up @@ -145,72 +146,69 @@ def parse_pip_list_output(all_packages, outdated_packages) -> List[PackageInfo]:
return list(packages.values())


class AsynchronousResetWorker(QtCore.QObject):
"""A worker class that runs pip to generate the package list."""
class PipCommand(Enum):
Install = 0
Upgrade = 1
List = 2


class AsynchronousPipWorker(QtCore.QObject):
"""A worker class that runs pip to install/update/list packages."""

finished = QtCore.Signal()

def __init__(self, parent=None):
def __init__(
self,
command: PipCommand,
package_list: list[str] | None = None,
parent=None,
) -> None:
super().__init__(parent)
self.is_running = False
self.error = ""
self.vendor_path = get_pip_target_directory()
self.package_list = []
self.package_list = package_list or []
self.command = command

def run(self):
"""Runs pip: when complete, either self.package_list is populated, or self.error is set."""
self.is_running = True
self.error = ""
self.package_list = []
try:
outdated_packages_stdout = call_pip(["list", "-o", "--path", self.vendor_path])
all_packages_stdout = call_pip(["list", "--path", self.vendor_path])
self.package_list = parse_pip_list_output(all_packages_stdout, outdated_packages_stdout)
except PipFailed as e:
self.error = str(e)
self.is_running = False
self.finished.emit()

if self.command in (PipCommand.Upgrade, PipCommand.Install):
self._install_or_update()
self._list()

class AsynchronousUpdateWorker(QtCore.QObject):
"""A worker class that runs pip to generate the package list."""

finished = QtCore.Signal()

def __init__(self, parent=None):
super().__init__(parent)
self.is_running = False
self.error = ""
self.vendor_path = get_pip_target_directory()
self.package_list = []
self.finished.emit()

def run(self):
"""Runs pip: when complete, either self.package_list is populated, or self.error is set."""
self.is_running = True
self.error = ""
def _install_or_update(self) -> None:
if not self.package_list:
return

update_string = " ".join(self.package_list)
action = "install" if self.command == PipCommand.Install else "upgrade"
log_message = f"Running pip to {action} the following packages in {self.vendor_path}: {update_string}\n"
upgrade = ["--upgrade"] if self.command == PipCommand.Upgrade else []
command = ["install", *upgrade, "--target", self.vendor_path]
command.extend(self.package_list)

fci.Console.PrintLog(f"{log_message}\n")
try:
fci.Console.PrintLog(
f"Running pip to upgrade the following packages in {self.vendor_path}: {update_string}\n"
)
command = ["install", "--upgrade", "--target", self.vendor_path]
command.extend(self.package_list)
upgrade_stdout = call_pip(command)
for line in upgrade_stdout:
fci.Console.PrintLog(line + "\n")
fci.Console.PrintLog(f"{line}\n")
except PipFailed as e:
self.error = str(e)
fci.Console.PrintError(self.error + "\n")
fci.Console.PrintError(f"{self.error}\n")

def _list(self) -> None:
try:
outdated_packages_stdout = call_pip(["list", "-o", "--path", self.vendor_path])
all_packages_stdout = call_pip(["list", "--path", self.vendor_path])
self.package_list = parse_pip_list_output(all_packages_stdout, outdated_packages_stdout)
except PipFailed as e:
self.error = str(e)
self.is_running = False
self.finished.emit()


class PythonPackageListModel(QtCore.QAbstractTableModel):
Expand Down Expand Up @@ -243,7 +241,7 @@ def reset_package_list(self):
otherwise synchronous."""
self.beginResetModel()
self.package_list.clear()
self.reset_worker = AsynchronousResetWorker()
self.reset_worker = AsynchronousPipWorker(PipCommand.List)
if self.can_use_thread():
self.reset_worker_thread = QtCore.QThread()
self.reset_worker.moveToThread(self.reset_worker_thread)
Expand Down Expand Up @@ -304,7 +302,7 @@ def headerData(self, section, orientation, role=...) -> Optional[str]:
elif section == 2:
return translate("AddonsInstaller", "Available Version")
elif section == 3:
return translate("AddonsInstaller", "Dependencies")
return translate("AddonsInstaller", "Used By")
return None

def flags(self, index) -> QtCore.Qt.ItemFlag:
Expand All @@ -331,12 +329,20 @@ def get_dependent_addons(self, package) -> List[DependentAddon]:
dependent_addons.append({"name": addon.name, "optional": True})
return dependent_addons

def update_all_packages(self):
def update_all_packages(self) -> None:
"""Re-installs all packages. Uses an asynchronous thread when possible."""
self.update_worker = AsynchronousUpdateWorker()

updates = [item.name for item in self.package_list]
self.update_worker.package_list = updates
if updates:
self._install_or_update_packages(updates, PipCommand.Upgrade)

def install_packages(self, packages: list[str]) -> None:
"""Installs packages. Uses an asynchronous thread when possible."""
installed = (item.name for item in self.package_list)
self._install_or_update_packages([*installed, *packages], PipCommand.Install)

def _install_or_update_packages(self, packages: list[str], command: PipCommand) -> None:
"""Installs/Upgrade packages. Uses an asynchronous thread when possible."""
self.update_worker = AsynchronousPipWorker(command, packages)
if not using_system_pip_installation_location():
# pip doesn't properly update when using the target directory, so we have to delete
# it and reinstall
Expand All @@ -356,9 +362,16 @@ def update_all_packages(self):
def update_call_finished(self):
self.update_complete.emit()
if not using_system_pip_installation_location():
shutil.rmtree(self.vendor_path + ".old")
# Clean up old package versions that may remain after update
self._cleanup_old_package_versions()
if self.update_worker.error:
try:
os.rename(self.vendor_path + ".old", self.vendor_path)
except Exception as err:
fci.Console.PrintError(f"Backup restore failed: {self.vendor_path}.old.\n")
fci.Console.PrintError(f"{err}\n")
else:
shutil.rmtree(self.vendor_path + ".old")
# Clean up old package versions that may remain after update
self._cleanup_old_package_versions()

def _cleanup_old_package_versions(self):
"""Remove old package version metadata directories after an update.
Expand Down
73 changes: 73 additions & 0 deletions addonmanager_python_deps_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# SPDX-FileCopyrightText: 2022 FreeCAD Project Association
# SPDX-FileNotice: Part of the AddonManager.

################################################################################
# #
# This addon is free software: you can redistribute it and/or modify #
# it under the terms of the GNU Lesser General Public License as #
# published by the Free Software Foundation, either version 2.1 #
# of the License, or (at your option) any later version. #
# #
# This addon is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty #
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. #
# See the GNU Lesser General Public License for more details. #
# #
# You should have received a copy of the GNU Lesser General Public #
# License along with this addon. If not, see https://www.gnu.org/licenses #
# #
################################################################################

"""
Provides addition atop level command to launch the pypi package installer.
"""


def QT_TRANSLATE_NOOP(_, txt) -> str:
return txt


class Std_AddonMgrPip:
"""Launch the Pip Installer Dialog."""

def GetResources(self) -> dict[str, str]:
return {
"Pixmap": "applications-python.svg",
"MenuText": QT_TRANSLATE_NOOP(
"AddonsInstaller",
"Python package manager",
),
"ToolTip": QT_TRANSLATE_NOOP(
"AddonsInstaller",
"Open python packages manager",
),
}

def Activated(self) -> None:
from addonmanager_python_deps_gui import PythonPackageManagerGui
from package_list import PackageListItemModel

model = PackageListItemModel()
dialog = PythonPackageManagerGui(model.repos)
dialog.show()

def IsActive(self) -> bool:
return True

def modifyMenuBar(self) -> list[dict[str, str]]:
return [
{
"insert": "Std_AddonMgrPip",
"menuItem": "Std_AddonMgr",
"after": "",
}
]

@classmethod
def install(cls) -> None:
import FreeCADGui as Gui

Gui.addCommand("Std_AddonMgrPip", Std_AddonMgrPip())
cls._instance = cls()
Gui.addWorkbenchManipulator(cls._instance)
Loading