FreeCAD
700 строк · 30.3 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022-2023 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"""
25#############################################################################
26#
27# ABOUT NETWORK MANAGER
28#
29# A wrapper around QNetworkAccessManager providing proxy-handling
30# capabilities, and simplified access to submitting requests from any
31# application thread.
32#
33#
34# USAGE
35#
36# Once imported, this file provides access to a global object called
37# AM_NETWORK_MANAGER. This is a QObject running on the main thread, but
38# designed to be interacted with from any other application thread. It
39# provides two principal methods: submit_unmonitored_get() and
40# submit_monitored_get(). Use the unmonitored version for small amounts of
41# data (suitable for caching in RAM, and without a need to show a progress
42# bar during download), and the monitored version for larger amounts of data.
43# Both functions take a URL, and return an integer index. That index allows
44# tracking of the completed request by attaching to the signals completed(),
45# progress_made(), and progress_complete(). All three provide, as the first
46# argument to the signal, the index of the request the signal refers to.
47# Code attached to those signals should filter them to look for the indices
48# of the requests they care about. Requests may complete in any order.
49#
50# A secondary blocking interface is also provided, for very short network
51# accesses: the blocking_get() function blocks until the network transmission
52# is complete, directly returning a QByteArray object with the received data.
53# Do not run on the main GUI thread!
54"""
55
56import threading57import os58import queue59import itertools60import tempfile61import sys62from typing import Dict, List, Optional63
64try:65import FreeCAD66
67if FreeCAD.GuiUp:68import FreeCADGui69
70HAVE_FREECAD = True71translate = FreeCAD.Qt.translate72except ImportError:73# For standalone testing support working without the FreeCAD import74HAVE_FREECAD = False75
76from PySide import QtCore77
78if FreeCAD.GuiUp:79from PySide import QtWidgets80
81
82# This is the global instance of the NetworkManager that outside code
83# should access
84AM_NETWORK_MANAGER = None85
86HAVE_QTNETWORK = True87try:88from PySide import QtNetwork89except ImportError:90if HAVE_FREECAD:91FreeCAD.Console.PrintError(92translate(93"AddonsInstaller",94'Could not import QtNetwork -- it does not appear to be installed on your system. Your provider may have a package for this dependency (often called "python3-pyside2.qtnetwork")',95)96+ "\n"97)98else:99print("Could not import QtNetwork, unable to test this file.")100sys.exit(1)101HAVE_QTNETWORK = False102
103if HAVE_QTNETWORK:104
105# Added in Qt 5.15106if hasattr(QtNetwork.QNetworkRequest, "DefaultTransferTimeoutConstant"):107timeoutConstant = QtNetwork.QNetworkRequest.DefaultTransferTimeoutConstant108if hasattr(timeoutConstant, "value"):109# Qt 6 changed the timeout constant to have a 'value' attribute.110# The function setTransferTimeout does not accept111# DefaultTransferTimeoutConstant of type112# QtNetwork.QNetworkRequest.TransferTimeoutConstant any113# longer but only an int.114default_timeout = timeoutConstant.value115else:116# In Qt 5.15 we can use the timeoutConstant as is.117default_timeout = timeoutConstant118else:119default_timeout = 30000120
121class QueueItem:122"""A container for information about an item in the network queue."""123
124def __init__(self, index: int, request: QtNetwork.QNetworkRequest, track_progress: bool):125self.index = index126self.request = request127self.original_url = request.url()128self.track_progress = track_progress129
130class NetworkManager(QtCore.QObject):131"""A single global instance of NetworkManager is instantiated and stored as132AM_NETWORK_MANAGER. Outside threads should send GET requests to this class by
133calling the submit_unmonitored_request() or submit_monitored_request() function,
134as needed. See the documentation of those functions for details."""
135
136# Connect to complete for requests with no progress monitoring (e.g. small amounts of data)137completed = QtCore.Signal(138int, int, QtCore.QByteArray139) # Index, http response code, received data (if any)140
141# Connect to progress_made and progress_complete for large amounts of data, which get buffered into a temp file142# That temp file should be deleted when your code is done with it143progress_made = QtCore.Signal(int, int, int) # Index, bytes read, total bytes (may be None)144
145progress_complete = QtCore.Signal(146int, int, os.PathLike147) # Index, http response code, filename148
149__request_queued = QtCore.Signal()150
151def __init__(self):152super().__init__()153
154self.counting_iterator = itertools.count()155self.queue = queue.Queue()156self.__last_started_index = 0157self.__abort_when_found: List[int] = []158self.replies: Dict[int, QtNetwork.QNetworkReply] = {}159self.file_buffers = {}160
161# We support an arbitrary number of threads using synchronous GET calls:162self.synchronous_lock = threading.Lock()163self.synchronous_complete: Dict[int, bool] = {}164self.synchronous_result_data: Dict[int, QtCore.QByteArray] = {}165
166# Make sure we exit nicely on quit167if QtCore.QCoreApplication.instance() is not None:168QtCore.QCoreApplication.instance().aboutToQuit.connect(self.__aboutToQuit)169
170# Create the QNAM on this thread:171self.QNAM = QtNetwork.QNetworkAccessManager()172self.QNAM.proxyAuthenticationRequired.connect(self.__authenticate_proxy)173self.QNAM.authenticationRequired.connect(self.__authenticate_resource)174self.QNAM.setRedirectPolicy(QtNetwork.QNetworkRequest.ManualRedirectPolicy)175
176qnam_cache = QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.CacheLocation)177os.makedirs(qnam_cache, exist_ok=True)178self.diskCache = QtNetwork.QNetworkDiskCache()179self.diskCache.setCacheDirectory(qnam_cache)180self.QNAM.setCache(self.diskCache)181
182self.monitored_connections: List[int] = []183self._setup_proxy()184
185# A helper connection for our blocking interface186self.completed.connect(self.__synchronous_process_completion)187
188# Set up our worker connection189self.__request_queued.connect(self.__setup_network_request)190
191def _setup_proxy(self):192"""Set up the proxy based on user preferences or prompts on command line"""193
194# Set up the proxy, if necessary:195if HAVE_FREECAD:196(197noProxyCheck,198systemProxyCheck,199userProxyCheck,200proxy_string,201) = self._setup_proxy_freecad()202else:203(204noProxyCheck,205systemProxyCheck,206userProxyCheck,207proxy_string,208) = self._setup_proxy_standalone()209
210if noProxyCheck:211pass212elif systemProxyCheck:213query = QtNetwork.QNetworkProxyQuery(214QtCore.QUrl("https://github.com/FreeCAD/FreeCAD")215)216proxy = QtNetwork.QNetworkProxyFactory.systemProxyForQuery(query)217if proxy and proxy[0]:218self.QNAM.setProxy(proxy[0]) # This may still be QNetworkProxy.NoProxy219elif userProxyCheck:220host, _, port_string = proxy_string.rpartition(":")221try:222port = 0 if not port_string else int(port_string)223except ValueError:224FreeCAD.Console.PrintError(225translate(226"AddonsInstaller",227"Failed to convert the specified proxy port '{}' to a port number",228).format(port_string)229+ "\n"230)231port = 0232# For now assume an HttpProxy, but eventually this should be a parameter233proxy = QtNetwork.QNetworkProxy(QtNetwork.QNetworkProxy.HttpProxy, host, port)234self.QNAM.setProxy(proxy)235
236def _setup_proxy_freecad(self):237"""If we are running within FreeCAD, this uses the config data to set up the proxy"""238noProxyCheck = True239systemProxyCheck = False240userProxyCheck = False241proxy_string = ""242pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")243noProxyCheck = pref.GetBool("NoProxyCheck", noProxyCheck)244systemProxyCheck = pref.GetBool("SystemProxyCheck", systemProxyCheck)245userProxyCheck = pref.GetBool("UserProxyCheck", userProxyCheck)246proxy_string = pref.GetString("ProxyUrl", "")247
248# Add some error checking to the proxy setup, since for historical reasons they249# are independent booleans, rather than an enumeration:250option_count = [noProxyCheck, systemProxyCheck, userProxyCheck].count(True)251if option_count != 1:252FreeCAD.Console.PrintWarning(253translate(254"AddonsInstaller",255"Parameter error: mutually exclusive proxy options set. Resetting to default.",256)257+ "\n"258)259noProxyCheck = True260systemProxyCheck = False261userProxyCheck = False262pref.SetBool("NoProxyCheck", noProxyCheck)263pref.SetBool("SystemProxyCheck", systemProxyCheck)264pref.SetBool("UserProxyCheck", userProxyCheck)265
266if userProxyCheck and not proxy_string:267FreeCAD.Console.PrintWarning(268translate(269"AddonsInstaller",270"Parameter error: user proxy indicated, but no proxy provided. Resetting to default.",271)272+ "\n"273)274noProxyCheck = True275userProxyCheck = False276pref.SetBool("NoProxyCheck", noProxyCheck)277pref.SetBool("UserProxyCheck", userProxyCheck)278return noProxyCheck, systemProxyCheck, userProxyCheck, proxy_string279
280def _setup_proxy_standalone(self):281"""If we are NOT running inside FreeCAD, prompt the user for proxy information"""282noProxyCheck = True283systemProxyCheck = False284userProxyCheck = False285proxy_string = ""286print("Please select a proxy type:")287print("1) No proxy")288print("2) Use system proxy settings")289print("3) Custom proxy settings")290result = input("Choice: ")291if result == "1":292pass293elif result == "2":294noProxyCheck = False295systemProxyCheck = True296elif result == "3":297noProxyCheck = False298userProxyCheck = True299proxy_string = input("Enter your proxy server (host:port): ")300else:301print(f"Got {result}, expected 1, 2, or 3.")302app.quit()303return noProxyCheck, systemProxyCheck, userProxyCheck, proxy_string304
305def __aboutToQuit(self):306"""Called when the application is about to quit. Not currently used."""307
308def __setup_network_request(self):309"""Get the next request off the queue and launch it."""310try:311item = self.queue.get_nowait()312if item:313if item.index in self.__abort_when_found:314self.__abort_when_found.remove(item.index)315return # Do not do anything with this item, it's been aborted...316if item.track_progress:317self.monitored_connections.append(item.index)318self.__launch_request(item.index, item.request)319except queue.Empty:320pass321
322def __launch_request(self, index: int, request: QtNetwork.QNetworkRequest) -> None:323"""Given a network request, ask the QNetworkAccessManager to begin processing it."""324reply = self.QNAM.get(request)325self.replies[index] = reply326
327self.__last_started_index = index328reply.finished.connect(self.__reply_finished)329reply.sslErrors.connect(self.__on_ssl_error)330if index in self.monitored_connections:331reply.readyRead.connect(self.__ready_to_read)332reply.downloadProgress.connect(self.__download_progress)333
334def submit_unmonitored_get(335self,336url: str,337timeout_ms: int = default_timeout,338) -> int:339"""Adds this request to the queue, and returns an index that can be used by calling code340in conjunction with the completed() signal to handle the results of the call. All data is
341kept in memory, and the completed() call includes a direct handle to the bytes returned. It
342is not called until the data transfer has finished and the connection is closed."""
343
344current_index = next(self.counting_iterator) # A thread-safe counter345# Use a queue because we can only put things on the QNAM from the main event loop thread346self.queue.put(347QueueItem(348current_index, self.__create_get_request(url, timeout_ms), track_progress=False349)350)351self.__request_queued.emit()352return current_index353
354def submit_monitored_get(355self,356url: str,357timeout_ms: int = default_timeout,358) -> int:359"""Adds this request to the queue, and returns an index that can be used by calling code360in conjunction with the progress_made() and progress_completed() signals to handle the
361results of the call. All data is cached to disk, and progress is reported periodically
362as the underlying QNetworkReply reports its progress. The progress_completed() signal
363contains a path to a temporary file with the stored data. Calling code should delete this
364file when done with it (or move it into its final place, etc.)."""
365
366current_index = next(self.counting_iterator) # A thread-safe counter367# Use a queue because we can only put things on the QNAM from the main event loop thread368self.queue.put(369QueueItem(370current_index, self.__create_get_request(url, timeout_ms), track_progress=True371)372)373self.__request_queued.emit()374return current_index375
376def blocking_get(377self,378url: str,379timeout_ms: int = default_timeout,380) -> Optional[QtCore.QByteArray]:381"""Submits a GET request to the QNetworkAccessManager and block until it is complete"""382
383current_index = next(self.counting_iterator) # A thread-safe counter384with self.synchronous_lock:385self.synchronous_complete[current_index] = False386
387self.queue.put(388QueueItem(389current_index, self.__create_get_request(url, timeout_ms), track_progress=False390)391)392self.__request_queued.emit()393while True:394if QtCore.QThread.currentThread().isInterruptionRequested():395return None396QtCore.QCoreApplication.processEvents()397with self.synchronous_lock:398if self.synchronous_complete[current_index]:399break400
401with self.synchronous_lock:402self.synchronous_complete.pop(current_index)403if current_index in self.synchronous_result_data:404return self.synchronous_result_data.pop(current_index)405return None406
407def __synchronous_process_completion(408self, index: int, code: int, data: QtCore.QByteArray409) -> None:410"""Check the return status of a completed process, and handle its returned data (if411any)."""
412with self.synchronous_lock:413if index in self.synchronous_complete:414if code == 200:415self.synchronous_result_data[index] = data416else:417FreeCAD.Console.PrintWarning(418translate(419"AddonsInstaller",420"Addon Manager: Unexpected {} response from server",421).format(code)422+ "\n"423)424self.synchronous_complete[index] = True425
426@staticmethod427def __create_get_request(url: str, timeout_ms: int) -> QtNetwork.QNetworkRequest:428"""Construct a network request to a given URL"""429request = QtNetwork.QNetworkRequest(QtCore.QUrl(url))430request.setAttribute(431QtNetwork.QNetworkRequest.RedirectPolicyAttribute,432QtNetwork.QNetworkRequest.ManualRedirectPolicy,433)434request.setAttribute(QtNetwork.QNetworkRequest.CacheSaveControlAttribute, True)435request.setAttribute(436QtNetwork.QNetworkRequest.CacheLoadControlAttribute,437QtNetwork.QNetworkRequest.PreferNetwork,438)439if hasattr(request, "setTransferTimeout"):440# Added in Qt 5.15441# In Qt 5, the function setTransferTimeout seems to accept442# DefaultTransferTimeoutConstant of type443# PySide2.QtNetwork.QNetworkRequest.TransferTimeoutConstant,444# whereas in Qt 6, the function seems to only accept an445# integer.446request.setTransferTimeout(timeout_ms)447return request448
449def abort_all(self):450"""Abort ALL network calls in progress, including clearing the queue"""451for reply in self.replies.values():452if reply.abort().isRunning():453reply.abort()454while True:455try:456self.queue.get()457self.queue.task_done()458except queue.Empty:459break460
461def abort(self, index: int):462"""Abort a specific request"""463if index in self.replies and self.replies[index].isRunning():464self.replies[index].abort()465elif index < self.__last_started_index:466# It's still in the queue. Mark it for later destruction.467self.__abort_when_found.append(index)468
469def __authenticate_proxy(470self,471reply: QtNetwork.QNetworkProxy,472authenticator: QtNetwork.QAuthenticator,473):474"""If proxy authentication is required, attempt to authenticate. If the GUI is running this displays475a window asking for credentials. If the GUI is not running, it prompts on the command line.
476"""
477if HAVE_FREECAD and FreeCAD.GuiUp:478proxy_authentication = FreeCADGui.PySideUic.loadUi(479os.path.join(os.path.dirname(__file__), "proxy_authentication.ui")480)481# Show the right labels, etc.482proxy_authentication.labelProxyAddress.setText(f"{reply.hostName()}:{reply.port()}")483if authenticator.realm():484proxy_authentication.labelProxyRealm.setText(authenticator.realm())485else:486proxy_authentication.labelProxyRealm.hide()487proxy_authentication.labelRealmCaption.hide()488result = proxy_authentication.exec()489if result == QtWidgets.QDialogButtonBox.Ok:490authenticator.setUser(proxy_authentication.lineEditUsername.text())491authenticator.setPassword(proxy_authentication.lineEditPassword.text())492else:493username = input("Proxy username: ")494import getpass495
496password = getpass.getpass()497authenticator.setUser(username)498authenticator.setPassword(password)499
500def __authenticate_resource(501self,502_reply: QtNetwork.QNetworkReply,503_authenticator: QtNetwork.QAuthenticator,504):505"""Unused."""506
507def __on_ssl_error(self, reply: str, errors: List[str] = None):508"""Called when an SSL error occurs: prints the error information."""509if HAVE_FREECAD:510FreeCAD.Console.PrintWarning(511translate("AddonsInstaller", "Error with encrypted connection") + "\n:"512)513FreeCAD.Console.PrintWarning(reply)514if errors is not None:515for error in errors:516FreeCAD.Console.PrintWarning(error)517else:518print("Error with encrypted connection")519if errors is not None:520for error in errors:521print(error)522
523def __download_progress(self, bytesReceived: int, bytesTotal: int) -> None:524"""Monitors download progress and emits a progress_made signal"""525sender = self.sender()526if not sender:527return528for index, reply in self.replies.items():529if reply == sender:530self.progress_made.emit(index, bytesReceived, bytesTotal)531return532
533def __ready_to_read(self) -> None:534"""Called when data is available, this reads that data."""535sender = self.sender()536if not sender:537return538
539for index, reply in self.replies.items():540if reply == sender:541self.__data_incoming(index, reply)542return543
544def __data_incoming(self, index: int, reply: QtNetwork.QNetworkReply) -> None:545"""Read incoming data and attach it to a data object"""546if not index in self.replies:547# We already finished this reply, this is a vestigial signal548return549buffer = reply.readAll()550if not index in self.file_buffers:551f = tempfile.NamedTemporaryFile("wb", delete=False)552self.file_buffers[index] = f553else:554f = self.file_buffers[index]555try:556f.write(buffer.data())557except OSError as e:558if HAVE_FREECAD:559FreeCAD.Console.PrintError(f"Network Manager internal error: {str(e)}")560else:561print(f"Network Manager internal error: {str(e)}")562
563def __reply_finished(self) -> None:564"""Called when a reply has been completed: this makes sure the data has been read and565any notifications have been called."""
566reply = self.sender()567if not reply:568# This can happen during a cancellation operation: silently do nothing569return570
571index = None572for key, value in self.replies.items():573if reply == value:574index = key575break576if index is None:577return578
579response_code = reply.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute)580redirect_codes = [301, 302, 303, 305, 307, 308]581if response_code in redirect_codes: # This is a redirect582timeout_ms = default_timeout583if hasattr(reply, "request"):584request = reply.request()585if hasattr(request, "transferTimeout"):586timeout_ms = request.transferTimeout()587new_url = reply.attribute(QtNetwork.QNetworkRequest.RedirectionTargetAttribute)588self.__launch_request(index, self.__create_get_request(new_url, timeout_ms))589return # The task is not done, so get out of this method now590if reply.error() != QtNetwork.QNetworkReply.NetworkError.OperationCanceledError:591# It this was not a timeout, make sure we mark the queue task done592self.queue.task_done()593if reply.error() == QtNetwork.QNetworkReply.NetworkError.NoError:594if index in self.monitored_connections:595# Make sure to read any remaining data596self.__data_incoming(index, reply)597self.monitored_connections.remove(index)598f = self.file_buffers[index]599f.close()600self.progress_complete.emit(index, response_code, f.name)601else:602data = reply.readAll()603self.completed.emit(index, response_code, data)604else:605if index in self.monitored_connections:606self.progress_complete.emit(index, response_code, "")607else:608self.completed.emit(index, response_code, None)609self.replies.pop(index)610
611else: # HAVE_QTNETWORK is false:612
613class NetworkManager(QtCore.QObject):614"""A dummy class to enable an offline mode when the QtNetwork package is not yet installed"""615
616completed = QtCore.Signal(617int, int, bytes618) # Emitted as soon as the request is made, with a connection failed error619progress_made = QtCore.Signal(int, int, int) # Never emitted, no progress is made here620progress_complete = QtCore.Signal(621int, int, os.PathLike622) # Emitted as soon as the request is made, with a connection failed error623
624def __init__(self):625super().__init__()626self.monitored_queue = queue.Queue()627self.unmonitored_queue = queue.Queue()628
629def submit_unmonitored_request(self, _) -> int:630"""Returns a fake index that can be used for testing -- nothing is actually queued"""631current_index = next(itertools.count())632self.unmonitored_queue.put(current_index)633return current_index634
635def submit_monitored_request(self, _) -> int:636"""Returns a fake index that can be used for testing -- nothing is actually queued"""637current_index = next(itertools.count())638self.monitored_queue.put(current_index)639return current_index640
641def blocking_get(self, _: str) -> QtCore.QByteArray:642"""No operation - returns None immediately"""643return None644
645def abort_all(646self,647):648"""There is nothing to abort in this case"""649
650def abort(self, _):651"""There is nothing to abort in this case"""652
653
654def InitializeNetworkManager():655"""Called once at the beginning of program execution to create the appropriate manager object"""656global AM_NETWORK_MANAGER657if AM_NETWORK_MANAGER is None:658AM_NETWORK_MANAGER = NetworkManager()659
660
661if __name__ == "__main__":662app = QtCore.QCoreApplication()663
664InitializeNetworkManager()665
666count = 0667
668# For testing, create several network requests and send them off in quick succession:669# (Choose small downloads, no need for significant data)670urls = [671"https://api.github.com/zen",672"http://climate.ok.gov/index.php/climate/rainfall_table/local_data",673"https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/AIANNHA/MapServer",674]675
676def handle_completion(index: int, code: int, data):677"""Attached to the completion signal, prints diagnostic information about the network access"""678global count679if code == 200:680print(f"For request {index+1}, response was {data.size()} bytes.", flush=True)681else:682print(683f"For request {index+1}, request failed with HTTP result code {code}",684flush=True,685)686
687count += 1688if count >= len(urls):689print("Shutting down...", flush=True)690AM_NETWORK_MANAGER.requestInterruption()691AM_NETWORK_MANAGER.wait(5000)692app.quit()693
694AM_NETWORK_MANAGER.completed.connect(handle_completion)695for test_url in urls:696AM_NETWORK_MANAGER.submit_unmonitored_get(test_url)697
698app.exec_()699
700print("Done with all requests.")701