From d83241f13aec281e29e08b47bf98be6d94e0d629 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 14:29:09 +0200 Subject: [PATCH 1/9] Add missing typing to number of decorators --- cura/Scene/ConvexHullDecorator.py | 63 ++++++++++++++++---------- cura/Scene/GCodeListDecorator.py | 14 ++++-- cura/Scene/SliceableObjectDecorator.py | 6 +-- cura/Scene/ZOffsetDecorator.py | 11 +++-- 4 files changed, 58 insertions(+), 36 deletions(-) diff --git a/cura/Scene/ConvexHullDecorator.py b/cura/Scene/ConvexHullDecorator.py index ea54d64642..85d1e8e309 100644 --- a/cura/Scene/ConvexHullDecorator.py +++ b/cura/Scene/ConvexHullDecorator.py @@ -13,19 +13,28 @@ from cura.Scene import ConvexHullNode import numpy +from typing import TYPE_CHECKING, Any, Optional + + + +if TYPE_CHECKING: + from UM.Scene.SceneNode import SceneNode + from cura.Settings.GlobalStack import GlobalStack + + ## The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node. # If a scene node has a convex hull decorator, it will have a shadow in which other objects can not be printed. class ConvexHullDecorator(SceneNodeDecorator): - def __init__(self): + def __init__(self) -> None: super().__init__() - self._convex_hull_node = None + self._convex_hull_node = None # type: Optional["SceneNode"] self._init2DConvexHullCache() - self._global_stack = None + self._global_stack = None # type: Optional[GlobalStack] # Make sure the timer is created on the main thread - self._recompute_convex_hull_timer = None + self._recompute_convex_hull_timer = None # type: Optional[QTimer] Application.getInstance().callLater(self.createRecomputeConvexHullTimer) self._raft_thickness = 0.0 @@ -39,13 +48,13 @@ class ConvexHullDecorator(SceneNodeDecorator): self._onGlobalStackChanged() - def createRecomputeConvexHullTimer(self): + def createRecomputeConvexHullTimer(self) -> None: self._recompute_convex_hull_timer = QTimer() self._recompute_convex_hull_timer.setInterval(200) self._recompute_convex_hull_timer.setSingleShot(True) self._recompute_convex_hull_timer.timeout.connect(self.recomputeConvexHull) - def setNode(self, node): + def setNode(self, node: "SceneNode") -> None: previous_node = self._node # Disconnect from previous node signals if previous_node is not None and node is not previous_node: @@ -64,7 +73,7 @@ class ConvexHullDecorator(SceneNodeDecorator): return ConvexHullDecorator() ## Get the unmodified 2D projected convex hull of the node - def getConvexHull(self): + def getConvexHull(self) -> Optional[Polygon]: if self._node is None: return None @@ -78,7 +87,7 @@ class ConvexHullDecorator(SceneNodeDecorator): return hull ## Get the convex hull of the node with the full head size - def getConvexHullHeadFull(self): + def getConvexHullHeadFull(self) -> Optional[Polygon]: if self._node is None: return None @@ -87,7 +96,7 @@ class ConvexHullDecorator(SceneNodeDecorator): ## Get convex hull of the object + head size # In case of printing all at once this is the same as the convex hull. # For one at the time this is area with intersection of mirrored head - def getConvexHullHead(self): + def getConvexHullHead(self) -> Optional[Polygon]: if self._node is None: return None @@ -101,7 +110,7 @@ class ConvexHullDecorator(SceneNodeDecorator): ## Get convex hull of the node # In case of printing all at once this is the same as the convex hull. # For one at the time this is the area without the head. - def getConvexHullBoundary(self): + def getConvexHullBoundary(self) -> Optional[Polygon]: if self._node is None: return None @@ -111,13 +120,13 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._compute2DConvexHull() return None - def recomputeConvexHullDelayed(self): + def recomputeConvexHullDelayed(self) -> None: if self._recompute_convex_hull_timer is not None: self._recompute_convex_hull_timer.start() else: self.recomputeConvexHull() - def recomputeConvexHull(self): + def recomputeConvexHull(self) -> None: controller = Application.getInstance().getController() root = controller.getScene().getRoot() if self._node is None or controller.isToolOperationActive() or not self.__isDescendant(root, self._node): @@ -132,7 +141,7 @@ class ConvexHullDecorator(SceneNodeDecorator): hull_node = ConvexHullNode.ConvexHullNode(self._node, convex_hull, self._raft_thickness, root) self._convex_hull_node = hull_node - def _onSettingValueChanged(self, key, property_name): + def _onSettingValueChanged(self, key: str, property_name: str) -> None: if property_name != "value": #Not the value that was changed. return @@ -142,7 +151,7 @@ class ConvexHullDecorator(SceneNodeDecorator): self._init2DConvexHullCache() #Invalidate the cache. self._onChanged() - def _init2DConvexHullCache(self): + def _init2DConvexHullCache(self) -> None: # Cache for the group code path in _compute2DConvexHull() self._2d_convex_hull_group_child_polygon = None self._2d_convex_hull_group_result = None @@ -152,7 +161,7 @@ class ConvexHullDecorator(SceneNodeDecorator): self._2d_convex_hull_mesh_world_transform = None self._2d_convex_hull_mesh_result = None - def _compute2DConvexHull(self): + def _compute2DConvexHull(self) -> Polygon: if self._node.callDecoration("isGroup"): points = numpy.zeros((0, 2), dtype=numpy.int32) for child in self._node.getChildren(): @@ -228,8 +237,10 @@ class ConvexHullDecorator(SceneNodeDecorator): return offset_hull - def _getHeadAndFans(self): - return Polygon(numpy.array(self._global_stack.getHeadAndFansCoordinates(), numpy.float32)) + def _getHeadAndFans(self) -> Polygon: + if self._global_stack: + return Polygon(numpy.array(self._global_stack.getHeadAndFansCoordinates(), numpy.float32)) + return Polygon() def _compute2DConvexHeadFull(self): return self._compute2DConvexHull().getMinkowskiHull(self._getHeadAndFans()) @@ -245,7 +256,9 @@ class ConvexHullDecorator(SceneNodeDecorator): ## Compensate given 2D polygon with adhesion margin # \return 2D polygon with added margin - def _add2DAdhesionMargin(self, poly): + def _add2DAdhesionMargin(self, poly: Polygon) -> Polygon: + if not self._global_stack: + return Polygon() # Compensate for raft/skirt/brim # Add extra margin depending on adhesion type adhesion_type = self._global_stack.getProperty("adhesion_type", "value") @@ -274,7 +287,7 @@ class ConvexHullDecorator(SceneNodeDecorator): # \param convex_hull Polygon of the original convex hull. # \return New Polygon instance that is offset with everything that # influences the collision area. - def _offsetHull(self, convex_hull): + def _offsetHull(self, convex_hull: Polygon) -> Polygon: horizontal_expansion = max( self._getSettingProperty("xy_offset", "value"), self._getSettingProperty("xy_offset_layer_0", "value") @@ -295,12 +308,12 @@ class ConvexHullDecorator(SceneNodeDecorator): else: return convex_hull - def _onChanged(self, *args): + def _onChanged(self, *args) -> None: self._raft_thickness = self._build_volume.getRaftThickness() if not args or args[0] == self._node: self.recomputeConvexHullDelayed() - def _onGlobalStackChanged(self): + def _onGlobalStackChanged(self) -> None: if self._global_stack: self._global_stack.propertyChanged.disconnect(self._onSettingValueChanged) self._global_stack.containersChanged.disconnect(self._onChanged) @@ -321,7 +334,9 @@ class ConvexHullDecorator(SceneNodeDecorator): self._onChanged() ## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property). - def _getSettingProperty(self, setting_key, prop = "value"): + def _getSettingProperty(self, setting_key: str, prop: str = "value") -> Any: + if not self._global_stack: + return None per_mesh_stack = self._node.callDecoration("getStack") if per_mesh_stack: return per_mesh_stack.getProperty(setting_key, prop) @@ -339,8 +354,8 @@ class ConvexHullDecorator(SceneNodeDecorator): # Limit_to_extruder is set. The global stack handles this then return self._global_stack.getProperty(setting_key, prop) - ## Returns true if node is a descendant or the same as the root node. - def __isDescendant(self, root, node): + ## Returns true if node is a descendant or the same as the root node. + def __isDescendant(self, root: "SceneNode", node: "SceneNode") -> bool: if node is None: return False if root is node: diff --git a/cura/Scene/GCodeListDecorator.py b/cura/Scene/GCodeListDecorator.py index 5738d0a7f2..572fea6ac4 100644 --- a/cura/Scene/GCodeListDecorator.py +++ b/cura/Scene/GCodeListDecorator.py @@ -1,13 +1,19 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator +from typing import List class GCodeListDecorator(SceneNodeDecorator): - def __init__(self): + def __init__(self) -> None: super().__init__() - self._gcode_list = [] + self._gcode_list = [] # type: List[str] - def getGCodeList(self): + def getGCodeList(self) -> List[str]: return self._gcode_list - def setGCodeList(self, list): + def setGCodeList(self, list: List[str]): self._gcode_list = list + + def __deepcopy__(self, memo) -> "GCodeListDecorator": + copied_decorator = GCodeListDecorator() + copied_decorator.setGCodeList(self.getGCodeList()) + return copied_decorator \ No newline at end of file diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py index 1cb589d9c6..982a38d667 100644 --- a/cura/Scene/SliceableObjectDecorator.py +++ b/cura/Scene/SliceableObjectDecorator.py @@ -2,11 +2,11 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator class SliceableObjectDecorator(SceneNodeDecorator): - def __init__(self): + def __init__(self) -> None: super().__init__() - def isSliceable(self): + def isSliceable(self) -> bool: return True - def __deepcopy__(self, memo): + def __deepcopy__(self, memo) -> "SliceableObjectDecorator": return type(self)() diff --git a/cura/Scene/ZOffsetDecorator.py b/cura/Scene/ZOffsetDecorator.py index d3ee5c8454..b35b17a412 100644 --- a/cura/Scene/ZOffsetDecorator.py +++ b/cura/Scene/ZOffsetDecorator.py @@ -1,18 +1,19 @@ from UM.Scene.SceneNodeDecorator import SceneNodeDecorator + ## A decorator that stores the amount an object has been moved below the platform. class ZOffsetDecorator(SceneNodeDecorator): - def __init__(self): + def __init__(self) -> None: super().__init__() - self._z_offset = 0 + self._z_offset = 0. - def setZOffset(self, offset): + def setZOffset(self, offset: float) -> None: self._z_offset = offset - def getZOffset(self): + def getZOffset(self) -> float: return self._z_offset - def __deepcopy__(self, memo): + def __deepcopy__(self, memo) -> "ZOffsetDecorator": copied_decorator = ZOffsetDecorator() copied_decorator.setZOffset(self.getZOffset()) return copied_decorator From 1467e703ae121c3f2fa02ee7258bdeba63f80c5c Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 15:16:46 +0200 Subject: [PATCH 2/9] No longer make BuildVolume set max bounds. We didn't use it anymore and it added an extra requirement for buildvolume to depend on Application --- cura/BuildVolume.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index 4059283a32..9d2f5c1f90 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -479,8 +479,6 @@ class BuildVolume(SceneNode): maximum = Vector(max_w - bed_adhesion_size - 1, max_h - self._raft_thickness - self._extra_z_clearance, max_d - disallowed_area_size + bed_adhesion_size - 1) ) - self._application.getController().getScene()._maximum_bounds = scale_to_max_bounds - self.updateNodeBoundaryCheck() def getBoundingBox(self) -> AxisAlignedBox: From b58c01400baf563bb537d6bc49511c898b1b9689 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 15:28:53 +0200 Subject: [PATCH 3/9] Updated typing & documentation --- cura/Scene/ConvexHullDecorator.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/cura/Scene/ConvexHullDecorator.py b/cura/Scene/ConvexHullDecorator.py index 85d1e8e309..a78f559aa1 100644 --- a/cura/Scene/ConvexHullDecorator.py +++ b/cura/Scene/ConvexHullDecorator.py @@ -15,8 +15,6 @@ import numpy from typing import TYPE_CHECKING, Any, Optional - - if TYPE_CHECKING: from UM.Scene.SceneNode import SceneNode from cura.Settings.GlobalStack import GlobalStack @@ -35,10 +33,11 @@ class ConvexHullDecorator(SceneNodeDecorator): # Make sure the timer is created on the main thread self._recompute_convex_hull_timer = None # type: Optional[QTimer] - Application.getInstance().callLater(self.createRecomputeConvexHullTimer) + + if Application.getInstance() is not None: + Application.getInstance().callLater(self.createRecomputeConvexHullTimer) self._raft_thickness = 0.0 - # For raft thickness, DRY self._build_volume = Application.getInstance().getBuildVolume() self._build_volume.raftThicknessChanged.connect(self._onChanged) @@ -72,7 +71,7 @@ class ConvexHullDecorator(SceneNodeDecorator): def __deepcopy__(self, memo): return ConvexHullDecorator() - ## Get the unmodified 2D projected convex hull of the node + ## Get the unmodified 2D projected convex hull of the node (if any) def getConvexHull(self) -> Optional[Polygon]: if self._node is None: return None @@ -120,6 +119,7 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._compute2DConvexHull() return None + ## The same as recomputeConvexHull, but using a timer if it was set. def recomputeConvexHullDelayed(self) -> None: if self._recompute_convex_hull_timer is not None: self._recompute_convex_hull_timer.start() @@ -142,13 +142,13 @@ class ConvexHullDecorator(SceneNodeDecorator): self._convex_hull_node = hull_node def _onSettingValueChanged(self, key: str, property_name: str) -> None: - if property_name != "value": #Not the value that was changed. + if property_name != "value": # Not the value that was changed. return if key in self._affected_settings: self._onChanged() if key in self._influencing_settings: - self._init2DConvexHullCache() #Invalidate the cache. + self._init2DConvexHullCache() # Invalidate the cache. self._onChanged() def _init2DConvexHullCache(self) -> None: @@ -161,7 +161,7 @@ class ConvexHullDecorator(SceneNodeDecorator): self._2d_convex_hull_mesh_world_transform = None self._2d_convex_hull_mesh_result = None - def _compute2DConvexHull(self) -> Polygon: + def _compute2DConvexHull(self) -> Optional[Polygon]: if self._node.callDecoration("isGroup"): points = numpy.zeros((0, 2), dtype=numpy.int32) for child in self._node.getChildren(): @@ -188,8 +188,6 @@ class ConvexHullDecorator(SceneNodeDecorator): else: offset_hull = None - mesh = None - world_transform = None if self._node.getMeshData(): mesh = self._node.getMeshData() world_transform = self._node.getWorldTransformation() @@ -242,10 +240,10 @@ class ConvexHullDecorator(SceneNodeDecorator): return Polygon(numpy.array(self._global_stack.getHeadAndFansCoordinates(), numpy.float32)) return Polygon() - def _compute2DConvexHeadFull(self): + def _compute2DConvexHeadFull(self) -> Polygon: return self._compute2DConvexHull().getMinkowskiHull(self._getHeadAndFans()) - def _compute2DConvexHeadMin(self): + def _compute2DConvexHeadMin(self) -> Polygon: headAndFans = self._getHeadAndFans() mirrored = headAndFans.mirror([0, 0], [0, 1]).mirror([0, 0], [1, 0]) # Mirror horizontally & vertically. head_and_fans = self._getHeadAndFans().intersectionConvexHulls(mirrored) @@ -276,7 +274,7 @@ class ConvexHullDecorator(SceneNodeDecorator): else: raise Exception("Unknown bed adhesion type. Did you forget to update the convex hull calculations for your new bed adhesion type?") - # adjust head_and_fans with extra margin + # Adjust head_and_fans with extra margin if extra_margin > 0: extra_margin_polygon = Polygon.approximatedCircle(extra_margin) poly = poly.getMinkowskiHull(extra_margin_polygon) @@ -354,7 +352,7 @@ class ConvexHullDecorator(SceneNodeDecorator): # Limit_to_extruder is set. The global stack handles this then return self._global_stack.getProperty(setting_key, prop) - ## Returns true if node is a descendant or the same as the root node. + ## Returns True if node is a descendant or the same as the root node. def __isDescendant(self, root: "SceneNode", node: "SceneNode") -> bool: if node is None: return False From d7901907aff0709c65d0f79439e073c8d3983c8d Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 15:37:08 +0200 Subject: [PATCH 4/9] Fix typing --- cura/PrinterOutput/ConfigurationModel.py | 16 ++++++++-------- cura/Scene/ConvexHullDecorator.py | 21 +++++++++++++-------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/cura/PrinterOutput/ConfigurationModel.py b/cura/PrinterOutput/ConfigurationModel.py index a3d6afd01d..b3e8373745 100644 --- a/cura/PrinterOutput/ConfigurationModel.py +++ b/cura/PrinterOutput/ConfigurationModel.py @@ -13,20 +13,20 @@ class ConfigurationModel(QObject): configurationChanged = pyqtSignal() - def __init__(self): + def __init__(self) -> None: super().__init__() - self._printer_type = None + self._printer_type = "" self._extruder_configurations = [] # type: List[ExtruderConfigurationModel] - self._buildplate_configuration = None + self._buildplate_configuration = "" def setPrinterType(self, printer_type): self._printer_type = printer_type @pyqtProperty(str, fset = setPrinterType, notify = configurationChanged) - def printerType(self): + def printerType(self) -> str: return self._printer_type - def setExtruderConfigurations(self, extruder_configurations): + def setExtruderConfigurations(self, extruder_configurations: List[ExtruderConfigurationModel]): if self._extruder_configurations != extruder_configurations: self._extruder_configurations = extruder_configurations @@ -39,16 +39,16 @@ class ConfigurationModel(QObject): def extruderConfigurations(self): return self._extruder_configurations - def setBuildplateConfiguration(self, buildplate_configuration): + def setBuildplateConfiguration(self, buildplate_configuration: str) -> None: self._buildplate_configuration = buildplate_configuration @pyqtProperty(str, fset = setBuildplateConfiguration, notify = configurationChanged) - def buildplateConfiguration(self): + def buildplateConfiguration(self) -> str: return self._buildplate_configuration ## This method is intended to indicate whether the configuration is valid or not. # The method checks if the mandatory fields are or not set - def isValid(self): + def isValid(self) -> bool: if not self._extruder_configurations: return False for configuration in self._extruder_configurations: diff --git a/cura/Scene/ConvexHullDecorator.py b/cura/Scene/ConvexHullDecorator.py index a78f559aa1..31e21df6bf 100644 --- a/cura/Scene/ConvexHullDecorator.py +++ b/cura/Scene/ConvexHullDecorator.py @@ -78,7 +78,7 @@ class ConvexHullDecorator(SceneNodeDecorator): hull = self._compute2DConvexHull() - if self._global_stack and self._node: + if self._global_stack and self._node and hull is not None: # Parent can be None if node is just loaded. if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): hull = hull.getMinkowskiHull(Polygon(numpy.array(self._global_stack.getProperty("machine_head_polygon", "value"), numpy.float32))) @@ -240,17 +240,22 @@ class ConvexHullDecorator(SceneNodeDecorator): return Polygon(numpy.array(self._global_stack.getHeadAndFansCoordinates(), numpy.float32)) return Polygon() - def _compute2DConvexHeadFull(self) -> Polygon: - return self._compute2DConvexHull().getMinkowskiHull(self._getHeadAndFans()) + def _compute2DConvexHeadFull(self) -> Optional[Polygon]: + convex_hull = self._compute2DConvexHeadFull() + if convex_hull: + return convex_hull.getMinkowskiHull(self._getHeadAndFans()) + return None - def _compute2DConvexHeadMin(self) -> Polygon: - headAndFans = self._getHeadAndFans() - mirrored = headAndFans.mirror([0, 0], [0, 1]).mirror([0, 0], [1, 0]) # Mirror horizontally & vertically. + def _compute2DConvexHeadMin(self) -> Optional[Polygon]: + head_and_fans = self._getHeadAndFans() + mirrored = head_and_fans.mirror([0, 0], [0, 1]).mirror([0, 0], [1, 0]) # Mirror horizontally & vertically. head_and_fans = self._getHeadAndFans().intersectionConvexHulls(mirrored) # Min head hull is used for the push free - min_head_hull = self._compute2DConvexHull().getMinkowskiHull(head_and_fans) - return min_head_hull + convex_hull = self._compute2DConvexHeadFull() + if convex_hull: + return convex_hull.getMinkowskiHull(head_and_fans) + return None ## Compensate given 2D polygon with adhesion margin # \return 2D polygon with added margin From 889035ebfadb50cb567860b50824a98fc348ac45 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 15:42:12 +0200 Subject: [PATCH 5/9] Fixed typo --- cura/PrinterOutput/ConfigurationModel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/PrinterOutput/ConfigurationModel.py b/cura/PrinterOutput/ConfigurationModel.py index b3e8373745..89e609c913 100644 --- a/cura/PrinterOutput/ConfigurationModel.py +++ b/cura/PrinterOutput/ConfigurationModel.py @@ -26,7 +26,7 @@ class ConfigurationModel(QObject): def printerType(self) -> str: return self._printer_type - def setExtruderConfigurations(self, extruder_configurations: List[ExtruderConfigurationModel]): + def setExtruderConfigurations(self, extruder_configurations: List["ExtruderConfigurationModel"]): if self._extruder_configurations != extruder_configurations: self._extruder_configurations = extruder_configurations From c15f8aa6935736bb1ed90c877a0fd2399d65d707 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 15:54:23 +0200 Subject: [PATCH 6/9] Move printer type checking to where it belongs; inside the UM3 plugin. --- .../PrinterOutput/NetworkedPrinterOutputDevice.py | 15 +-------------- .../src/UM3OutputDevicePlugin.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 94f86f19a3..d9c5707a03 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -53,21 +53,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._sending_gcode = False self._compressing_gcode = False self._gcode = [] # type: List[str] - self._connection_state_before_timeout = None # type: Optional[ConnectionState] - printer_type = self._properties.get(b"machine", b"").decode("utf-8") - printer_type_identifiers = { - "9066": "ultimaker3", - "9511": "ultimaker3_extended", - "9051": "ultimaker_s5" - } - self._printer_type = "Unknown" - for key, value in printer_type_identifiers.items(): - if printer_type.startswith(key): - self._printer_type = value - break - def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: raise NotImplementedError("requestWrite needs to be implemented") @@ -341,7 +328,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): @pyqtProperty(str, constant = True) def printerType(self) -> str: - return self._printer_type + return self._properties.get(b"printer_type", b"Unknown").decode("utf-8") ## IP adress of this printer @pyqtProperty(str, constant = True) diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index f4749a6747..9c070f2de2 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -260,6 +260,19 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): # or "Legacy" UM3 device. cluster_size = int(properties.get(b"cluster_size", -1)) + printer_type = properties.get(b"machine", b"").decode("utf-8") + printer_type_identifiers = { + "9066": "ultimaker3", + "9511": "ultimaker3_extended", + "9051": "ultimaker_s5" + } + + for key, value in printer_type_identifiers.items(): + if printer_type.startswith(key): + properties[b"printer_type"] = bytes(value, encoding="utf8") + break + else: + properties[b"printer_type"] = b"Unknown" if cluster_size >= 0: device = ClusterUM3OutputDevice.ClusterUM3OutputDevice(name, address, properties) else: From 7310a677ced78acab279bfc2a8677ebe1b4bd084 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 16:07:18 +0200 Subject: [PATCH 7/9] Clean up more code This fixes some typing and moves a property to protected, as it should be --- cura/Machines/ContainerNode.py | 13 +++++-------- cura/Machines/MaterialManager.py | 1 - .../Models/SettingVisibilityPresetsModel.py | 4 ++-- cura/Machines/QualityChangesGroup.py | 4 ++-- cura/Machines/QualityNode.py | 8 ++++---- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/cura/Machines/ContainerNode.py b/cura/Machines/ContainerNode.py index 0d44c7c4a3..eef1c63127 100644 --- a/cura/Machines/ContainerNode.py +++ b/cura/Machines/ContainerNode.py @@ -9,9 +9,6 @@ from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.Logger import Logger from UM.Settings.InstanceContainer import InstanceContainer -if TYPE_CHECKING: - from cura.Machines.QualityGroup import QualityGroup - ## # A metadata / container combination. Use getContainer() to get the container corresponding to the metadata. @@ -24,11 +21,11 @@ if TYPE_CHECKING: # This is used in Variant, Material, and Quality Managers. # class ContainerNode: - __slots__ = ("_metadata", "container", "children_map") + __slots__ = ("_metadata", "_container", "children_map") def __init__(self, metadata: Optional[Dict[str, Any]] = None) -> None: self._metadata = metadata - self.container = None + self._container = None # type: Optional[InstanceContainer] self.children_map = OrderedDict() # type: ignore # This is because it's children are supposed to override it. ## Get an entry value from the metadata @@ -50,7 +47,7 @@ class ContainerNode: Logger.log("e", "Cannot get container for a ContainerNode without metadata.") return None - if self.container is None: + if self._container is None: container_id = self._metadata["id"] from UM.Settings.ContainerRegistry import ContainerRegistry container_list = ContainerRegistry.getInstance().findInstanceContainers(id = container_id) @@ -59,9 +56,9 @@ class ContainerNode: error_message = ConfigurationErrorMessage.getInstance() error_message.addFaultyContainers(container_id) return None - self.container = container_list[0] + self._container = container_list[0] - return self.container + return self._container def __str__(self) -> str: return "%s[%s]" % (self.__class__.__name__, self.getMetaDataEntry("id")) diff --git a/cura/Machines/MaterialManager.py b/cura/Machines/MaterialManager.py index 0ca9047620..be97fbc161 100644 --- a/cura/Machines/MaterialManager.py +++ b/cura/Machines/MaterialManager.py @@ -21,7 +21,6 @@ from .VariantType import VariantType if TYPE_CHECKING: from UM.Settings.DefinitionContainer import DefinitionContainer - from UM.Settings.InstanceContainer import InstanceContainer from cura.Settings.GlobalStack import GlobalStack from cura.Settings.ExtruderStack import ExtruderStack diff --git a/cura/Machines/Models/SettingVisibilityPresetsModel.py b/cura/Machines/Models/SettingVisibilityPresetsModel.py index 3062e83889..d5fa51d20a 100644 --- a/cura/Machines/Models/SettingVisibilityPresetsModel.py +++ b/cura/Machines/Models/SettingVisibilityPresetsModel.py @@ -58,7 +58,7 @@ class SettingVisibilityPresetsModel(ListModel): break return result - def _populate(self): + def _populate(self) -> None: from cura.CuraApplication import CuraApplication items = [] for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.SettingVisibilityPreset): @@ -147,7 +147,7 @@ class SettingVisibilityPresetsModel(ListModel): def activePreset(self) -> str: return self._active_preset_item["id"] - def _onPreferencesChanged(self, name: str): + def _onPreferencesChanged(self, name: str) -> None: if name != "general/visible_settings": return diff --git a/cura/Machines/QualityChangesGroup.py b/cura/Machines/QualityChangesGroup.py index 3dcf2ab1c8..7844b935dc 100644 --- a/cura/Machines/QualityChangesGroup.py +++ b/cura/Machines/QualityChangesGroup.py @@ -24,9 +24,9 @@ class QualityChangesGroup(QualityGroup): ConfigurationErrorMessage.getInstance().addFaultyContainers(node.getMetaDataEntry("id")) return - if extruder_position is None: #Then we're a global quality changes profile. + if extruder_position is None: # Then we're a global quality changes profile. self.node_for_global = node - else: #This is an extruder's quality changes profile. + else: # This is an extruder's quality changes profile. self.nodes_for_extruders[extruder_position] = node def __str__(self) -> str: diff --git a/cura/Machines/QualityNode.py b/cura/Machines/QualityNode.py index a821a1e15d..991388a4bd 100644 --- a/cura/Machines/QualityNode.py +++ b/cura/Machines/QualityNode.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict, cast +from typing import Optional, Dict, cast, Any from .ContainerNode import ContainerNode from .QualityChangesGroup import QualityChangesGroup @@ -12,21 +12,21 @@ from .QualityChangesGroup import QualityChangesGroup # class QualityNode(ContainerNode): - def __init__(self, metadata: Optional[dict] = None) -> None: + def __init__(self, metadata: Optional[Dict[str, Any]] = None) -> None: super().__init__(metadata = metadata) self.quality_type_map = {} # type: Dict[str, QualityNode] # quality_type -> QualityNode for InstanceContainer def getChildNode(self, child_key: str) -> Optional["QualityNode"]: return self.children_map.get(child_key) - def addQualityMetadata(self, quality_type: str, metadata: dict): + def addQualityMetadata(self, quality_type: str, metadata: Dict[str, Any]): if quality_type not in self.quality_type_map: self.quality_type_map[quality_type] = QualityNode(metadata) def getQualityNode(self, quality_type: str) -> Optional["QualityNode"]: return self.quality_type_map.get(quality_type) - def addQualityChangesMetadata(self, quality_type: str, metadata: dict): + def addQualityChangesMetadata(self, quality_type: str, metadata: Dict[str, Any]): if quality_type not in self.quality_type_map: self.quality_type_map[quality_type] = QualityNode() quality_type_node = self.quality_type_map[quality_type] From f585afe77bdff9653784242005e1728b972028d3 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 27 Sep 2018 17:31:45 +0200 Subject: [PATCH 8/9] Fix spacing --- cura/Settings/GlobalStack.py | 1 + tests/Settings/TestGlobalStack.py | 43 +++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cura/Settings/GlobalStack.py b/cura/Settings/GlobalStack.py index e2f7df41ea..517b45eb98 100755 --- a/cura/Settings/GlobalStack.py +++ b/cura/Settings/GlobalStack.py @@ -23,6 +23,7 @@ from .CuraContainerStack import CuraContainerStack if TYPE_CHECKING: from cura.Settings.ExtruderStack import ExtruderStack + ## Represents the Global or Machine stack and its related containers. # class GlobalStack(CuraContainerStack): diff --git a/tests/Settings/TestGlobalStack.py b/tests/Settings/TestGlobalStack.py index f8052aa4bb..0f1579f78b 100755 --- a/tests/Settings/TestGlobalStack.py +++ b/tests/Settings/TestGlobalStack.py @@ -15,6 +15,7 @@ import UM.Settings.SettingDefinition #To add settings to the definition. from cura.Settings.cura_empty_instance_containers import empty_container + ## Gets an instance container with a specified container type. # # \param container_type The type metadata for the instance container. @@ -24,22 +25,27 @@ def getInstanceContainer(container_type) -> InstanceContainer: container.setMetaDataEntry("type", container_type) return container + class DefinitionContainerSubClass(DefinitionContainer): def __init__(self): super().__init__(container_id = "SubDefinitionContainer") + class InstanceContainerSubClass(InstanceContainer): def __init__(self, container_type): super().__init__(container_id = "SubInstanceContainer") self.setMetaDataEntry("type", container_type) + #############################START OF TEST CASES################################ + ## Tests whether adding a container is properly forbidden. def test_addContainer(global_stack): with pytest.raises(InvalidOperationError): global_stack.addContainer(unittest.mock.MagicMock()) + ## Tests adding extruders to the global stack. def test_addExtruder(global_stack): mock_definition = unittest.mock.MagicMock() @@ -67,6 +73,7 @@ def test_addExtruder(global_stack): # global_stack.addExtruder(unittest.mock.MagicMock()) assert len(global_stack.extruders) == 2 #Didn't add the faulty extruder. + #Tests setting user changes profiles to invalid containers. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "wrong container type"), @@ -77,6 +84,7 @@ def test_constrainUserChangesInvalid(container, global_stack): with pytest.raises(InvalidContainerError): #Invalid container, should raise an error. global_stack.userChanges = container + #Tests setting user changes profiles. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "user"), @@ -85,6 +93,7 @@ def test_constrainUserChangesInvalid(container, global_stack): def test_constrainUserChangesValid(container, global_stack): global_stack.userChanges = container #Should not give an error. + #Tests setting quality changes profiles to invalid containers. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "wrong container type"), @@ -95,6 +104,7 @@ def test_constrainQualityChangesInvalid(container, global_stack): with pytest.raises(InvalidContainerError): #Invalid container, should raise an error. global_stack.qualityChanges = container + #Test setting quality changes profiles. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "quality_changes"), @@ -103,6 +113,7 @@ def test_constrainQualityChangesInvalid(container, global_stack): def test_constrainQualityChangesValid(container, global_stack): global_stack.qualityChanges = container #Should not give an error. + #Tests setting quality profiles to invalid containers. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "wrong container type"), @@ -113,6 +124,7 @@ def test_constrainQualityInvalid(container, global_stack): with pytest.raises(InvalidContainerError): #Invalid container, should raise an error. global_stack.quality = container + #Test setting quality profiles. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "quality"), @@ -121,6 +133,7 @@ def test_constrainQualityInvalid(container, global_stack): def test_constrainQualityValid(container, global_stack): global_stack.quality = container #Should not give an error. + #Tests setting materials to invalid containers. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "wrong container type"), @@ -131,6 +144,7 @@ def test_constrainMaterialInvalid(container, global_stack): with pytest.raises(InvalidContainerError): #Invalid container, should raise an error. global_stack.material = container + #Test setting materials. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "material"), @@ -139,6 +153,7 @@ def test_constrainMaterialInvalid(container, global_stack): def test_constrainMaterialValid(container, global_stack): global_stack.material = container #Should not give an error. + #Tests setting variants to invalid containers. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "wrong container type"), @@ -149,6 +164,7 @@ def test_constrainVariantInvalid(container, global_stack): with pytest.raises(InvalidContainerError): #Invalid container, should raise an error. global_stack.variant = container + #Test setting variants. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "variant"), @@ -157,6 +173,7 @@ def test_constrainVariantInvalid(container, global_stack): def test_constrainVariantValid(container, global_stack): global_stack.variant = container #Should not give an error. + #Tests setting definition changes profiles to invalid containers. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "wrong container type"), @@ -167,6 +184,7 @@ def test_constrainDefinitionChangesInvalid(container, global_stack): with pytest.raises(InvalidContainerError): #Invalid container, should raise an error. global_stack.definitionChanges = container + #Test setting definition changes profiles. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "definition_changes"), @@ -175,6 +193,7 @@ def test_constrainDefinitionChangesInvalid(container, global_stack): def test_constrainDefinitionChangesValid(container, global_stack): global_stack.definitionChanges = container #Should not give an error. + #Tests setting definitions to invalid containers. @pytest.mark.parametrize("container", [ getInstanceContainer(container_type = "wrong class"), @@ -184,6 +203,7 @@ def test_constrainDefinitionInvalid(container, global_stack): with pytest.raises(InvalidContainerError): #Invalid container, should raise an error. global_stack.definition = container + #Test setting definitions. @pytest.mark.parametrize("container", [ DefinitionContainer(container_id = "DefinitionContainer"), @@ -192,6 +212,7 @@ def test_constrainDefinitionInvalid(container, global_stack): def test_constrainDefinitionValid(container, global_stack): global_stack.definition = container #Should not give an error. + ## Tests whether deserialising completes the missing containers with empty ones. The initial containers are just the # definition and the definition_changes (that cannot be empty after CURA-5281) def test_deserializeCompletesEmptyContainers(global_stack): @@ -207,6 +228,7 @@ def test_deserializeCompletesEmptyContainers(global_stack): continue assert global_stack.getContainer(container_type_index) == empty_container #All others need to be empty. + ## Tests whether an instance container with the wrong type gets removed when deserialising. def test_deserializeRemovesWrongInstanceContainer(global_stack): global_stack._containers[cura.Settings.CuraContainerStack._ContainerIndexes.Quality] = getInstanceContainer(container_type = "wrong type") @@ -217,6 +239,7 @@ def test_deserializeRemovesWrongInstanceContainer(global_stack): assert global_stack.quality == global_stack._empty_instance_container #Replaced with empty. + ## Tests whether a container with the wrong class gets removed when deserialising. def test_deserializeRemovesWrongContainerClass(global_stack): global_stack._containers[cura.Settings.CuraContainerStack._ContainerIndexes.Quality] = DefinitionContainer(container_id = "wrong class") @@ -227,6 +250,7 @@ def test_deserializeRemovesWrongContainerClass(global_stack): assert global_stack.quality == global_stack._empty_instance_container #Replaced with empty. + ## Tests whether an instance container in the definition spot results in an error. def test_deserializeWrongDefinitionClass(global_stack): global_stack._containers[cura.Settings.CuraContainerStack._ContainerIndexes.Definition] = getInstanceContainer(container_type = "definition") #Correct type but wrong class. @@ -235,6 +259,7 @@ def test_deserializeWrongDefinitionClass(global_stack): with pytest.raises(UM.Settings.ContainerStack.InvalidContainerStackError): #Must raise an error that there is no definition container. global_stack.deserialize("") + ## Tests whether an instance container with the wrong type is moved into the correct slot by deserialising. def test_deserializeMoveInstanceContainer(global_stack): global_stack._containers[cura.Settings.CuraContainerStack._ContainerIndexes.Quality] = getInstanceContainer(container_type = "material") #Not in the correct spot. @@ -246,6 +271,7 @@ def test_deserializeMoveInstanceContainer(global_stack): assert global_stack.quality == empty_container assert global_stack.material != empty_container + ## Tests whether a definition container in the wrong spot is moved into the correct spot by deserialising. def test_deserializeMoveDefinitionContainer(global_stack): global_stack._containers[cura.Settings.CuraContainerStack._ContainerIndexes.Material] = DefinitionContainer(container_id = "some definition") #Not in the correct spot. @@ -256,6 +282,7 @@ def test_deserializeMoveDefinitionContainer(global_stack): assert global_stack.material == empty_container assert global_stack.definition != empty_container + ## Tests whether getProperty properly applies the stack-like behaviour on its containers. def test_getPropertyFallThrough(global_stack): #A few instance container mocks to put in the stack. @@ -298,6 +325,7 @@ def test_getPropertyFallThrough(global_stack): global_stack.userChanges = mock_layer_heights[container_indexes.UserChanges] assert global_stack.getProperty("layer_height", "value") == container_indexes.UserChanges + ## In definitions, test whether having no resolve allows us to find the value. def test_getPropertyNoResolveInDefinition(global_stack): value = unittest.mock.MagicMock() #Just sets the value for bed temperature. @@ -307,6 +335,7 @@ def test_getPropertyNoResolveInDefinition(global_stack): global_stack.definition = value assert global_stack.getProperty("material_bed_temperature", "value") == 10 #No resolve, so fall through to value. + ## In definitions, when the value is asked and there is a resolve function, it must get the resolve first. def test_getPropertyResolveInDefinition(global_stack): resolve_and_value = unittest.mock.MagicMock() #Sets the resolve and value for bed temperature. @@ -316,6 +345,7 @@ def test_getPropertyResolveInDefinition(global_stack): global_stack.definition = resolve_and_value assert global_stack.getProperty("material_bed_temperature", "value") == 7.5 #Resolve wins in the definition. + ## In instance containers, when the value is asked and there is a resolve function, it must get the value first. def test_getPropertyResolveInInstance(global_stack): container_indices = cura.Settings.CuraContainerStack._ContainerIndexes @@ -342,6 +372,7 @@ def test_getPropertyResolveInInstance(global_stack): global_stack.userChanges = instance_containers[container_indices.UserChanges] assert global_stack.getProperty("material_bed_temperature", "value") == 5 + ## Tests whether the value in instances gets evaluated before the resolve in definitions. def test_getPropertyInstancesBeforeResolve(global_stack): value = unittest.mock.MagicMock() #Sets just the value. @@ -356,6 +387,7 @@ def test_getPropertyInstancesBeforeResolve(global_stack): assert global_stack.getProperty("material_bed_temperature", "value") == 10 + ## Tests whether the hasUserValue returns true for settings that are changed in the user-changes container. def test_hasUserValueUserChanges(global_stack): container = unittest.mock.MagicMock() @@ -367,6 +399,7 @@ def test_hasUserValueUserChanges(global_stack): assert not global_stack.hasUserValue("infill_sparse_density") assert not global_stack.hasUserValue("") + ## Tests whether the hasUserValue returns true for settings that are changed in the quality-changes container. def test_hasUserValueQualityChanges(global_stack): container = unittest.mock.MagicMock() @@ -378,6 +411,7 @@ def test_hasUserValueQualityChanges(global_stack): assert not global_stack.hasUserValue("infill_sparse_density") assert not global_stack.hasUserValue("") + ## Tests whether a container in some other place on the stack is correctly not recognised as user value. def test_hasNoUserValue(global_stack): container = unittest.mock.MagicMock() @@ -387,21 +421,25 @@ def test_hasNoUserValue(global_stack): assert not global_stack.hasUserValue("layer_height") #However this container is quality, so it's not a user value. + ## Tests whether inserting a container is properly forbidden. def test_insertContainer(global_stack): with pytest.raises(InvalidOperationError): global_stack.insertContainer(0, unittest.mock.MagicMock()) + ## Tests whether removing a container is properly forbidden. def test_removeContainer(global_stack): with pytest.raises(InvalidOperationError): global_stack.removeContainer(unittest.mock.MagicMock()) + ## Tests whether changing the next stack is properly forbidden. def test_setNextStack(global_stack): with pytest.raises(InvalidOperationError): global_stack.setNextStack(unittest.mock.MagicMock()) + ## Tests setting properties directly on the global stack. @pytest.mark.parametrize("key, property, value", [ ("layer_height", "value", 0.1337), @@ -415,6 +453,7 @@ def test_setPropertyUser(key, property, value, global_stack): user_changes.getMetaDataEntry = unittest.mock.MagicMock(return_value = "user") global_stack.userChanges = user_changes - global_stack.setProperty(key, property, value) #The actual test. + global_stack.setProperty(key, property, value) # The actual test. - global_stack.userChanges.setProperty.assert_called_once_with(key, property, value, None, False) #Make sure that the user container gets a setProperty call. \ No newline at end of file + # Make sure that the user container gets a setProperty call. + global_stack.userChanges.setProperty.assert_called_once_with(key, property, value, None, False) \ No newline at end of file From dc2c074bc0b5297bf80a20d962bda36cf12c75e5 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Thu, 16 Aug 2018 15:34:05 +0200 Subject: [PATCH 9/9] Verbose output for Linux CI --- Jenkinsfile | 44 ++++++++++++++++++++++++++++++++++++++++--- cmake/CuraTests.cmake | 2 +- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4f755dcae2..35f07d3987 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -52,10 +52,48 @@ parallel_nodes(['linux && cura', 'windows && cura']) { // Try and run the unit tests. If this stage fails, we consider the build to be "unstable". stage('Unit Test') { - try { + if (isUnix()) { + // For Linux to show everything + def branch = env.BRANCH_NAME + if(!fileExists("${env.CURA_ENVIRONMENT_PATH}/${branch}")) { + branch = "master" + } + def uranium_dir = get_workspace_dir("Ultimaker/Uranium/${branch}") + + try { + sh """ + cd .. + export PYTHONPATH=.:"${uranium_dir}" + ${env.CURA_ENVIRONMENT_PATH}/${branch}/bin/pytest -x --verbose --full-trace --capture=no ./tests + """ + } catch(e) { + currentBuild.result = "UNSTABLE" + } + } + else { + // For Windows make('test') - } catch(e) { - currentBuild.result = "UNSTABLE" + } + } + + stage('Code Style') { + if (isUnix()) { + // For Linux to show everything + def branch = env.BRANCH_NAME + if(!fileExists("${env.CURA_ENVIRONMENT_PATH}/${branch}")) { + branch = "master" + } + def uranium_dir = get_workspace_dir("Ultimaker/Uranium/${branch}") + + try { + sh """ + cd .. + export PYTHONPATH=.:"${uranium_dir}" + ${env.CURA_ENVIRONMENT_PATH}/${branch}/bin/python3 run_mypy.py + """ + } catch(e) { + currentBuild.result = "UNSTABLE" + } } } } diff --git a/cmake/CuraTests.cmake b/cmake/CuraTests.cmake index 801f054bc3..30794ed608 100644 --- a/cmake/CuraTests.cmake +++ b/cmake/CuraTests.cmake @@ -34,7 +34,7 @@ function(cura_add_test) if (NOT ${test_exists}) add_test( NAME ${_NAME} - COMMAND ${PYTHON_EXECUTABLE} -m pytest --junitxml=${CMAKE_BINARY_DIR}/junit-${_NAME}.xml ${_DIRECTORY} + COMMAND ${PYTHON_EXECUTABLE} -m pytest --verbose --full-trace --capture=no --no-print-log --junitxml=${CMAKE_BINARY_DIR}/junit-${_NAME}.xml ${_DIRECTORY} ) set_tests_properties(${_NAME} PROPERTIES ENVIRONMENT LANG=C) set_tests_properties(${_NAME} PROPERTIES ENVIRONMENT "PYTHONPATH=${_PYTHONPATH}")