-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathaddonmanager_python_deps_gui.py
505 lines (424 loc) · 20 KB
/
addonmanager_python_deps_gui.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2025 FreeCAD Project Association AISBL *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD 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. *
# * *
# * FreeCAD 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 FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""Provides classes and support functions for managing the automatically-installed
Python library dependencies. No support is provided for uninstalling those dependencies
because pip's uninstall function does not support the target directory argument."""
import json
import os
import platform
import shutil
import subprocess
import sys
from functools import partial
from typing import Dict, Iterable, List, Tuple, TypedDict
from addonmanager_utilities import create_pip_call
import addonmanager_freecad_interface as fci
try:
from PySide import QtCore, QtGui, QtWidgets
except ImportError:
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
# Make sure this can run inside and outside FreeCAD, and don't require that (when run inside FreeCAD) the user has the
# python QtUiTools installed, because FreeCAD wraps it for us.
try:
import FreeCADGui
loadUi = FreeCADGui.PySideUic.loadUi
except ImportError:
try:
from PySide6.QtUiTools import QUiLoader
except ImportError:
from PySide2.QtUiTools import QUiLoader
def loadUi(ui_file: str) -> QtWidgets.QWidget:
q_ui_file = QtCore.QFile(ui_file)
q_ui_file.open(QtCore.QFile.OpenModeFlag.ReadOnly)
loader = QUiLoader()
return loader.load(ui_file)
try:
from freecad.utils import get_python_exe
except ImportError:
def get_python_exe():
return shutil.which("python")
import addonmanager_utilities as utils
translate = fci.translate
# pylint: disable=too-few-public-methods
class PipFailed(Exception):
"""Exception thrown when pip times out or otherwise fails to return valid results"""
class CheckForPythonPackageUpdatesWorker(QtCore.QThread):
"""Perform non-blocking Python library update availability checking"""
python_package_updates_available = QtCore.Signal()
def __init__(self):
QtCore.QThread.__init__(self)
def run(self):
"""Usually not called directly: instead, instantiate this class and call its start()
function in a parent thread. emits a python_package_updates_available signal if updates
are available for any of the installed Python packages."""
if python_package_updates_are_available():
self.python_package_updates_available.emit()
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(fci.DataPaths().data_dir, "AdditionalPythonPackages")
package_counter = 0
try:
outdated_packages_stdout = call_pip(["list", "-o", "--path", vendor_path])
except PipFailed as e:
fci.Console.PrintError(str(e) + "\n")
return False
fci.Console.PrintLog("Output from pip -o:\n")
for line in outdated_packages_stdout:
if len(line) > 0:
package_counter += 1
fci.Console.PrintLog(f" {line}\n")
return package_counter > 0
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."""
try:
call_args = create_pip_call(args)
except RuntimeError as exception:
raise PipFailed() from exception
try:
proc = utils.run_interruptable_subprocess(call_args)
except subprocess.CalledProcessError as exception:
raise PipFailed("pip timed out") from exception
if proc.returncode != 0:
raise PipFailed(proc.stderr)
data = proc.stdout
return data.split("\n")
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:
"""A GUI-based pip interface allowing packages to be updated, either individually or all at
once."""
class PipRunner(QtCore.QObject):
"""Run pip in a separate thread so the UI doesn't block while it runs"""
finished = QtCore.Signal()
error = QtCore.Signal(str)
def __init__(self, vendor_path, parent=None):
super().__init__(parent)
self.all_packages_stdout = []
self.outdated_packages_stdout = []
self.vendor_path = vendor_path
self.package_list = {}
def process(self):
"""Execute this object."""
try:
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:
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 = loadUi(
os.path.join(os.path.dirname(__file__), "PythonDependencyUpdateDialog.ui")
)
self.addons = addons
self.vendor_path = utils.get_pip_target_directory()
self.worker_thread = None
self.worker_object = None
self.package_list = []
def show(self):
"""Run the modal dialog"""
known_python_versions = self.get_known_python_versions()
if self._current_python_version_is_new() and known_python_versions:
# pylint: disable=line-too-long
result = QtWidgets.QMessageBox.question(
None,
translate("AddonsInstaller", "New Python Version Detected"),
translate(
"AddonsInstaller",
"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.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
)
if result == QtWidgets.QMessageBox.StandardButton.Yes:
self._reinstall_all_packages()
self._add_current_python_version()
self._create_list_from_pip()
self.dlg.tableWidget.setSortingEnabled(False)
self.dlg.labelInstallationPath.setText(self.vendor_path)
self.dlg.exec()
def _create_list_from_pip(self):
"""Uses pip and pip -o to generate a list of installed packages, and creates the user
interface elements for those packages. Asynchronous, will complete AFTER the window is
showing in most cases."""
self.worker_thread = QtCore.QThread()
self.worker_object = PythonPackageManager.PipRunner(self.vendor_path)
self.worker_object.moveToThread(self.worker_thread)
self.worker_object.finished.connect(self._worker_finished)
self.worker_object.finished.connect(self.worker_thread.quit)
self.worker_thread.started.connect(self.worker_object.process)
self.worker_thread.start()
self.dlg.tableWidget.setRowCount(1)
self.dlg.tableWidget.setItem(
0,
0,
QtWidgets.QTableWidgetItem(translate("AddonsInstaller", "Processing, please wait...")),
)
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
)
def _worker_finished(self):
"""Callback for when the worker process has completed"""
all_packages_stdout = self.worker_object.all_packages_stdout
outdated_packages_stdout = self.worker_object.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))
update_buttons = []
counter = 0
update_counter = 0
self.dlg.tableWidget.setSortingEnabled(False)
for package_name, package_details in self.package_list.items():
dependent_addons = self._get_dependent_addons(package_name)
dependencies = []
for addon in dependent_addons:
if addon["optional"]:
dependencies.append(addon["name"] + "*")
else:
dependencies.append(addon["name"])
self.dlg.tableWidget.setItem(counter, 0, QtWidgets.QTableWidgetItem(package_name))
self.dlg.tableWidget.setItem(
counter,
1,
QtWidgets.QTableWidgetItem(package_details["installed_version"]),
)
self.dlg.tableWidget.setItem(
counter,
2,
QtWidgets.QTableWidgetItem(package_details["available_version"]),
)
self.dlg.tableWidget.setItem(
counter,
3,
QtWidgets.QTableWidgetItem(", ".join(dependencies)),
)
if len(package_details["available_version"]) > 0:
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)
counter += 1
self.dlg.tableWidget.setSortingEnabled(True)
self.dlg.tableWidget.horizontalHeader().setStretchLastSection(False)
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.Stretch
)
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
)
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
)
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
)
if update_counter > 0:
self.dlg.buttonUpdateAll.setEnabled(True)
else:
self.dlg.buttonUpdateAll.setEnabled(False)
def _get_dependent_addons(self, package) -> List[DependentAddon]:
dependent_addons = []
for addon in self.addons:
# if addon.installed_version is not None:
if package.lower() in addon.python_requires:
dependent_addons.append({"name": addon.name, "optional": False})
elif package.lower() in addon.python_optional:
dependent_addons.append({"name": addon.name, "optional": True})
return dependent_addons
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()):
if self.dlg.tableWidget.item(line, 0).text() == package_name:
self.dlg.tableWidget.setItem(
line,
2,
QtWidgets.QTableWidgetItem(translate("AddonsInstaller", "Updating...")),
)
break
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 50)
try:
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.ProcessEventsFlag.AllEvents, 50
)
except PipFailed as e:
fci.Console.PrintError(str(e) + "\n")
return
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 50)
def _update_all_packages(self, package_list) -> None:
"""Updates all packages with available updates."""
updates = []
for package_name, package_details in package_list.items():
if (
len(package_details["available_version"]) > 0
and package_details["available_version"] != package_details["installed_version"]
):
updates.append(package_name)
fci.Console.PrintLog(f"Running update for {len(updates)} Python packages...\n")
for package_name in updates:
self._update_package(package_name)
@classmethod
def migrate_old_am_installations(cls) -> bool:
"""Move packages installed before the Addon Manager switched to a versioned directory
structure into the versioned structure. Returns True if a migration was done, or false
if no migration was needed."""
migrated = False
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]
if not os.path.exists(old_directory) or os.path.exists(
os.path.join(old_directory, "MIGRATION_COMPLETE")
):
# Nothing to migrate
return False
os.makedirs(new_directory, mode=0o777, exist_ok=True)
for content_item in os.listdir(old_directory):
if content_item == new_directory_name:
continue
old_path = os.path.join(old_directory, content_item)
new_path = os.path.join(new_directory, content_item)
fci.Console.PrintLog(
f"Moving {content_item} into the new (versioned) directory structure\n"
)
fci.Console.PrintLog(f" {old_path} --> {new_path}\n")
shutil.move(old_path, new_path)
migrated = True
sys.path.append(new_directory)
cls._add_current_python_version()
with open(os.path.join(old_directory, "MIGRATION_COMPLETE"), "w", encoding="utf-8") as f:
f.write("Files originally installed in this directory have been migrated to:\n")
f.write(new_directory)
f.write(
"\nThe existence of this file prevents the Addon Manager from "
"attempting the migration again.\n"
)
return migrated
@classmethod
def get_known_python_versions(cls) -> List[Tuple[int, int]]:
"""Get the list of Python versions that the Addon Manager has seen before."""
known_python_versions_string = fci.Preferences().get("KnownPythonVersions")
known_python_versions = json.loads(known_python_versions_string)
return known_python_versions
@classmethod
def _add_current_python_version(cls) -> None:
known_python_versions = cls.get_known_python_versions()
major, minor, _ = platform.python_version_tuple()
if not [major, minor] in known_python_versions:
known_python_versions.append((major, minor))
fci.Preferences().set("KnownPythonVersions", json.dumps(known_python_versions))
@classmethod
def _current_python_version_is_new(cls) -> bool:
"""Returns True if this is the first time the Addon Manager has seen this version of
Python"""
known_python_versions = cls.get_known_python_versions()
major, minor, _ = platform.python_version_tuple()
if not [major, minor] in known_python_versions:
return True
return False
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:
return []
last_version = known_python_versions[-1]
expected_directory = f"py{last_version[0]}{last_version[1]}"
expected_directory = os.path.join(
fci.DataPaths().data_dir, "AdditionalPythonPackages", expected_directory
)
# For now just do this synchronously
worker_object = PythonPackageManager.PipRunner(expected_directory)
worker_object.process()
packages = parse_pip_list_output(
worker_object.all_packages_stdout, worker_object.outdated_packages_stdout
)
return packages.keys()
def _reinstall_all_packages(self) -> None:
"""Loads the package manifest from another Python version, and installs the same packages
for the current (presumably new) version of Python."""
packages = self._load_old_package_list()
args = ["install"]
args.extend(packages)
args.extend(["--target", self.vendor_path])
try:
call_pip(args)
except PipFailed as e:
fci.Console.PrintError(str(e) + "\n")
return