-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathaddonmanager_workers_installation.py
315 lines (275 loc) · 14 KB
/
addonmanager_workers_installation.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
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 FreeCAD Project Association *
# * Copyright (c) 2019 Yorik van Havre <[email protected]> *
# * *
# * 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/>. *
# * *
# ***************************************************************************
"""Worker thread classes for Addon Manager installation and removal"""
# pylint: disable=c-extension-no-member,too-few-public-methods,too-many-instance-attributes
import json
import os
from typing import Dict
from enum import Enum, auto
import xml.etree.ElementTree
from PySide import QtCore
import FreeCAD
import addonmanager_utilities as utils
from addonmanager_metadata import MetadataReader
from Addon import Addon
import NetworkManager
import addonmanager_freecad_interface as fci
translate = FreeCAD.Qt.translate
# @package AddonManager_workers
# \ingroup ADDONMANAGER
# \brief Multithread workers for the addon manager
# @{
class UpdateMetadataCacheWorker(QtCore.QThread):
"""Scan through all available packages and see if our local copy of package.xml needs to be
updated"""
progress_made = QtCore.Signal(str, int, int)
package_updated = QtCore.Signal(Addon)
class RequestType(Enum):
"""The type of item being downloaded."""
PACKAGE_XML = auto()
METADATA_TXT = auto()
REQUIREMENTS_TXT = auto()
ICON = auto()
def __init__(self, repos):
QtCore.QThread.__init__(self)
self.repos = repos
self.requests: Dict[int, (Addon, UpdateMetadataCacheWorker.RequestType)] = {}
NetworkManager.AM_NETWORK_MANAGER.completed.connect(self.download_completed)
self.requests_completed = 0
self.total_requests = 0
self.store = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata")
FreeCAD.Console.PrintLog(f"Storing Addon Manager cache data in {self.store}\n")
self.updated_repos = set()
self.remote_cache_data = {}
def run(self):
"""Not usually called directly: instead, create an instance and call its
start() function to spawn a new thread."""
self.update_from_remote_cache()
current_thread = QtCore.QThread.currentThread()
for repo in self.repos:
if repo.name in self.remote_cache_data:
self.update_addon_from_remote_cache_data(repo)
elif not repo.macro and repo.url and utils.recognized_git_location(repo):
# package.xml
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(
utils.construct_git_url(repo, "package.xml")
)
self.requests[index] = (
repo,
UpdateMetadataCacheWorker.RequestType.PACKAGE_XML,
)
self.total_requests += 1
# metadata.txt
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(
utils.construct_git_url(repo, "metadata.txt")
)
self.requests[index] = (
repo,
UpdateMetadataCacheWorker.RequestType.METADATA_TXT,
)
self.total_requests += 1
# requirements.txt
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(
utils.construct_git_url(repo, "requirements.txt")
)
self.requests[index] = (
repo,
UpdateMetadataCacheWorker.RequestType.REQUIREMENTS_TXT,
)
self.total_requests += 1
while self.requests:
if current_thread.isInterruptionRequested():
for request in self.requests:
NetworkManager.AM_NETWORK_MANAGER.abort(request)
return
# 50 ms maximum between checks for interruption
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
# This set contains one copy of each of the repos that got some kind of data in
# this process. For those repos, tell the main Addon Manager code that it needs
# to update its copy of the repo, and redraw its information.
for repo in self.updated_repos:
self.package_updated.emit(repo)
def update_from_remote_cache(self) -> None:
"""Pull the data on the official repos from a remote cache site (usually
https://freecad.org/addons/addon_cache.json)"""
data_source = fci.Preferences().get("AddonsCacheURL")
try:
fetch_result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(data_source, 5000)
if fetch_result:
self.remote_cache_data = json.loads(fetch_result.data())
else:
fci.Console.PrintWarning(
f"Failed to read from {data_source}. Continuing without remote cache...\n"
)
except RuntimeError:
# If the remote cache can't be fetched, we continue anyway
pass
def update_addon_from_remote_cache_data(self, addon: Addon):
"""Given a repo that exists in the remote cache, load in its metadata."""
fci.Console.PrintLog(f"Used remote cache data for {addon.name} metadata\n")
if "package.xml" in self.remote_cache_data[addon.name]:
self.process_package_xml(addon, self.remote_cache_data[addon.name]["package.xml"])
if "requirements.txt" in self.remote_cache_data[addon.name]:
self.process_requirements_txt(
addon, self.remote_cache_data[addon.name]["requirements.txt"]
)
if "metadata.txt" in self.remote_cache_data[addon.name]:
self.process_metadata_txt(addon, self.remote_cache_data[addon.name]["metadata.txt"])
def download_completed(self, index: int, code: int, data: QtCore.QByteArray) -> None:
"""Callback for handling a completed metadata file download."""
if index in self.requests:
self.requests_completed += 1
request = self.requests.pop(index)
if code == 200: # HTTP success
self.updated_repos.add(request[0]) # mark this repo as updated
file = "unknown"
if request[1] == UpdateMetadataCacheWorker.RequestType.PACKAGE_XML:
self.process_package_xml(request[0], data)
file = "package.xml"
elif request[1] == UpdateMetadataCacheWorker.RequestType.METADATA_TXT:
self.process_metadata_txt(request[0], data)
file = "metadata.txt"
elif request[1] == UpdateMetadataCacheWorker.RequestType.REQUIREMENTS_TXT:
self.process_requirements_txt(request[0], data)
file = "requirements.txt"
elif request[1] == UpdateMetadataCacheWorker.RequestType.ICON:
self.process_icon(request[0], data)
file = "icon"
message = translate("AddonsInstaller", "Downloaded {} for {}").format(
file, request[0].display_name
)
self.progress_made.emit(message, self.requests_completed, self.total_requests)
def process_package_xml(self, repo: Addon, data: QtCore.QByteArray):
"""Process the package.xml metadata file"""
repo.repo_type = Addon.Kind.PACKAGE # By definition
package_cache_directory = os.path.join(self.store, repo.name)
if not os.path.exists(package_cache_directory):
os.makedirs(package_cache_directory)
new_xml_file = os.path.join(package_cache_directory, "package.xml")
with open(new_xml_file, "w", encoding="utf-8") as f:
string_data = self._ensure_string(data, repo.name, "package.xml")
f.write(string_data)
try:
metadata = MetadataReader.from_file(new_xml_file)
except xml.etree.ElementTree.ParseError:
fci.Console.PrintWarning("An invalid or corrupted package.xml file was downloaded for")
fci.Console.PrintWarning(f" {self.name}... ignoring the bad data.\n")
return
repo.set_metadata(metadata)
FreeCAD.Console.PrintLog(f"Downloaded package.xml for {repo.name}\n")
# Grab a new copy of the icon as well: we couldn't enqueue this earlier because
# we didn't know the path to it, which is stored in the package.xml file.
icon = repo.get_best_icon_relative_path()
icon_url = utils.construct_git_url(repo, icon)
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(icon_url)
self.requests[index] = (repo, UpdateMetadataCacheWorker.RequestType.ICON)
self.total_requests += 1
def _ensure_string(self, arbitrary_data, addon_name, file_name) -> str:
if isinstance(arbitrary_data, str):
return arbitrary_data
if isinstance(arbitrary_data, QtCore.QByteArray):
return self._decode_data(arbitrary_data.data(), addon_name, file_name)
return self._decode_data(arbitrary_data, addon_name, file_name)
def _decode_data(self, byte_data, addon_name, file_name) -> str:
"""UTF-8 decode data, and print an error message if that fails"""
# For review and debugging purposes, store the file locally
package_cache_directory = os.path.join(self.store, addon_name)
if not os.path.exists(package_cache_directory):
os.makedirs(package_cache_directory)
new_xml_file = os.path.join(package_cache_directory, file_name)
with open(new_xml_file, "wb") as f:
f.write(byte_data)
f = ""
try:
f = byte_data.decode("utf-8")
except UnicodeDecodeError as e:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
"Failed to decode {} file for Addon '{}'",
).format(file_name, addon_name)
+ "\n"
)
FreeCAD.Console.PrintWarning(str(e) + "\n")
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
"Any dependency information in this file will be ignored",
)
+ "\n"
)
return f
def process_metadata_txt(self, repo: Addon, data: QtCore.QByteArray):
"""Process the metadata.txt metadata file"""
f = self._ensure_string(data, repo.name, "metadata.txt")
lines = f.splitlines()
for line in lines:
if line.startswith("workbenches="):
depswb = line.split("=")[1].split(",")
for wb in depswb:
wb_name = wb.strip()
if wb_name:
repo.requires.add(wb_name)
FreeCAD.Console.PrintLog(
f"{repo.display_name} requires FreeCAD Addon '{wb_name}'\n"
)
elif line.startswith("pylibs="):
depspy = line.split("=")[1].split(",")
for pl in depspy:
dep = pl.strip()
if dep:
repo.python_requires.add(dep)
FreeCAD.Console.PrintLog(
f"{repo.display_name} requires python package '{dep}'\n"
)
elif line.startswith("optionalpylibs="):
opspy = line.split("=")[1].split(",")
for pl in opspy:
dep = pl.strip()
if dep:
repo.python_optional.add(dep)
FreeCAD.Console.PrintLog(
f"{repo.display_name} optionally imports python package"
+ f" '{pl.strip()}'\n"
)
def process_requirements_txt(self, repo: Addon, data: QtCore.QByteArray):
"""Process the requirements.txt metadata file"""
f = self._ensure_string(data, repo.name, "requirements.txt")
lines = f.splitlines()
for line in lines:
break_chars = " <>=~!+#"
package = line
for n, c in enumerate(line):
if c in break_chars:
package = line[:n].strip()
break
if package:
repo.python_requires.add(package)
def process_icon(self, repo: Addon, data: QtCore.QByteArray):
"""Convert icon data into a valid icon file and store it"""
cache_file = repo.get_cached_icon_filename()
with open(cache_file, "wb") as icon_file:
icon_file.write(data.data())
repo.cached_icon_filename = cache_file
# @}