Merge remote-tracking branch 'origin/master' into doxygen_to_restructuredtext_comments

# Conflicts:
#	cura/API/__init__.py
#	cura/Settings/CuraContainerRegistry.py
#	cura/Settings/ExtruderManager.py
#	plugins/PostProcessingPlugin/scripts/PauseAtHeight.py
#	plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py
#	plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py
#	plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py
This commit is contained in:
Nino van Hooff 2020-05-28 17:31:24 +02:00
commit 58ffc9dcae
234 changed files with 3945 additions and 1142 deletions

View file

@ -6,11 +6,15 @@ from time import time
from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast
from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from cura.API import Account
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud import UltimakerCloudAuthentication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .ToolPathUploader import ToolPathUploader
from ..Models.BaseModel import BaseModel
from ..Models.Http.CloudClusterResponse import CloudClusterResponse
@ -26,7 +30,7 @@ CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
class CloudApiClient:
"""The cloud API client is responsible for handling the requests and responses from the cloud.
Each method should only handle models instead of exposing Any HTTP details.
"""
@ -35,18 +39,23 @@ class CloudApiClient:
CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH)
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[], None]]
DEFAULT_REQUEST_TIMEOUT = 10 # seconds
def __init__(self, account: Account, on_error: Callable[[List[CloudError]], None]) -> None:
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
def __init__(self, app: CuraApplication, on_error: Callable[[List[CloudError]], None]) -> None:
"""Initializes a new cloud API client.
:param app:
:param account: The user's account object
:param on_error: The callback to be called whenever we receive errors from the server.
"""
super().__init__()
self._manager = QNetworkAccessManager()
self._account = account
self._app = app
self._account = app.getCuraAPI().account
self._scope = JsonDecoratorScope(UltimakerCloudScope(app))
self._http = HttpRequestManager.getInstance()
self._on_error = on_error
self._upload = None # type: Optional[ToolPathUploader]
@ -58,43 +67,52 @@ class CloudApiClient:
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None:
"""Retrieves all the clusters for the user that is currently logged in.
:param on_finished: The function to be called after the result is parsed.
"""
url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, CloudClusterResponse, failed)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, CloudClusterResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None:
"""Retrieves the status of the given cluster.
:param cluster_id: The ID of the cluster.
:param on_finished: The function to be called after the result is parsed.
"""
url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, CloudClusterStatus)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, CloudClusterStatus),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def requestUpload(self, request: CloudPrintJobUploadRequest,
on_finished: Callable[[CloudPrintJobResponse], Any]) -> None:
"""Requests the cloud to register the upload of a print job mesh.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
"""
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
body = json.dumps({"data": request.toDict()})
reply = self._manager.put(self._createEmptyRequest(url), body.encode())
self._addCallback(reply, on_finished, CloudPrintJobResponse)
data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, CloudPrintJobResponse),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
"""Uploads a print job tool path to the cloud.
:param print_job: The object received after requesting an upload with `self.requestUpload`.
:param mesh: The tool path data to be uploaded.
:param on_finished: The function to be called after the upload is successful.
@ -102,7 +120,7 @@ class CloudApiClient:
:param on_error: A function to be called if the upload fails.
"""
self._upload = ToolPathUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error)
self._upload = ToolPathUploader(self._http, print_job, mesh, on_finished, on_progress, on_error)
self._upload.start()
# Requests a cluster to print the given print job.
@ -111,14 +129,17 @@ class CloudApiClient:
# \param on_finished: The function to be called after the result is parsed.
def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None:
url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id)
reply = self._manager.post(self._createEmptyRequest(url), b"")
self._addCallback(reply, on_finished, CloudPrintResponse)
self._http.post(url,
scope = self._scope,
data = b"",
callback = self._parseCallback(on_finished, CloudPrintResponse),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str,
data: Optional[Dict[str, Any]] = None) -> None:
"""Send a print job action to the cluster for the given print job.
:param cluster_id: The ID of the cluster.
:param cluster_job_id: The ID of the print job within the cluster.
:param action: The name of the action to execute.
@ -126,11 +147,14 @@ class CloudApiClient:
body = json.dumps({"data": data}).encode() if data else b""
url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action)
self._manager.post(self._createEmptyRequest(url), body)
self._http.post(url,
scope = self._scope,
data = body,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
"""We override _createEmptyRequest in order to add the user credentials.
:param url: The URL to request
:param content_type: The type of the body contents.
"""
@ -146,7 +170,7 @@ class CloudApiClient:
@staticmethod
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
"""Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
:param reply: The reply from the server.
:return: A tuple with a status code and a dictionary.
"""
@ -164,7 +188,7 @@ class CloudApiClient:
def _parseModels(self, response: Dict[str, Any], on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]], model_class: Type[CloudApiClientModel]) -> None:
"""Parses the given models and calls the correct callback depending on the result.
:param response: The response from the server, after being converted to a dict.
:param on_finished: The callback in case the response is successful.
:param model_class: The type of the model to convert the response to. It may either be a single record or a list.
@ -185,14 +209,14 @@ class CloudApiClient:
else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
def _addCallback(self,
reply: QNetworkReply,
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None) -> None:
def _parseCallback(self,
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None) -> Callable[[QNetworkReply], None]:
"""Creates a callback function so that it includes the parsing of the response into the correct model.
The callback is added to the 'finished' signal of the reply.
:param reply: The reply that should be listened to.
:param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
@ -200,7 +224,8 @@ class CloudApiClient:
:param model: The type of the model to convert the response to.
"""
def parse() -> None:
def parse(reply: QNetworkReply) -> None:
self._anti_gc_callbacks.remove(parse)
# Don't try to parse the reply if we didn't get one
@ -216,6 +241,4 @@ class CloudApiClient:
self._parseModels(response, on_finished, model)
self._anti_gc_callbacks.append(parse)
reply.finished.connect(parse)
if on_error is not None:
reply.error.connect(on_error)
return parse

View file

@ -4,6 +4,7 @@ import os
from typing import Dict, List, Optional
from PyQt5.QtCore import QTimer
from PyQt5.QtNetwork import QNetworkReply
from UM import i18nCatalog
from UM.Logger import Logger # To log errors talking to the API.
@ -40,7 +41,7 @@ class CloudOutputDeviceManager:
# Persistent dict containing the remote clusters for the authenticated user.
self._remote_clusters = {} # type: Dict[str, CloudOutputDevice]
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._api = CloudApiClient(self._account, on_error = lambda error: Logger.log("e", str(error)))
self._api = CloudApiClient(CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error)))
self._account.loginStateChanged.connect(self._onLoginStateChanged)
# Ensure we don't start twice.
@ -118,7 +119,7 @@ class CloudOutputDeviceManager:
self._syncing = False
self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)
def _onGetRemoteClusterFailed(self):
def _onGetRemoteClusterFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
self._syncing = False
self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
@ -265,6 +266,13 @@ class CloudOutputDeviceManager:
machine.setName(device.name)
machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
machine.setMetaDataEntry("group_name", device.name)
machine.setMetaDataEntry("removal_warning", self.I18N_CATALOG.i18nc(
"@label ({} is printer name)",
"{} will be removed until the next account sync. <br> To remove {} permanently, "
"visit <a href='https://mycloud.ultimaker.com/'>Ultimaker Digital Factory</a>. "
"<br><br>Are you sure you want to remove {} temporarily?",
device.name, device.name, device.name
))
machine.addConfiguredConnectionType(device.connectionType.value)
def _connectToOutputDevice(self, device: CloudOutputDevice, machine: GlobalStack) -> None:
@ -277,4 +285,4 @@ class CloudOutputDeviceManager:
output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
if device.key not in output_device_manager.getOutputDeviceIds():
output_device_manager.addOutputDevice(device)
output_device_manager.addOutputDevice(device)

View file

@ -1,11 +1,11 @@
# Copyright (c) 2019 Ultimaker B.V.
# !/usr/bin/env python
# -*- coding: utf-8 -*-
from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
from typing import Optional, Callable, Any, Tuple, cast
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from typing import Callable, Any, Tuple, cast, Dict, Optional
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
@ -23,11 +23,11 @@ class ToolPathUploader:
# The amount of bytes to send per request
BYTES_PER_REQUEST = 256 * 1024
def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes,
def __init__(self, http: HttpRequestManager, print_job: CloudPrintJobResponse, data: bytes,
on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]
) -> None:
"""Creates a mesh upload object.
:param manager: The network access manager that will handle the HTTP requests.
:param print_job: The print job response that was returned by the cloud after registering the upload.
:param data: The mesh bytes to be uploaded.
@ -36,7 +36,7 @@ class ToolPathUploader:
:param on_error: The method to be called when an error occurs.
"""
self._manager = manager
self._http = http
self._print_job = print_job
self._data = data
@ -47,7 +47,6 @@ class ToolPathUploader:
self._sent_bytes = 0
self._retries = 0
self._finished = False
self._reply = None # type: Optional[QNetworkReply]
@property
def printJob(self):
@ -55,22 +54,9 @@ class ToolPathUploader:
return self._print_job
def _createRequest(self) -> QNetworkRequest:
"""Creates a network request to the print job upload URL, adding the needed content range header."""
request = QNetworkRequest(QUrl(self._print_job.upload_url))
request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type)
first_byte, last_byte = self._chunkRange()
content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
request.setRawHeader(b"Content-Range", content_range.encode())
Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url)
return request
def _chunkRange(self) -> Tuple[int, int]:
"""Determines the bytes that should be uploaded next.
:return: A tuple with the first and the last byte to upload.
"""
last_byte = min(len(self._data), self._sent_bytes + self.BYTES_PER_REQUEST)
@ -99,17 +85,27 @@ class ToolPathUploader:
raise ValueError("The upload is already finished")
first_byte, last_byte = self._chunkRange()
request = self._createRequest()
content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data))
# now send the reply and subscribe to the results
self._reply = self._manager.put(request, self._data[first_byte:last_byte])
self._reply.finished.connect(self._finishedCallback)
self._reply.uploadProgress.connect(self._progressCallback)
self._reply.error.connect(self._errorCallback)
headers = {
"Content-Type": cast(str, self._print_job.content_type),
"Content-Range": content_range
} # type: Dict[str, str]
Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url)
self._http.put(
url = cast(str, self._print_job.upload_url),
headers_dict = headers,
data = self._data[first_byte:last_byte],
callback = self._finishedCallback,
error_callback = self._errorCallback,
upload_progress_callback = self._progressCallback
)
def _progressCallback(self, bytes_sent: int, bytes_total: int) -> None:
"""Handles an update to the upload progress
:param bytes_sent: The amount of bytes sent in the current request.
:param bytes_total: The amount of bytes to send in the current request.
"""
@ -118,7 +114,8 @@ class ToolPathUploader:
total_sent = self._sent_bytes + bytes_sent
self._on_progress(int(total_sent / len(self._data) * 100))
def _errorCallback(self) -> None:
## Handles an error uploading.
def _errorCallback(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""Handles an error uploading."""
reply = cast(QNetworkReply, self._reply)
@ -127,7 +124,7 @@ class ToolPathUploader:
self.stop()
self._on_error()
def _finishedCallback(self) -> None:
def _finishedCallback(self, reply: QNetworkReply) -> None:
"""Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed."""
reply = cast(QNetworkReply, self._reply)
@ -148,7 +145,7 @@ class ToolPathUploader:
# Http codes that are not to be retried are assumed to be errors.
if status_code > 308:
self._errorCallback()
self._errorCallback(reply, None)
return
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,

View file

@ -151,7 +151,7 @@ class LocalClusterOutputDeviceManager:
@staticmethod
def _getPrinterTypeIdentifiers() -> Dict[str, str]:
"""Returns a dict of printer BOM numbers to machine types.
These numbers are available in the machine definition already so we just search for them here.
"""
@ -286,7 +286,7 @@ class LocalClusterOutputDeviceManager:
def _showCloudFlowMessage(device: LocalClusterOutputDevice) -> None:
"""Nudge the user to start using Ultimaker Cloud."""
if CuraApplication.getInstance().getMachineManager().activeMachineIsUsingCloudConnection:
if CuraApplication.getInstance().getMachineManager().activeMachineHasCloudRegistration:
# This printer is already cloud connected, so we do not bother the user anymore.
return
if not CuraApplication.getInstance().getCuraAPI().account.isLoggedIn: