Merge pull request #4994 from Ultimaker/STAR-322_cloud-connection-multipart-upload

STAR-322: Cloud connection resumable upload
This commit is contained in:
ChrisTerBeke 2018-12-17 15:29:56 +01:00 committed by GitHub
commit ab85e4794b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 530 additions and 525 deletions

View File

@ -1,238 +0,0 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from time import time
from typing import Optional, Dict, Callable, List, Union
from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \
QAuthenticator
from UM.Application import Application
from UM.Logger import Logger
## Abstraction of QNetworkAccessManager for easier networking in Cura.
# This was originally part of NetworkedPrinterOutputDevice but was moved out for re-use in other classes.
class NetworkClient:
def __init__(self) -> None:
# Network manager instance to use for this client.
self._manager = None # type: Optional[QNetworkAccessManager]
# Timings.
self._last_manager_create_time = None # type: Optional[float]
self._last_response_time = None # type: Optional[float]
self._last_request_time = None # type: Optional[float]
# The user agent of Cura.
application = Application.getInstance()
self._user_agent = "%s/%s " % (application.getApplicationName(), application.getVersion())
# Uses to store callback methods for finished network requests.
# This allows us to register network calls with a callback directly instead of having to dissect the reply.
self._on_finished_callbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]]
# QHttpMultiPart objects need to be kept alive and not garbage collected during the
# HTTP which uses them. We hold references to these QHttpMultiPart objects here.
self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart]
## Creates a network manager with all the required properties and event bindings.
def _createNetworkManager(self) -> None:
if self._manager:
self._manager.finished.disconnect(self.__handleOnFinished)
self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired)
self._manager = QNetworkAccessManager()
self._manager.finished.connect(self.__handleOnFinished)
self._last_manager_create_time = time()
self._manager.authenticationRequired.connect(self._onAuthenticationRequired)
## Create a new empty network request.
# Automatically adds the required HTTP headers.
# \param url: The URL to request
# \param content_type: The type of the body contents.
def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
request = QNetworkRequest(QUrl(url))
if content_type:
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
return request
## Executes the correct callback method when a network request finishes.
def __handleOnFinished(self, reply: QNetworkReply) -> None:
# Due to garbage collection, we need to cache certain bits of post operations.
# As we don't want to keep them around forever, delete them if we get a reply.
if reply.operation() == QNetworkAccessManager.PostOperation:
self._clearCachedMultiPart(reply)
# No status code means it never even reached remote.
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
return
# Not used by this class itself, but children might need it for better network handling.
# An example of this is the _update method in the NetworkedPrinterOutputDevice.
self._last_response_time = time()
# Find the right callback and execute it.
# It always takes the full reply as single parameter.
callback_key = reply.url().toString() + str(reply.operation())
if callback_key in self._on_finished_callbacks:
self._on_finished_callbacks[callback_key](reply)
else:
Logger.log("w", "Received reply to URL %s but no callbacks are registered", reply.url())
## Removes all cached Multi-Part items.
def _clearCachedMultiPart(self, reply: QNetworkReply) -> None:
if reply in self._kept_alive_multiparts:
del self._kept_alive_multiparts[reply]
## Makes sure the network manager is created.
def _validateManager(self) -> None:
if self._manager is None:
self._createNetworkManager()
assert self._manager is not None
## Callback for when the network manager detects that authentication is required but was not given.
@staticmethod
def _onAuthenticationRequired(reply: QNetworkReply, authenticator: QAuthenticator) -> None:
Logger.log("w", "Request to {} required authentication but was not given".format(reply.url().toString()))
## Register a method to be executed when the associated network request finishes.
def _registerOnFinishedCallback(self, reply: QNetworkReply,
on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
if on_finished is not None:
self._on_finished_callbacks[reply.url().toString() + str(reply.operation())] = on_finished
## Add a part to a Multi-Part form.
@staticmethod
def _createFormPart(content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
part = QHttpPart()
if not content_header.startswith("form-data;"):
content_header = "form_data; " + content_header
part.setHeader(QNetworkRequest.ContentDispositionHeader, content_header)
if content_type is not None:
part.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
part.setBody(data)
return part
## Public version of _createFormPart. Both are needed for backward compatibility with 3rd party plugins.
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
return self._createFormPart(content_header, data, content_type)
## Sends a put request to the given path.
# url: The path after the API prefix.
# data: The data to be sent in the body
# content_type: The content type of the body data.
# on_finished: The function to call when the response is received.
# on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None,
on_finished: Optional[Callable[[QNetworkReply], None]] = None,
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager()
request = self._createEmptyRequest(url, content_type = content_type)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "No network manager was created to execute the PUT call with.")
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.put(request, body)
self._registerOnFinishedCallback(reply, on_finished)
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
## Sends a delete request to the given path.
# url: The path after the API prefix.
# on_finished: The function to be call when the response is received.
def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "No network manager was created to execute the DELETE call with.")
reply = self._manager.deleteResource(request)
self._registerOnFinishedCallback(reply, on_finished)
## Sends a get request to the given path.
# \param url: The path after the API prefix.
# \param on_finished: The function to be call when the response is received.
def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "No network manager was created to execute the GET call with.")
reply = self._manager.get(request)
self._registerOnFinishedCallback(reply, on_finished)
## Sends a post request to the given path.
# \param url: The path after the API prefix.
# \param data: The data to be sent in the body
# \param on_finished: The function to call when the response is received.
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def post(self, url: str, data: Union[str, bytes],
on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "Could not find manager.")
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.post(request, body)
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
self._registerOnFinishedCallback(reply, on_finished)
## Does a POST request with form data to the given URL.
def postForm(self, url: str, header_data: str, body_data: bytes,
on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
post_part = QHttpPart()
post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data)
post_part.setBody(body_data)
self.postFormWithParts(url, [post_part], on_finished, on_progress)
## Does a POST request with form parts to the given URL.
def postFormWithParts(self, target: str, parts: List[QHttpPart],
on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> Optional[QNetworkReply]:
self._validateManager()
request = self._createEmptyRequest(target, content_type = None)
multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
for part in parts:
multi_post_part.append(part)
self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the POST call with.")
return None
reply = self._manager.post(request, multi_post_part)
self._kept_alive_multiparts[reply] = multi_post_part
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
self._registerOnFinishedCallback(reply, on_finished)
return reply

View File

@ -52,8 +52,11 @@ class AuthorizationService:
if not self._user_profile: if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT. # If no user profile was stored locally, we try to get it from JWT.
self._user_profile = self._parseJWT() self._user_profile = self._parseJWT()
if not self._user_profile:
if not self._user_profile and self._auth_data:
# If there is still no user profile from the JWT, we have to log in again. # If there is still no user profile from the JWT, we have to log in again.
Logger.log("w", "The user profile could not be loaded. The user must log in again!")
self.deleteAuthData()
return None return None
return self._user_profile return self._user_profile

View File

@ -2,14 +2,16 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import json import json
from json import JSONDecodeError from json import JSONDecodeError
from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any from time import time
from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager
from UM.Logger import Logger from UM.Logger import Logger
from cura import CuraConstants from cura import CuraConstants
from cura.API import Account from cura.API import Account
from cura.NetworkClient import NetworkClient from .MeshUploader import MeshUploader
from ..Models import BaseModel from ..Models import BaseModel
from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudClusterResponse import CloudClusterResponse
from .Models.CloudErrorObject import CloudErrorObject from .Models.CloudErrorObject import CloudErrorObject
@ -21,7 +23,7 @@ from .Models.CloudPrintJobResponse import CloudPrintJobResponse
## The cloud API client is responsible for handling the requests and responses from the cloud. ## 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. # Each method should only handle models instead of exposing Any HTTP details.
class CloudApiClient(NetworkClient): class CloudApiClient:
# The cloud URL to use for this remote cluster. # The cloud URL to use for this remote cluster.
ROOT_PATH = CuraConstants.CuraCloudAPIRoot ROOT_PATH = CuraConstants.CuraCloudAPIRoot
@ -33,8 +35,12 @@ class CloudApiClient(NetworkClient):
# \param on_error: The callback to be called whenever we receive errors from the server. # \param on_error: The callback to be called whenever we receive errors from the server.
def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]) -> None: def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]) -> None:
super().__init__() super().__init__()
self._manager = QNetworkAccessManager()
self._account = account self._account = account
self._on_error = on_error self._on_error = on_error
self._upload = None # type: Optional[MeshUploader]
# in order to avoid garbage collection we keep the callbacks in this list.
self._anti_gc_callbacks = [] # type: List[Callable[[], None]]
## Gets the account used for the API. ## Gets the account used for the API.
@property @property
@ -45,14 +51,16 @@ class CloudApiClient(NetworkClient):
# \param on_finished: The function to be called after the result is parsed. # \param on_finished: The function to be called after the result is parsed.
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None: def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None:
url = "{}/clusters".format(self.CLUSTER_API_ROOT) url = "{}/clusters".format(self.CLUSTER_API_ROOT)
self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterResponse)) reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, CloudClusterResponse)
## Retrieves the status of the given cluster. ## Retrieves the status of the given cluster.
# \param cluster_id: The ID of the cluster. # \param cluster_id: The ID of the cluster.
# \param on_finished: The function to be called after the result is parsed. # \param on_finished: The function to be called after the result is parsed.
def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None:
url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterStatus)) reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, CloudClusterStatus)
## Requests the cloud to register the upload of a print job mesh. ## Requests the cloud to register the upload of a print job mesh.
# \param request: The request object. # \param request: The request object.
@ -61,32 +69,19 @@ class CloudApiClient(NetworkClient):
) -> None: ) -> None:
url = "{}/jobs/upload".format(self.CURA_API_ROOT) url = "{}/jobs/upload".format(self.CURA_API_ROOT)
body = json.dumps({"data": request.toDict()}) body = json.dumps({"data": request.toDict()})
self.put(url, body, on_finished=self._wrapCallback(on_finished, CloudPrintJobResponse)) reply = self._manager.put(self._createEmptyRequest(url), body.encode())
self._addCallback(reply, on_finished, CloudPrintJobResponse)
## Requests the cloud to register the upload of a print job mesh. ## Uploads a print job mesh to the cloud.
# \param upload_response: The object received after requesting an upload with `self.requestUpload`. # \param print_job: The object received after requesting an upload with `self.requestUpload`.
# \param mesh: The mesh data to be uploaded. # \param mesh: The mesh data to be uploaded.
# \param on_finished: The function to be called after the result is parsed. It receives the print job ID. # \param on_finished: The function to be called after the upload is successful.
# \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
# \param on_error: A function to be called if the upload fails. It receives a dict with the error. # \param on_error: A function to be called if the upload fails.
def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[str], Any], def uploadMesh(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
on_progress: Callable[[int], Any], on_error: Callable[[dict], Any]): on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
self._upload = MeshUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error)
def progressCallback(bytes_sent: int, bytes_total: int) -> None: self._upload.start()
if bytes_total:
on_progress(int((bytes_sent / bytes_total) * 100))
def finishedCallback(reply: QNetworkReply):
status_code, response = self._parseReply(reply)
if status_code < 300:
on_finished(upload_response.job_id)
else:
Logger.log("e", "Received unexpected response %s uploading mesh: %s", status_code, response)
on_error(response)
# TODO: Multipart upload
self.put(upload_response.upload_url, data = mesh, content_type = upload_response.content_type,
on_finished = finishedCallback, on_progress = progressCallback)
# Requests a cluster to print the given print job. # Requests a cluster to print the given print job.
# \param cluster_id: The ID of the cluster. # \param cluster_id: The ID of the cluster.
@ -94,13 +89,16 @@ class CloudApiClient(NetworkClient):
# \param on_finished: The function to be called after the result is parsed. # \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: 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) url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id)
self.post(url, data = "", on_finished=self._wrapCallback(on_finished, CloudPrintResponse)) reply = self._manager.post(self._createEmptyRequest(url), b"")
self._addCallback(reply, on_finished, CloudPrintResponse)
## We override _createEmptyRequest in order to add the user credentials. ## We override _createEmptyRequest in order to add the user credentials.
# \param url: The URL to request # \param url: The URL to request
# \param content_type: The type of the body contents. # \param content_type: The type of the body contents.
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
request = super()._createEmptyRequest(path, content_type) request = QNetworkRequest(QUrl(path))
if content_type:
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
if self._account.isLoggedIn: if self._account.isLoggedIn:
request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode())
Logger.log("i", "Created request for URL %s. Logged in = %s", path, self._account.isLoggedIn) Logger.log("i", "Created request for URL %s. Logged in = %s", path, self._account.isLoggedIn)
@ -117,9 +115,10 @@ class CloudApiClient(NetworkClient):
Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response) Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response)
return status_code, json.loads(response) return status_code, json.loads(response)
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
error = {"code": type(err).__name__, "title": str(err), "http_code": str(status_code)} error = CloudErrorObject(code=type(err).__name__, title=str(err), http_code=str(status_code),
id=str(time()), http_status="500")
Logger.logException("e", "Could not parse the stardust response: %s", error) Logger.logException("e", "Could not parse the stardust response: %s", error)
return status_code, {"errors": [error]} return status_code, {"errors": [error.toDict()]}
## The generic type variable used to document the methods below. ## The generic type variable used to document the methods below.
Model = TypeVar("Model", bound=BaseModel) Model = TypeVar("Model", bound=BaseModel)
@ -129,26 +128,38 @@ class CloudApiClient(NetworkClient):
# \param on_finished: The callback in case the response is successful. # \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. # \param model_class: The type of the model to convert the response to. It may either be a single record or a list.
def _parseModels(self, response: Dict[str, Any], def _parseModels(self, response: Dict[str, Any],
on_finished: Callable[[Union[Model, List[Model]]], Any], on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]],
model_class: Type[Model]) -> None: model_class: Type[Model]) -> None:
if "data" in response: if "data" in response:
data = response["data"] data = response["data"]
result = [model_class(**c) for c in data] if isinstance(data, list) else model_class(**data) if isinstance(data, list):
on_finished(result) results = [model_class(**c) for c in data] # type: List[CloudApiClient.Model]
on_finished_list = cast(Callable[[List[CloudApiClient.Model]], Any], on_finished)
on_finished_list(results)
else:
result = model_class(**data) # type: CloudApiClient.Model
on_finished_item = cast(Callable[[CloudApiClient.Model], Any], on_finished)
on_finished_item(result)
elif "errors" in response: elif "errors" in response:
self._on_error([CloudErrorObject(**error) for error in response["errors"]]) self._on_error([CloudErrorObject(**error) for error in response["errors"]])
else: else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
## Wraps a callback function so that it includes the parsing of the response into the correct model. ## Creates a callback function so that it includes the parsing of the response into the correct model.
# \param on_finished: The callback in case the response is successful. # The callback is added to the 'finished' signal of the reply.
# \param model: The type of the model to convert the response to. It may either be a single record or a list. # \param reply: The reply that should be listened to.
# \return: A function that can be passed to the # \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
def _wrapCallback(self, # a list or a single item.
on_finished: Callable[[Union[Model, List[Model]]], Any], # \param model: The type of the model to convert the response to.
def _addCallback(self,
reply: QNetworkReply,
on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]],
model: Type[Model], model: Type[Model],
) -> Callable[[QNetworkReply], None]: ) -> None:
def parse(reply: QNetworkReply) -> None: def parse() -> None:
status_code, response = self._parseReply(reply) status_code, response = self._parseReply(reply)
self._anti_gc_callbacks.remove(parse)
return self._parseModels(response, on_finished, model) return self._parseModels(response, on_finished, model)
return parse
self._anti_gc_callbacks.append(parse)
reply.finished.connect(parse)

View File

@ -3,22 +3,26 @@
import os import os
from time import time from time import time
from typing import Dict, List, Optional, Set from typing import Dict, List, Optional, Set, cast
from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot
from UM import i18nCatalog from UM import i18nCatalog
from UM.Backend.Backend import BackendState
from UM.FileHandler.FileHandler import FileHandler from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Qt.Duration import Duration, DurationFormat from UM.Qt.Duration import Duration, DurationFormat
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController
from ..MeshFormatHandler import MeshFormatHandler from ..MeshFormatHandler import MeshFormatHandler
from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel
from .CloudProgressMessage import CloudProgressMessage
from .CloudApiClient import CloudApiClient from .CloudApiClient import CloudApiClient
from .Models.CloudClusterResponse import CloudClusterResponse
from .Models.CloudClusterStatus import CloudClusterStatus from .Models.CloudClusterStatus import CloudClusterStatus
from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
from .Models.CloudPrintResponse import CloudPrintResponse from .Models.CloudPrintResponse import CloudPrintResponse
@ -43,9 +47,6 @@ class T:
COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.") COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.")
SENDING_DATA_TEXT = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster")
SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster")
ERROR = _I18N_CATALOG.i18nc("@info:title", "Error") ERROR = _I18N_CATALOG.i18nc("@info:title", "Error")
UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.") UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.")
@ -68,7 +69,7 @@ class T:
class CloudOutputDevice(NetworkedPrinterOutputDevice): class CloudOutputDevice(NetworkedPrinterOutputDevice):
# The interval with which the remote clusters are checked # The interval with which the remote clusters are checked
CHECK_CLUSTER_INTERVAL = 4.0 # seconds CHECK_CLUSTER_INTERVAL = 50.0 # seconds
# Signal triggered when the print jobs in the queue were changed. # Signal triggered when the print jobs in the queue were changed.
printJobsChanged = pyqtSignal() printJobsChanged = pyqtSignal()
@ -82,16 +83,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
## Creates a new cloud output device ## Creates a new cloud output device
# \param api_client: The client that will run the API calls # \param api_client: The client that will run the API calls
# \param device_id: The ID of the device (i.e. the cluster_id for the cloud API) # \param cluster: The device response received from the cloud API.
# \param parent: The optional parent of this output device. # \param parent: The optional parent of this output device.
def __init__(self, api_client: CloudApiClient, device_id: str, host_name: str, parent: QObject = None) -> None: def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None:
super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) super().__init__(device_id = cluster.cluster_id, address = "", properties = {}, parent = parent)
self._api = api_client self._api = api_client
self._host_name = host_name self._cluster = cluster
self._setInterfaceElements() self._setInterfaceElements()
self._device_id = device_id
self._account = api_client.account self._account = api_client.account
# We use the Cura Connect monitor tab to get most functionality right away. # We use the Cura Connect monitor tab to get most functionality right away.
@ -109,9 +109,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines.
# We only allow a single upload at a time. # We only allow a single upload at a time.
self._sending_job = False self._progress = CloudProgressMessage()
# TODO: handle progress messages in another class.
self._progress_message = None # type: Optional[Message]
# Keep server string of the last generated time to avoid updating models more than once for the same response # Keep server string of the last generated time to avoid updating models more than once for the same response
self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]] self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]]
@ -120,21 +118,42 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
# A set of the user's job IDs that have finished # A set of the user's job IDs that have finished
self._finished_jobs = set() # type: Set[str] self._finished_jobs = set() # type: Set[str]
## Gets the host name of this device # Reference to the uploaded print job / mesh
@property self._mesh = None # type: Optional[bytes]
def host_name(self) -> str: self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
return self._host_name
## Updates the host name of the output device ## Connects this device.
@host_name.setter def connect(self) -> None:
def host_name(self, value: str) -> None: super().connect()
self._host_name = value Logger.log("i", "Connected to cluster %s", self.key)
CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
## Disconnects the device
def disconnect(self) -> None:
super().disconnect()
Logger.log("i", "Disconnected to cluster %s", self.key)
CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange)
## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices.
def _onBackendStateChange(self, _: BackendState) -> None:
self._mesh = None
self._uploaded_print_job = None
## Gets the cluster response from which this device was created.
@property
def clusterData(self) -> CloudClusterResponse:
return self._cluster
## Updates the cluster data from the cloud.
@clusterData.setter
def clusterData(self, value: CloudClusterResponse) -> None:
self._cluster = value
## Checks whether the given network key is found in the cloud's host name ## Checks whether the given network key is found in the cloud's host name
def matchesNetworkKey(self, network_key: str) -> bool: def matchesNetworkKey(self, network_key: str) -> bool:
# A network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local." # A network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
# the host name should then be "ultimakersystem-aabbccdd0011" # the host name should then be "ultimakersystem-aabbccdd0011"
return network_key.startswith(self._host_name) return network_key.startswith(self.clusterData.host_name)
## Set all the interface elements and texts for this output device. ## Set all the interface elements and texts for this output device.
def _setInterfaceElements(self) -> None: def _setInterfaceElements(self) -> None:
@ -145,16 +164,21 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
self.setConnectionText(T.CONNECTED_VIA_CLOUD) self.setConnectionText(T.CONNECTED_VIA_CLOUD)
## Called when Cura requests an output device to receive a (G-code) file. ## Called when Cura requests an output device to receive a (G-code) file.
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
# Show an error message if we're already sending a job. # Show an error message if we're already sending a job.
if self._sending_job: if self._progress.visible:
self._onUploadError(T.BLOCKED_UPLOADING) message = Message(text = T.BLOCKED_UPLOADING, title = T.ERROR, lifetime = 10)
message.show()
return
if self._uploaded_print_job:
# the mesh didn't change, let's not upload it again
self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintRequested)
return return
# Indicate we have started sending a job. # Indicate we have started sending a job.
self._sending_job = True
self.writeStarted.emit(self) self.writeStarted.emit(self)
mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion)
@ -162,24 +186,28 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
Logger.log("e", "Missing file or mesh writer!") Logger.log("e", "Missing file or mesh writer!")
return self._onUploadError(T.COULD_NOT_EXPORT) return self._onUploadError(T.COULD_NOT_EXPORT)
mesh_bytes = mesh_format.getBytes(nodes) mesh = mesh_format.getBytes(nodes)
self._mesh = mesh
request = CloudPrintJobUploadRequest( request = CloudPrintJobUploadRequest(
job_name = file_name, job_name = file_name or mesh_format.file_extension,
file_size = len(mesh_bytes), file_size = len(mesh),
content_type = mesh_format.mime_type, content_type = mesh_format.mime_type,
) )
self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh_bytes, response)) self._api.requestUpload(request, self._onPrintJobCreated)
## Called when the network data should be updated. ## Called when the network data should be updated.
def _update(self) -> None: def _update(self) -> None:
super()._update() super()._update()
if self._last_response_time and time() - self._last_response_time < self.CHECK_CLUSTER_INTERVAL: if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL:
Logger.log("i", "Not updating: %s - %s < %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL)
return # avoid calling the cloud too often return # avoid calling the cloud too often
Logger.log("i", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL)
if self._account.isLoggedIn: if self._account.isLoggedIn:
self.setAuthenticationState(AuthState.Authenticated) self.setAuthenticationState(AuthState.Authenticated)
self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) self._last_request_time = time()
self._api.getClusterStatus(self.key, self._onStatusCallFinished)
else: else:
self.setAuthenticationState(AuthState.NotAuthenticated) self.setAuthenticationState(AuthState.NotAuthenticated)
@ -187,6 +215,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
# Contains both printers and print jobs statuses in a single response. # Contains both printers and print jobs statuses in a single response.
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
# Update all data from the cluster. # Update all data from the cluster.
self._last_response_time = time()
if self._received_printers != status.printers: if self._received_printers != status.printers:
self._received_printers = status.printers self._received_printers = status.printers
self._updatePrinters(status.printers) self._updatePrinters(status.printers)
@ -283,62 +312,41 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
model.updateAssignedPrinter(printer) model.updateAssignedPrinter(printer)
## Uploads the mesh when the print job was registered with the cloud API. ## Uploads the mesh when the print job was registered with the cloud API.
# \param mesh: The bytes to upload.
# \param job_response: The response received from the cloud API. # \param job_response: The response received from the cloud API.
def _onPrintJobCreated(self, mesh: bytes, job_response: CloudPrintJobResponse) -> None: def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None:
self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, self._progress.show()
lambda _: self._onUploadError(T.UPLOAD_ERROR)) self._uploaded_print_job = job_response
mesh = cast(bytes, self._mesh)
self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._progress.update, self._onUploadError)
## Requests the print to be sent to the printer when we finished uploading the mesh. ## Requests the print to be sent to the printer when we finished uploading the mesh.
# \param job_id: The ID of the job. def _onPrintJobUploaded(self) -> None:
def _onPrintJobUploaded(self, job_id: str) -> None: self._progress.update(100)
self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
self._api.requestPrint(self.key, print_job.job_id, self._onPrintRequested)
## Updates the progress of the mesh upload.
# \param progress: The amount of percentage points uploaded until now (0-100).
def _updateUploadProgress(self, progress: int) -> None:
if not self._progress_message:
self._progress_message = Message(
text = T.SENDING_DATA_TEXT,
title = T.SENDING_DATA_TITLE,
progress = -1,
lifetime = 0,
dismissable = False,
use_inactivity_timer = False
)
self._progress_message.setProgress(progress)
self._progress_message.show()
## Hides the upload progress bar
def _resetUploadProgress(self) -> None:
if self._progress_message:
self._progress_message.hide()
self._progress_message = None
## Displays the given message if uploading the mesh has failed ## Displays the given message if uploading the mesh has failed
# \param message: The message to display. # \param message: The message to display.
def _onUploadError(self, message: str = None) -> None: def _onUploadError(self, message = None) -> None:
self._resetUploadProgress() self._progress.hide()
if message: self._uploaded_print_job = None
Message( Message(
text = message, text = message or T.UPLOAD_ERROR,
title = T.ERROR, title = T.ERROR,
lifetime = 10 lifetime = 10
).show() ).show()
self._sending_job = False # the upload has finished so we're not sending a job anymore
self.writeError.emit() self.writeError.emit()
## Shows a message when the upload has succeeded ## Shows a message when the upload has succeeded
# \param response: The response from the cloud API. # \param response: The response from the cloud API.
def _onUploadSuccess(self, response: CloudPrintResponse) -> None: def _onPrintRequested(self, response: CloudPrintResponse) -> None:
Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id)
self._resetUploadProgress() self._progress.hide()
Message( Message(
text = T.UPLOAD_SUCCESS_TEXT, text = T.UPLOAD_SUCCESS_TEXT,
title = T.UPLOAD_SUCCESS_TITLE, title = T.UPLOAD_SUCCESS_TITLE,
lifetime = 5 lifetime = 5
).show() ).show()
self._sending_job = False # the upload has finished so we're not sending a job anymore
self.writeFinished.emit() self.writeFinished.emit()
## Gets the remote printers. ## Gets the remote printers.

View File

@ -26,7 +26,7 @@ class CloudOutputDeviceManager:
META_CLUSTER_ID = "um_cloud_cluster_id" META_CLUSTER_ID = "um_cloud_cluster_id"
# The interval with which the remote clusters are checked # The interval with which the remote clusters are checked
CHECK_CLUSTER_INTERVAL = 5.0 # seconds CHECK_CLUSTER_INTERVAL = 50.0 # seconds
# The translation catalog for this device. # The translation catalog for this device.
I18N_CATALOG = i18nCatalog("cura") I18N_CATALOG = i18nCatalog("cura")
@ -39,26 +39,22 @@ class CloudOutputDeviceManager:
self._output_device_manager = application.getOutputDeviceManager() self._output_device_manager = application.getOutputDeviceManager()
self._account = application.getCuraAPI().account # type: Account self._account = application.getCuraAPI().account # type: Account
self._account.loginStateChanged.connect(self._onLoginStateChanged)
self._api = CloudApiClient(self._account, self._onApiError) self._api = CloudApiClient(self._account, self._onApiError)
# When switching machines we check if we have to activate a remote cluster.
application.globalContainerStackChanged.connect(self._connectToActiveMachine)
# create a timer to update the remote cluster list # create a timer to update the remote cluster list
self._update_timer = QTimer(application) self._update_timer = QTimer(application)
self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000))
self._update_timer.setSingleShot(False) self._update_timer.setSingleShot(False)
self._update_timer.timeout.connect(self._getRemoteClusters)
# Make sure the timer is started in case we missed the loginChanged signal self._running = False
self._onLoginStateChanged(self._account.isLoggedIn)
# Called when the uses logs in or out # Called when the uses logs in or out
def _onLoginStateChanged(self, is_logged_in: bool) -> None: def _onLoginStateChanged(self, is_logged_in: bool) -> None:
Logger.log("i", "Log in state changed to %s", is_logged_in)
if is_logged_in: if is_logged_in:
if not self._update_timer.isActive(): if not self._update_timer.isActive():
self._update_timer.start() self._update_timer.start()
self._getRemoteClusters()
else: else:
if self._update_timer.isActive(): if self._update_timer.isActive():
self._update_timer.stop() self._update_timer.stop()
@ -77,7 +73,8 @@ class CloudOutputDeviceManager:
removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters) removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters)
Logger.log("i", "Parsed remote clusters to %s", online_clusters) Logger.log("i", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()])
Logger.log("i", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates))
# Remove output devices that are gone # Remove output devices that are gone
for removed_cluster in removed_devices: for removed_cluster in removed_devices:
@ -90,12 +87,12 @@ class CloudOutputDeviceManager:
# Add an output device for each new remote cluster. # Add an output device for each new remote cluster.
# We only add when is_online as we don't want the option in the drop down if the cluster is not online. # We only add when is_online as we don't want the option in the drop down if the cluster is not online.
for added_cluster in added_clusters: for added_cluster in added_clusters:
device = CloudOutputDevice(self._api, added_cluster.cluster_id, added_cluster.host_name) device = CloudOutputDevice(self._api, added_cluster)
self._output_device_manager.addOutputDevice(device) self._output_device_manager.addOutputDevice(device)
self._remote_clusters[added_cluster.cluster_id] = device self._remote_clusters[added_cluster.cluster_id] = device
for device, cluster in updates: for device, cluster in updates:
device.host_name = cluster.host_name device.clusterData = cluster
self._connectToActiveMachine() self._connectToActiveMachine()
@ -103,6 +100,7 @@ class CloudOutputDeviceManager:
def _connectToActiveMachine(self) -> None: def _connectToActiveMachine(self) -> None:
active_machine = CuraApplication.getInstance().getGlobalContainerStack() active_machine = CuraApplication.getInstance().getGlobalContainerStack()
if not active_machine: if not active_machine:
Logger.log("i", "no active machine")
return return
# Check if the stored cluster_id for the active machine is in our list of remote clusters. # Check if the stored cluster_id for the active machine is in our list of remote clusters.
@ -111,6 +109,7 @@ class CloudOutputDeviceManager:
device = self._remote_clusters[stored_cluster_id] device = self._remote_clusters[stored_cluster_id]
if not device.isConnected(): if not device.isConnected():
device.connect() device.connect()
Logger.log("i", "Device connected by metadata %s", stored_cluster_id)
else: else:
self._connectByNetworkKey(active_machine) self._connectByNetworkKey(active_machine)
@ -126,13 +125,38 @@ class CloudOutputDeviceManager:
active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
device.connect() device.connect()
Logger.log("i", "Found cluster %s with network key %s", device, local_network_key)
## Handles an API error received from the cloud. ## Handles an API error received from the cloud.
# \param errors: The errors received # \param errors: The errors received
def _onApiError(self, errors: List[CloudErrorObject]) -> None: def _onApiError(self, errors: List[CloudErrorObject]) -> None:
message = ". ".join(e.title for e in errors) # TODO: translate errors text = ". ".join(e.title for e in errors) # TODO: translate errors
Message( message = Message(
text = message, text = text,
title = self.I18N_CATALOG.i18nc("@info:title", "Error"), title = self.I18N_CATALOG.i18nc("@info:title", "Error"),
lifetime = 10, lifetime = 10,
dismissable = True dismissable = True
).show() )
message.show()
## Starts running the cloud output device manager, thus periodically requesting cloud data.
def start(self):
if self._running:
return
application = CuraApplication.getInstance()
self._account.loginStateChanged.connect(self._onLoginStateChanged)
# When switching machines we check if we have to activate a remote cluster.
application.globalContainerStackChanged.connect(self._connectToActiveMachine)
self._update_timer.timeout.connect(self._getRemoteClusters)
self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn)
## Stops running the cloud output device manager.
def stop(self):
if not self._running:
return
application = CuraApplication.getInstance()
self._account.loginStateChanged.disconnect(self._onLoginStateChanged)
# When switching machines we check if we have to activate a remote cluster.
application.globalContainerStackChanged.disconnect(self._connectToActiveMachine)
self._update_timer.timeout.disconnect(self._getRemoteClusters)
self._onLoginStateChanged(is_logged_in = False)

View File

@ -0,0 +1,42 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM import i18nCatalog
from UM.Message import Message
## Class that contains all the translations for this module.
class T:
_I18N_CATALOG = i18nCatalog("cura")
SENDING_DATA_TEXT = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster")
SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster")
## Class responsible for showing a progress message while a mesh is being uploaded to the cloud.
class CloudProgressMessage(Message):
def __init__(self):
super().__init__(
text = T.SENDING_DATA_TEXT,
title = T.SENDING_DATA_TITLE,
progress = -1,
lifetime = 0,
dismissable = False,
use_inactivity_timer = False
)
## Shows the progress message.
def show(self):
self.setProgress(0)
super().show()
## Updates the percentage of the uploaded.
# \param percentage: The percentage amount (0-100).
def update(self, percentage: int) -> None:
if not self._visible:
super().show()
self.setProgress(percentage)
## Returns a boolean indicating whether the message is currently visible.
@property
def visible(self) -> bool:
return self._visible

View File

@ -0,0 +1,148 @@
# Copyright (c) 2018 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 UM.Logger import Logger
from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse
## Class responsible for uploading meshes to the cloud in separate requests.
class MeshUploader:
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
MAX_RETRIES = 10
# The HTTP codes that should trigger a retry.
RETRY_HTTP_CODES = {500, 502, 503, 504}
# The amount of bytes to send per request
BYTES_PER_REQUEST = 256 * 1024
## 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.
# \param on_finished: The method to be called when done.
# \param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
# \param on_error: The method to be called when an error occurs.
def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes,
on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]
) -> None:
self._manager = manager
self._print_job = print_job
self._data = data
self._on_finished = on_finished
self._on_progress = on_progress
self._on_error = on_error
self._sent_bytes = 0
self._retries = 0
self._finished = False
self._reply = None # type: Optional[QNetworkReply]
## Returns the print job for which this object was created.
@property
def printJob(self):
return self._print_job
## Creates a network request to the print job upload URL, adding the needed content range header.
def _createRequest(self) -> QNetworkRequest:
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
## Determines the bytes that should be uploaded next.
# \return: A tuple with the first and the last byte to upload.
def _chunkRange(self) -> Tuple[int, int]:
last_byte = min(len(self._data), self._sent_bytes + self.BYTES_PER_REQUEST)
return self._sent_bytes, last_byte
## Starts uploading the mesh.
def start(self) -> None:
if self._finished:
# reset state.
self._sent_bytes = 0
self._retries = 0
self._finished = False
self._uploadChunk()
## Stops uploading the mesh, marking it as finished.
def stop(self):
Logger.log("i", "Stopped uploading")
self._finished = True
## Uploads a chunk of the mesh to the cloud.
def _uploadChunk(self) -> None:
if self._finished:
raise ValueError("The upload is already finished")
first_byte, last_byte = self._chunkRange()
request = self._createRequest()
# 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)
## 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.
def _progressCallback(self, bytes_sent: int, bytes_total: int) -> None:
Logger.log("i", "Progress callback %s / %s", bytes_sent, bytes_total)
if bytes_total:
total_sent = self._sent_bytes + bytes_sent
self._on_progress(int(total_sent / len(self._data) * 100))
## Handles an error uploading.
def _errorCallback(self) -> None:
reply = cast(QNetworkReply, self._reply)
body = bytes(reply.readAll()).decode()
Logger.log("e", "Received error while uploading: %s", body)
self.stop()
self._on_error()
## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
def _finishedCallback(self) -> None:
reply = cast(QNetworkReply, self._reply)
Logger.log("i", "Finished callback %s %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: int
# check if we should retry the last chunk
if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES:
self._retries += 1
Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString())
self._uploadChunk()
return
# Http codes that are not to be retried are assumed to be errors.
if status_code > 308:
self._errorCallback()
return
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,
[bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode())
self._chunkUploaded()
## Handles a chunk of data being uploaded, starting the next chunk if needed.
def _chunkUploaded(self) -> None:
# We got a successful response. Let's start the next chunk or report the upload is finished.
first_byte, last_byte = self._chunkRange()
self._sent_bytes += last_byte - first_byte
if self._sent_bytes >= len(self._data):
self.stop()
self._on_finished()
else:
self._uploadChunk()

View File

@ -390,10 +390,10 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
## Called when the connection to the cluster changes. ## Called when the connection to the cluster changes.
def connect(self) -> None: def connect(self) -> None:
pass
# TODO: uncomment this once cloud implementation works for testing # TODO: uncomment this once cloud implementation works for testing
# super().connect() # super().connect()
# self.sendMaterialProfiles() # self.sendMaterialProfiles()
pass
def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None:
reply_url = reply.url().toString() reply_url = reply.url().toString()

View File

@ -18,4 +18,3 @@ class ClusterUM3PrinterOutputController(PrinterOutputController):
def setJobState(self, job: "PrintJobOutputModel", state: str): def setJobState(self, job: "PrintJobOutputModel", state: str):
data = "{\"action\": \"%s\"}" % state data = "{\"action\": \"%s\"}" % state
self._output_device.put("print_jobs/%s/action" % job.key, data, on_finished=None) self._output_device.put("print_jobs/%s/action" % job.key, data, on_finished=None)

View File

@ -86,6 +86,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
## Start looking for devices on network. ## Start looking for devices on network.
def start(self): def start(self):
self.startDiscovery() self.startDiscovery()
self._cloud_output_device_manager.start()
def startDiscovery(self): def startDiscovery(self):
self.stop() self.stop()
@ -142,6 +143,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
if self._zero_conf is not None: if self._zero_conf is not None:
Logger.log("d", "zeroconf close...") Logger.log("d", "zeroconf close...")
self._zero_conf.close() self._zero_conf.close()
self._cloud_output_device_manager.stop()
def removeManualDevice(self, key, address = None): def removeManualDevice(self, key, address = None):
if key in self._discovered_devices: if key in self._discovered_devices:

View File

@ -1,15 +1,30 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import json import json
from typing import Dict, Tuple, Union, Optional from typing import Dict, Tuple, Union, Optional, Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
from UM.Logger import Logger from UM.Logger import Logger
from UM.Signal import Signal from UM.Signal import Signal
class FakeSignal:
def __init__(self):
self._callbacks = []
def connect(self, callback):
self._callbacks.append(callback)
def disconnect(self, callback):
self._callbacks.remove(callback)
def emit(self, *args, **kwargs):
for callback in self._callbacks:
callback(*args, **kwargs)
## This class can be used to mock the QNetworkManager class and test the code using it. ## This class can be used to mock the QNetworkManager class and test the code using it.
# After patching the QNetworkManager class, requests are prepared before they can be executed. # After patching the QNetworkManager class, requests are prepared before they can be executed.
# Any requests not prepared beforehand will cause KeyErrors. # Any requests not prepared beforehand will cause KeyErrors.
@ -27,7 +42,7 @@ class NetworkManagerMock:
## Initializes the network manager mock. ## Initializes the network manager mock.
def __init__(self) -> None: def __init__(self) -> None:
# a dict with the prepared replies, using the format {(http_method, url): reply} # a dict with the prepared replies, using the format {(http_method, url): reply}
self.replies = {} # type: Dict[Tuple[str, str], QNetworkReply] self.replies = {} # type: Dict[Tuple[str, str], MagicMock]
self.request_bodies = {} # type: Dict[Tuple[str, str], bytes] self.request_bodies = {} # type: Dict[Tuple[str, str], bytes]
# signals used in the network manager. # signals used in the network manager.
@ -38,7 +53,7 @@ class NetworkManagerMock:
# Since the methods are very simple and the same it didn't make sense to repeat the code. # Since the methods are very simple and the same it didn't make sense to repeat the code.
# \param method: The method being called. # \param method: The method being called.
# \return The mocked function, if the method name is known. Defaults to the standard getattr function. # \return The mocked function, if the method name is known. Defaults to the standard getattr function.
def __getattr__(self, method: str) -> any: def __getattr__(self, method: str) -> Any:
## This mock implementation will simply return the reply from the prepared ones. ## This mock implementation will simply return the reply from the prepared ones.
# it raises a KeyError if requests are done without being prepared. # it raises a KeyError if requests are done without being prepared.
def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_): def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_):
@ -64,6 +79,8 @@ class NetworkManagerMock:
reply_mock.url().toString.return_value = url reply_mock.url().toString.return_value = url
reply_mock.operation.return_value = self._OPERATIONS[method] reply_mock.operation.return_value = self._OPERATIONS[method]
reply_mock.attribute.return_value = status_code reply_mock.attribute.return_value = status_code
reply_mock.finished = FakeSignal()
reply_mock.isFinished.return_value = False
reply_mock.readAll.return_value = response if isinstance(response, bytes) else json.dumps(response).encode() reply_mock.readAll.return_value = response if isinstance(response, bytes) else json.dumps(response).encode()
self.replies[method, url] = reply_mock self.replies[method, url] = reply_mock
Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url) Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url)
@ -76,7 +93,10 @@ class NetworkManagerMock:
## Emits the signal that the reply is ready to all prepared replies. ## Emits the signal that the reply is ready to all prepared replies.
def flushReplies(self) -> None: def flushReplies(self) -> None:
for reply in self.replies.values(): for key, reply in self.replies.items():
Logger.log("i", "Flushing reply to {} {}", *key)
reply.isFinished.return_value = True
reply.finished.emit()
self.finished.emit(reply) self.finished.emit(reply)
self.reset() self.reset()

View File

@ -6,8 +6,10 @@ from typing import List
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from cura.CuraApplication import CuraApplication from cura.CuraConstants import CuraCloudAPIRoot
from src.Cloud.CloudApiClient import CloudApiClient from src.Cloud.CloudApiClient import CloudApiClient
from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
from src.Cloud.Models.CloudClusterStatus import CloudClusterStatus
from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse
from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
from src.Cloud.Models.CloudErrorObject import CloudErrorObject from src.Cloud.Models.CloudErrorObject import CloudErrorObject
@ -15,8 +17,9 @@ from tests.Cloud.Fixtures import readFixture, parseFixture
from .NetworkManagerMock import NetworkManagerMock from .NetworkManagerMock import NetworkManagerMock
@patch("cura.NetworkClient.QNetworkAccessManager")
class TestCloudApiClient(TestCase): class TestCloudApiClient(TestCase):
maxDiff = None
def _errorHandler(self, errors: List[CloudErrorObject]): def _errorHandler(self, errors: List[CloudErrorObject]):
raise Exception("Received unexpected error: {}".format(errors)) raise Exception("Received unexpected error: {}".format(errors))
@ -25,83 +28,74 @@ class TestCloudApiClient(TestCase):
self.account = MagicMock() self.account = MagicMock()
self.account.isLoggedIn.return_value = True self.account.isLoggedIn.return_value = True
self.app = CuraApplication.getInstance()
self.network = NetworkManagerMock() self.network = NetworkManagerMock()
with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network):
self.api = CloudApiClient(self.account, self._errorHandler) self.api = CloudApiClient(self.account, self._errorHandler)
def test_GetClusters(self, network_mock): def test_getClusters(self):
network_mock.return_value = self.network
result = [] result = []
with open("{}/Fixtures/getClusters.json".format(os.path.dirname(__file__)), "rb") as f: response = readFixture("getClusters")
response = f.read() data = parseFixture("getClusters")["data"]
self.network.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", 200, response) self.network.prepareReply("GET", CuraCloudAPIRoot + "/connect/v1/clusters", 200, response)
# the callback is a function that adds the result of the call to getClusters to the result list # the callback is a function that adds the result of the call to getClusters to the result list
self.api.getClusters(lambda clusters: result.extend(clusters)) self.api.getClusters(lambda clusters: result.extend(clusters))
self.network.flushReplies() self.network.flushReplies()
self.assertEqual(2, len(result)) self.assertEqual([CloudClusterResponse(**data[0]), CloudClusterResponse(**data[1])], result)
def test_getClusterStatus(self, network_mock):
network_mock.return_value = self.network
def test_getClusterStatus(self):
result = [] result = []
with open("{}/Fixtures/getClusterStatusResponse.json".format(os.path.dirname(__file__)), "rb") as f: response = readFixture("getClusterStatusResponse")
response = f.read() data = parseFixture("getClusterStatusResponse")["data"]
self.network.prepareReply("GET", url = CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status"
"https://api-staging.ultimaker.com/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status", self.network.prepareReply("GET", url, 200, response)
200, response self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s))
)
self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda status: result.append(status))
self.network.flushReplies() self.network.flushReplies()
self.assertEqual(len(result), 1) self.assertEqual([CloudClusterStatus(**data)], result)
status = result[0]
self.assertEqual(len(status.printers), 2) def test_requestUpload(self):
self.assertEqual(len(status.print_jobs), 1)
def test_requestUpload(self, network_mock):
network_mock.return_value = self.network
results = [] results = []
response = readFixture("putJobUploadResponse") response = readFixture("putJobUploadResponse")
self.network.prepareReply("PUT", "https://api-staging.ultimaker.com/cura/v1/jobs/upload", 200, response) self.network.prepareReply("PUT", CuraCloudAPIRoot + "/cura/v1/jobs/upload", 200, response)
request = CloudPrintJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain") request = CloudPrintJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain")
self.api.requestUpload(request, lambda r: results.append(r)) self.api.requestUpload(request, lambda r: results.append(r))
self.network.flushReplies() self.network.flushReplies()
self.assertEqual(results[0].content_type, "text/plain") self.assertEqual(["text/plain"], [r.content_type for r in results])
self.assertEqual(results[0].status, "uploading") self.assertEqual(["uploading"], [r.status for r in results])
def test_uploadMesh(self):
def test_uploadMesh(self, network_mock):
network_mock.return_value = self.network
results = [] results = []
progress = MagicMock() progress = MagicMock()
data = parseFixture("putJobUploadResponse")["data"] data = parseFixture("putJobUploadResponse")["data"]
upload_response = CloudPrintJobResponse(**data) upload_response = CloudPrintJobResponse(**data)
self.network.prepareReply("PUT", upload_response.upload_url, 200, # Network client doesn't look into the reply
b'{ data : "" }') # Network client doesn't look into the reply self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}')
self.api.uploadMesh(upload_response, b'', lambda job_id: results.append(job_id), mesh = ("1234" * 100000).encode()
progress.advance, progress.error) self.api.uploadMesh(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error)
for _ in range(10):
self.network.flushReplies() self.network.flushReplies()
self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}')
self.assertEqual(len(results), 1) self.assertEqual(["sent"], results)
self.assertEqual(results[0], upload_response.job_id)
def test_requestPrint(self):
def test_requestPrint(self, network_mock):
network_mock.return_value = self.network
results = [] results = []
response = readFixture("postJobPrintResponse") response = readFixture("postJobPrintResponse")
@ -111,7 +105,7 @@ class TestCloudApiClient(TestCase):
job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
self.network.prepareReply("POST", self.network.prepareReply("POST",
"https://api-staging.ultimaker.com/connect/v1/clusters/{}/print/{}" CuraCloudAPIRoot + "/connect/v1/clusters/{}/print/{}"
.format(cluster_id, job_id), .format(cluster_id, job_id),
200, response) 200, response)
@ -119,7 +113,6 @@ class TestCloudApiClient(TestCase):
self.network.flushReplies() self.network.flushReplies()
self.assertEqual(len(results), 1) self.assertEqual([job_id], [r.job_id for r in results])
self.assertEqual(results[0].job_id, job_id) self.assertEqual([cluster_job_id], [r.cluster_job_id for r in results])
self.assertEqual(results[0].cluster_job_id, cluster_job_id) self.assertEqual(["queued"], [r.status for r in results])
self.assertEqual(results[0].status, "queued")

View File

@ -5,41 +5,58 @@ from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.CuraConstants import CuraCloudAPIRoot
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
from src.Cloud.CloudApiClient import CloudApiClient from src.Cloud.CloudApiClient import CloudApiClient
from src.Cloud.CloudOutputDevice import CloudOutputDevice from src.Cloud.CloudOutputDevice import CloudOutputDevice
from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
from tests.Cloud.Fixtures import readFixture, parseFixture from tests.Cloud.Fixtures import readFixture, parseFixture
from .NetworkManagerMock import NetworkManagerMock from .NetworkManagerMock import NetworkManagerMock
@patch("cura.NetworkClient.QNetworkAccessManager")
class TestCloudOutputDevice(TestCase): class TestCloudOutputDevice(TestCase):
maxDiff = None
CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq"
JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
HOST_NAME = "ultimakersystem-ccbdd30044ec" HOST_NAME = "ultimakersystem-ccbdd30044ec"
HOST_GUID = "e90ae0ac-1257-4403-91ee-a44c9b7e8050"
BASE_URL = "https://api-staging.ultimaker.com" STATUS_URL = "{}/connect/v1/clusters/{}/status".format(CuraCloudAPIRoot, CLUSTER_ID)
STATUS_URL = "{}/connect/v1/clusters/{}/status".format(BASE_URL, CLUSTER_ID) PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(CuraCloudAPIRoot, CLUSTER_ID, JOB_ID)
PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(BASE_URL, CLUSTER_ID, JOB_ID) REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(CuraCloudAPIRoot)
REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(BASE_URL)
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.app = CuraApplication.getInstance() self.app = MagicMock()
self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app),
patch("UM.Application.Application.getInstance", return_value=self.app)]
for patched_method in self.patches:
patched_method.start()
self.cluster = CloudClusterResponse(self.CLUSTER_ID, self.HOST_GUID, self.HOST_NAME, is_online=True,
status="active")
self.network = NetworkManagerMock() self.network = NetworkManagerMock()
self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken")
self.onError = MagicMock() self.onError = MagicMock()
self.device = CloudOutputDevice(CloudApiClient(self.account, self.onError), self.CLUSTER_ID, self.HOST_NAME) with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network):
self._api = CloudApiClient(self.account, self.onError)
self.device = CloudOutputDevice(self._api, self.cluster)
self.cluster_status = parseFixture("getClusterStatusResponse") self.cluster_status = parseFixture("getClusterStatusResponse")
self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse")) self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse"))
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
self.network.flushReplies() self.network.flushReplies()
for patched_method in self.patches:
patched_method.stop()
def test_status(self, network_mock): def test_status(self):
network_mock.return_value = self.network
self.device._update() self.device._update()
self.network.flushReplies() self.network.flushReplies()
@ -69,33 +86,34 @@ class TestCloudOutputDevice(TestCase):
self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]}, self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]},
{job.name for job in self.device.printJobs}) {job.name for job in self.device.printJobs})
def test_remove_print_job(self, network_mock): def test_remove_print_job(self):
network_mock.return_value = self.network
self.device._update() self.device._update()
self.network.flushReplies() self.network.flushReplies()
self.assertEqual(1, len(self.device.printJobs)) self.assertEqual(1, len(self.device.printJobs))
self.cluster_status["data"]["print_jobs"].clear() self.cluster_status["data"]["print_jobs"].clear()
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
self.device._last_request_time = None
self.device._update() self.device._update()
self.network.flushReplies() self.network.flushReplies()
self.assertEqual([], self.device.printJobs) self.assertEqual([], self.device.printJobs)
def test_remove_printers(self, network_mock): def test_remove_printers(self):
network_mock.return_value = self.network
self.device._update() self.device._update()
self.network.flushReplies() self.network.flushReplies()
self.assertEqual(2, len(self.device.printers)) self.assertEqual(2, len(self.device.printers))
self.cluster_status["data"]["printers"].clear() self.cluster_status["data"]["printers"].clear()
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
self.device._last_request_time = None
self.device._update() self.device._update()
self.network.flushReplies() self.network.flushReplies()
self.assertEqual([], self.device.printers) self.assertEqual([], self.device.printers)
@patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") def test_print_to_cloud(self):
def test_print_to_cloud(self, global_container_stack_mock, network_mock): active_machine_mock = self.app.getGlobalContainerStack.return_value
active_machine_mock = global_container_stack_mock.return_value
active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/gzip"}.get active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/gzip"}.get
request_upload_response = parseFixture("putJobUploadResponse") request_upload_response = parseFixture("putJobUploadResponse")
@ -104,7 +122,6 @@ class TestCloudOutputDevice(TestCase):
self.network.prepareReply("PUT", request_upload_response["data"]["upload_url"], 201, b"{}") self.network.prepareReply("PUT", request_upload_response["data"]["upload_url"], 201, b"{}")
self.network.prepareReply("POST", self.PRINT_URL, 200, request_print_response) self.network.prepareReply("POST", self.PRINT_URL, 200, request_print_response)
network_mock.return_value = self.network
file_handler = MagicMock() file_handler = MagicMock()
file_handler.getSupportedFileTypesWrite.return_value = [{ file_handler.getSupportedFileTypesWrite.return_value = [{
"extension": "gcode.gz", "extension": "gcode.gz",

View File

@ -1,23 +1,36 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch, MagicMock
from cura.CuraApplication import CuraApplication from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager
from cura.CuraConstants import CuraCloudAPIRoot
from src.Cloud.CloudOutputDevice import CloudOutputDevice from src.Cloud.CloudOutputDevice import CloudOutputDevice
from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager
from tests.Cloud.Fixtures import parseFixture, readFixture from tests.Cloud.Fixtures import parseFixture, readFixture
from .NetworkManagerMock import NetworkManagerMock from .NetworkManagerMock import NetworkManagerMock, FakeSignal
@patch("cura.NetworkClient.QNetworkAccessManager")
class TestCloudOutputDeviceManager(TestCase): class TestCloudOutputDeviceManager(TestCase):
URL = "https://api-staging.ultimaker.com/connect/v1/clusters" maxDiff = None
URL = CuraCloudAPIRoot + "/connect/v1/clusters"
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.app = CuraApplication.getInstance() self.app = MagicMock()
self.device_manager = OutputDeviceManager()
self.app.getOutputDeviceManager.return_value = self.device_manager
self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app),
patch("UM.Application.Application.getInstance", return_value=self.app)]
for patched_method in self.patches:
patched_method.start()
self.network = NetworkManagerMock() self.network = NetworkManagerMock()
self.timer = MagicMock(timeout = FakeSignal())
with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network), \
patch("src.Cloud.CloudOutputDeviceManager.QTimer", return_value = self.timer):
self.manager = CloudOutputDeviceManager() self.manager = CloudOutputDeviceManager()
self.clusters_response = parseFixture("getClusters") self.clusters_response = parseFixture("getClusters")
self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters")) self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters"))
@ -25,6 +38,11 @@ class TestCloudOutputDeviceManager(TestCase):
def tearDown(self): def tearDown(self):
try: try:
self._beforeTearDown() self._beforeTearDown()
self.network.flushReplies()
self.manager.stop()
for patched_method in self.patches:
patched_method.stop()
finally: finally:
super().tearDown() super().tearDown()
@ -34,30 +52,29 @@ class TestCloudOutputDeviceManager(TestCase):
# let the network send replies # let the network send replies
self.network.flushReplies() self.network.flushReplies()
# get the created devices # get the created devices
device_manager = self.app.getOutputDeviceManager() devices = self.device_manager.getOutputDevices()
devices = device_manager.getOutputDevices()
# get the server data # get the server data
clusters = self.clusters_response.get("data", []) clusters = self.clusters_response.get("data", [])
self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices]) self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices])
self.assertEqual({cluster["cluster_id"] for cluster in clusters}, {device.key for device in devices}) self.assertEqual({cluster["cluster_id"] for cluster in clusters}, {device.key for device in devices})
self.assertEqual({cluster["host_name"] for cluster in clusters}, {device.host_name for device in devices}) self.assertEqual(clusters, sorted((device.clusterData.toDict() for device in devices),
key=lambda device_dict: device_dict["host_version"]))
for device in clusters: for device in clusters:
device_manager.getOutputDevice(device["cluster_id"]).close() self.device_manager.getOutputDevice(device["cluster_id"]).close()
device_manager.removeOutputDevice(device["cluster_id"]) self.device_manager.removeOutputDevice(device["cluster_id"])
## Runs the initial request to retrieve the clusters. ## Runs the initial request to retrieve the clusters.
def _loadData(self, network_mock): def _loadData(self):
network_mock.return_value = self.network self.manager.start()
self.manager._account.loginStateChanged.emit(True) self.network.flushReplies()
self.manager._update_timer.timeout.emit()
def test_device_is_created(self, network_mock): def test_device_is_created(self):
# just create the cluster, it is checked at tearDown # just create the cluster, it is checked at tearDown
self._loadData(network_mock) self._loadData()
def test_device_is_updated(self, network_mock): def test_device_is_updated(self):
self._loadData(network_mock) self._loadData()
# update the cluster from member variable, which is checked at tearDown # update the cluster from member variable, which is checked at tearDown
self.clusters_response["data"][0]["host_name"] = "New host name" self.clusters_response["data"][0]["host_name"] = "New host name"
@ -65,8 +82,8 @@ class TestCloudOutputDeviceManager(TestCase):
self.manager._update_timer.timeout.emit() self.manager._update_timer.timeout.emit()
def test_device_is_removed(self, network_mock): def test_device_is_removed(self):
self._loadData(network_mock) self._loadData()
# delete the cluster from member variable, which is checked at tearDown # delete the cluster from member variable, which is checked at tearDown
del self.clusters_response["data"][1] del self.clusters_response["data"][1]
@ -74,42 +91,39 @@ class TestCloudOutputDeviceManager(TestCase):
self.manager._update_timer.timeout.emit() self.manager._update_timer.timeout.emit()
@patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") def test_device_connects_by_cluster_id(self):
def test_device_connects_by_cluster_id(self, global_container_stack_mock, network_mock): active_machine_mock = self.app.getGlobalContainerStack.return_value
active_machine_mock = global_container_stack_mock.return_value
cluster1, cluster2 = self.clusters_response["data"] cluster1, cluster2 = self.clusters_response["data"]
cluster_id = cluster1["cluster_id"] cluster_id = cluster1["cluster_id"]
active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get
self._loadData(network_mock) self._loadData()
self.network.flushReplies()
self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) self.assertTrue(self.device_manager.getOutputDevice(cluster1["cluster_id"]).isConnected())
self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) self.assertFalse(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected())
self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls) self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls)
@patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") def test_device_connects_by_network_key(self):
def test_device_connects_by_network_key(self, global_container_stack_mock, network_mock): active_machine_mock = self.app.getGlobalContainerStack.return_value
active_machine_mock = global_container_stack_mock.return_value
cluster1, cluster2 = self.clusters_response["data"] cluster1, cluster2 = self.clusters_response["data"]
network_key = cluster2["host_name"] + ".ultimaker.local" network_key = cluster2["host_name"] + ".ultimaker.local"
active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get
self._loadData(network_mock) self._loadData()
self.network.flushReplies()
self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) self.assertEqual([False, True],
self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) [self.device_manager.getOutputDevice(cluster["cluster_id"]).isConnected()
for cluster in (cluster1, cluster2)])
active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"])
@patch("UM.Message.Message.show") @patch("src.Cloud.CloudOutputDeviceManager.Message")
def test_api_error(self, message_mock, network_mock): def test_api_error(self, message_mock):
self.clusters_response = { self.clusters_response = {
"errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}] "errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}]
} }
self.network.prepareReply("GET", self.URL, 200, self.clusters_response) self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
self._loadData(network_mock) self._loadData()
self.network.flushReplies() message_mock.assert_called_once_with(text='Not found!', title='Error', lifetime=10, dismissable=True)
message_mock.assert_called_once_with() message_mock.return_value.show.assert_called_once_with()

View File

@ -10,7 +10,7 @@ from PyQt5.QtCore import QByteArray
from UM.MimeTypeDatabase import MimeType from UM.MimeTypeDatabase import MimeType
from UM.Application import Application from UM.Application import Application
from src.SendMaterialJob import SendMaterialJob from plugins.UM3NetworkPrinting.src.SendMaterialJob import SendMaterialJob
@patch("builtins.open", lambda _, __: io.StringIO("<xml></xml>")) @patch("builtins.open", lambda _, __: io.StringIO("<xml></xml>"))

View File

@ -0,0 +1,2 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

View File

@ -1,40 +0,0 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import pytest
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
from cura.Machines.MaterialManager import MaterialManager
# This mock application must extend from Application and not QtApplication otherwise some QObjects are created and
# a segfault is raised.
class FixtureApplication(CuraApplication):
def __init__(self):
super().__init__()
super().initialize()
Signal._signalQueue = self
self.getPreferences().addPreference("cura/favorite_materials", "")
self._material_manager = MaterialManager(self._container_registry, parent = self)
self._material_manager.initialize()
def functionEvent(self, event):
event.call()
def parseCommandLine(self):
pass
def processEvents(self):
pass
@pytest.fixture(autouse=True)
def application():
# Since we need to use it more that once, we create the application the first time and use its instance the second
application = FixtureApplication.getInstance()
if application is None:
application = FixtureApplication()
return application