FreeCAD
200 строк · 8.1 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022 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"""Class to manage installation of sets of Python dependencies."""
25
26import os
27import subprocess
28from typing import List
29
30from freecad.utils import get_python_exe
31
32import addonmanager_freecad_interface as fci
33from addonmanager_pyside_interface import QObject, Signal, is_interruption_requested
34
35import addonmanager_utilities as utils
36from addonmanager_installer import AddonInstaller, MacroInstaller
37from Addon import Addon
38
39translate = fci.translate
40
41
42class DependencyInstaller(QObject):
43"""Install Python dependencies using pip. Intended to be instantiated and then moved into a
44QThread: connect the run() function to the QThread's started() signal."""
45
46no_python_exe = Signal()
47no_pip = Signal(str) # Attempted command
48failure = Signal(str, str) # Short message, detailed message
49finished = Signal()
50
51def __init__(
52self,
53addons: List[Addon],
54python_requires: List[str],
55python_optional: List[str],
56location: os.PathLike = None,
57):
58"""Install the various types of dependencies that might be specified. If an optional
59dependency fails this is non-fatal, but other failures are considered fatal. If location
60is specified it overrides the FreeCAD user base directory setting: this is used mostly
61for testing purposes and shouldn't be set by normal code in most circumstances.
62"""
63super().__init__()
64self.addons = addons
65self.python_requires = python_requires
66self.python_optional = python_optional
67self.location = location
68
69def run(self):
70"""Normally not called directly, but rather connected to the worker thread's started
71signal."""
72if self._verify_pip():
73if self.python_requires or self.python_optional:
74if not is_interruption_requested():
75self._install_python_packages()
76if not is_interruption_requested():
77self._install_addons()
78self.finished.emit()
79
80def _install_python_packages(self):
81"""Install required and optional Python dependencies using pip."""
82
83if self.location:
84vendor_path = os.path.join(self.location, "AdditionalPythonPackages")
85else:
86vendor_path = utils.get_pip_target_directory()
87if not os.path.exists(vendor_path):
88os.makedirs(vendor_path)
89
90self._install_required(vendor_path)
91self._install_optional(vendor_path)
92
93def _verify_pip(self) -> bool:
94"""Ensure that pip is working -- returns True if it is, or False if not. Also emits the
95no_pip signal if pip cannot execute."""
96python_exe = self._get_python()
97if not python_exe:
98return False
99try:
100proc = self._run_pip(["--version"])
101fci.Console.PrintMessage(proc.stdout + "\n")
102except subprocess.CalledProcessError:
103self.no_pip.emit(f"{python_exe} -m pip --version")
104return False
105return True
106
107def _install_required(self, vendor_path: str) -> bool:
108"""Install the required Python package dependencies. If any fail a failure
109signal is emitted and the function exits without proceeding with any additional
110installations."""
111for pymod in self.python_requires:
112if is_interruption_requested():
113return False
114try:
115proc = self._run_pip(
116[
117"install",
118"--disable-pip-version-check",
119"--target",
120vendor_path,
121pymod,
122]
123)
124fci.Console.PrintMessage(proc.stdout + "\n")
125except subprocess.CalledProcessError as e:
126fci.Console.PrintError(str(e) + "\n")
127self.failure.emit(
128translate(
129"AddonsInstaller",
130"Installation of Python package {} failed",
131).format(pymod),
132str(e),
133)
134return False
135return True
136
137def _install_optional(self, vendor_path: str):
138"""Install the optional Python package dependencies. If any fail a message is printed to
139the console, but installation of the others continues."""
140for pymod in self.python_optional:
141if is_interruption_requested():
142return
143try:
144proc = self._run_pip(
145[
146"install",
147"--disable-pip-version-check",
148"--target",
149vendor_path,
150pymod,
151]
152)
153fci.Console.PrintMessage(proc.stdout + "\n")
154except subprocess.CalledProcessError as e:
155fci.Console.PrintError(
156translate("AddonsInstaller", "Installation of optional package failed")
157+ ":\n"
158+ str(e)
159+ "\n"
160)
161
162def _run_pip(self, args):
163python_exe = self._get_python()
164final_args = [python_exe, "-m", "pip"]
165final_args.extend(args)
166return self._subprocess_wrapper(final_args)
167
168@staticmethod
169def _subprocess_wrapper(args) -> subprocess.CompletedProcess:
170"""Wrap subprocess call so test code can mock it."""
171return utils.run_interruptable_subprocess(args)
172
173def _get_python(self) -> str:
174"""Wrap Python access so test code can mock it."""
175python_exe = get_python_exe()
176if not python_exe:
177self.no_python_exe.emit()
178return python_exe
179
180def _install_addons(self):
181for addon in self.addons:
182if is_interruption_requested():
183return
184fci.Console.PrintMessage(
185translate("AddonsInstaller", "Installing required dependency {}").format(addon.name)
186+ "\n"
187)
188if addon.macro is None:
189installer = AddonInstaller(addon)
190else:
191installer = MacroInstaller(addon)
192result = installer.run() # Run in this thread, which should be off the GUI thread
193if not result:
194self.failure.emit(
195translate("AddonsInstaller", "Installation of Addon {} failed").format(
196addon.name
197),
198"",
199)
200return
201