FreeCAD
564 строки · 25.5 Кб
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""" Contains the classes to manage Addon installation: intended as a stable API, safe for external
25code to call and to rely upon existing. See classes AddonInstaller and MacroInstaller for details.
26"""
27import json
28from datetime import datetime, timezone
29from enum import IntEnum, auto
30import os
31import shutil
32from typing import List, Optional
33import tempfile
34from urllib.parse import urlparse
35import zipfile
36
37import FreeCAD
38
39from PySide import QtCore
40
41from Addon import Addon
42import addonmanager_utilities as utils
43from addonmanager_metadata import get_branch_from_metadata
44from addonmanager_git import initialize_git, GitFailed
45
46if FreeCAD.GuiUp:
47import NetworkManager # Requires an event loop
48
49translate = FreeCAD.Qt.translate
50
51# pylint: disable=too-few-public-methods
52
53
54class InstallationMethod(IntEnum):
55"""For packages installed from a git repository, in most cases it is possible to either use git
56or to download a zip archive of the addon. For a local repository, a direct copy may be used
57instead. If "ANY" is given, the internal code decides which to use."""
58
59GIT = auto()
60COPY = auto()
61ZIP = auto()
62ANY = auto()
63
64
65class AddonInstaller(QtCore.QObject):
66"""The core, non-GUI installer class. Usually instantiated and moved to its own thread,
67otherwise it will block the GUI (if the GUI is running). In all cases in this class, the
68generic Python 'object' is intended to be an Addon-like object that provides, at a minimum,
69a 'name', 'url', and 'branch' attribute. The Addon manager uses the Addon class for this
70purpose, but external code may use any other class that meets those criteria.
71
72Recommended Usage (when running with the GUI up, so you don't block the GUI thread):
73
74import functools # With the rest of your imports, for functools.partial
75
76...
77
78addon_to_install = MyAddon() # Some class with name, url, and branch attributes
79
80self.worker_thread = QtCore.QThread()
81self.installer = AddonInstaller(addon_to_install)
82self.installer.moveToThread(self.worker_thread)
83self.installer.success.connect(self.installation_succeeded)
84self.installer.failure.connect(self.installation_failed)
85self.installer.finished.connect(self.worker_thread.quit)
86self.worker_thread.started.connect(self.installer.run)
87self.worker_thread.start() # Returns immediately
88
89# On success, the connections above result in self.installation_succeeded being called, and
90# on failure, self.installation_failed is called.
91
92
93Recommended non-GUI usage (blocks until complete):
94
95addon_to_install = MyAddon() # Some class with name, url, and branch attributes
96installer = AddonInstaller(addon_to_install)
97installer.run()
98
99"""
100
101# Signal: progress_update
102# In GUI mode this signal is emitted periodically during long downloads. The two integers are
103# the number of bytes downloaded, and the number of bytes expected, respectively. Note that the
104# number of bytes expected might be set to 0 to indicate an unknown download size.
105progress_update = QtCore.Signal(int, int)
106
107# Signals: success and failure
108# Emitted when the installation process is complete. The object emitted is the object that the
109# installation was requested for (usually of class Addon, but any class that provides a name,
110# url, and branch attribute can be used).
111success = QtCore.Signal(object)
112failure = QtCore.Signal(object, str)
113
114# Finished: regardless of the outcome, this is emitted when all work that is going to be done
115# is done (i.e. whatever thread this is running in can quit).
116finished = QtCore.Signal()
117
118allowed_packages = set()
119
120def __init__(self, addon: Addon, allow_list: List[str] = None):
121"""Initialize the installer with an optional list of addons. If provided, then installation
122by name is supported, as long as the objects in the list contain a "name" and "url"
123property. In most use cases it is expected that addons is a List of Addon objects, but that
124is not a requirement. An optional allow_list lets calling code override the allowed Python
125packages list with a custom list. It is mostly for unit testing purposes."""
126super().__init__()
127self.addon_to_install = addon
128
129self.git_manager = initialize_git()
130
131if allow_list is not None:
132AddonInstaller.allowed_packages = set(allow_list if allow_list is not None else [])
133elif not AddonInstaller.allowed_packages:
134AddonInstaller._load_local_allowed_packages_list()
135AddonInstaller._update_allowed_packages_list()
136
137basedir = FreeCAD.getUserAppDataDir()
138self.installation_path = os.path.join(basedir, "Mod")
139self.macro_installation_path = FreeCAD.getUserMacroDir(True)
140self.zip_download_index = None
141
142def run(self, install_method: InstallationMethod = InstallationMethod.ANY) -> bool:
143"""Install an addon. Returns True if the addon was installed, or False if not. Emits
144either success or failure prior to returning."""
145try:
146addon_url = self.addon_to_install.url.replace(os.path.sep, "/")
147method_to_use = self._determine_install_method(addon_url, install_method)
148success = False
149if method_to_use == InstallationMethod.ZIP:
150success = self._install_by_zip()
151elif method_to_use == InstallationMethod.GIT:
152success = self._install_by_git()
153elif method_to_use == InstallationMethod.COPY:
154success = self._install_by_copy()
155if (
156hasattr(self.addon_to_install, "contains_workbench")
157and self.addon_to_install.contains_workbench()
158):
159self.addon_to_install.enable_workbench()
160except utils.ProcessInterrupted:
161pass
162except Exception as e:
163FreeCAD.Console.PrintLog(e + "\n")
164success = False
165if success:
166if (
167hasattr(self.addon_to_install, "contains_workbench")
168and self.addon_to_install.contains_workbench()
169):
170self.addon_to_install.set_status(Addon.Status.PENDING_RESTART)
171else:
172self.addon_to_install.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
173self.finished.emit()
174return success
175
176@classmethod
177def _load_local_allowed_packages_list(cls) -> None:
178"""Read in the local allow-list, in case the remote one is unavailable."""
179cls.allowed_packages.clear()
180allow_file = os.path.join(os.path.dirname(__file__), "ALLOWED_PYTHON_PACKAGES.txt")
181if os.path.exists(allow_file):
182with open(allow_file, encoding="utf8") as f:
183lines = f.readlines()
184for line in lines:
185if line and len(line) > 0 and line[0] != "#":
186cls.allowed_packages.add(line.strip().lower())
187
188@classmethod
189def _update_allowed_packages_list(cls) -> None:
190"""Get a new remote copy of the allowed packages list from GitHub."""
191FreeCAD.Console.PrintLog(
192"Attempting to fetch remote copy of ALLOWED_PYTHON_PACKAGES.txt...\n"
193)
194p = utils.blocking_get(
195"https://raw.githubusercontent.com/"
196"FreeCAD/FreeCAD-addons/master/ALLOWED_PYTHON_PACKAGES.txt"
197)
198if p:
199FreeCAD.Console.PrintLog(
200"Overriding local ALLOWED_PYTHON_PACKAGES.txt with newer remote version\n"
201)
202p = p.decode("utf8")
203lines = p.split("\n")
204cls.allowed_packages.clear() # Unset the locally-defined list
205for line in lines:
206if line and len(line) > 0 and line[0] != "#":
207cls.allowed_packages.add(line.strip().lower())
208else:
209FreeCAD.Console.PrintLog(
210"Could not fetch remote ALLOWED_PYTHON_PACKAGES.txt, using local copy\n"
211)
212
213def _determine_install_method(
214self, addon_url: str, install_method: InstallationMethod
215) -> Optional[InstallationMethod]:
216"""Given a URL and preferred installation method, determine the actual installation method
217to use. Will return either None, if installation is not possible for the given url and
218method, or a specific concrete method (GIT, ZIP, or COPY) based on the inputs."""
219
220# If we don't have access to git, and that is the method selected, return early
221if not self.git_manager and install_method == InstallationMethod.GIT:
222return None
223
224parse_result = urlparse(addon_url)
225is_git_only = parse_result.scheme in ["git", "ssh", "rsync"]
226is_remote = parse_result.scheme in ["http", "https", "git", "ssh", "rsync"]
227is_zipfile = parse_result.path.lower().endswith(".zip")
228
229# Can't use "copy" for a remote URL
230if is_remote and install_method == InstallationMethod.COPY:
231return None
232
233if is_git_only:
234if (
235install_method in (InstallationMethod.GIT, InstallationMethod.ANY)
236) and self.git_manager:
237# If it's a git-only URL, only git can be used for the installation
238return InstallationMethod.GIT
239# So if it's not a git installation, return None
240return None
241
242if is_zipfile:
243if install_method == InstallationMethod.GIT:
244# Can't use git on zip files
245return None
246return InstallationMethod.ZIP # Copy just becomes zip
247if not is_remote and install_method == InstallationMethod.ZIP:
248return None # Can't use zip on local paths that aren't zip files
249
250# Whatever scheme was passed in appears to be reasonable, return it
251if install_method != InstallationMethod.ANY:
252return install_method
253
254# Prefer to copy, if it's local:
255if not is_remote:
256return InstallationMethod.COPY
257
258# Prefer git if we have git
259if self.git_manager:
260return InstallationMethod.GIT
261
262# Fall back to ZIP in other cases, though this relies on remote hosts falling
263# into one of a few particular patterns
264return InstallationMethod.ZIP
265
266def _install_by_copy(self) -> bool:
267"""Installs the specified url by copying directly from it into the installation
268location. addon_url must be copyable using filesystem operations. Any existing files at
269that location are overwritten."""
270addon_url = self.addon_to_install.url
271if addon_url.startswith("file://"):
272addon_url = addon_url[len("file://") :] # Strip off the file:// part
273name = self.addon_to_install.name
274shutil.copytree(addon_url, os.path.join(self.installation_path, name), dirs_exist_ok=True)
275self._finalize_successful_installation()
276return True
277
278def _install_by_git(self) -> bool:
279"""Installs the specified url by using git to clone from it. The URL can be local or remote,
280but must represent a git repository, and the url must be in a format that git can handle
281(git, ssh, rsync, file, or a bare filesystem path)."""
282install_path = os.path.join(self.installation_path, self.addon_to_install.name)
283try:
284if os.path.isdir(install_path):
285old_branch = get_branch_from_metadata(self.addon_to_install.installed_metadata)
286new_branch = get_branch_from_metadata(self.addon_to_install.metadata)
287if old_branch != new_branch:
288utils.rmdir(install_path)
289self.git_manager.clone(self.addon_to_install.url, install_path)
290else:
291self.git_manager.update(install_path)
292else:
293self.git_manager.clone(self.addon_to_install.url, install_path)
294self.git_manager.checkout(install_path, self.addon_to_install.branch)
295except GitFailed as e:
296self.failure.emit(self.addon_to_install, str(e))
297return False
298self._finalize_successful_installation()
299return True
300
301def _install_by_zip(self) -> bool:
302"""Installs the specified url by downloading the file (if it is remote) and unzipping it
303into the appropriate installation location. If the GUI is running the download is
304asynchronous, and issues periodic updates about how much data has been downloaded."""
305if self.addon_to_install.url.endswith(".zip"):
306zip_url = self.addon_to_install.url
307else:
308zip_url = utils.get_zip_url(self.addon_to_install)
309
310FreeCAD.Console.PrintLog(f"Downloading ZIP file from {zip_url}...\n")
311parse_result = urlparse(zip_url)
312is_remote = parse_result.scheme in ["http", "https"]
313
314if is_remote:
315if FreeCAD.GuiUp:
316self._run_zip_downloader_in_event_loop(zip_url)
317else:
318zip_data = utils.blocking_get(zip_url)
319with tempfile.NamedTemporaryFile(delete=False) as f:
320tempfile_name = f.name
321f.write(zip_data)
322self._finalize_zip_installation(tempfile_name)
323else:
324self._finalize_zip_installation(zip_url)
325return True
326
327def _run_zip_downloader_in_event_loop(self, zip_url: str):
328"""Runs the zip downloader in a private event loop. This function does not exit until the
329ZIP download is complete. It requires the GUI to be up, and should not be run on the main
330GUI thread."""
331NetworkManager.AM_NETWORK_MANAGER.progress_made.connect(self._update_zip_status)
332NetworkManager.AM_NETWORK_MANAGER.progress_complete.connect(self._finish_zip)
333self.zip_download_index = NetworkManager.AM_NETWORK_MANAGER.submit_monitored_get(zip_url)
334while self.zip_download_index is not None:
335if QtCore.QThread.currentThread().isInterruptionRequested():
336break
337QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
338
339def _update_zip_status(self, index: int, bytes_read: int, data_size: int):
340"""Called periodically when downloading a zip file, emits a signal to display the
341download progress."""
342if index == self.zip_download_index:
343self.progress_update.emit(bytes_read, data_size)
344
345def _finish_zip(self, index: int, response_code: int, filename: os.PathLike):
346"""Once the zip download is finished, unzip it into the correct location. Only called if
347the GUI is up, and the NetworkManager was responsible for the download. Do not call
348directly."""
349if index != self.zip_download_index:
350return
351self.zip_download_index = None
352if response_code != 200:
353self.failure.emit(
354self.addon_to_install,
355translate("AddonsInstaller", "Received {} response code from server").format(
356response_code
357),
358)
359return
360QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
361
362FreeCAD.Console.PrintLog("ZIP download complete. Installing...\n")
363self._finalize_zip_installation(filename)
364
365def _finalize_zip_installation(self, filename: os.PathLike):
366"""Given a path to a zipfile, extract that file and put its contents in the correct
367location. Has special handling for GitHub's zip structure, which places the data in a
368subdirectory of the main directory."""
369
370destination = os.path.join(self.installation_path, self.addon_to_install.name)
371with zipfile.ZipFile(filename, "r") as zfile:
372zfile.extractall(destination)
373
374# GitHub (and possibly other hosts) put all files in the zip into a subdirectory named
375# after the branch. If that is the setup that we just extracted, move all files out of
376# that subdirectory.
377if self._code_in_branch_subdirectory(destination):
378actual_path = os.path.join(
379destination, f"{self.addon_to_install.name}-{self.addon_to_install.branch}"
380)
381FreeCAD.Console.PrintLog(
382f"ZIP installation moving code from {actual_path} to {destination}"
383)
384self._move_code_out_of_subdirectory(destination)
385
386FreeCAD.Console.PrintLog("ZIP installation complete.\n")
387self._finalize_successful_installation()
388
389def _code_in_branch_subdirectory(self, destination: str) -> bool:
390test_path = os.path.join(destination, self._expected_subdirectory_name())
391FreeCAD.Console.PrintLog(f"Checking for possible zip sub-path {test_path}...")
392if os.path.isdir(test_path):
393FreeCAD.Console.PrintLog(f"path exists.\n")
394return True
395FreeCAD.Console.PrintLog(f"path does not exist.\n")
396return False
397
398def _expected_subdirectory_name(self) -> str:
399url = self.addon_to_install.url
400if url.endswith(".git"):
401url = url[:-4]
402_, _, name = url.rpartition("/")
403branch = self.addon_to_install.branch
404return f"{name}-{branch}"
405
406def _move_code_out_of_subdirectory(self, destination):
407subdirectory = os.path.join(destination, self._expected_subdirectory_name())
408for extracted_filename in os.listdir(os.path.join(destination, subdirectory)):
409shutil.move(
410os.path.join(destination, subdirectory, extracted_filename),
411os.path.join(destination, extracted_filename),
412)
413os.rmdir(os.path.join(destination, subdirectory))
414
415def _finalize_successful_installation(self):
416"""Perform any necessary additional steps after installing the addon."""
417self._update_metadata()
418self._install_macros()
419self.success.emit(self.addon_to_install)
420
421def _update_metadata(self):
422"""Loads the package metadata from the Addon's downloaded package.xml file."""
423package_xml = os.path.join(
424self.installation_path, self.addon_to_install.name, "package.xml"
425)
426
427if hasattr(self.addon_to_install, "metadata") and os.path.isfile(package_xml):
428self.addon_to_install.load_metadata_file(package_xml)
429self.addon_to_install.installed_version = self.addon_to_install.metadata.version
430self.addon_to_install.updated_timestamp = os.path.getmtime(package_xml)
431
432def _install_macros(self):
433"""For any workbenches, copy FCMacro files into the macro directory. Exclude packages that
434have preference packs, otherwise we will litter the macro directory with the pre and post
435scripts."""
436if (
437isinstance(self.addon_to_install, Addon)
438and self.addon_to_install.contains_preference_pack()
439):
440return
441
442if not os.path.exists(self.macro_installation_path):
443os.makedirs(self.macro_installation_path)
444
445installed_macro_files = []
446for root, _, files in os.walk(
447os.path.join(self.installation_path, self.addon_to_install.name)
448):
449for f in files:
450if f.lower().endswith(".fcmacro"):
451src = os.path.join(root, f)
452dst = os.path.join(self.macro_installation_path, f)
453shutil.copy2(src, dst)
454installed_macro_files.append(dst)
455if installed_macro_files:
456with open(
457os.path.join(
458self.installation_path,
459self.addon_to_install.name,
460"AM_INSTALLATION_DIGEST.txt",
461),
462"a",
463encoding="utf-8",
464) as f:
465now = datetime.now(timezone.utc)
466f.write(
467"# The following files were created outside this installation "
468f"path during the installation of this Addon on {now}:\n"
469)
470for fcmacro_file in installed_macro_files:
471f.write(fcmacro_file + "\n")
472
473@classmethod
474def _validate_object(cls, addon: object):
475"""Make sure the object has the necessary attributes (name, url, and branch) to be
476installed."""
477
478if not hasattr(addon, "name") or not hasattr(addon, "url") or not hasattr(addon, "branch"):
479raise RuntimeError(
480"Provided object does not provide a name, url, and/or branch attribute"
481)
482
483
484class MacroInstaller(QtCore.QObject):
485"""Install a macro."""
486
487# Signals: success and failure
488# Emitted when the installation process is complete. The object emitted is the object that the
489# installation was requested for (usually of class Addon, but any class that provides a macro
490# can be used).
491success = QtCore.Signal(object)
492failure = QtCore.Signal(object)
493
494# Finished: regardless of the outcome, this is emitted when all work that is going to be done
495# is done (i.e. whatever thread this is running in can quit).
496finished = QtCore.Signal()
497
498def __init__(self, addon: object):
499"""The provided addon object must have an attribute called "macro", and that attribute must
500itself provide a callable "install" method that takes a single string, the path to the
501installation location."""
502super().__init__()
503self._validate_object(addon)
504self.addon_to_install = addon
505self.installation_path = FreeCAD.getUserMacroDir(True)
506
507def run(self) -> bool:
508"""Install a macro. Returns True if the macro was installed, or False if not. Emits
509either success or failure prior to returning."""
510
511# To try to ensure atomicity, perform the installation into a temp directory
512macro = self.addon_to_install.macro
513with tempfile.TemporaryDirectory() as temp_dir:
514temp_install_succeeded, error_list = macro.install(temp_dir)
515if not temp_install_succeeded:
516FreeCAD.Console.PrintError(
517translate("AddonsInstaller", "Failed to install macro {}").format(macro.name)
518+ "\n"
519)
520for e in error_list:
521FreeCAD.Console.PrintError(e + "\n")
522self.failure.emit(self.addon_to_install, "\n".join(error_list))
523self.finished.emit()
524return False
525
526# If it succeeded, move all the files to the macro install location,
527# keeping a list of all the files we installed, so they can be removed later
528# if this macro is uninstalled.
529manifest = []
530for item in os.listdir(temp_dir):
531src = os.path.join(temp_dir, item)
532dst = os.path.join(self.installation_path, item)
533shutil.move(src, dst)
534manifest.append(dst)
535self._write_installation_manifest(manifest)
536self.success.emit(self.addon_to_install)
537self.addon_to_install.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
538self.finished.emit()
539return True
540
541def _write_installation_manifest(self, manifest):
542manifest_file = os.path.join(
543self.installation_path, self.addon_to_install.macro.filename + ".manifest"
544)
545try:
546with open(manifest_file, "w", encoding="utf-8") as f:
547f.write(json.dumps(manifest, indent=" "))
548except OSError as e:
549FreeCAD.Console.PrintWarning(
550translate("AddonsInstaller", "Failed to create installation manifest " "file:\n")
551)
552FreeCAD.Console.PrintWarning(manifest_file)
553
554@classmethod
555def _validate_object(cls, addon: object):
556"""Make sure this object provides an attribute called "macro" with a method called
557"install" """
558if (
559not hasattr(addon, "macro")
560or addon.macro is None
561or not hasattr(addon.macro, "install")
562or not callable(addon.macro.install)
563):
564raise RuntimeError("Provided object does not provide a macro with an install method")
565