Convert doxygen to rst for UM3NetworkPrinting

This commit is contained in:
Nino van Hooff 2020-05-15 15:05:38 +02:00
parent de82406782
commit 5eb5ffd916
38 changed files with 797 additions and 487 deletions

View file

@ -20,13 +20,15 @@ from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
from ..Models.Http.CloudPrintResponse import CloudPrintResponse
## The generic type variable used to document the methods below.
CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
"""The generic type variable used to document the methods below."""
## 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:
"""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.
"""
# The cloud URL to use for this remote cluster.
ROOT_PATH = UltimakerCloudAuthentication.CuraCloudAPIRoot
@ -36,54 +38,70 @@ class CloudApiClient:
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[], None]]
## Initializes a new cloud API client.
# \param account: The user's account object
# \param on_error: The callback to be called whenever we receive errors from the server.
def __init__(self, account: Account, on_error: Callable[[List[CloudError]], None]) -> None:
"""Initializes a new cloud API client.
:param account: The user's account object
:param on_error: The callback to be called whenever we receive errors from the server.
"""
super().__init__()
self._manager = QNetworkAccessManager()
self._account = account
self._on_error = on_error
self._upload = None # type: Optional[ToolPathUploader]
## Gets the account used for the API.
@property
def account(self) -> Account:
"""Gets the account used for the API."""
return self._account
## Retrieves all the clusters for the user that is currently logged in.
# \param on_finished: The function to be called after the result is parsed.
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None:
"""Retrieves all the clusters for the user that is currently logged in.
:param on_finished: The function to be called after the result is parsed.
"""
url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, CloudClusterResponse, failed)
## 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:
"""Retrieves the status of the given cluster.
:param cluster_id: The ID of the cluster.
:param on_finished: The function to be called after the result is parsed.
"""
url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, CloudClusterStatus)
## Requests the cloud to register the upload of a print job mesh.
# \param request: The request object.
# \param on_finished: The function to be called after the result is parsed.
def requestUpload(self, request: CloudPrintJobUploadRequest,
on_finished: Callable[[CloudPrintJobResponse], Any]) -> None:
"""Requests the cloud to register the upload of a print job mesh.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
"""
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
body = json.dumps({"data": request.toDict()})
reply = self._manager.put(self._createEmptyRequest(url), body.encode())
self._addCallback(reply, on_finished, CloudPrintJobResponse)
## Uploads a print job tool path to the cloud.
# \param print_job: The object received after requesting an upload with `self.requestUpload`.
# \param mesh: The tool path data to be uploaded.
# \param on_finished: The function to be called after the upload is successful.
# \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.
def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
"""Uploads a print job tool path to the cloud.
:param print_job: The object received after requesting an upload with `self.requestUpload`.
:param mesh: The tool path data to be uploaded.
:param on_finished: The function to be called after the upload is successful.
: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.
"""
self._upload = ToolPathUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error)
self._upload.start()
@ -96,20 +114,27 @@ class CloudApiClient:
reply = self._manager.post(self._createEmptyRequest(url), b"")
self._addCallback(reply, on_finished, CloudPrintResponse)
## Send a print job action to the cluster for the given print job.
# \param cluster_id: The ID of the cluster.
# \param cluster_job_id: The ID of the print job within the cluster.
# \param action: The name of the action to execute.
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str,
data: Optional[Dict[str, Any]] = None) -> None:
"""Send a print job action to the cluster for the given print job.
:param cluster_id: The ID of the cluster.
:param cluster_job_id: The ID of the print job within the cluster.
:param action: The name of the action to execute.
"""
body = json.dumps({"data": data}).encode() if data else b""
url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action)
self._manager.post(self._createEmptyRequest(url), body)
## 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:
"""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.
"""
request = QNetworkRequest(QUrl(path))
if content_type:
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
@ -118,11 +143,14 @@ class CloudApiClient:
request.setRawHeader(b"Authorization", "Bearer {}".format(access_token).encode())
return request
## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
# \param reply: The reply from the server.
# \return A tuple with a status code and a dictionary.
@staticmethod
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
"""Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
:param reply: The reply from the server.
:return: A tuple with a status code and a dictionary.
"""
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
try:
response = bytes(reply.readAll()).decode()
@ -133,14 +161,15 @@ class CloudApiClient:
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
return status_code, {"errors": [error.toDict()]}
## Parses the given models and calls the correct callback depending on the result.
# \param response: The response from the server, after being converted to a dict.
# \param on_finished: The callback in case the response is successful.
# \param model_class: The type of the model to convert the response to. It may either be a single record or a list.
def _parseModels(self, response: Dict[str, Any],
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model_class: Type[CloudApiClientModel]) -> None:
def _parseModels(self, response: Dict[str, Any], on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]], model_class: Type[CloudApiClientModel]) -> None:
"""Parses the given models and calls the correct callback depending on the result.
:param response: The response from the server, after being converted to a dict.
:param on_finished: The callback in case the response is successful.
:param model_class: The type of the model to convert the response to. It may either be a single record or a list.
"""
if "data" in response:
data = response["data"]
if isinstance(data, list):
@ -156,18 +185,21 @@ class CloudApiClient:
else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
## 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[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None) -> 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 parse() -> None:
self._anti_gc_callbacks.remove(parse)

View file

@ -35,11 +35,13 @@ from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
I18N_CATALOG = i18nCatalog("cura")
## The cloud output device is a network output device that works remotely but has limited functionality.
# Currently it only supports viewing the printer and print job status and adding a new job to the queue.
# As such, those methods have been implemented here.
# Note that this device represents a single remote cluster, not a list of multiple clusters.
class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
"""The cloud output device is a network output device that works remotely but has limited functionality.
Currently it only supports viewing the printer and print job status and adding a new job to the queue.
As such, those methods have been implemented here.
Note that this device represents a single remote cluster, not a list of multiple clusters.
"""
# The interval with which the remote cluster is checked.
# We can do this relatively often as this API call is quite fast.
@ -56,11 +58,13 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
# Therefore we create a private signal used to trigger the printersChanged signal.
_cloudClusterPrintersChanged = pyqtSignal()
## Creates a new cloud output device
# \param api_client: The client that will run the API calls
# \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, cluster: CloudClusterResponse, parent: QObject = None) -> None:
"""Creates a new cloud output device
:param api_client: The client that will run the API calls
:param cluster: The device response received from the cloud API.
:param parent: The optional parent of this output device.
"""
# The following properties are expected on each networked output device.
# Because the cloud connection does not off all of these, we manually construct this version here.
@ -99,8 +103,9 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._tool_path = None # type: Optional[bytes]
self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
## Connects this device.
def connect(self) -> None:
"""Connects this device."""
if self.isConnected():
return
super().connect()
@ -108,21 +113,24 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
self._update()
## Disconnects the device
def disconnect(self) -> None:
"""Disconnects the device"""
if not self.isConnected():
return
super().disconnect()
Logger.log("i", "Disconnected from 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:
"""Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices."""
self._tool_path = None
self._uploaded_print_job = None
## Checks whether the given network key is found in the cloud's host name
def matchesNetworkKey(self, network_key: str) -> bool:
"""Checks whether the given network key is found in the cloud's host name"""
# Typically, a network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
# the host name should then be "ultimakersystem-aabbccdd0011"
if network_key.startswith(str(self.clusterData.host_name or "")):
@ -133,15 +141,17 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
return True
return False
## Set all the interface elements and texts for this output device.
def _setInterfaceElements(self) -> None:
"""Set all the interface elements and texts for this output device."""
self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'.
self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud"))
self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud"))
self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud"))
## Called when the network data should be updated.
def _update(self) -> None:
"""Called when the network data should be updated."""
super()._update()
if time() - self._time_of_last_request < self.CHECK_CLUSTER_INTERVAL:
return # avoid calling the cloud too often
@ -153,9 +163,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
else:
self.setAuthenticationState(AuthState.NotAuthenticated)
## Method called when HTTP request to status endpoint is finished.
# Contains both printers and print jobs statuses in a single response.
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
"""Method called when HTTP request to status endpoint is finished.
Contains both printers and print jobs statuses in a single response.
"""
self._responseReceived()
if status.printers != self._received_printers:
self._received_printers = status.printers
@ -164,10 +176,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._received_print_jobs = status.print_jobs
self._updatePrintJobs(status.print_jobs)
## 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_mimetypes: bool = False,
file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
"""Called when Cura requests an output device to receive a (G-code) file."""
# Show an error message if we're already sending a job.
if self._progress.visible:
PrintJobUploadBlockedMessage().show()
@ -187,9 +200,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
job.finished.connect(self._onPrintJobCreated)
job.start()
## Handler for when the print job was created locally.
# It can now be sent over the cloud.
def _onPrintJobCreated(self, job: ExportFileJob) -> None:
"""Handler for when the print job was created locally.
It can now be sent over the cloud.
"""
output = job.getOutput()
self._tool_path = output # store the tool path to prevent re-uploading when printing the same file again
file_name = job.getFileName()
@ -200,9 +215,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
)
self._api.requestUpload(request, self._uploadPrintJob)
## Uploads the mesh when the print job was registered with the cloud API.
# \param job_response: The response received from the cloud API.
def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None:
"""Uploads the mesh when the print job was registered with the cloud API.
:param job_response: The response received from the cloud API.
"""
if not self._tool_path:
return self._onUploadError()
self._progress.show()
@ -210,38 +227,45 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, self._progress.update,
self._onUploadError)
## Requests the print to be sent to the printer when we finished uploading the mesh.
def _onPrintJobUploaded(self) -> None:
"""Requests the print to be sent to the printer when we finished uploading the mesh."""
self._progress.update(100)
print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted)
## Shows a message when the upload has succeeded
# \param response: The response from the cloud API.
def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None:
"""Shows a message when the upload has succeeded
:param response: The response from the cloud API.
"""
self._progress.hide()
PrintJobUploadSuccessMessage().show()
self.writeFinished.emit()
## Displays the given message if uploading the mesh has failed
# \param message: The message to display.
def _onUploadError(self, message: str = None) -> None:
"""Displays the given message if uploading the mesh has failed
:param message: The message to display.
"""
self._progress.hide()
self._uploaded_print_job = None
PrintJobUploadErrorMessage(message).show()
self.writeError.emit()
## Whether the printer that this output device represents supports print job actions via the cloud.
@pyqtProperty(bool, notify=_cloudClusterPrintersChanged)
def supportsPrintJobActions(self) -> bool:
"""Whether the printer that this output device represents supports print job actions via the cloud."""
if not self._printers:
return False
version_number = self.printers[0].firmwareVersion.split(".")
firmware_version = Version([version_number[0], version_number[1], version_number[2]])
return firmware_version >= self.PRINT_JOB_ACTIONS_MIN_VERSION
## Set the remote print job state.
def setJobState(self, print_job_uuid: str, state: str) -> None:
"""Set the remote print job state."""
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, state)
@pyqtSlot(str, name="sendJobToTop")
@ -265,18 +289,21 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
def openPrinterControlPanel(self) -> None:
QDesktopServices.openUrl(QUrl(self.clusterCloudUrl))
## Gets the cluster response from which this device was created.
@property
def clusterData(self) -> CloudClusterResponse:
"""Gets the cluster response from which this device was created."""
return self._cluster
## Updates the cluster data from the cloud.
@clusterData.setter
def clusterData(self, value: CloudClusterResponse) -> None:
"""Updates the cluster data from the cloud."""
self._cluster = value
## Gets the URL on which to monitor the cluster via the cloud.
@property
def clusterCloudUrl(self) -> str:
"""Gets the URL on which to monitor the cluster via the cloud."""
root_url_prefix = "-staging" if self._account.is_staging else ""
return "https://mycloud{}.ultimaker.com/app/jobs/{}".format(root_url_prefix, self.clusterData.cluster_id)

View file

@ -10,8 +10,9 @@ from UM.Logger import Logger
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
## Class responsible for uploading meshes to the cloud in separate requests.
class ToolPathUploader:
"""Class responsible for uploading meshes to the cloud in separate requests."""
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
MAX_RETRIES = 10
@ -22,16 +23,19 @@ class ToolPathUploader:
# 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:
"""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.
"""
self._manager = manager
self._print_job = print_job
self._data = data
@ -45,13 +49,15 @@ class ToolPathUploader:
self._finished = False
self._reply = None # type: Optional[QNetworkReply]
## Returns the print job for which this object was created.
@property
def printJob(self):
"""Returns the print job for which this object was created."""
return self._print_job
## Creates a network request to the print job upload URL, adding the needed content range header.
def _createRequest(self) -> QNetworkRequest:
"""Creates a network request to the print job upload URL, adding the needed content range header."""
request = QNetworkRequest(QUrl(self._print_job.upload_url))
request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type)
@ -62,14 +68,17 @@ class ToolPathUploader:
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]:
"""Determines the bytes that should be uploaded next.
:return: A tuple with the first and the last byte to upload.
"""
last_byte = min(len(self._data), self._sent_bytes + self.BYTES_PER_REQUEST)
return self._sent_bytes, last_byte
## Starts uploading the mesh.
def start(self) -> None:
"""Starts uploading the mesh."""
if self._finished:
# reset state.
self._sent_bytes = 0
@ -77,13 +86,15 @@ class ToolPathUploader:
self._finished = False
self._uploadChunk()
## Stops uploading the mesh, marking it as finished.
def stop(self):
"""Stops uploading the mesh, marking it as finished."""
Logger.log("i", "Stopped uploading")
self._finished = True
## Uploads a chunk of the mesh to the cloud.
def _uploadChunk(self) -> None:
"""Uploads a chunk of the mesh to the cloud."""
if self._finished:
raise ValueError("The upload is already finished")
@ -96,25 +107,29 @@ class ToolPathUploader:
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:
"""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.
"""
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:
"""Handles an error uploading."""
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:
"""Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed."""
reply = cast(QNetworkReply, self._reply)
Logger.log("i", "Finished callback %s %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
@ -140,8 +155,9 @@ class ToolPathUploader:
[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:
"""Handles a chunk of data being uploaded, starting the next chunk if needed."""
# 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