diff --git a/plugins/UM3NetworkPrinting/src/Models.py b/plugins/UM3NetworkPrinting/src/Models.py index e8efa577f6..a9210ac5b4 100644 --- a/plugins/UM3NetworkPrinting/src/Models.py +++ b/plugins/UM3NetworkPrinting/src/Models.py @@ -23,6 +23,7 @@ LocalMaterial = namedtuple('LocalMaterial', [ 'brand', 'material', 'color_name', + 'color_code', 'description', 'adhesion_info', 'approximate_diameter', diff --git a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py index cbe79aef6a..fe0cd98964 100644 --- a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json import os +import re import urllib.parse from typing import Dict, TYPE_CHECKING, Set @@ -19,7 +20,6 @@ from .Models import ClusterMaterial, LocalMaterial if TYPE_CHECKING: from .ClusterUM3OutputDevice import ClusterUM3OutputDevice - ## Asynchronous job to send material profiles to the printer. # # This way it won't freeze up the interface while sending those materials. @@ -50,7 +50,7 @@ class SendMaterialJob(Job): self._sendMissingMaterials(remote_materials_by_guid) except json.JSONDecodeError: Logger.logException("w", "Error parsing materials from printer") - except KeyError: + except TypeError: Logger.logException("w", "Error parsing materials from printer") ## Determine which materials should be updated and send them to the printer. @@ -75,7 +75,8 @@ class SendMaterialJob(Job): ## From the local and remote materials, determine which ones should be synchronized. # - # Makes a Set containing only the materials that are not on the printer yet or the ones that are newer in Cura. + # Makes a Set of id's containing only the id's of the materials that are not on the printer yet or the ones that + # are newer in Cura. # # \param local_materials The local materials by GUID. # \param remote_materials The remote materials by GUID. @@ -83,8 +84,9 @@ class SendMaterialJob(Job): def _determineMaterialsToSend(local_materials: Dict[str, LocalMaterial], remote_materials: Dict[str, ClusterMaterial]) -> Set[str]: return { - material.id for guid, material in local_materials.items() - if guid not in remote_materials or material.version > remote_materials[guid].version + material.id + for guid, material in local_materials.items() + if guid not in remote_materials or int(material.version) > remote_materials[guid].version } ## Send the materials to the printer. @@ -157,7 +159,7 @@ class SendMaterialJob(Job): @classmethod def _parseReply(cls, reply: QNetworkReply) -> Dict[str, ClusterMaterial]: remote_materials = json.loads(reply.readAll().data().decode("utf-8")) - return {material["id"]: ClusterMaterial(**material) for material in remote_materials} + return {material["guid"]: ClusterMaterial(**material) for material in remote_materials} ## Retrieves a list of local materials # @@ -170,12 +172,19 @@ class SendMaterialJob(Job): material_containers = container_registry.findContainersMetadata(type = "material") # Find the latest version of all material containers in the registry. - local_materials = {} # type: Dict[str, LocalMaterial] for material in material_containers: try: material = LocalMaterial(**material) + + # material version must be an int + if not re.match("\d+", material.version): + Logger.logException("w", "Local material {} has invalid version '{}'." + .format(material["id"], material.version)) + continue + if material.GUID not in result or material.version > result.get(material.GUID).version: - local_materials[material.GUID] = material + result[material.GUID] = material except ValueError: Logger.logException("w", "Local material {} has invalid values.".format(material["id"])) + return result diff --git a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py index 73bca2b0ad..f4604580fe 100644 --- a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py @@ -1,53 +1,23 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +import io import json - -from typing import Any, List -from unittest import TestCase +from unittest import TestCase, mock from unittest.mock import patch, call from PyQt5.QtCore import QByteArray -from UM.Settings.ContainerRegistry import ContainerInterface, ContainerRegistryInterface, DefinitionContainerInterface -from plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice import ClusterUM3OutputDevice +from UM.MimeTypeDatabase import MimeType +from cura.CuraApplication import CuraApplication from plugins.UM3NetworkPrinting.src.SendMaterialJob import SendMaterialJob -class ContainerRegistryMock(ContainerRegistryInterface): - - def __init__(self): - self.containersMetaData = None - - def findContainers(self, *, ignore_case: bool = False, **kwargs: Any) -> List[ContainerInterface]: - raise NotImplementedError() - - def findDefinitionContainers(self, **kwargs: Any) -> List[DefinitionContainerInterface]: - raise NotImplementedError() - - @classmethod - def getApplication(cls) -> "Application": - raise NotImplementedError() - - def getEmptyInstanceContainer(self) -> "InstanceContainer": - raise NotImplementedError() - - def isReadOnly(self, container_id: str) -> bool: - raise NotImplementedError() - - def setContainersMetadata(self, value): - self.containersMetaData = value - - def findContainersMetadata(self, type): - return self.containersMetaData - - -class MockOutputDevice(ClusterUM3OutputDevice): - def _createFormPart(self, content_header, data, content_type=None): - return "xxx" - - +@patch("builtins.open", lambda _, __: io.StringIO("")) +@patch("UM.MimeTypeDatabase.MimeTypeDatabase.getMimeTypeForFile", + lambda _: MimeType(name = "application/x-ultimaker-material-profile", comment = "Ultimaker Material Profile", + suffixes = ["xml.fdm_material"])) +@patch("UM.Resources.Resources.getAllResourcesOfType", lambda _: ["/materials/generic_pla_white.xml.fdm_material"]) class TestSendMaterialJob(TestCase): - _LOCAL_MATERIAL_WHITE = {"type": "material", "status": "unknown", "id": "generic_pla_white", "base_file": "generic_pla_white", "setting_version": 5, "name": "White PLA", "brand": "Generic", "material": "PLA", "color_name": "White", @@ -88,11 +58,11 @@ class TestSendMaterialJob(TestCase): job.run() # We expect the materials endpoint to be called when the job runs. - device_mock.get.assert_called_with("materials/", on_finished=job._onGetRemoteMaterials) + device_mock.get.assert_called_with("materials/", on_finished = job._onGetRemoteMaterials) @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") @patch("PyQt5.QtNetwork.QNetworkReply") - def test_sendMissingMaterials_withFailedRequest(self, reply_mock, device_mock): + def test__onGetRemoteMaterials_withFailedRequest(self, reply_mock, device_mock): reply_mock.attribute.return_value = 404 job = SendMaterialJob(device_mock) job._onGetRemoteMaterials(reply_mock) @@ -103,7 +73,7 @@ class TestSendMaterialJob(TestCase): @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") @patch("PyQt5.QtNetwork.QNetworkReply") - def test_sendMissingMaterials_withBadJsonAnswer(self, reply_mock, device_mock): + def test__onGetRemoteMaterials_withBadJsonAnswer(self, reply_mock, device_mock): reply_mock.attribute.return_value = 200 reply_mock.readAll.return_value = QByteArray(b"Six sick hicks nick six slick bricks with picks and sticks.") job = SendMaterialJob(device_mock) @@ -116,7 +86,7 @@ class TestSendMaterialJob(TestCase): @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") @patch("PyQt5.QtNetwork.QNetworkReply") - def test_sendMissingMaterials_withMissingGuid(self, reply_mock, device_mock): + def test__onGetRemoteMaterials_withMissingGuidInRemoteMaterial(self, reply_mock, device_mock): reply_mock.attribute.return_value = 200 remote_material_without_guid = self._REMOTE_MATERIAL_WHITE.copy() del remote_material_without_guid["guid"] @@ -127,151 +97,114 @@ class TestSendMaterialJob(TestCase): # We expect the reply to be called once to try to get the printers from the list (readAll()). # Given that parsing fails we do not expect the device to be called for any follow up. self.assertEqual([call.attribute(0), call.readAll()], reply_mock.method_calls) - self.assertEqual(1, device_mock.createFormPart.call_count) + self.assertEqual(0, device_mock.createFormPart.call_count) - # @patch("UM.Resources.Resources.getAllResourcesOfType", lambda _: []) - # @patch("PyQt5.QtNetwork.QNetworkReply") - # def test_sendMissingMaterials_WithInvalidVersionInLocalMaterial(self, reply_mock): - # reply_mock.attribute.return_value = 200 - # reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTEMATERIAL_WHITE]).encode("ascii")) - # - # containerRegistry = ContainerRegistryMock() - # localMaterialWhiteWithInvalidVersion = self._LOCALMATERIAL_WHITE.copy() - # localMaterialWhiteWithInvalidVersion["version"] = "one" - # containerRegistry.setContainersMetadata([localMaterialWhiteWithInvalidVersion]) - # - # with mock.patch.object(Logger, "log", new=new_log): - # with mock.patch.object(ContainerRegistry, "getInstance", lambda: containerRegistry): - # SendMaterialJob(None).sendMissingMaterials(reply_mock) - # - # reply_mock.attribute.assert_called_with(0) - # self.assertEqual(reply_mock.method_calls, [call.attribute(0), call.readAll()]) - # self._assertLogEntries([("e", "Material generic_pla_white has invalid version number one.")], _logentries) - # - # @patch("UM.Resources.Resources.getAllResourcesOfType", lambda _: []) - # @patch("PyQt5.QtNetwork.QNetworkReply") - # def test_sendMissingMaterials_WithMultipleLocalVersionsLowFirst(self, reply_mock): - # reply_mock.attribute.return_value = 200 - # reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTEMATERIAL_WHITE]).encode("ascii")) - # - # containerRegistry = ContainerRegistryMock() - # localMaterialWhiteWithHigherVersion = self._LOCALMATERIAL_WHITE.copy() - # localMaterialWhiteWithHigherVersion["version"] = "2" - # containerRegistry.setContainersMetadata([self._LOCALMATERIAL_WHITE, localMaterialWhiteWithHigherVersion]) - # - # with mock.patch.object(Logger, "log", new=new_log): - # with mock.patch.object(ContainerRegistry, "getInstance", lambda: containerRegistry): - # SendMaterialJob(None).sendMissingMaterials(reply_mock) - # - # reply_mock.attribute.assert_called_with(0) - # self.assertEqual(reply_mock.method_calls, [call.attribute(0), call.readAll()]) - # self._assertLogEntries([], _logentries) - # - # @patch("UM.Resources.Resources.getAllResourcesOfType", lambda _: []) - # @patch("PyQt5.QtNetwork.QNetworkReply") - # def test_sendMissingMaterials_MaterialMissingOnPrinter(self, reply_mock): - # reply_mock.attribute.return_value = 200 - # reply_mock.readAll.return_value = QByteArray( - # json.dumps([self._REMOTEMATERIAL_WHITE]).encode("ascii")) - # - # containerRegistry = ContainerRegistryMock() - # containerRegistry.setContainersMetadata([self._LOCALMATERIAL_WHITE, self._LOCALMATERIAL_BLACK]) - # - # with mock.patch.object(Logger, "log", new=new_log): - # with mock.patch.object(ContainerRegistry, "getInstance", lambda: containerRegistry): - # SendMaterialJob(None).sendMissingMaterials(reply_mock) - # - # reply_mock.attribute.assert_called_with(0) - # self.assertEqual(reply_mock.method_calls, [call.attribute(0), call.readAll()]) - # self._assertLogEntries([], _logentries) - # - # @patch("builtins.open", lambda a, b: io.StringIO("")) - # @patch("UM.MimeTypeDatabase.MimeTypeDatabase.getMimeTypeForFile", - # lambda _: MimeType(name="application/x-ultimaker-material-profile", comment="Ultimaker Material Profile", - # suffixes=["xml.fdm_material"])) - # @patch("UM.Resources.Resources.getAllResourcesOfType", lambda _: ["/materials/generic_pla_white.xml.fdm_material"]) - # @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") - # def test_sendMaterialsToPrinter(self, device_mock): - # device_mock._createFormPart.return_value = "_xXx_" - # with mock.patch.object(Logger, "log", new=new_log): - # job = SendMaterialJob(device_mock) - # job.sendMaterialsToPrinter({"generic_pla_white"}) - # - # self._assertLogEntries([("d", "Syncing material generic_pla_white with cluster.")], _logentries) - # self.assertEqual([call._createFormPart("name="file"; filename="generic_pla_white.xml.fdm_material"", ""), - # call.postFormWithParts(on_finished=job.sendingFinished, parts = ["_xXx_"], target = "materials/")], device_mock.method_calls) - # - # @patch("PyQt5.QtNetwork.QNetworkReply") - # def test_sendingFinished_success(self, reply_mock) -> None: - # reply_mock.attribute.return_value = 200 - # with mock.patch.object(Logger, "log", new=new_log): - # SendMaterialJob(None).sendingFinished(reply_mock) - # - # reply_mock.attribute.assert_called_once_with(0) - # self.assertEqual(0, len(_logentries)) - # - # @patch("PyQt5.QtNetwork.QNetworkReply") - # def test_sendingFinished_failed(self, reply_mock) -> None: - # reply_mock.attribute.return_value = 404 - # reply_mock.readAll.return_value = QByteArray(b"Six sick hicks nick six slick bricks with picks and sticks.") - # - # with mock.patch.object(Logger, "log", new=new_log): - # SendMaterialJob(None).sendingFinished(reply_mock) - # - # reply_mock.attribute.assert_called_with(0) - # self.assertEqual(reply_mock.method_calls, [call.attribute(0), call.attribute(0), call.readAll()]) - # - # self._assertLogEntries([ - # ("e", "Received error code from printer when syncing material: 404"), - # ("e", "Six sick hicks nick six slick bricks with picks and sticks.") - # ], _logentries) - # - # @patch("PyQt5.QtNetwork.QNetworkReply") - # def test_parseReply(self, reply_mock): - # reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTEMATERIAL_WHITE]).encode("ascii")) - # - # response = SendMaterialJob._parseReply(reply_mock) - # - # self.assertTrue(len(response) == 1) - # self.assertEqual(next(iter(response.values())), ClusterMaterial(**self._REMOTEMATERIAL_WHITE)) - # - # @patch("PyQt5.QtNetwork.QNetworkReply") - # def test_parseReplyWithInvalidMaterial(self, reply_mock): - # remoteMaterialWithInvalidVersion = self._REMOTEMATERIAL_WHITE.copy() - # remoteMaterialWithInvalidVersion["version"] = "one" - # reply_mock.readAll.return_value = QByteArray(json.dumps([remoteMaterialWithInvalidVersion]).encode("ascii")) - # - # with self.assertRaises(ValueError): - # SendMaterialJob._parseReply(reply_mock) - # - # def test__getLocalMaterials(self): - # containerRegistry = ContainerRegistryMock() - # containerRegistry.setContainersMetadata([self._LOCALMATERIAL_WHITE, self._LOCALMATERIAL_BLACK]) - # - # with mock.patch.object(Logger, "log", new=new_log): - # with mock.patch.object(ContainerRegistry, "getInstance", lambda: containerRegistry): - # local_materials = SendMaterialJob(None)._getLocalMaterials() - # - # self.assertTrue(len(local_materials) == 2) - # - # def test__getLocalMaterialsWithMultipleVersions(self): - # containerRegistry = ContainerRegistryMock() - # localMaterialWithNewerVersion = self._LOCALMATERIAL_WHITE.copy() - # localMaterialWithNewerVersion["version"] = 2 - # containerRegistry.setContainersMetadata([self._LOCALMATERIAL_WHITE, localMaterialWithNewerVersion]) - # - # with mock.patch.object(Logger, "log", new=new_log): - # with mock.patch.object(ContainerRegistry, "getInstance", lambda: containerRegistry): - # local_materials = SendMaterialJob(None)._getLocalMaterials() - # - # self.assertTrue(len(local_materials) == 1) - # self.assertTrue(list(local_materials.values())[0].version == 2) - # - # containerRegistry.setContainersMetadata([localMaterialWithNewerVersion, self._LOCALMATERIAL_WHITE]) - # - # with mock.patch.object(Logger, "log", new=new_log): - # with mock.patch.object(ContainerRegistry, "getInstance", lambda: containerRegistry): - # local_materials = SendMaterialJob(None)._getLocalMaterials() - # - # self.assertTrue(len(local_materials) == 1) - # self.assertTrue(list(local_materials.values())[0].version == 2) + @patch("cura.Settings.CuraContainerRegistry") + @patch("cura.CuraApplication") + @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") + @patch("PyQt5.QtNetwork.QNetworkReply") + def test__onGetRemoteMaterials_withInvalidVersionInLocalMaterial(self, reply_mock, device_mock, application_mock, + container_registry_mock): + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) + + localMaterialWhiteWithInvalidVersion = self._LOCAL_MATERIAL_WHITE.copy() + localMaterialWhiteWithInvalidVersion["version"] = "one" + container_registry_mock.findContainersMetadata.return_value = [localMaterialWhiteWithInvalidVersion] + + application_mock.getContainerRegistry.return_value = container_registry_mock + + with mock.patch.object(CuraApplication, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual([call.attribute(0), call.readAll()], reply_mock.method_calls) + self.assertEqual([call.getContainerRegistry()], application_mock.method_calls) + self.assertEqual([call.findContainersMetadata(type = "material")], container_registry_mock.method_calls) + self.assertEqual(0, device_mock.createFormPart.call_count) + + @patch("cura.Settings.CuraContainerRegistry") + @patch("cura.CuraApplication") + @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") + @patch("PyQt5.QtNetwork.QNetworkReply") + def test__onGetRemoteMaterials_withNoUpdate(self, reply_mock, device_mock, application_mock, + container_registry_mock): + application_mock.getContainerRegistry.return_value = container_registry_mock + + device_mock.createFormPart.return_value = "_xXx_" + + container_registry_mock.findContainersMetadata.return_value = [self._LOCAL_MATERIAL_WHITE] + + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) + + with mock.patch.object(CuraApplication, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual([call.attribute(0), call.readAll()], reply_mock.method_calls) + self.assertEqual([call.getContainerRegistry()], application_mock.method_calls) + self.assertEqual([call.findContainersMetadata(type = "material")], container_registry_mock.method_calls) + self.assertEqual(0, device_mock.createFormPart.call_count) + self.assertEqual(0, device_mock.postFormWithParts.call_count) + + @patch("cura.Settings.CuraContainerRegistry") + @patch("cura.CuraApplication") + @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") + @patch("PyQt5.QtNetwork.QNetworkReply") + def test__onGetRemoteMaterials_withUpdatedMaterial(self, reply_mock, device_mock, application_mock, + container_registry_mock): + application_mock.getContainerRegistry.return_value = container_registry_mock + + device_mock.createFormPart.return_value = "_xXx_" + + localMaterialWhiteWithHigherVersion = self._LOCAL_MATERIAL_WHITE.copy() + localMaterialWhiteWithHigherVersion["version"] = "2" + container_registry_mock.findContainersMetadata.return_value = [localMaterialWhiteWithHigherVersion] + + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) + + with mock.patch.object(CuraApplication, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual([call.attribute(0), call.readAll()], reply_mock.method_calls) + self.assertEqual([call.getContainerRegistry()], application_mock.method_calls) + self.assertEqual([call.findContainersMetadata(type = "material")], container_registry_mock.method_calls) + self.assertEqual(1, device_mock.createFormPart.call_count) + self.assertEqual(1, device_mock.postFormWithParts.call_count) + self.assertEquals( + [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), + call.postFormWithParts(target = "materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], + device_mock.method_calls) + + @patch("cura.Settings.CuraContainerRegistry") + @patch("cura.CuraApplication") + @patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") + @patch("PyQt5.QtNetwork.QNetworkReply") + def test__onGetRemoteMaterials_withNewMaterial(self, reply_mock, device_mock, application_mock, + container_registry_mock): + application_mock.getContainerRegistry.return_value = container_registry_mock + + device_mock.createFormPart.return_value = "_xXx_" + + container_registry_mock.findContainersMetadata.return_value = [self._LOCAL_MATERIAL_WHITE, + self._LOCAL_MATERIAL_BLACK] + + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_BLACK]).encode("ascii")) + + with mock.patch.object(CuraApplication, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual([call.attribute(0), call.readAll()], reply_mock.method_calls) + self.assertEqual([call.getContainerRegistry()], application_mock.method_calls) + self.assertEqual([call.findContainersMetadata(type = "material")], container_registry_mock.method_calls) + self.assertEqual(1, device_mock.createFormPart.call_count) + self.assertEqual(1, device_mock.postFormWithParts.call_count) + self.assertEquals( + [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), + call.postFormWithParts(target = "materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], + device_mock.method_calls)