mirror of
https://git.mirrors.martin98.com/https://github.com/Ultimaker/Cura
synced 2025-05-14 15:18:03 +08:00
Merge pull request #4994 from Ultimaker/STAR-322_cloud-connection-multipart-upload
STAR-322: Cloud connection resumable upload
This commit is contained in:
commit
ab85e4794b
@ -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
|
@ -52,8 +52,11 @@ class AuthorizationService:
|
||||
if not self._user_profile:
|
||||
# If no user profile was stored locally, we try to get it from JWT.
|
||||
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.
|
||||
Logger.log("w", "The user profile could not be loaded. The user must log in again!")
|
||||
self.deleteAuthData()
|
||||
return None
|
||||
|
||||
return self._user_profile
|
||||
|
@ -2,14 +2,16 @@
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
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 cura import CuraConstants
|
||||
from cura.API import Account
|
||||
from cura.NetworkClient import NetworkClient
|
||||
from .MeshUploader import MeshUploader
|
||||
from ..Models import BaseModel
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
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.
|
||||
# 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.
|
||||
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.
|
||||
def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]) -> None:
|
||||
super().__init__()
|
||||
self._manager = QNetworkAccessManager()
|
||||
self._account = account
|
||||
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.
|
||||
@property
|
||||
@ -45,14 +51,16 @@ class CloudApiClient(NetworkClient):
|
||||
# \param on_finished: The function to be called after the result is parsed.
|
||||
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None:
|
||||
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.
|
||||
# \param cluster_id: The ID of the cluster.
|
||||
# \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:
|
||||
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.
|
||||
# \param request: The request object.
|
||||
@ -61,32 +69,19 @@ class CloudApiClient(NetworkClient):
|
||||
) -> None:
|
||||
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
|
||||
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.
|
||||
# \param upload_response: The object received after requesting an upload with `self.requestUpload`.
|
||||
## Uploads a print job mesh to the cloud.
|
||||
# \param print_job: The object received after requesting an upload with `self.requestUpload`.
|
||||
# \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_error: A function to be called if the upload fails. It receives a dict with the error.
|
||||
def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[str], Any],
|
||||
on_progress: Callable[[int], Any], on_error: Callable[[dict], Any]):
|
||||
|
||||
def progressCallback(bytes_sent: int, bytes_total: int) -> None:
|
||||
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)
|
||||
# \param on_error: A function to be called if the upload fails.
|
||||
def uploadMesh(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
|
||||
on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
|
||||
self._upload = MeshUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error)
|
||||
self._upload.start()
|
||||
|
||||
# Requests a cluster to print the given print job.
|
||||
# \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.
|
||||
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)
|
||||
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.
|
||||
# \param url: The URL to request
|
||||
# \param content_type: The type of the body contents.
|
||||
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:
|
||||
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)
|
||||
@ -117,9 +115,10 @@ class CloudApiClient(NetworkClient):
|
||||
Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response)
|
||||
return status_code, json.loads(response)
|
||||
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)
|
||||
return status_code, {"errors": [error]}
|
||||
return status_code, {"errors": [error.toDict()]}
|
||||
|
||||
## The generic type variable used to document the methods below.
|
||||
Model = TypeVar("Model", bound=BaseModel)
|
||||
@ -129,26 +128,38 @@ class CloudApiClient(NetworkClient):
|
||||
# \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.
|
||||
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:
|
||||
if "data" in response:
|
||||
data = response["data"]
|
||||
result = [model_class(**c) for c in data] if isinstance(data, list) else model_class(**data)
|
||||
on_finished(result)
|
||||
if isinstance(data, list):
|
||||
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:
|
||||
self._on_error([CloudErrorObject(**error) for error in response["errors"]])
|
||||
else:
|
||||
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.
|
||||
# \param on_finished: The callback in case the response is successful.
|
||||
# \param model: The type of the model to convert the response to. It may either be a single record or a list.
|
||||
# \return: A function that can be passed to the
|
||||
def _wrapCallback(self,
|
||||
on_finished: Callable[[Union[Model, List[Model]]], Any],
|
||||
model: Type[Model],
|
||||
) -> Callable[[QNetworkReply], None]:
|
||||
def parse(reply: 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
|
||||
# a list or a single item.
|
||||
# \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],
|
||||
) -> None:
|
||||
def parse() -> None:
|
||||
status_code, response = self._parseReply(reply)
|
||||
self._anti_gc_callbacks.remove(parse)
|
||||
return self._parseModels(response, on_finished, model)
|
||||
return parse
|
||||
|
||||
self._anti_gc_callbacks.append(parse)
|
||||
reply.finished.connect(parse)
|
||||
|
@ -3,22 +3,26 @@
|
||||
import os
|
||||
|
||||
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 UM import i18nCatalog
|
||||
from UM.Backend.Backend import BackendState
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Qt.Duration import Duration, DurationFormat
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice
|
||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||
from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController
|
||||
from ..MeshFormatHandler import MeshFormatHandler
|
||||
from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||
from .CloudProgressMessage import CloudProgressMessage
|
||||
from .CloudApiClient import CloudApiClient
|
||||
from .Models.CloudClusterResponse import CloudClusterResponse
|
||||
from .Models.CloudClusterStatus import CloudClusterStatus
|
||||
from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from .Models.CloudPrintResponse import CloudPrintResponse
|
||||
@ -43,9 +47,6 @@ class T:
|
||||
|
||||
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")
|
||||
UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.")
|
||||
|
||||
@ -68,7 +69,7 @@ class T:
|
||||
class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
# 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.
|
||||
printJobsChanged = pyqtSignal()
|
||||
@ -82,16 +83,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
## Creates a new cloud output device
|
||||
# \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.
|
||||
def __init__(self, api_client: CloudApiClient, device_id: str, host_name: str, parent: QObject = None) -> None:
|
||||
super().__init__(device_id = device_id, address = "", properties = {}, parent = parent)
|
||||
def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None:
|
||||
super().__init__(device_id = cluster.cluster_id, address = "", properties = {}, parent = parent)
|
||||
self._api = api_client
|
||||
self._host_name = host_name
|
||||
self._cluster = cluster
|
||||
|
||||
self._setInterfaceElements()
|
||||
|
||||
self._device_id = device_id
|
||||
self._account = api_client.account
|
||||
|
||||
# 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.
|
||||
|
||||
# We only allow a single upload at a time.
|
||||
self._sending_job = False
|
||||
# TODO: handle progress messages in another class.
|
||||
self._progress_message = None # type: Optional[Message]
|
||||
self._progress = CloudProgressMessage()
|
||||
|
||||
# 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]]
|
||||
@ -120,21 +118,42 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
# A set of the user's job IDs that have finished
|
||||
self._finished_jobs = set() # type: Set[str]
|
||||
|
||||
## Gets the host name of this device
|
||||
@property
|
||||
def host_name(self) -> str:
|
||||
return self._host_name
|
||||
# Reference to the uploaded print job / mesh
|
||||
self._mesh = None # type: Optional[bytes]
|
||||
self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
|
||||
|
||||
## Updates the host name of the output device
|
||||
@host_name.setter
|
||||
def host_name(self, value: str) -> None:
|
||||
self._host_name = value
|
||||
## Connects this device.
|
||||
def connect(self) -> None:
|
||||
super().connect()
|
||||
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
|
||||
def matchesNetworkKey(self, network_key: str) -> bool:
|
||||
# A network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
|
||||
# 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.
|
||||
def _setInterfaceElements(self) -> None:
|
||||
@ -145,16 +164,21 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
self.setConnectionText(T.CONNECTED_VIA_CLOUD)
|
||||
|
||||
## 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:
|
||||
|
||||
# Show an error message if we're already sending a job.
|
||||
if self._sending_job:
|
||||
self._onUploadError(T.BLOCKED_UPLOADING)
|
||||
if self._progress.visible:
|
||||
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
|
||||
|
||||
# Indicate we have started sending a job.
|
||||
self._sending_job = True
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion)
|
||||
@ -162,24 +186,28 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
Logger.log("e", "Missing file or mesh writer!")
|
||||
return self._onUploadError(T.COULD_NOT_EXPORT)
|
||||
|
||||
mesh_bytes = mesh_format.getBytes(nodes)
|
||||
mesh = mesh_format.getBytes(nodes)
|
||||
|
||||
self._mesh = mesh
|
||||
request = CloudPrintJobUploadRequest(
|
||||
job_name = file_name,
|
||||
file_size = len(mesh_bytes),
|
||||
job_name = file_name or mesh_format.file_extension,
|
||||
file_size = len(mesh),
|
||||
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.
|
||||
def _update(self) -> None:
|
||||
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
|
||||
|
||||
Logger.log("i", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL)
|
||||
if self._account.isLoggedIn:
|
||||
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:
|
||||
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||
|
||||
@ -187,6 +215,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
# Contains both printers and print jobs statuses in a single response.
|
||||
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
|
||||
# Update all data from the cluster.
|
||||
self._last_response_time = time()
|
||||
if self._received_printers != status.printers:
|
||||
self._received_printers = status.printers
|
||||
self._updatePrinters(status.printers)
|
||||
@ -283,62 +312,41 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||
model.updateAssignedPrinter(printer)
|
||||
|
||||
## 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.
|
||||
def _onPrintJobCreated(self, mesh: bytes, job_response: CloudPrintJobResponse) -> None:
|
||||
self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress,
|
||||
lambda _: self._onUploadError(T.UPLOAD_ERROR))
|
||||
def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None:
|
||||
self._progress.show()
|
||||
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.
|
||||
# \param job_id: The ID of the job.
|
||||
def _onPrintJobUploaded(self, job_id: str) -> None:
|
||||
self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess)
|
||||
|
||||
## 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
|
||||
def _onPrintJobUploaded(self) -> None:
|
||||
self._progress.update(100)
|
||||
print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
|
||||
self._api.requestPrint(self.key, print_job.job_id, self._onPrintRequested)
|
||||
|
||||
## Displays the given message if uploading the mesh has failed
|
||||
# \param message: The message to display.
|
||||
def _onUploadError(self, message: str = None) -> None:
|
||||
self._resetUploadProgress()
|
||||
if message:
|
||||
Message(
|
||||
text = message,
|
||||
title = T.ERROR,
|
||||
lifetime = 10
|
||||
).show()
|
||||
self._sending_job = False # the upload has finished so we're not sending a job anymore
|
||||
def _onUploadError(self, message = None) -> None:
|
||||
self._progress.hide()
|
||||
self._uploaded_print_job = None
|
||||
Message(
|
||||
text = message or T.UPLOAD_ERROR,
|
||||
title = T.ERROR,
|
||||
lifetime = 10
|
||||
).show()
|
||||
self.writeError.emit()
|
||||
|
||||
## Shows a message when the upload has succeeded
|
||||
# \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)
|
||||
self._resetUploadProgress()
|
||||
self._progress.hide()
|
||||
Message(
|
||||
text = T.UPLOAD_SUCCESS_TEXT,
|
||||
title = T.UPLOAD_SUCCESS_TITLE,
|
||||
lifetime = 5
|
||||
).show()
|
||||
self._sending_job = False # the upload has finished so we're not sending a job anymore
|
||||
self.writeFinished.emit()
|
||||
|
||||
## Gets the remote printers.
|
||||
|
@ -26,7 +26,7 @@ class CloudOutputDeviceManager:
|
||||
META_CLUSTER_ID = "um_cloud_cluster_id"
|
||||
|
||||
# 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.
|
||||
I18N_CATALOG = i18nCatalog("cura")
|
||||
@ -39,26 +39,22 @@ class CloudOutputDeviceManager:
|
||||
self._output_device_manager = application.getOutputDeviceManager()
|
||||
|
||||
self._account = application.getCuraAPI().account # type: Account
|
||||
self._account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
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
|
||||
self._update_timer = QTimer(application)
|
||||
self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000))
|
||||
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._onLoginStateChanged(self._account.isLoggedIn)
|
||||
self._running = False
|
||||
|
||||
# Called when the uses logs in or out
|
||||
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 not self._update_timer.isActive():
|
||||
self._update_timer.start()
|
||||
self._getRemoteClusters()
|
||||
else:
|
||||
if self._update_timer.isActive():
|
||||
self._update_timer.stop()
|
||||
@ -77,7 +73,8 @@ class CloudOutputDeviceManager:
|
||||
|
||||
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
|
||||
for removed_cluster in removed_devices:
|
||||
@ -90,12 +87,12 @@ class CloudOutputDeviceManager:
|
||||
# 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.
|
||||
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._remote_clusters[added_cluster.cluster_id] = device
|
||||
|
||||
for device, cluster in updates:
|
||||
device.host_name = cluster.host_name
|
||||
device.clusterData = cluster
|
||||
|
||||
self._connectToActiveMachine()
|
||||
|
||||
@ -103,6 +100,7 @@ class CloudOutputDeviceManager:
|
||||
def _connectToActiveMachine(self) -> None:
|
||||
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
|
||||
if not active_machine:
|
||||
Logger.log("i", "no active machine")
|
||||
return
|
||||
|
||||
# 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]
|
||||
if not device.isConnected():
|
||||
device.connect()
|
||||
Logger.log("i", "Device connected by metadata %s", stored_cluster_id)
|
||||
else:
|
||||
self._connectByNetworkKey(active_machine)
|
||||
|
||||
@ -126,13 +125,38 @@ class CloudOutputDeviceManager:
|
||||
active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
|
||||
device.connect()
|
||||
|
||||
Logger.log("i", "Found cluster %s with network key %s", device, local_network_key)
|
||||
|
||||
## Handles an API error received from the cloud.
|
||||
# \param errors: The errors received
|
||||
def _onApiError(self, errors: List[CloudErrorObject]) -> None:
|
||||
message = ". ".join(e.title for e in errors) # TODO: translate errors
|
||||
Message(
|
||||
text = message,
|
||||
text = ". ".join(e.title for e in errors) # TODO: translate errors
|
||||
message = Message(
|
||||
text = text,
|
||||
title = self.I18N_CATALOG.i18nc("@info:title", "Error"),
|
||||
lifetime = 10,
|
||||
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)
|
||||
|
42
plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py
Normal file
42
plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py
Normal 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
|
148
plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py
Normal file
148
plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py
Normal 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()
|
@ -390,10 +390,10 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
|
||||
|
||||
## Called when the connection to the cluster changes.
|
||||
def connect(self) -> None:
|
||||
pass
|
||||
# TODO: uncomment this once cloud implementation works for testing
|
||||
# super().connect()
|
||||
# self.sendMaterialProfiles()
|
||||
pass
|
||||
|
||||
def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None:
|
||||
reply_url = reply.url().toString()
|
||||
|
@ -18,4 +18,3 @@ class ClusterUM3PrinterOutputController(PrinterOutputController):
|
||||
def setJobState(self, job: "PrintJobOutputModel", state: str):
|
||||
data = "{\"action\": \"%s\"}" % state
|
||||
self._output_device.put("print_jobs/%s/action" % job.key, data, on_finished=None)
|
||||
|
||||
|
@ -86,6 +86,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
## Start looking for devices on network.
|
||||
def start(self):
|
||||
self.startDiscovery()
|
||||
self._cloud_output_device_manager.start()
|
||||
|
||||
def startDiscovery(self):
|
||||
self.stop()
|
||||
@ -142,6 +143,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
|
||||
if self._zero_conf is not None:
|
||||
Logger.log("d", "zeroconf close...")
|
||||
self._zero_conf.close()
|
||||
self._cloud_output_device_manager.stop()
|
||||
|
||||
def removeManualDevice(self, key, address = None):
|
||||
if key in self._discovered_devices:
|
||||
|
@ -1,15 +1,30 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import json
|
||||
from typing import Dict, Tuple, Union, Optional
|
||||
from typing import Dict, Tuple, Union, Optional, Any
|
||||
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.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.
|
||||
# After patching the QNetworkManager class, requests are prepared before they can be executed.
|
||||
# Any requests not prepared beforehand will cause KeyErrors.
|
||||
@ -27,7 +42,7 @@ class NetworkManagerMock:
|
||||
## Initializes the network manager mock.
|
||||
def __init__(self) -> None:
|
||||
# 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]
|
||||
|
||||
# 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.
|
||||
# \param method: The method being called.
|
||||
# \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.
|
||||
# it raises a KeyError if requests are done without being prepared.
|
||||
def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_):
|
||||
@ -64,6 +79,8 @@ class NetworkManagerMock:
|
||||
reply_mock.url().toString.return_value = url
|
||||
reply_mock.operation.return_value = self._OPERATIONS[method]
|
||||
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()
|
||||
self.replies[method, url] = reply_mock
|
||||
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.
|
||||
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.reset()
|
||||
|
||||
|
@ -6,8 +6,10 @@ from typing import List
|
||||
from unittest import TestCase
|
||||
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.Models.CloudClusterResponse import CloudClusterResponse
|
||||
from src.Cloud.Models.CloudClusterStatus import CloudClusterStatus
|
||||
from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse
|
||||
from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
||||
from src.Cloud.Models.CloudErrorObject import CloudErrorObject
|
||||
@ -15,8 +17,9 @@ from tests.Cloud.Fixtures import readFixture, parseFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock
|
||||
|
||||
|
||||
@patch("cura.NetworkClient.QNetworkAccessManager")
|
||||
class TestCloudApiClient(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def _errorHandler(self, errors: List[CloudErrorObject]):
|
||||
raise Exception("Received unexpected error: {}".format(errors))
|
||||
|
||||
@ -25,83 +28,74 @@ class TestCloudApiClient(TestCase):
|
||||
self.account = MagicMock()
|
||||
self.account.isLoggedIn.return_value = True
|
||||
|
||||
self.app = CuraApplication.getInstance()
|
||||
self.network = NetworkManagerMock()
|
||||
self.api = CloudApiClient(self.account, self._errorHandler)
|
||||
|
||||
def test_GetClusters(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network):
|
||||
self.api = CloudApiClient(self.account, self._errorHandler)
|
||||
|
||||
def test_getClusters(self):
|
||||
result = []
|
||||
|
||||
with open("{}/Fixtures/getClusters.json".format(os.path.dirname(__file__)), "rb") as f:
|
||||
response = f.read()
|
||||
response = readFixture("getClusters")
|
||||
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
|
||||
self.api.getClusters(lambda clusters: result.extend(clusters))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual(2, len(result))
|
||||
|
||||
def test_getClusterStatus(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
self.assertEqual([CloudClusterResponse(**data[0]), CloudClusterResponse(**data[1])], result)
|
||||
|
||||
def test_getClusterStatus(self):
|
||||
result = []
|
||||
|
||||
with open("{}/Fixtures/getClusterStatusResponse.json".format(os.path.dirname(__file__)), "rb") as f:
|
||||
response = f.read()
|
||||
response = readFixture("getClusterStatusResponse")
|
||||
data = parseFixture("getClusterStatusResponse")["data"]
|
||||
|
||||
self.network.prepareReply("GET",
|
||||
"https://api-staging.ultimaker.com/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status",
|
||||
200, response
|
||||
)
|
||||
self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda status: result.append(status))
|
||||
url = CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status"
|
||||
self.network.prepareReply("GET", url, 200, response)
|
||||
self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s))
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
status = result[0]
|
||||
self.assertEqual([CloudClusterStatus(**data)], result)
|
||||
|
||||
self.assertEqual(len(status.printers), 2)
|
||||
self.assertEqual(len(status.print_jobs), 1)
|
||||
|
||||
def test_requestUpload(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
def test_requestUpload(self):
|
||||
|
||||
results = []
|
||||
|
||||
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")
|
||||
self.api.requestUpload(request, lambda r: results.append(r))
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual(results[0].content_type, "text/plain")
|
||||
self.assertEqual(results[0].status, "uploading")
|
||||
self.assertEqual(["text/plain"], [r.content_type for r in results])
|
||||
self.assertEqual(["uploading"], [r.status for r in results])
|
||||
|
||||
def test_uploadMesh(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
def test_uploadMesh(self):
|
||||
|
||||
results = []
|
||||
progress = MagicMock()
|
||||
|
||||
data = parseFixture("putJobUploadResponse")["data"]
|
||||
upload_response = CloudPrintJobResponse(**data)
|
||||
|
||||
self.network.prepareReply("PUT", upload_response.upload_url, 200,
|
||||
b'{ data : "" }') # Network client doesn't look into the reply
|
||||
# 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),
|
||||
progress.advance, progress.error)
|
||||
mesh = ("1234" * 100000).encode()
|
||||
self.api.uploadMesh(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error)
|
||||
|
||||
self.network.flushReplies()
|
||||
for _ in range(10):
|
||||
self.network.flushReplies()
|
||||
self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}')
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0], upload_response.job_id)
|
||||
self.assertEqual(["sent"], results)
|
||||
|
||||
def test_requestPrint(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
def test_requestPrint(self):
|
||||
|
||||
results = []
|
||||
|
||||
response = readFixture("postJobPrintResponse")
|
||||
@ -111,7 +105,7 @@ class TestCloudApiClient(TestCase):
|
||||
job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
|
||||
|
||||
self.network.prepareReply("POST",
|
||||
"https://api-staging.ultimaker.com/connect/v1/clusters/{}/print/{}"
|
||||
CuraCloudAPIRoot + "/connect/v1/clusters/{}/print/{}"
|
||||
.format(cluster_id, job_id),
|
||||
200, response)
|
||||
|
||||
@ -119,7 +113,6 @@ class TestCloudApiClient(TestCase):
|
||||
|
||||
self.network.flushReplies()
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].job_id, job_id)
|
||||
self.assertEqual(results[0].cluster_job_id, cluster_job_id)
|
||||
self.assertEqual(results[0].status, "queued")
|
||||
self.assertEqual([job_id], [r.job_id for r in results])
|
||||
self.assertEqual([cluster_job_id], [r.cluster_job_id for r in results])
|
||||
self.assertEqual(["queued"], [r.status for r in results])
|
||||
|
@ -5,41 +5,58 @@ from unittest import TestCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Signal import Signal
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.CuraConstants import CuraCloudAPIRoot
|
||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||
from src.Cloud.CloudApiClient import CloudApiClient
|
||||
from src.Cloud.CloudOutputDevice import CloudOutputDevice
|
||||
from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse
|
||||
from tests.Cloud.Fixtures import readFixture, parseFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock
|
||||
|
||||
|
||||
@patch("cura.NetworkClient.QNetworkAccessManager")
|
||||
class TestCloudOutputDevice(TestCase):
|
||||
maxDiff = None
|
||||
|
||||
CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq"
|
||||
JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE="
|
||||
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(BASE_URL, CLUSTER_ID)
|
||||
PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(BASE_URL, CLUSTER_ID, JOB_ID)
|
||||
REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(BASE_URL)
|
||||
STATUS_URL = "{}/connect/v1/clusters/{}/status".format(CuraCloudAPIRoot, CLUSTER_ID)
|
||||
PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(CuraCloudAPIRoot, CLUSTER_ID, JOB_ID)
|
||||
REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(CuraCloudAPIRoot)
|
||||
|
||||
def setUp(self):
|
||||
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.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken")
|
||||
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.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse"))
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.network.flushReplies()
|
||||
for patched_method in self.patches:
|
||||
patched_method.stop()
|
||||
|
||||
def test_status(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
def test_status(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
|
||||
@ -69,33 +86,34 @@ class TestCloudOutputDevice(TestCase):
|
||||
self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]},
|
||||
{job.name for job in self.device.printJobs})
|
||||
|
||||
def test_remove_print_job(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
def test_remove_print_job(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(1, len(self.device.printJobs))
|
||||
|
||||
self.cluster_status["data"]["print_jobs"].clear()
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
|
||||
|
||||
self.device._last_request_time = None
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual([], self.device.printJobs)
|
||||
|
||||
def test_remove_printers(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
def test_remove_printers(self):
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual(2, len(self.device.printers))
|
||||
|
||||
self.cluster_status["data"]["printers"].clear()
|
||||
self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status)
|
||||
|
||||
self.device._last_request_time = None
|
||||
self.device._update()
|
||||
self.network.flushReplies()
|
||||
self.assertEqual([], self.device.printers)
|
||||
|
||||
@patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack")
|
||||
def test_print_to_cloud(self, global_container_stack_mock, network_mock):
|
||||
active_machine_mock = global_container_stack_mock.return_value
|
||||
def test_print_to_cloud(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/gzip"}.get
|
||||
|
||||
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("POST", self.PRINT_URL, 200, request_print_response)
|
||||
|
||||
network_mock.return_value = self.network
|
||||
file_handler = MagicMock()
|
||||
file_handler.getSupportedFileTypesWrite.return_value = [{
|
||||
"extension": "gcode.gz",
|
||||
|
@ -1,30 +1,48 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
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.CloudOutputDeviceManager import CloudOutputDeviceManager
|
||||
from tests.Cloud.Fixtures import parseFixture, readFixture
|
||||
from .NetworkManagerMock import NetworkManagerMock
|
||||
from .NetworkManagerMock import NetworkManagerMock, FakeSignal
|
||||
|
||||
|
||||
@patch("cura.NetworkClient.QNetworkAccessManager")
|
||||
class TestCloudOutputDeviceManager(TestCase):
|
||||
URL = "https://api-staging.ultimaker.com/connect/v1/clusters"
|
||||
maxDiff = None
|
||||
|
||||
URL = CuraCloudAPIRoot + "/connect/v1/clusters"
|
||||
|
||||
def setUp(self):
|
||||
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.manager = CloudOutputDeviceManager()
|
||||
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.clusters_response = parseFixture("getClusters")
|
||||
self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters"))
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self._beforeTearDown()
|
||||
|
||||
self.network.flushReplies()
|
||||
self.manager.stop()
|
||||
for patched_method in self.patches:
|
||||
patched_method.stop()
|
||||
finally:
|
||||
super().tearDown()
|
||||
|
||||
@ -34,30 +52,29 @@ class TestCloudOutputDeviceManager(TestCase):
|
||||
# let the network send replies
|
||||
self.network.flushReplies()
|
||||
# get the created devices
|
||||
device_manager = self.app.getOutputDeviceManager()
|
||||
devices = device_manager.getOutputDevices()
|
||||
devices = self.device_manager.getOutputDevices()
|
||||
# get the server data
|
||||
clusters = self.clusters_response.get("data", [])
|
||||
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["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:
|
||||
device_manager.getOutputDevice(device["cluster_id"]).close()
|
||||
device_manager.removeOutputDevice(device["cluster_id"])
|
||||
self.device_manager.getOutputDevice(device["cluster_id"]).close()
|
||||
self.device_manager.removeOutputDevice(device["cluster_id"])
|
||||
|
||||
## Runs the initial request to retrieve the clusters.
|
||||
def _loadData(self, network_mock):
|
||||
network_mock.return_value = self.network
|
||||
self.manager._account.loginStateChanged.emit(True)
|
||||
self.manager._update_timer.timeout.emit()
|
||||
def _loadData(self):
|
||||
self.manager.start()
|
||||
self.network.flushReplies()
|
||||
|
||||
def test_device_is_created(self, network_mock):
|
||||
def test_device_is_created(self):
|
||||
# just create the cluster, it is checked at tearDown
|
||||
self._loadData(network_mock)
|
||||
self._loadData()
|
||||
|
||||
def test_device_is_updated(self, network_mock):
|
||||
self._loadData(network_mock)
|
||||
def test_device_is_updated(self):
|
||||
self._loadData()
|
||||
|
||||
# update the cluster from member variable, which is checked at tearDown
|
||||
self.clusters_response["data"][0]["host_name"] = "New host name"
|
||||
@ -65,8 +82,8 @@ class TestCloudOutputDeviceManager(TestCase):
|
||||
|
||||
self.manager._update_timer.timeout.emit()
|
||||
|
||||
def test_device_is_removed(self, network_mock):
|
||||
self._loadData(network_mock)
|
||||
def test_device_is_removed(self):
|
||||
self._loadData()
|
||||
|
||||
# delete the cluster from member variable, which is checked at tearDown
|
||||
del self.clusters_response["data"][1]
|
||||
@ -74,42 +91,39 @@ class TestCloudOutputDeviceManager(TestCase):
|
||||
|
||||
self.manager._update_timer.timeout.emit()
|
||||
|
||||
@patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack")
|
||||
def test_device_connects_by_cluster_id(self, global_container_stack_mock, network_mock):
|
||||
active_machine_mock = global_container_stack_mock.return_value
|
||||
def test_device_connects_by_cluster_id(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
cluster1, cluster2 = self.clusters_response["data"]
|
||||
cluster_id = cluster1["cluster_id"]
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get
|
||||
|
||||
self._loadData(network_mock)
|
||||
self.network.flushReplies()
|
||||
self._loadData()
|
||||
|
||||
self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected())
|
||||
self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected())
|
||||
self.assertTrue(self.device_manager.getOutputDevice(cluster1["cluster_id"]).isConnected())
|
||||
self.assertFalse(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected())
|
||||
self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls)
|
||||
|
||||
@patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack")
|
||||
def test_device_connects_by_network_key(self, global_container_stack_mock, network_mock):
|
||||
active_machine_mock = global_container_stack_mock.return_value
|
||||
def test_device_connects_by_network_key(self):
|
||||
active_machine_mock = self.app.getGlobalContainerStack.return_value
|
||||
|
||||
cluster1, cluster2 = self.clusters_response["data"]
|
||||
network_key = cluster2["host_name"] + ".ultimaker.local"
|
||||
active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get
|
||||
|
||||
self._loadData(network_mock)
|
||||
self.network.flushReplies()
|
||||
self._loadData()
|
||||
|
||||
self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected())
|
||||
self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected())
|
||||
self.assertEqual([False, True],
|
||||
[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"])
|
||||
|
||||
@patch("UM.Message.Message.show")
|
||||
def test_api_error(self, message_mock, network_mock):
|
||||
@patch("src.Cloud.CloudOutputDeviceManager.Message")
|
||||
def test_api_error(self, message_mock):
|
||||
self.clusters_response = {
|
||||
"errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}]
|
||||
}
|
||||
self.network.prepareReply("GET", self.URL, 200, self.clusters_response)
|
||||
self._loadData(network_mock)
|
||||
self.network.flushReplies()
|
||||
message_mock.assert_called_once_with()
|
||||
self._loadData()
|
||||
message_mock.assert_called_once_with(text='Not found!', title='Error', lifetime=10, dismissable=True)
|
||||
message_mock.return_value.show.assert_called_once_with()
|
||||
|
@ -10,7 +10,7 @@ from PyQt5.QtCore import QByteArray
|
||||
|
||||
from UM.MimeTypeDatabase import MimeType
|
||||
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>"))
|
||||
|
2
plugins/UM3NetworkPrinting/tests/__init__.py
Normal file
2
plugins/UM3NetworkPrinting/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2018 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user