From 66405dc50d2cf92287a68dcc8921c3c5630fdb6d Mon Sep 17 00:00:00 2001 From: Yorik van Havre Date: Thu, 9 Mar 2023 14:26:09 +0100 Subject: [PATCH 01/75] Draft: Added LayerManager command from BIM --- src/Mod/Draft/Resources/Draft.qrc | 2 + .../Resources/icons/Draft_LayerManager.svg | 515 ++++++++++++++++++ src/Mod/Draft/Resources/ui/dialogLayers.ui | 94 ++++ src/Mod/Draft/draftguitools/gui_layers.py | 444 +++++++++++++++ src/Mod/Draft/draftutils/init_tools.py | 3 +- 5 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 src/Mod/Draft/Resources/icons/Draft_LayerManager.svg create mode 100644 src/Mod/Draft/Resources/ui/dialogLayers.ui diff --git a/src/Mod/Draft/Resources/Draft.qrc b/src/Mod/Draft/Resources/Draft.qrc index 1df9126aaa..7907e7423e 100644 --- a/src/Mod/Draft/Resources/Draft.qrc +++ b/src/Mod/Draft/Resources/Draft.qrc @@ -49,6 +49,7 @@ icons/Draft_Join.svg icons/Draft_Label.svg icons/Draft_Layer.svg + icons/Draft_LayerManager.svg icons/Draft_Line.svg icons/Draft_LinkArray.svg icons/Draft_Lock.svg @@ -191,5 +192,6 @@ ui/dialog_AnnotationStyleEditor.ui ui/TaskPanel_SetStyle.ui ui/dialogHatch.ui + ui/dialogLayers.ui diff --git a/src/Mod/Draft/Resources/icons/Draft_LayerManager.svg b/src/Mod/Draft/Resources/icons/Draft_LayerManager.svg new file mode 100644 index 0000000000..34134c781f --- /dev/null +++ b/src/Mod/Draft/Resources/icons/Draft_LayerManager.svg @@ -0,0 +1,515 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Jakub Steiner + + + http://jimmac.musichall.cz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mod/Draft/Resources/ui/dialogLayers.ui b/src/Mod/Draft/Resources/ui/dialogLayers.ui new file mode 100644 index 0000000000..32c1468231 --- /dev/null +++ b/src/Mod/Draft/Resources/ui/dialogLayers.ui @@ -0,0 +1,94 @@ + + + Dialog + + + + 0 + 0 + 667 + 320 + + + + Layers manager + + + + + + true + + + + + + + + + New + + + + + + + Delete + + + + + + + Select all + + + + + + + Toggle on/off + + + + + + + Isolate + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + OK + + + + + + + + + + diff --git a/src/Mod/Draft/draftguitools/gui_layers.py b/src/Mod/Draft/draftguitools/gui_layers.py index ab66fa7347..432e754c7f 100644 --- a/src/Mod/Draft/draftguitools/gui_layers.py +++ b/src/Mod/Draft/draftguitools/gui_layers.py @@ -30,7 +30,10 @@ # @{ from PySide.QtCore import QT_TRANSLATE_NOOP +import os +import FreeCAD import FreeCADGui as Gui +import Draft import Draft_rc import draftguitools.gui_base as gui_base @@ -40,6 +43,18 @@ from draftutils.translate import translate bool(Draft_rc.__name__) +def getColorIcon(color): + + "returns a QtGui.QIcon from a color 3-float tuple" + + from PySide import QtCore,QtGui + c = QtGui.QColor(int(color[0]*255),int(color[1]*255),int(color[2]*255)) + im = QtGui.QImage(48,48,QtGui.QImage.Format_ARGB32) + im.fill(c) + px = QtGui.QPixmap.fromImage(im) + return QtGui.QIcon(px) + + class Layer(gui_base.GuiCommandSimplest): """GuiCommand to create a Layer object in the document.""" @@ -66,6 +81,435 @@ class Layer(gui_base.GuiCommandSimplest): self.doc.commitTransaction() +class LayerManager: + + """GuiCommand that displays a Layers manager dialog""" + + def GetResources(self): + + return {'Pixmap' : 'Draft_LayerManager', + 'MenuText': QT_TRANSLATE_NOOP("Draft_LayerManager", "Manage layers..."), + 'ToolTip' : QT_TRANSLATE_NOOP("Draft_LayerManager", "Set/modify the different layers of this document")} + + def Activated(self): + + from PySide import QtCore, QtGui + + # store changes to be committed + self.deleteList = [] + + # create the dialog + self.dialog = Gui.PySideUic.loadUi(":/ui/dialogLayers.ui") + + # set nice icons + self.dialog.setWindowIcon(QtGui.QIcon(":/icons/Draft_Layer.svg")) + self.dialog.buttonNew.setIcon(QtGui.QIcon(":/icons/document-new.svg")) + self.dialog.buttonDelete.setIcon(QtGui.QIcon(":/icons/delete.svg")) + self.dialog.buttonSelectAll.setIcon(QtGui.QIcon(":/icons/edit-select-all.svg")) + self.dialog.buttonToggle.setIcon(QtGui.QIcon(":/icons/dagViewVisible.svg")) + self.dialog.buttonIsolate.setIcon(QtGui.QIcon(":/icons/view-refresh.svg")) + self.dialog.buttonCancel.setIcon(QtGui.QIcon(":/icons/edit_Cancel.svg")) + self.dialog.buttonOK.setIcon(QtGui.QIcon(":/icons/edit_OK.svg")) + + # restore window geometry from stored state + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Draft") + w = pref.GetInt("LayersManagerWidth",640) + h = pref.GetInt("LayersManagerHeight",320) + self.dialog.resize(w,h) + + # center the dialog over FreeCAD window + mw = Gui.getMainWindow() + self.dialog.move(mw.frameGeometry().topLeft() + mw.rect().center() - self.dialog.rect().center()) + + # connect signals/slots + self.dialog.buttonNew.clicked.connect(self.addItem) + self.dialog.buttonDelete.clicked.connect(self.onDelete) + self.dialog.buttonSelectAll.clicked.connect(self.dialog.tree.selectAll) + self.dialog.buttonToggle.clicked.connect(self.onToggle) + self.dialog.buttonCancel.clicked.connect(self.dialog.reject) + self.dialog.buttonIsolate.clicked.connect(self.onIsolate) + self.dialog.buttonOK.clicked.connect(self.accept) + self.dialog.rejected.connect(self.reject) + + # set the model up + self.model = QtGui.QStandardItemModel() + self.dialog.tree.setModel(self.model) + self.dialog.tree.setUniformRowHeights(True) + self.dialog.tree.setItemDelegate(Layers_Delegate()) + self.dialog.tree.setItemsExpandable(False) + self.dialog.tree.setRootIsDecorated(False) # removes spacing in first column + self.dialog.tree.setSelectionMode(QtGui.QTreeView.ExtendedSelection) # allow to select many + + # fill the tree view + self.update() + + # rock 'n roll!!! + self.dialog.exec_() + + def accept(self): + + "when OK button is pressed" + + changed = False + + # delete layers + for name in self.deleteList: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + FreeCAD.ActiveDocument.removeObject(name) + + # apply changes + for row in range(self.model.rowCount()): + + # get or create layer + name = self.model.item(row,1).toolTip() + obj = None + if name: + obj = FreeCAD.ActiveDocument.getObject(name) + if not obj: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj = Draft.make_layer() + + # visibility + checked = True if self.model.item(row,0).checkState() == QtCore.Qt.Checked else False + if checked != obj.ViewObject.Visibility: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.ViewObject.Visibility = checked + + # label + label = self.model.item(row,1).text() + if label: + if obj.Label != label: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.Label = label + + # line width + width = self.model.item(row,2).data(QtCore.Qt.DisplayRole) + if width: + if obj.ViewObject.LineWidth != width: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.ViewObject.LineWidth = width + + # draw style + style = self.model.item(row,3).text() + if style: + if obj.ViewObject.DrawStyle != style: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.ViewObject.DrawStyle = style + + # line color + color = self.model.item(row,4).data(QtCore.Qt.UserRole) + if color: + if obj.ViewObject.LineColor[3:] != color: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.ViewObject.LineColor = color + + # shape color + color = self.model.item(row,5).data(QtCore.Qt.UserRole) + if color: + if obj.ViewObject.ShapeColor[3:] != color: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.ViewObject.ShapeColor = color + + # transparency + transparency = self.model.item(row,6).data(QtCore.Qt.DisplayRole) + if transparency: + if obj.ViewObject.Transparency != transparency: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.ViewObject.Transparency = transparency + + # line print color + color = self.model.item(row,7).data(QtCore.Qt.UserRole) + if color: + if not "LinePrintColor" in obj.ViewObject.PropertiesList: + if hasattr(obj.ViewObject.Proxy,"set_properties"): + obj.ViewObject.Proxy.set_properties(obj.ViewObject) + if "LinePrintColor" in obj.ViewObject.PropertiesList: + if obj.ViewObject.LinePrintColor[3:] != color: + if not changed: + FreeCAD.ActiveDocument.openTransaction("Layers change") + changed = True + obj.ViewObject.LinePrintColor = color + + # recompute + if changed: + FreeCAD.ActiveDocument.commitTransaction() + FreeCAD.ActiveDocument.recompute() + + # exit + self.dialog.reject() + + def reject(self): + + "when Cancel button is pressed or dialog is closed" + + # save dialog size + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Draft") + pref.SetInt("LayersManagerWidth",self.dialog.width()) + pref.SetInt("LayersManagerHeight",self.dialog.height()) + + return True + + def update(self): + + "rebuild the model from document contents" + + self.model.clear() + + # set header + self.model.setHorizontalHeaderLabels([translate("Draft","On"), + translate("Draft","Name"), + translate("Draft","Line width"), + translate("Draft","Draw style"), + translate("Draft","Line color"), + translate("Draft","Face color"), + translate("Draft","Transparency"), + translate("Draft","Line print color")]) + self.dialog.tree.header().setDefaultSectionSize(72) + self.dialog.tree.setColumnWidth(0,32) # on/off column + self.dialog.tree.setColumnWidth(1,128) # name column + + # populate + objs = [obj for obj in FreeCAD.ActiveDocument.Objects if Draft.getType(obj) == "Layer"] + objs.sort(key=lambda o:o.Label) + for obj in objs: + self.addItem(obj) + + def addItem(self,obj=None): + + "adds a row to the model" + + from PySide import QtCore, QtGui + + # create row with default values + onItem = QtGui.QStandardItem() + onItem.setCheckable(True) + onItem.setCheckState(QtCore.Qt.Checked) + nameItem = QtGui.QStandardItem(translate("Draft","New Layer")) + widthItem = QtGui.QStandardItem() + widthItem.setData(self.getPref("DefaultShapeLineWidth",2,"Integer"),QtCore.Qt.DisplayRole) + styleItem = QtGui.QStandardItem("Solid") + lineColorItem = QtGui.QStandardItem() + lineColorItem.setData(self.getPref("DefaultShapeLineColor",421075455),QtCore.Qt.UserRole) + shapeColorItem = QtGui.QStandardItem() + shapeColorItem.setData(self.getPref("DefaultShapeColor",3435973887),QtCore.Qt.UserRole) + transparencyItem = QtGui.QStandardItem() + transparencyItem.setData(0,QtCore.Qt.DisplayRole) + linePrintColorItem = QtGui.QStandardItem() + linePrintColorItem.setData(self.getPref("DefaultPrintColor",0),QtCore.Qt.UserRole) + + # populate with object data + if obj: + onItem.setCheckState(QtCore.Qt.Checked if obj.ViewObject.Visibility else QtCore.Qt.Unchecked) + nameItem.setText(obj.Label) + nameItem.setToolTip(obj.Name) + widthItem.setData(obj.ViewObject.LineWidth,QtCore.Qt.DisplayRole) + styleItem.setText(obj.ViewObject.DrawStyle) + lineColorItem.setData(obj.ViewObject.LineColor[:3],QtCore.Qt.UserRole) + shapeColorItem.setData(obj.ViewObject.ShapeColor[:3],QtCore.Qt.UserRole) + transparencyItem.setData(obj.ViewObject.Transparency,QtCore.Qt.DisplayRole) + if hasattr(obj.ViewObject,"LinePrintColor"): + linePrintColorItem.setData(obj.ViewObject.LinePrintColor[:3],QtCore.Qt.UserRole) + lineColorItem.setIcon(getColorIcon(lineColorItem.data(QtCore.Qt.UserRole))) + shapeColorItem.setIcon(getColorIcon(shapeColorItem.data(QtCore.Qt.UserRole))) + linePrintColorItem.setIcon(getColorIcon(linePrintColorItem.data(QtCore.Qt.UserRole))) + + # append row + self.model.appendRow([onItem, + nameItem, + widthItem, + styleItem, + lineColorItem, + shapeColorItem, + transparencyItem, + linePrintColorItem]) + + def getPref(self,value,default,valuetype="Unsigned"): + + "retrieves a view pref value" + + p = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/View") + if valuetype == "Unsigned": + c = p.GetUnsigned(value,default) + r = float((c>>24)&0xFF)/255.0 + g = float((c>>16)&0xFF)/255.0 + b = float((c>>8)&0xFF)/255.0 + return (r,g,b,) + elif valuetype == "Integer": + return p.GetInt(value,default) + + def onDelete(self): + + "delete selected rows" + + rows = [] + for index in self.dialog.tree.selectedIndexes(): + if not index.row() in rows: + rows.append(index.row()) + + # append layer name to the delete list + if index.column() == 1: + name = self.model.itemFromIndex(index).toolTip() + if name: + if not name in self.deleteList: + self.deleteList.append(name) + + # delete rows starting from the lowest, to not alter row indexes while deleting + rows.sort() + rows.reverse() + for row in rows: + self.model.takeRow(row) + + def onToggle(self): + + "toggle selected layers on/off" + + from PySide import QtCore, QtGui + + state = None + for index in self.dialog.tree.selectedIndexes(): + if index.column() == 0: + # get state from first selected row + if state is None: + if self.model.itemFromIndex(index).checkState() == QtCore.Qt.Checked: + state = QtCore.Qt.Unchecked + else: + state = QtCore.Qt.Checked + self.model.itemFromIndex(index).setCheckState(state) + + def onIsolate(self): + + "isolates the selected layers (turns all the others off" + + from PySide import QtCore, QtGui + + onrows = [] + for index in self.dialog.tree.selectedIndexes(): + if not index.row() in onrows: + onrows.append(index.row()) + for row in range(self.model.rowCount()): + if not row in onrows: + self.model.item(row,0).setCheckState(QtCore.Qt.Unchecked) + + +if FreeCAD.GuiUp: + + from PySide import QtCore, QtGui + + class Layers_Delegate(QtGui.QStyledItemDelegate): + + "model delegate" + + def __init__(self, parent=None, *args): + + QtGui.QStyledItemDelegate.__init__(self, parent, *args) + # setEditorData() is triggered several times. + # But we want to show the color dialog only the first time + self.first = True + + def createEditor(self,parent,option,index): + + if index.column() == 0: # Layer on/off + editor = QtGui.QCheckBox(parent) + if index.column() == 1: # Layer name + editor = QtGui.QLineEdit(parent) + elif index.column() == 2: # Line width + editor = QtGui.QSpinBox(parent) + editor.setMaximum(99) + elif index.column() == 3: # Line style + editor = QtGui.QComboBox(parent) + editor.addItems(["Solid","Dashed","Dotted","Dashdot"]) + elif index.column() == 4: # Line color + editor = QtGui.QLineEdit(parent) + self.first = True + elif index.column() == 5: # Shape color + editor = QtGui.QLineEdit(parent) + self.first = True + elif index.column() == 6: # Transparency + editor = QtGui.QSpinBox(parent) + editor.setMaximum(100) + elif index.column() == 7: # Line print color + editor = QtGui.QLineEdit(parent) + self.first = True + return editor + + def setEditorData(self, editor, index): + + if index.column() == 0: # Layer on/off + editor.setChecked(index.data()) + elif index.column() == 1: # Layer name + editor.setText(index.data()) + elif index.column() == 2: # Line width + editor.setValue(index.data()) + elif index.column() == 3: # Line style + editor.setCurrentIndex(["Solid","Dashed","Dotted","Dashdot"].index(index.data())) + elif index.column() == 4: # Line color + editor.setText(str(index.data(QtCore.Qt.UserRole))) + if self.first: + c = index.data(QtCore.Qt.UserRole) + color = QtGui.QColorDialog.getColor(QtGui.QColor(int(c[0]*255),int(c[1]*255),int(c[2]*255))) + editor.setText(str(color.getRgbF())) + self.first = False + elif index.column() == 5: # Shape color + editor.setText(str(index.data(QtCore.Qt.UserRole))) + if self.first: + c = index.data(QtCore.Qt.UserRole) + color = QtGui.QColorDialog.getColor(QtGui.QColor(int(c[0]*255),int(c[1]*255),int(c[2]*255))) + editor.setText(str(color.getRgbF())) + self.first = False + elif index.column() == 6: # Transparency + editor.setValue(index.data()) + elif index.column() == 7: # Line print color + editor.setText(str(index.data(QtCore.Qt.UserRole))) + if self.first: + c = index.data(QtCore.Qt.UserRole) + color = QtGui.QColorDialog.getColor(QtGui.QColor(int(c[0]*255),int(c[1]*255),int(c[2]*255))) + editor.setText(str(color.getRgbF())) + self.first = False + + def setModelData(self, editor, model, index): + + if index.column() == 0: # Layer on/off + model.setData(index,editor.isChecked()) + elif index.column() == 1: # Layer name + model.setData(index,editor.text()) + elif index.column() == 2: # Line width + model.setData(index,editor.value()) + elif index.column() == 3: # Line style + model.setData(index,["Solid","Dashed","Dotted","Dashdot"][editor.currentIndex()]) + elif index.column() == 4: # Line color + model.setData(index,eval(editor.text()),QtCore.Qt.UserRole) + model.itemFromIndex(index).setIcon(getColorIcon(eval(editor.text()))) + elif index.column() == 5: # Shape color + model.setData(index,eval(editor.text()),QtCore.Qt.UserRole) + model.itemFromIndex(index).setIcon(getColorIcon(eval(editor.text()))) + elif index.column() == 6: # Transparency + model.setData(index,editor.value()) + elif index.column() == 7: # Line prin color + model.setData(index,eval(editor.text()),QtCore.Qt.UserRole) + model.itemFromIndex(index).setIcon(getColorIcon(eval(editor.text()))) + + + + Gui.addCommand('Draft_Layer', Layer()) +Gui.addCommand('Draft_LayerManager', LayerManager()) ## @} diff --git a/src/Mod/Draft/draftutils/init_tools.py b/src/Mod/Draft/draftutils/init_tools.py index 49b401719c..82a7114f0c 100644 --- a/src/Mod/Draft/draftutils/init_tools.py +++ b/src/Mod/Draft/draftutils/init_tools.py @@ -112,6 +112,7 @@ def get_draft_utility_commands_menu(): "Draft_ApplyStyle", "Separator", "Draft_Layer", + "Draft_LayerManager", "Draft_AddNamedGroup", "Draft_AddToGroup", "Draft_SelectGroup", @@ -130,7 +131,7 @@ def get_draft_utility_commands_menu(): def get_draft_utility_commands_toolbar(): """Return the utility commands list for the toolbar.""" - return ["Draft_Layer", + return ["Draft_LayerManager", "Draft_AddNamedGroup", "Draft_AddToGroup", "Draft_SelectGroup", From 53bc32b03ed074cc3bb3edcba01fda110b7fd9e9 Mon Sep 17 00:00:00 2001 From: Yorik van Havre Date: Fri, 10 Mar 2023 12:19:27 +0100 Subject: [PATCH 02/75] Draft: Fixes grid on/off behaviour - fixes #5878 - If grid is on and auto grid on: Grid always appears - If grid is on and auto grid off: Grid off at start, on when using a tool, off afterwards - If grid is off: Grid never appears --- src/Mod/Draft/draftguitools/gui_base_original.py | 2 +- src/Mod/Draft/draftguitools/gui_grid.py | 2 +- src/Mod/Draft/draftguitools/gui_lines.py | 2 +- src/Mod/Draft/draftguitools/gui_snapper.py | 13 ++++++++----- src/Mod/Draft/draftguitools/gui_trackers.py | 5 +++-- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Mod/Draft/draftguitools/gui_base_original.py b/src/Mod/Draft/draftguitools/gui_base_original.py index 69430e3a5c..1b58a33d9b 100644 --- a/src/Mod/Draft/draftguitools/gui_base_original.py +++ b/src/Mod/Draft/draftguitools/gui_base_original.py @@ -148,7 +148,7 @@ class DraftTool: if utils.get_param("showPlaneTracker", False): self.planetrack = trackers.PlaneTracker() if hasattr(Gui, "Snapper"): - Gui.Snapper.setTrackers() + Gui.Snapper.setTrackers(tool=True) _msg("{}".format(16*"-")) _msg("GuiCommand: {}".format(self.featureName)) diff --git a/src/Mod/Draft/draftguitools/gui_grid.py b/src/Mod/Draft/draftguitools/gui_grid.py index 835a98cd2d..0173dd8dc2 100644 --- a/src/Mod/Draft/draftguitools/gui_grid.py +++ b/src/Mod/Draft/draftguitools/gui_grid.py @@ -66,7 +66,7 @@ class ToggleGrid(gui_base.GuiCommandSimplest): super(ToggleGrid, self).Activated() if hasattr(Gui, "Snapper"): - Gui.Snapper.setTrackers() + Gui.Snapper.setTrackers(tool=True) if Gui.Snapper.grid: if Gui.Snapper.grid.Visible: Gui.Snapper.grid.off() diff --git a/src/Mod/Draft/draftguitools/gui_lines.py b/src/Mod/Draft/draftguitools/gui_lines.py index cfe5f607bd..274ccfe3b3 100644 --- a/src/Mod/Draft/draftguitools/gui_lines.py +++ b/src/Mod/Draft/draftguitools/gui_lines.py @@ -139,7 +139,7 @@ class Line(gui_base_original.Creator): if self.oldWP: App.DraftWorkingPlane.setFromParameters(self.oldWP) if hasattr(Gui, "Snapper"): - Gui.Snapper.setGrid() + Gui.Snapper.setGrid(tool=True) Gui.Snapper.restack() self.oldWP = None diff --git a/src/Mod/Draft/draftguitools/gui_snapper.py b/src/Mod/Draft/draftguitools/gui_snapper.py index a373e9e65a..171b531c3d 100644 --- a/src/Mod/Draft/draftguitools/gui_snapper.py +++ b/src/Mod/Draft/draftguitools/gui_snapper.py @@ -1594,15 +1594,15 @@ class Snapper: self.toolbar.toggleViewAction().setVisible(False) - def setGrid(self): + def setGrid(self, tool=False): """Set the grid, if visible.""" self.setTrackers() if self.grid and (not self.forceGridOff): if self.grid.Visible: - self.grid.set() + self.grid.set(tool) - def setTrackers(self): + def setTrackers(self, tool=False): """Set the trackers.""" v = Draft.get3DView() if v and (v != self.activeview): @@ -1620,7 +1620,10 @@ class Snapper: else: if Draft.getParam("grid", True): self.grid = trackers.gridTracker() - self.grid.on() + if Draft.getParam("alwaysShowGrid", True) or tool: + self.grid.on() + else: + self.grid.off() else: self.grid = None self.tracker = trackers.snapTracker() @@ -1651,7 +1654,7 @@ class Snapper: self.activeview = v if self.grid and (not self.forceGridOff): - self.grid.set() + self.grid.set(tool) def addHoldPoint(self): diff --git a/src/Mod/Draft/draftguitools/gui_trackers.py b/src/Mod/Draft/draftguitools/gui_trackers.py index a2ebb17b17..577ae5c13b 100644 --- a/src/Mod/Draft/draftguitools/gui_trackers.py +++ b/src/Mod/Draft/draftguitools/gui_trackers.py @@ -1216,7 +1216,7 @@ class gridTracker(Tracker): self.numlines = Draft.getParam("gridSize", 100) self.update() - def set(self): + def set(self,tool=False): """Move and rotate the grid according to the current working plane.""" self.reset() Q = FreeCAD.DraftWorkingPlane.getRotation().Rotation.Q @@ -1225,7 +1225,8 @@ class gridTracker(Tracker): self.trans.translation.setValue([P.x, P.y, P.z]) self.displayHumanFigure() self.setAxesColor() - self.on() + if tool: + self.on() def getClosestNode(self, point): """Return the closest node from the given point.""" From 01e8bbc2bf92b3f10313a0f2fcdfeaa7a213720b Mon Sep 17 00:00:00 2001 From: luzpaz Date: Fri, 10 Mar 2023 12:35:18 +0000 Subject: [PATCH 03/75] Fix various typos and whitespace --- src/Mod/Draft/DraftVecUtils.py | 4 ++-- src/Mod/Fem/Gui/CMakeLists.txt | 4 ++-- src/Mod/MeshPart/App/MeshFlatteningPy.cpp | 2 +- src/Mod/Part/App/ExtrusionHelper.cpp | 2 +- src/Mod/Part/Gui/DlgFilletEdges.cpp | 2 +- src/Mod/Part/parttests/TopoShapeListTest.py | 6 +++--- src/Mod/PartDesign/App/Feature.cpp | 2 +- src/Mod/PartDesign/Gui/Command.cpp | 2 +- src/Mod/PartDesign/Gui/TaskExtrudeParameters.cpp | 2 +- src/Mod/Sketcher/Gui/SketcherSettingsGrid.ui | 4 ++-- src/Tools/embedded/PySide/mainwindow3.py | 6 +++--- tests/src/App/License.cpp | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Mod/Draft/DraftVecUtils.py b/src/Mod/Draft/DraftVecUtils.py index a819117552..82f4a667ba 100644 --- a/src/Mod/Draft/DraftVecUtils.py +++ b/src/Mod/Draft/DraftVecUtils.py @@ -742,12 +742,12 @@ def getPlaneRotation(u, v, _ = None): v : Base::Vector3 Hint for the second vector. _ : Ignored. For backwards compatibility - + Returns ------- Base::Matrix4D The new rotation matrix defining a new coordinate system, - or `None` if `u` or `v` is `None` or + or `None` if `u` or `v` is `None` or if `u` and `v` are parallel. """ if (not u) or (not v): diff --git a/src/Mod/Fem/Gui/CMakeLists.txt b/src/Mod/Fem/Gui/CMakeLists.txt index e4ce4ba555..13bf23dd56 100755 --- a/src/Mod/Fem/Gui/CMakeLists.txt +++ b/src/Mod/Fem/Gui/CMakeLists.txt @@ -101,7 +101,7 @@ if(BUILD_FEM_VTK) TaskPostDataAtPoint.ui TaskPostDisplay.ui TaskPostScalarClip.ui - TaskPostWarpVector.ui + TaskPostWarpVector.ui ) endif(BUILD_FEM_VTK) @@ -282,7 +282,7 @@ if(BUILD_FEM_VTK) TaskPostDataAtPoint.ui TaskPostDisplay.ui TaskPostScalarClip.ui - TaskPostWarpVector.ui + TaskPostWarpVector.ui ) endif(BUILD_FEM_VTK) SOURCE_GROUP("Task_Boxes" FILES ${FemGui_SRCS_TaskBoxes}) diff --git a/src/Mod/MeshPart/App/MeshFlatteningPy.cpp b/src/Mod/MeshPart/App/MeshFlatteningPy.cpp index e81802701a..8ba715cb26 100644 --- a/src/Mod/MeshPart/App/MeshFlatteningPy.cpp +++ b/src/Mod/MeshPart/App/MeshFlatteningPy.cpp @@ -34,7 +34,7 @@ # include #endif -// neccessary for the feature despite not all are necessary for compilation +// necessary for the feature despite not all are necessary for compilation #include #include #include diff --git a/src/Mod/Part/App/ExtrusionHelper.cpp b/src/Mod/Part/App/ExtrusionHelper.cpp index 0efaf139f0..1079c332a0 100644 --- a/src/Mod/Part/App/ExtrusionHelper.cpp +++ b/src/Mod/Part/App/ExtrusionHelper.cpp @@ -362,7 +362,7 @@ void ExtrusionHelper::checkInnerWires(std::vector& isInnerWire, const gp_D } if (saveIsInnerWireIterator == *isInnerWireIterator) // nothing was changed and we can remove it from the list to be checked - // but we cannot do this before the foor loop was fully run + // but we cannot do this before the for loop was fully run toDisable[outer] = true; ++isInnerWireIterator; ++toCheckIterator; diff --git a/src/Mod/Part/Gui/DlgFilletEdges.cpp b/src/Mod/Part/Gui/DlgFilletEdges.cpp index efe34a6f04..11c7137cfe 100644 --- a/src/Mod/Part/Gui/DlgFilletEdges.cpp +++ b/src/Mod/Part/Gui/DlgFilletEdges.cpp @@ -635,7 +635,7 @@ void DlgFilletEdges::setupFillet(const std::vector& objs) * will do the check * // If sub-objects are already selected then only add the un-selected parts. - // This is impotant to avoid recursive calls of rmvSelection() which + // This is important to avoid recursive calls of rmvSelection() which // invalidates the internal iterator (#0002200). if (selIt != selObj.end()) { std::vector selElements = selIt->getSubNames(); diff --git a/src/Mod/Part/parttests/TopoShapeListTest.py b/src/Mod/Part/parttests/TopoShapeListTest.py index d638d90935..3ad1b24422 100644 --- a/src/Mod/Part/parttests/TopoShapeListTest.py +++ b/src/Mod/Part/parttests/TopoShapeListTest.py @@ -48,14 +48,14 @@ class TopoShapeListTest(unittest.TestCase): self.assertLessEqual(error, maxError, "TopoShapeList entry 1 has wrong volume: {0}".format(boxes[1].Volume)) error = abs(3.0 - boxes[2].Volume) self.assertLessEqual(error, maxError, "TopoShapeList entry 2 has wrong volume: {0}".format(boxes[2].Volume)) - + twoboxes = [boxes[1], boxes[2]] doc.openTransaction("Change shapes") obj.Shapes = twoboxes - doc.commitTransaction() + doc.commitTransaction() self.assertEqual(len(obj.Shapes), 2, "TopoShapeList has wrong entry count (1): {0}".format(len(obj.Shapes))) doc.undo() - + self.assertEqual(len(obj.Shapes), 3, "TopoShapeList has wrong entry count (2): {0}".format(len(obj.Shapes))) diff --git a/src/Mod/PartDesign/App/Feature.cpp b/src/Mod/PartDesign/App/Feature.cpp index fee65f0313..d2a2946947 100644 --- a/src/Mod/PartDesign/App/Feature.cpp +++ b/src/Mod/PartDesign/App/Feature.cpp @@ -256,7 +256,7 @@ App::DocumentObject *Feature::getSubObject(const char *subname, // supposed to be contained inside a body. It makes // little sense to transform its sub-object. So if 'no // transform' is requested, we need to actively apply - // an inverse trasnform. + // an inverse transform. _mat = Placement.getValue().inverse().toMatrix(); if (pmat) *pmat *= _mat; diff --git a/src/Mod/PartDesign/Gui/Command.cpp b/src/Mod/PartDesign/Gui/Command.cpp index 7fae3e3eb5..1c9371cacf 100644 --- a/src/Mod/PartDesign/Gui/Command.cpp +++ b/src/Mod/PartDesign/Gui/Command.cpp @@ -729,7 +729,7 @@ void prepareProfileBased(PartDesign::Body *pcActiveBody, Gui::Command* cmd, cons // `ProfileBased::getProfileShape()` and other methods will return // just the sub-shapes if they are set. So when whole sketches are - // desired, don not set sub-values. + // desired, don't set sub-values. if (feature->isDerivedFrom(Part::Part2DObject::getClassTypeId()) && subName.compare(0, 6, "Vertex") != 0) runProfileCmd(); diff --git a/src/Mod/PartDesign/Gui/TaskExtrudeParameters.cpp b/src/Mod/PartDesign/Gui/TaskExtrudeParameters.cpp index 2d733fda0e..0aec944896 100644 --- a/src/Mod/PartDesign/Gui/TaskExtrudeParameters.cpp +++ b/src/Mod/PartDesign/Gui/TaskExtrudeParameters.cpp @@ -626,7 +626,7 @@ void TaskExtrudeParameters::setDirectionMode(int index) extrude->UseCustomVector.setValue(false); } - // if we dont use custom direction, only allow to show its direction + // if we don't use custom direction, only allow to show its direction if (index != DirectionModes::Custom) { ui->XDirectionEdit->setEnabled(false); ui->YDirectionEdit->setEnabled(false); diff --git a/src/Mod/Sketcher/Gui/SketcherSettingsGrid.ui b/src/Mod/Sketcher/Gui/SketcherSettingsGrid.ui index c24ad3fc96..3fbbcd9bc0 100644 --- a/src/Mod/Sketcher/Gui/SketcherSettingsGrid.ui +++ b/src/Mod/Sketcher/Gui/SketcherSettingsGrid.ui @@ -436,12 +436,12 @@ The grid spacing change if it becomes smaller than this number of pixel.Gui::QuantitySpinBox QAbstractSpinBox
Gui/QuantitySpinBox.h
- + Gui::PrefColorButton Gui::ColorButton
Gui/PrefWidgets.h
-
+ Gui::ColorButton QPushButton diff --git a/src/Tools/embedded/PySide/mainwindow3.py b/src/Tools/embedded/PySide/mainwindow3.py index c1f6f7fe81..cfbee18c92 100644 --- a/src/Tools/embedded/PySide/mainwindow3.py +++ b/src/Tools/embedded/PySide/mainwindow3.py @@ -15,7 +15,7 @@ class MainWindow(QtGui.QMainWindow): # when setting up the internally used network interface. Doing this before # creating the icons fixes the issue. QtNetwork.QNetworkConfigurationManager() - + @QtCore.Slot() def on_actionEmbed_triggered(self): FreeCADGui.showMainWindow() @@ -33,11 +33,11 @@ class MainWindow(QtGui.QMainWindow): return "Gui::BlankWorkbench" FreeCADGui.addWorkbench(BlankWorkbench) FreeCADGui.activateWorkbench("BlankWorkbench") - + @QtCore.Slot() def on_actionDocument_triggered(self): FreeCAD.newDocument() - + @QtCore.Slot() def on_actionCube_triggered(self): FreeCAD.ActiveDocument.addObject("Part::Box") diff --git a/tests/src/App/License.cpp b/tests/src/App/License.cpp index c6aa3501ae..d3062ce9ff 100644 --- a/tests/src/App/License.cpp +++ b/tests/src/App/License.cpp @@ -24,7 +24,7 @@ TEST(License, direct) TEST(License, findLicenseByIdent) { App::TLicenseArr arr {App::licenseItems.at(App::findLicense("CC_BY_40"))}; - + EXPECT_STREQ(arr.at(App::posnOfIdentifier), "CC_BY_40"); EXPECT_STREQ(arr.at(App::posnOfFullName), "Creative Commons Attribution"); EXPECT_STREQ(arr.at(App::posnOfUrl), "https://creativecommons.org/licenses/by/4.0/"); From 91f06886257097cc17b792f45ead1680aa976642 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 10 Mar 2023 12:20:42 +0100 Subject: [PATCH 04/75] remove unnecessary include --- src/Mod/Part/App/Attacher.h | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Mod/Part/App/Attacher.h b/src/Mod/Part/App/Attacher.h index cf9c30eb29..bd5cd3898e 100644 --- a/src/Mod/Part/App/Attacher.h +++ b/src/Mod/Part/App/Attacher.h @@ -28,7 +28,6 @@ #ifndef PARTATTACHER_H #define PARTATTACHER_H -#include #include #include From fb13cbf4b5291bb5e9f5643fad3a8676d6ed533f Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 10 Mar 2023 01:44:19 +0100 Subject: [PATCH 05/75] [TD] fix compiler warning about code duplication and unused variable --- src/Mod/TechDraw/App/Geometry.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Mod/TechDraw/App/Geometry.cpp b/src/Mod/TechDraw/App/Geometry.cpp index 632b7e3d9d..d5eb694332 100644 --- a/src/Mod/TechDraw/App/Geometry.cpp +++ b/src/Mod/TechDraw/App/Geometry.cpp @@ -1775,11 +1775,8 @@ TopoDS_Edge GeometryUtils::asCircle(TopoDS_Edge occEdge, bool& arc) } } } - catch (const Standard_Failure& e) { - // return null shape to indicate that we could not make a circle from this bspline - return TopoDS_Edge(); - } catch (...) { + // return null shape to indicate that we could not make a circle from this bspline return TopoDS_Edge(); } return result; From 686e5d06b235ad5b395aa552a9af00d9ad4dc706 Mon Sep 17 00:00:00 2001 From: Abdullah Tahiri Date: Fri, 10 Mar 2023 15:38:54 +0100 Subject: [PATCH 06/75] Sketcher: Remove references to non-existing icons ================================================= User Syres realised that some commands that are in use by the solver messages url referenced non-existing icons, which triggers errors in the report view while customising: https://forum.freecad.org/viewtopic.php?p=666167&sid=16ac6777c440d632e5f60083fb4327ca#p666167 This commit removes them. --- src/Mod/Sketcher/Gui/CommandSketcherTools.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Mod/Sketcher/Gui/CommandSketcherTools.cpp b/src/Mod/Sketcher/Gui/CommandSketcherTools.cpp index 7cdbc0fefd..c0a5308f4e 100644 --- a/src/Mod/Sketcher/Gui/CommandSketcherTools.cpp +++ b/src/Mod/Sketcher/Gui/CommandSketcherTools.cpp @@ -346,8 +346,6 @@ CmdSketcherSelectMalformedConstraints::CmdSketcherSelectMalformedConstraints() sToolTipText = QT_TR_NOOP("Select malformed constraints"); sWhatsThis = "Sketcher_SelectMalformedConstraints"; sStatusTip = sToolTipText; - sPixmap = "Sketcher_SelectMalformedConstraints"; - sAccel = "Z, P, M"; eType = ForEdit; } @@ -407,8 +405,6 @@ CmdSketcherSelectPartiallyRedundantConstraints::CmdSketcherSelectPartiallyRedund sToolTipText = QT_TR_NOOP("Select partially redundant constraints"); sWhatsThis = "Sketcher_SelectPartiallyRedundantConstraints"; sStatusTip = sToolTipText; - sPixmap = "Sketcher_SelectPartiallyRedundantConstraints"; - sAccel = "Z, P, P"; eType = ForEdit; } From 189013389ceede427586f539a5db3aae5699b313 Mon Sep 17 00:00:00 2001 From: Abdullah Tahiri Date: Fri, 10 Mar 2023 15:27:37 +0100 Subject: [PATCH 07/75] Sketcher: Remove "Show Edit Section" from preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ===================================================== In a previous commit the Edit Section and its preferences where removed, however Adrián Insaurralde realised that the setting for showing or not this section had been left behind: https://github.com/FreeCAD/FreeCAD/pull/8716#issuecomment-1462364487 This commit just removes that settings. --- src/Mod/Sketcher/Gui/SketcherSettings.cpp | 2 -- src/Mod/Sketcher/Gui/SketcherSettings.ui | 20 -------------------- 2 files changed, 22 deletions(-) diff --git a/src/Mod/Sketcher/Gui/SketcherSettings.cpp b/src/Mod/Sketcher/Gui/SketcherSettings.cpp index bf9a7390ef..d4dcd33175 100644 --- a/src/Mod/Sketcher/Gui/SketcherSettings.cpp +++ b/src/Mod/Sketcher/Gui/SketcherSettings.cpp @@ -62,7 +62,6 @@ void SketcherSettings::saveSettings() { // Sketch editing ui->checkBoxAdvancedSolverTaskBox->onSave(); - ui->checkBoxSettingsTaskBox->onSave(); ui->checkBoxRecalculateInitialSolutionWhileDragging->onSave(); ui->checkBoxEnableEscape->onSave(); ui->checkBoxNotifyConstraintSubstitutions->onSave(); @@ -73,7 +72,6 @@ void SketcherSettings::loadSettings() { // Sketch editing ui->checkBoxAdvancedSolverTaskBox->onRestore(); - ui->checkBoxSettingsTaskBox->onRestore(); ui->checkBoxRecalculateInitialSolutionWhileDragging->onRestore(); ui->checkBoxEnableEscape->onRestore(); ui->checkBoxNotifyConstraintSubstitutions->onRestore(); diff --git a/src/Mod/Sketcher/Gui/SketcherSettings.ui b/src/Mod/Sketcher/Gui/SketcherSettings.ui index c846dd9d90..47a71f3c80 100644 --- a/src/Mod/Sketcher/Gui/SketcherSettings.ui +++ b/src/Mod/Sketcher/Gui/SketcherSettings.ui @@ -37,26 +37,6 @@ - - - - Sketcher dialog will have additional section -'Edit controls' to easily access basic settings. - - - Show section 'Edit controls' - - - true - - - ShowSettingsWidget - - - Mod/Sketcher - - - From f93122e12c2fe583e84cee347d6570bee7b54cfe Mon Sep 17 00:00:00 2001 From: wmayer Date: Fri, 10 Mar 2023 16:39:53 +0100 Subject: [PATCH 08/75] PD: add unit test for issue #6156 or PR #8748 --- .../PartDesign/PartDesignTests/TestLoft.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/Mod/PartDesign/PartDesignTests/TestLoft.py b/src/Mod/PartDesign/PartDesignTests/TestLoft.py index 238e7ea444..a75079899a 100644 --- a/src/Mod/PartDesign/PartDesignTests/TestLoft.py +++ b/src/Mod/PartDesign/PartDesignTests/TestLoft.py @@ -22,6 +22,10 @@ import unittest import FreeCAD +from FreeCAD import Base +from FreeCAD import Units +import Part +import Sketcher import TestSketcherApp class TestLoft(unittest.TestCase): @@ -77,6 +81,66 @@ class TestLoft(unittest.TestCase): self.Doc.recompute() self.assertAlmostEqual(self.SubtractiveLoft.Shape.Volume, 1) + def testClosedAdditiveLoftCase(self): + """ Test issue #6156: Loft tool "Closed" option not working """ + body = self.Doc.addObject('PartDesign::Body','Body') + + sketch1 = body.newObject('Sketcher::SketchObject','Sketch') + sketch1.Support = (self.Doc.XZ_Plane,['']) + sketch1.MapMode = 'FlatFace' + sketch1.addGeometry(Part.Circle(Base.Vector(-40.0,0.0,0.0),Base.Vector(0,0,1),10.0), False) + sketch1.addConstraint(Sketcher.Constraint('PointOnObject',0,3,-1)) + sketch1.addConstraint(Sketcher.Constraint('Diameter',0,20.0)) + sketch1.setDatum(1,Units.Quantity('20.000000 mm')) + sketch1.addConstraint(Sketcher.Constraint('Distance',-1,1,0,3,40.0)) + sketch1.setDatum(2,Units.Quantity('40.000000 mm')) + + sketch2 = body.newObject('Sketcher::SketchObject','Sketch001') + sketch2.Support = (self.Doc.YZ_Plane,'') + sketch2.MapMode = 'FlatFace' + sketch2.addGeometry(Part.Circle(Base.Vector(-10.0,0.0,0.0),Base.Vector(0,0,1),10.0),False) + sketch2.addConstraint(Sketcher.Constraint('PointOnObject',0,3,-1)) + sketch2.addConstraint(Sketcher.Constraint('Diameter',0,20.0)) + sketch2.setDatum(1,Units.Quantity('20.000000 mm')) + sketch2.addConstraint(Sketcher.Constraint('Distance',-1,1,0,3,40.0)) + sketch2.setDatum(2,Units.Quantity('40.000000 mm')) + + sketch3 = body.newObject('Sketcher::SketchObject','Sketch002') + sketch3.Support = (self.Doc.getObject('YZ_Plane'),'') + sketch3.MapMode = 'FlatFace' + sketch3.addGeometry(Part.Circle(Base.Vector(40.0,0.0,0.0),Base.Vector(0,0,1),10.0),False) + sketch3.addConstraint(Sketcher.Constraint('PointOnObject',0,3,-1)) + sketch3.addConstraint(Sketcher.Constraint('Distance',-1,1,0,3,40.0)) + sketch3.setDatum(1,Units.Quantity('40.000000 mm')) + sketch3.addConstraint(Sketcher.Constraint('Diameter',0,20.0)) + sketch3.setDatum(2,Units.Quantity('20.000000 mm')) + + sketch4 = body.newObject('Sketcher::SketchObject','Sketch003') + sketch4.Support = (self.Doc.XZ_Plane,'') + sketch4.MapMode = 'FlatFace' + sketch4.addGeometry(Part.Circle(Base.Vector(40.0,0.0,0.0),Base.Vector(0,0,1),10.0),False) + sketch4.addConstraint(Sketcher.Constraint('PointOnObject',0,3,-1)) + sketch4.addConstraint(Sketcher.Constraint('Distance',-1,1,0,3,40.0)) + sketch4.setDatum(1,Units.Quantity('40.000000 mm')) + sketch4.addConstraint(Sketcher.Constraint('Diameter',0,20.0)) + sketch4.setDatum(2,Units.Quantity('20.000000 mm')) + + self.Doc.recompute() + + loft = body.newObject('PartDesign::AdditiveLoft','AdditiveLoft') + loft.Profile = sketch1 + loft.Sections = [sketch2, sketch4, sketch3] + loft.Closed = True + + sketch1.Visibility = False + sketch2.Visibility = False + sketch3.Visibility = False + sketch4.Visibility = False + + self.Doc.recompute() + + self.assertGreater(loft.Shape.Volume, 80000.0) # 85105.5788704151 + def tearDown(self): #closing doc FreeCAD.closeDocument("PartDesignTestLoft") From 243088a8c3028484e5a88a7a3e6eb921aec7d547 Mon Sep 17 00:00:00 2001 From: wmayer Date: Fri, 10 Mar 2023 18:54:27 +0100 Subject: [PATCH 09/75] PD: add unit test for PR #8763 --- .../PartDesignTests/TestShapeBinder.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/Mod/PartDesign/PartDesignTests/TestShapeBinder.py b/src/Mod/PartDesign/PartDesignTests/TestShapeBinder.py index b50078bd0c..8bbf518580 100644 --- a/src/Mod/PartDesign/PartDesignTests/TestShapeBinder.py +++ b/src/Mod/PartDesign/PartDesignTests/TestShapeBinder.py @@ -22,6 +22,9 @@ import unittest import FreeCAD +from FreeCAD import Base +import Part +import Sketcher class TestShapeBinder(unittest.TestCase): def setUp(self): @@ -77,3 +80,55 @@ class TestSubShapeBinder(unittest.TestCase): self.Doc.recompute() self.assertAlmostEqual(binder.Shape.Length, 80) + + def testBinderBeforeOrAfterPad(self): + """ Test case for PR #8763 """ + body = self.Doc.addObject('PartDesign::Body','Body') + sketch = body.newObject('Sketcher::SketchObject','Sketch') + sketch.Support = (self.Doc.XZ_Plane,['']) + sketch.MapMode = 'FlatFace' + self.Doc.recompute() + + geoList = [] + geoList.append(Part.LineSegment(Base.Vector(-21.762587,19.904083,0),Base.Vector(32.074337,19.904083,0))) + geoList.append(Part.LineSegment(Base.Vector(32.074337,19.904083,0),Base.Vector(32.074337,-27.458027,0))) + geoList.append(Part.LineSegment(Base.Vector(32.074337,-27.458027,0),Base.Vector(-21.762587,-27.458027,0))) + geoList.append(Part.LineSegment(Base.Vector(-21.762587,-27.458027,0),Base.Vector(-21.762587,19.904083,0))) + sketch.addGeometry(geoList,False) + + conList = [] + conList.append(Sketcher.Constraint('Coincident',0,2,1,1)) + conList.append(Sketcher.Constraint('Coincident',1,2,2,1)) + conList.append(Sketcher.Constraint('Coincident',2,2,3,1)) + conList.append(Sketcher.Constraint('Coincident',3,2,0,1)) + conList.append(Sketcher.Constraint('Horizontal',0)) + conList.append(Sketcher.Constraint('Horizontal',2)) + conList.append(Sketcher.Constraint('Vertical',1)) + conList.append(Sketcher.Constraint('Vertical',3)) + sketch.addConstraint(conList) + del geoList, conList + + self.Doc.recompute() + + binder1 = body.newObject('PartDesign::SubShapeBinder','Binder') + binder1.Support = sketch + self.Doc.recompute() + pad = body.newObject('PartDesign::Pad','Pad') + pad.Profile = sketch + pad.Length = 10 + self.Doc.recompute() + pad.ReferenceAxis = (sketch,['N_Axis']) + sketch.Visibility = False + self.Doc.recompute() + + binder2 = body.newObject('PartDesign::SubShapeBinder','Binder001') + binder2.Support = [pad, "Sketch."] + self.Doc.recompute() + + self.assertAlmostEqual(binder1.Shape.BoundBox.XLength, binder2.Shape.BoundBox.XLength, 2) + self.assertAlmostEqual(binder1.Shape.BoundBox.YLength, binder2.Shape.BoundBox.YLength, 2) + self.assertAlmostEqual(binder1.Shape.BoundBox.ZLength, binder2.Shape.BoundBox.ZLength, 2) + + nor1 = binder1.Shape.Face1.normalAt(0,0) + nor2 = binder2.Shape.Face1.normalAt(0,0) + self.assertAlmostEqual(nor1.getAngle(nor2), 0.0, 2) From 0b241f78f4fc20f9d97d2ead503e7cc765a9f3e1 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Fri, 3 Mar 2023 09:36:53 -0600 Subject: [PATCH 10/75] Addon Manager: Refactor Metadata Create a Python-native metadata class. Includes unit tests, and some PyLint cleanup. --- src/Mod/AddonManager/Addon.py | 272 ++++---- .../AddonManagerTest/app/mocks.py | 63 -- .../AddonManagerTest/app/test_addon.py | 53 +- .../AddonManagerTest/app/test_installer.py | 120 ++-- .../AddonManagerTest/app/test_macro.py | 52 +- .../AddonManagerTest/app/test_metadata.py | 620 ++++++++++++++++++ src/Mod/AddonManager/CMakeLists.txt | 2 + src/Mod/AddonManager/TestAddonManagerApp.py | 14 + .../addonmanager_devmode_metadata_checker.py | 48 +- .../addonmanager_freecad_interface.py | 13 +- .../AddonManager/addonmanager_installer.py | 4 +- src/Mod/AddonManager/addonmanager_macro.py | 79 +-- src/Mod/AddonManager/addonmanager_metadata.py | 415 ++++++++++++ .../AddonManager/addonmanager_utilities.py | 63 +- .../addonmanager_workers_installation.py | 19 +- .../addonmanager_workers_startup.py | 19 +- .../addonmanager_workers_utility.py | 6 +- src/Mod/AddonManager/package_details.py | 92 +-- src/Mod/AddonManager/package_list.py | 41 +- 19 files changed, 1471 insertions(+), 524 deletions(-) create mode 100644 src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py create mode 100644 src/Mod/AddonManager/addonmanager_metadata.py diff --git a/src/Mod/AddonManager/Addon.py b/src/Mod/AddonManager/Addon.py index 9421d74624..cbe91f34d2 100644 --- a/src/Mod/AddonManager/Addon.py +++ b/src/Mod/AddonManager/Addon.py @@ -25,20 +25,23 @@ import os from urllib.parse import urlparse -from typing import Dict, Set, List +from typing import Dict, Set, List, Optional from threading import Lock from enum import IntEnum, auto -import FreeCAD - -if FreeCAD.GuiUp: - import FreeCADGui - +import addonmanager_freecad_interface as fci from addonmanager_macro import Macro import addonmanager_utilities as utils from addonmanager_utilities import construct_git_url +from addonmanager_metadata import ( + Metadata, + MetadataReader, + UrlType, + Version, + DependencyType, +) -translate = FreeCAD.Qt.translate +translate = fci.translate INTERNAL_WORKBENCHES = { "arch": "Arch", @@ -137,10 +140,10 @@ class Addon: """An exception type for dependency resolution failure.""" # The location of Addon Manager cache files: overridden by testing code - cache_directory = os.path.join(FreeCAD.getUserCachePath(), "AddonManager") + cache_directory = os.path.join(fci.DataPaths().cache_dir, "AddonManager") # The location of the Mod directory: overridden by testing code - mod_directory = os.path.join(FreeCAD.getUserAppDataDir(), "Mod") + mod_directory = fci.DataPaths().mod_dir def __init__( self, @@ -161,7 +164,8 @@ class Addon: self.tags = set() # Just a cache, loaded from Metadata self.last_updated = None - # To prevent multiple threads from running git actions on this repo at the same time + # To prevent multiple threads from running git actions on this repo at the + # same time self.git_lock = Lock() # To prevent multiple threads from accessing the status at the same time @@ -183,7 +187,7 @@ class Addon: self.metadata_url = construct_git_url(self, "package.xml") else: self.metadata_url = None - self.metadata = None + self.metadata: Optional[Metadata] = None self.icon = None # Relative path to remote icon file self.icon_file: str = "" # Absolute local path to cached icon file self.best_icon_relative_path = "" @@ -290,126 +294,82 @@ class Addon: """Read a given metadata file and set it as this object's metadata""" if os.path.exists(file): - metadata = FreeCAD.Metadata(file) + metadata = MetadataReader.from_file(file) self.set_metadata(metadata) else: - FreeCAD.Console.PrintLog(f"Internal error: {file} does not exist") + fci.Console.PrintLog(f"Internal error: {file} does not exist") - def set_metadata(self, metadata: FreeCAD.Metadata) -> None: - """Set the given metadata object as this object's metadata, updating the object's display name - and package type information to match, as well as updating any dependency information, etc. + def set_metadata(self, metadata: Metadata) -> None: + """Set the given metadata object as this object's metadata, updating the + object's display name and package type information to match, as well as + updating any dependency information, etc. """ self.metadata = metadata - self.display_name = metadata.Name + self.display_name = metadata.name self.repo_type = Addon.Kind.PACKAGE - self.description = metadata.Description - for url in metadata.Urls: - if "type" in url and url["type"] == "repository": - self.url = url["location"] - if "branch" in url: - self.branch = url["branch"] - else: - self.branch = "master" + self.description = metadata.description + for url in metadata.url: + if url.type == UrlType.repository: + self.url = url.location + self.branch = url.branch if url.branch else "master" self.extract_tags(self.metadata) self.extract_metadata_dependencies(self.metadata) - def version_is_ok(self, metadata) -> bool: - """Checks to see if the current running version of FreeCAD meets the requirements set by - the passed-in metadata parameter.""" + @staticmethod + def version_is_ok(metadata: Metadata) -> bool: + """Checks to see if the current running version of FreeCAD meets the + requirements set by the passed-in metadata parameter.""" - dep_fc_min = metadata.FreeCADMin - dep_fc_max = metadata.FreeCADMax + from_fci = list(fci.Version()) + fc_version = Version(from_list=from_fci) - fc_major = int(FreeCAD.Version()[0]) - fc_minor = int(FreeCAD.Version()[1]) + dep_fc_min = metadata.freecadmin if metadata.freecadmin else fc_version + dep_fc_max = metadata.freecadmax if metadata.freecadmax else fc_version - try: - if dep_fc_min and dep_fc_min != "0.0.0": - required_version = dep_fc_min.split(".") - if fc_major < int(required_version[0]): - return False # Major version is too low - if fc_major == int(required_version[0]): - if len(required_version) > 1 and fc_minor < int( - required_version[1] - ): - return False # Same major, and minor is too low - except ValueError: - FreeCAD.Console.PrintMessage( - f"Metadata file for {self.name} has invalid FreeCADMin version info\n" - ) + return dep_fc_min <= fc_version <= dep_fc_max - try: - if dep_fc_max and dep_fc_max != "0.0.0": - required_version = dep_fc_max.split(".") - if fc_major > int(required_version[0]): - return False # Major version is too high - if fc_major == int(required_version[0]): - if len(required_version) > 1 and fc_minor > int( - required_version[1] - ): - return False # Same major, and minor is too high - except ValueError: - FreeCAD.Console.PrintMessage( - f"Metadata file for {self.name} has invalid FreeCADMax version info\n" - ) - - return True - - def extract_metadata_dependencies(self, metadata): - """Read dependency information from a metadata object and store it in this Addon""" + def extract_metadata_dependencies(self, metadata: Metadata): + """Read dependency information from a metadata object and store it in this + Addon""" # Version check: if this piece of metadata doesn't apply to this version of # FreeCAD, just skip it. - if not self.version_is_ok(metadata): + if not Addon.version_is_ok(metadata): return - if metadata.PythonMin != "0.0.0": - split_version_string = metadata.PythonMin.split(".") - if len(split_version_string) >= 2: - try: - self.python_min_version["major"] = int(split_version_string[0]) - self.python_min_version["minor"] = int(split_version_string[1]) - FreeCAD.Console.PrintLog( - f"Package {self.name}: Requires Python " - f"{split_version_string[0]}.{split_version_string[1]} or greater\n" - ) - except ValueError: - FreeCAD.Console.PrintWarning( - f"Package {self.name}: Invalid Python version requirement specified\n" - ) + if metadata.pythonmin: + self.python_min_version["major"] = metadata.pythonmin.version_as_list[0] + self.python_min_version["minor"] = metadata.pythonmin.version_as_list[1] - for dep in metadata.Depend: - if "type" in dep: - if dep["type"] == "internal": - if dep["package"] in INTERNAL_WORKBENCHES: - self.requires.add(dep["package"]) - else: - FreeCAD.Console.PrintWarning( - translate( - "AddonsInstaller", - "{}: Unrecognized internal workbench '{}'", - ).format(self.name, dep["package"]) - ) - elif dep["type"] == "addon": - self.requires.add(dep["package"]) - elif dep["type"] == "python": - if "optional" in dep and dep["optional"]: - self.python_optional.add(dep["package"]) - else: - self.python_requires.add(dep["package"]) + for dep in metadata.depend: + if dep.dependency_type == DependencyType.internal: + if dep.package in INTERNAL_WORKBENCHES: + self.requires.add(dep.package) else: - # Automatic resolution happens later, once we have a complete list of Addons - self.requires.add(dep["package"]) + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "{}: Unrecognized internal workbench '{}'", + ).format(self.name, dep.package) + ) + elif dep.dependency_type == DependencyType.addon: + self.requires.add(dep.package) + elif dep.dependency_type == DependencyType.python: + if dep.optional: + self.python_optional.add(dep.package) + else: + self.python_requires.add(dep.package) else: - # Automatic resolution happens later, once we have a complete list of Addons - self.requires.add(dep["package"]) + # Automatic resolution happens later, once we have a complete list of + # Addons + self.requires.add(dep.package) - for dep in metadata.Conflict: - self.blocks.add(dep["package"]) + for dep in metadata.conflict: + self.blocks.add(dep.package) # Recurse - content = metadata.Content + content = metadata.content for _, value in content.items(): for item in value: self.extract_metadata_dependencies(item) @@ -420,7 +380,7 @@ class Addon: the wrong branch name.""" if self.url != url: - FreeCAD.Console.PrintWarning( + fci.Console.PrintWarning( translate( "AddonsInstaller", "Addon Developer Warning: Repository URL set in package.xml file for addon {} ({}) does not match the URL it was fetched from ({})", @@ -428,7 +388,7 @@ class Addon: + "\n" ) if self.branch != branch: - FreeCAD.Console.PrintWarning( + fci.Console.PrintWarning( translate( "AddonsInstaller", "Addon Developer Warning: Repository branch set in package.xml file for addon {} ({}) does not match the branch it was fetched from ({})", @@ -436,18 +396,18 @@ class Addon: + "\n" ) - def extract_tags(self, metadata: FreeCAD.Metadata) -> None: + def extract_tags(self, metadata: Metadata) -> None: """Read the tags from the metadata object""" # Version check: if this piece of metadata doesn't apply to this version of # FreeCAD, just skip it. - if not self.version_is_ok(metadata): + if not Addon.version_is_ok(metadata): return - for new_tag in metadata.Tag: + for new_tag in metadata.tag: self.tags.add(new_tag) - content = metadata.Content + content = metadata.content for _, value in content.items(): for item in value: self.extract_tags(item) @@ -459,15 +419,12 @@ class Addon: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content if not content: - FreeCAD.Console.PrintLog( - f"Package {self.display_name} does not list any content items in its package.xml metadata file.\n" - ) return False return "workbench" in content return False @@ -479,11 +436,11 @@ class Addon: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content return "macro" in content return False @@ -492,18 +449,18 @@ class Addon: if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content return "preferencepack" in content return False def get_best_icon_relative_path(self) -> str: - """Get the path within the repo the addon's icon. Usually specified by top-level metadata, - but some authors omit it and specify only icons for the contents. Find the first one of - those, in such cases.""" + """Get the path within the repo the addon's icon. Usually specified by + top-level metadata, but some authors omit it and specify only icons for the + contents. Find the first one of those, in such cases.""" if self.best_icon_relative_path: return self.best_icon_relative_path @@ -511,19 +468,20 @@ class Addon: if not self.metadata: return "" - real_icon = self.metadata.Icon + real_icon = self.metadata.icon if not real_icon: - # If there is no icon set for the entire package, see if there are any workbenches, which - # are required to have icons, and grab the first one we find: - content = self.metadata.Content + # If there is no icon set for the entire package, see if there are any + # workbenches, which are required to have icons, and grab the first one + # we find: + content = self.metadata.content if "workbench" in content: wb = content["workbench"][0] - if wb.Icon: - if wb.Subdirectory: - subdir = wb.Subdirectory + if wb.icon: + if wb.subdirectory: + subdir = wb.subdirectory else: - subdir = wb.Name - real_icon = subdir + wb.Icon + subdir = wb.name + real_icon = subdir + wb.icon self.best_icon_relative_path = real_icon return self.best_icon_relative_path @@ -537,19 +495,20 @@ class Addon: if not self.metadata: return "" - real_icon = self.metadata.Icon + real_icon = self.metadata.icon if not real_icon: - # If there is no icon set for the entire package, see if there are any workbenches, which - # are required to have icons, and grab the first one we find: - content = self.metadata.Content + # If there is no icon set for the entire package, see if there are any + # workbenches, which are required to have icons, and grab the first one + # we find: + content = self.metadata.content if "workbench" in content: wb = content["workbench"][0] - if wb.Icon: - if wb.Subdirectory: - subdir = wb.Subdirectory + if wb.icon: + if wb.subdirectory: + subdir = wb.subdirectory else: - subdir = wb.Name - real_icon = subdir + wb.Icon + subdir = wb.name + real_icon = subdir + wb.icon real_icon = real_icon.replace( "/", os.path.sep @@ -581,7 +540,7 @@ class Addon: deps.python_min_version["minor"], self.python_min_version["minor"] ) else: - FreeCAD.Console.PrintWarning("Unrecognized Python version information") + fci.Console.PrintWarning("Unrecognized Python version information") for dep in self.requires: if dep in all_repos: @@ -624,7 +583,8 @@ class Addon: return os.path.exists(stopfile) def disable(self): - """Disable this addon from loading when FreeCAD starts up by creating a stopfile""" + """Disable this addon from loading when FreeCAD starts up by creating a + stopfile""" stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED") with open(stopfile, "w", encoding="utf-8") as f: @@ -661,8 +621,8 @@ class MissingDependencies: repo_name_dict[r.display_name] = r if hasattr(repo, "walk_dependency_tree"): - # Sometimes the test harness doesn't provide this function, to override any dependency - # checking + # Sometimes the test harness doesn't provide this function, to override + # any dependency checking repo.walk_dependency_tree(repo_name_dict, deps) self.external_addons = [] @@ -671,8 +631,8 @@ class MissingDependencies: self.external_addons.append(dep.name) # Now check the loaded addons to see if we are missing an internal workbench: - if FreeCAD.GuiUp: - wbs = [wb.lower() for wb in FreeCADGui.listWorkbenches()] + if fci.FreeCADGui: + wbs = [wb.lower() for wb in fci.FreeCADGui.listWorkbenches()] else: wbs = [] @@ -686,7 +646,7 @@ class MissingDependencies: except ImportError: # Plot might fail for a number of reasons self.wbs.append(dep) - FreeCAD.Console.PrintLog("Failed to import Plot module") + fci.Console.PrintLog("Failed to import Plot module") else: self.wbs.append(dep) @@ -699,6 +659,13 @@ class MissingDependencies: __import__(py_dep) except ImportError: self.python_requires.append(py_dep) + except (OSError, NameError, TypeError, RuntimeError) as e: + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "Got an error when trying to import {}", + ).format(py_dep) + ":\n" + str(e) + ) self.python_optional = [] for py_dep in deps.python_optional: @@ -706,6 +673,13 @@ class MissingDependencies: __import__(py_dep) except ImportError: self.python_optional.append(py_dep) + except (OSError, NameError, TypeError, RuntimeError) as e: + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "Got an error when trying to import {}", + ).format(py_dep) + ":\n" + str(e) + ) self.wbs.sort() self.external_addons.sort() diff --git a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py index eda4fd44e6..470a2393b4 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py @@ -74,57 +74,6 @@ class MockConsole: return counter -class MockMetadata: - """Minimal implementation of a Metadata-like object.""" - - def __init__(self): - self.Name = "MockMetadata" - self.Urls = {"repository": {"location": "file://localhost/", "branch": "main"}} - self.Description = "Mock metadata object for testing" - self.Icon = None - self.Version = "1.2.3beta" - self.Content = {} - - def minimal_file_scan(self, file: Union[os.PathLike, bytes]): - """Don't use the real metadata class, but try to read in the parameters we care about - from the given metadata file (or file-like object, as the case probably is). This - allows us to test whether the data is being passed around correctly.""" - - # pylint: disable=too-many-branches - xml = None - root = None - try: - if os.path.exists(file): - xml = ElemTree.parse(file) - root = xml.getroot() - except TypeError: - pass - if xml is None: - root = ElemTree.fromstring(file) - if root is None: - raise RuntimeError("Failed to parse XML data") - - accepted_namespaces = ["", "{https://wiki.freecad.org/Package_Metadata}"] - - for ns in accepted_namespaces: - for child in root: - if child.tag == ns + "name": - self.Name = child.text - elif child.tag == ns + "description": - self.Description = child.text - elif child.tag == ns + "icon": - self.Icon = child.text - elif child.tag == ns + "url": - if "type" in child.attrib and child.attrib["type"] == "repository": - url = child.text - if "branch" in child.attrib: - branch = child.attrib["branch"] - else: - branch = "master" - self.Urls["repository"]["location"] = url - self.Urls["repository"]["branch"] = branch - - class MockAddon: """Minimal Addon class""" @@ -161,18 +110,6 @@ class MockAddon: def set_status(self, status): self.update_status = status - def set_metadata(self, metadata_like: MockMetadata): - """Set (some) of the metadata, but don't use a real Metadata object""" - self.metadata = metadata_like - if "repository" in self.metadata.Urls: - self.branch = self.metadata.Urls["repository"]["branch"] - self.url = self.metadata.Urls["repository"]["location"] - - def load_metadata_file(self, metadata_file: os.PathLike): - if os.path.exists(metadata_file): - self.metadata = MockMetadata() - self.metadata.minimal_file_scan(metadata_file) - @staticmethod def get_best_icon_relative_path(): return "" diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py index 4d6d83dc67..8ba6e8e447 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py @@ -23,7 +23,9 @@ import unittest import os -import FreeCAD +import sys + +sys.path.append("../../") from Addon import Addon, INTERNAL_WORKBENCHES from addonmanager_macro import Macro @@ -35,7 +37,7 @@ class TestAddon(unittest.TestCase): def setUp(self): self.test_dir = os.path.join( - FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data" + os.path.dirname(__file__), "..", "data" ) def test_display_name(self): @@ -55,51 +57,6 @@ class TestAddon(unittest.TestCase): self.assertEqual(addon.name, "FreeCAD") self.assertEqual(addon.display_name, "Test Workbench") - def test_metadata_loading(self): - addon = Addon( - "FreeCAD", - "https://github.com/FreeCAD/FreeCAD", - Addon.Status.NOT_INSTALLED, - "master", - ) - addon.load_metadata_file(os.path.join(self.test_dir, "good_package.xml")) - - # Generic tests: - self.assertIsNotNone(addon.metadata) - self.assertEqual(addon.metadata.Version, "1.0.1") - self.assertEqual( - addon.metadata.Description, "A package.xml file for unit testing." - ) - - maintainer_list = addon.metadata.Maintainer - self.assertEqual(len(maintainer_list), 1, "Wrong number of maintainers found") - self.assertEqual(maintainer_list[0]["name"], "FreeCAD Developer") - self.assertEqual(maintainer_list[0]["email"], "developer@freecad.org") - - license_list = addon.metadata.License - self.assertEqual(len(license_list), 1, "Wrong number of licenses found") - self.assertEqual(license_list[0]["name"], "LGPLv2.1") - self.assertEqual(license_list[0]["file"], "LICENSE") - - url_list = addon.metadata.Urls - self.assertEqual(len(url_list), 2, "Wrong number of urls found") - self.assertEqual(url_list[0]["type"], "repository") - self.assertEqual( - url_list[0]["location"], "https://github.com/chennes/FreeCAD-Package" - ) - self.assertEqual(url_list[0]["branch"], "main") - self.assertEqual(url_list[1]["type"], "readme") - self.assertEqual( - url_list[1]["location"], - "https://github.com/chennes/FreeCAD-Package/blob/main/README.md", - ) - - contents = addon.metadata.Content - self.assertEqual(len(contents), 1, "Wrong number of content catetories found") - self.assertEqual( - len(contents["workbench"]), 1, "Wrong number of workbenches found" - ) - def test_git_url_cleanup(self): base_url = "https://github.com/FreeCAD/FreeCAD" test_urls = [f" {base_url} ", f"{base_url}.git", f" {base_url}.git "] @@ -124,7 +81,7 @@ class TestAddon(unittest.TestCase): expected_tags.add("TagA") expected_tags.add("TagB") expected_tags.add("TagC") - self.assertEqual(tags, expected_tags) + self.assertEqual(expected_tags, tags) def test_contains_functions(self): diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py index aadafb0c7f..df76569a1f 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py @@ -24,21 +24,20 @@ """Contains the unit test class for addonmanager_installer.py non-GUI functionality.""" import unittest +from unittest.mock import Mock import os import shutil import tempfile -import time from zipfile import ZipFile +import sys + +sys.path.append("../../") # So the IDE can find the imports below + import FreeCAD - -from typing import Dict - from addonmanager_installer import InstallationMethod, AddonInstaller, MacroInstaller - from addonmanager_git import GitManager, initialize_git - +from addonmanager_metadata import MetadataReader from Addon import Addon - from AddonManagerTest.app.mocks import MockAddon, MockMacro @@ -100,17 +99,12 @@ class TestAddonInstaller(unittest.TestCase): AddonInstaller._validate_object(no_branch) def test_update_metadata(self): - """If a metadata file exists in the installation location, it should be loaded.""" - installer = AddonInstaller(self.mock_addon, []) - with tempfile.TemporaryDirectory() as temp_dir: - installer.installation_path = temp_dir - addon_dir = os.path.join(temp_dir, self.mock_addon.name) - os.mkdir(addon_dir) - shutil.copy( - os.path.join(self.test_data_dir, "good_package.xml"), - os.path.join(addon_dir, "package.xml"), - ) - installer._update_metadata() # Does nothing, but should not crash + """If a metadata file exists in the installation location, it should be + loaded.""" + addon = Mock() + addon.name = "MockAddon" + installer = AddonInstaller(addon, []) + installer._update_metadata() # Does nothing, but should not crash installer = AddonInstaller(self.real_addon, []) with tempfile.TemporaryDirectory() as temp_dir: @@ -122,12 +116,12 @@ class TestAddonInstaller(unittest.TestCase): os.path.join(self.test_data_dir, "good_package.xml"), os.path.join(addon_dir, "package.xml"), ) - good_metadata = FreeCAD.Metadata(os.path.join(addon_dir, "package.xml")) + good_metadata = MetadataReader.from_file(os.path.join(addon_dir, "package.xml")) installer._update_metadata() - self.assertEqual(self.real_addon.installed_version, good_metadata.Version) + self.assertEqual(self.real_addon.installed_version, good_metadata.version) def test_finalize_zip_installation_non_github(self): - """Ensure that zipfiles are correctly extracted.""" + """Ensure that zip files are correctly extracted.""" with tempfile.TemporaryDirectory() as temp_dir: test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip") non_gh_mock = MockAddon() @@ -160,52 +154,66 @@ class TestAddonInstaller(unittest.TestCase): """When there is a subdirectory with the branch name in it, find it""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}")) + os.mkdir( + os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) + ) result = installer._code_in_branch_subdirectory(temp_dir) - self.assertTrue(result,"Failed to find ZIP subdirectory") + self.assertTrue(result, "Failed to find ZIP subdirectory") def test_code_in_branch_subdirectory_false(self): - """When there is not a subdirectory with the branch name in it, don't find one""" + """When there is not a subdirectory with the branch name in it, don't find + one""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: result = installer._code_in_branch_subdirectory(temp_dir) - self.assertFalse(result,"Found ZIP subdirectory when there was none") + self.assertFalse(result, "Found ZIP subdirectory when there was none") def test_code_in_branch_subdirectory_more_than_one(self): """When there are multiple subdirectories, never find a branch subdirectory""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}")) - os.mkdir(os.path.join(temp_dir,"AnotherSubdir")) + os.mkdir( + os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) + ) + os.mkdir(os.path.join(temp_dir, "AnotherSubdir")) result = installer._code_in_branch_subdirectory(temp_dir) - self.assertFalse(result,"Found ZIP subdirectory when there were multiple subdirs") + self.assertFalse( + result, "Found ZIP subdirectory when there were multiple subdirs" + ) def test_move_code_out_of_subdirectory(self): """All files are moved out and the subdirectory is deleted""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - subdir = os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}") + subdir = os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) os.mkdir(subdir) - with open(os.path.join(subdir,"README.txt"),"w",encoding="utf-8") as f: + with open(os.path.join(subdir, "README.txt"), "w", encoding="utf-8") as f: f.write("# Test file for unit testing") - with open(os.path.join(subdir,"AnotherFile.txt"),"w",encoding="utf-8") as f: + with open( + os.path.join(subdir, "AnotherFile.txt"), "w", encoding="utf-8" + ) as f: f.write("# Test file for unit testing") installer._move_code_out_of_subdirectory(temp_dir) - self.assertTrue(os.path.isfile(os.path.join(temp_dir,"README.txt"))) - self.assertTrue(os.path.isfile(os.path.join(temp_dir,"AnotherFile.txt"))) + self.assertTrue(os.path.isfile(os.path.join(temp_dir, "README.txt"))) + self.assertTrue(os.path.isfile(os.path.join(temp_dir, "AnotherFile.txt"))) self.assertFalse(os.path.isdir(subdir)) - def test_install_by_git(self): - """Test using git to install. Depends on there being a local git installation: the test - is skipped if there is no local git.""" + """Test using git to install. Depends on there being a local git + installation: the test is skipped if there is no local git.""" git_manager = initialize_git() if not git_manager: self.skipTest("git not found, skipping git installer tests") return - # Our test git repo has to be in a zipfile, otherwise it cannot itself be stored in git, - # since it has a .git subdirectory. + # Our test git repo has to be in a zipfile, otherwise it cannot itself be + # stored in git, since it has a .git subdirectory. with tempfile.TemporaryDirectory() as temp_dir: git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip") with ZipFile(git_repo_zip, "r") as zip_repo: @@ -334,28 +342,32 @@ class TestAddonInstaller(unittest.TestCase): self.assertEqual(method, InstallationMethod.ZIP) def test_determine_install_method_https_known_sites_copy(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.COPY ) self.assertIsNone(method, f"Allowed copying from {site} URL") def test_determine_install_method_https_known_sites_git(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.GIT ) @@ -366,14 +378,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_zip(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ZIP ) @@ -384,14 +398,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_any_gm(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ANY ) @@ -402,14 +418,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_any_no_gm(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = None for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ANY ) @@ -436,7 +454,6 @@ class TestAddonInstaller(unittest.TestCase): class TestMacroInstaller(unittest.TestCase): - MODULE = "test_installer" # file name without extension def setUp(self): @@ -448,8 +465,9 @@ class TestMacroInstaller(unittest.TestCase): def test_installation(self): """Test the wrapper around the macro installer""" - # Note that this doesn't test the underlying Macro object's install function, it only - # tests whether that function is called appropriately by the MacroInstaller wrapper. + # Note that this doesn't test the underlying Macro object's install function, + # it only tests whether that function is called appropriately by the + # MacroInstaller wrapper. with tempfile.TemporaryDirectory() as temp_dir: installer = MacroInstaller(self.mock) installer.installation_path = temp_dir diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py index 280e6abf22..20960e3c6e 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py @@ -21,18 +21,21 @@ # * * # *************************************************************************** -import unittest import os +import sys import tempfile -import FreeCAD - from typing import Dict +import unittest +from unittest.mock import MagicMock + +sys.path.append("../../") # So the IDE can find the + +import FreeCAD from addonmanager_macro import Macro class TestMacro(unittest.TestCase): - MODULE = "test_macro" # file name without extension def setUp(self): @@ -183,49 +186,28 @@ static char * blarg_xpm[] = { return m def test_fetch_raw_code_no_data(self): - class MockNetworkManagerNoData: - def __init__(self): - self.fetched_url = None - - def blocking_get(self, url): - self.fetched_url = url - return None - - nmNoData = MockNetworkManagerNoData() m = Macro("Unit Test Macro") - Macro.network_manager = nmNoData + Macro.blocking_get = MagicMock(return_value=None) returned_data = m._fetch_raw_code( 'rawcodeurl Totally fake' ) self.assertIsNone(returned_data) - self.assertEqual(nmNoData.fetched_url, "https://fake_url.com") + m.blocking_get.assert_called_with("https://fake_url.com") + Macro.blocking_get = None - nmNoData.fetched_url = None + def test_fetch_raw_code_no_url(self): + m = Macro("Unit Test Macro") + Macro.blocking_get = MagicMock(return_value=None) returned_data = m._fetch_raw_code("Fake pagedata with no URL at all.") self.assertIsNone(returned_data) - self.assertIsNone(nmNoData.fetched_url) - - Macro.network_manager = None + m.blocking_get.assert_not_called() + Macro.blocking_get = None def test_fetch_raw_code_with_data(self): - class MockNetworkManagerWithData: - class MockQByteArray: - def data(self): - return "Data returned to _fetch_raw_code".encode("utf-8") - - def __init__(self): - self.fetched_url = None - - def blocking_get(self, url): - self.fetched_url = url - return MockNetworkManagerWithData.MockQByteArray() - - nmWithData = MockNetworkManagerWithData() m = Macro("Unit Test Macro") - Macro.network_manager = nmWithData + Macro.blocking_get = MagicMock(return_value=b"Data returned to _fetch_raw_code") returned_data = m._fetch_raw_code( 'rawcodeurl Totally fake' ) self.assertEqual(returned_data, "Data returned to _fetch_raw_code") - - Macro.network_manager = None + Macro.blocking_get = None diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py b/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py new file mode 100644 index 0000000000..807ed7d494 --- /dev/null +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py @@ -0,0 +1,620 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2023 FreeCAD Project Association * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** +import os +import sys +import tempfile +import unittest +import unittest.mock + +Mock = unittest.mock.MagicMock + +sys.path.append("../../") + + +class TestVersion(unittest.TestCase): + def setUp(self) -> None: + if "addonmanager_metadata" in sys.modules: + sys.modules.pop("addonmanager_metadata") + self.packaging_version = None + if "packaging.version" in sys.modules: + self.packaging_version = sys.modules["packaging.version"] + sys.modules.pop("packaging.version") + + def tearDown(self) -> None: + if self.packaging_version is not None: + sys.modules["packaging.version"] = self.packaging_version + + def test_init_from_string_manual(self): + import addonmanager_metadata as amm + + version = amm.Version() + version._parse_string_to_tuple = unittest.mock.MagicMock() + version._init_from_string("1.2.3beta") + self.assertTrue(version._parse_string_to_tuple.called) + + def test_init_from_list_good(self): + """Initialization from a list works for good input""" + import addonmanager_metadata as amm + + test_cases = [ + {"input": (1,), "output": [1, 0, 0, ""]}, + {"input": (1, 2), "output": [1, 2, 0, ""]}, + {"input": (1, 2, 3), "output": [1, 2, 3, ""]}, + {"input": (1, 2, 3, "b1"), "output": [1, 2, 3, "b1"]}, + ] + for test_case in test_cases: + with self.subTest(test_case=test_case): + v = amm.Version(from_list=test_case["input"]) + self.assertListEqual(test_case["output"], v.version_as_list) + + def test_parse_string_to_tuple_normal(self): + """Parsing of complete version string works for normal cases""" + import addonmanager_metadata as amm + + cases = { + "1": [1, 0, 0, ""], + "1.2": [1, 2, 0, ""], + "1.2.3": [1, 2, 3, ""], + "1.2.3beta": [1, 2, 3, "beta"], + "12_345.6_7.8pre-alpha": [12345, 67, 8, "pre-alpha"], + # The above test is mostly to point out that Python gets permits underscore + # characters in a number. + } + for inp, output in cases.items(): + with self.subTest(inp=inp, output=output): + version = amm.Version() + version._parse_string_to_tuple(inp) + self.assertListEqual(version.version_as_list, output) + + def test_parse_string_to_tuple_invalid(self): + """Parsing of invalid version string raises an exception""" + import addonmanager_metadata as amm + + cases = {"One", "1,2,3", "1-2-3", "1/2/3"} + for inp in cases: + with self.subTest(inp=inp): + with self.assertRaises(ValueError): + version = amm.Version() + version._parse_string_to_tuple(inp) + + def test_parse_final_entry_normal(self): + """Parsing of the final entry works for normal cases""" + import addonmanager_metadata as amm + + cases = { + "3beta": (3, "beta"), + "42.alpha": (42, ".alpha"), + "123.45.6": (123, ".45.6"), + "98_delta": (98, "_delta"), + "1 and some words": (1, " and some words"), + } + for inp, output in cases.items(): + with self.subTest(inp=inp, output=output): + number, text = amm.Version._parse_final_entry(inp) + self.assertEqual(number, output[0]) + self.assertEqual(text, output[1]) + + def test_parse_final_entry_invalid(self): + """Invalid input raises an exception""" + import addonmanager_metadata as amm + + cases = ["beta", "", ["a", "b"]] + for case in cases: + with self.subTest(case=case): + with self.assertRaises(ValueError): + amm.Version._parse_final_entry(case) + + def test_operators_internal(self): + """Test internal (non-package) comparison operators""" + sys.modules["packaging.version"] = None + import addonmanager_metadata as amm + + cases = self.given_comparison_cases() + for case in cases: + with self.subTest(case=case): + first = amm.Version(case[0]) + second = amm.Version(case[1]) + self.assertEqual(first < second, case[0] < case[1]) + self.assertEqual(first > second, case[0] > case[1]) + self.assertEqual(first <= second, case[0] <= case[1]) + self.assertEqual(first >= second, case[0] >= case[1]) + self.assertEqual(first == second, case[0] == case[1]) + + @staticmethod + def given_comparison_cases(): + return [ + ("0.0.0alpha", "1.0.0alpha"), + ("0.0.0alpha", "0.1.0alpha"), + ("0.0.0alpha", "0.0.1alpha"), + ("0.0.0alpha", "0.0.0beta"), + ("0.0.0alpha", "0.0.0alpha"), + ("1.0.0alpha", "0.0.0alpha"), + ("0.1.0alpha", "0.0.0alpha"), + ("0.0.1alpha", "0.0.0alpha"), + ("0.0.0beta", "0.0.0alpha"), + ] + + +class TestDependencyType(unittest.TestCase): + """Ensure that the DependencyType dataclass converts to the correct strings""" + + def setUp(self) -> None: + from addonmanager_metadata import DependencyType + + self.DependencyType = DependencyType + + def test_string_conversion_automatic(self): + self.assertEqual(str(self.DependencyType.automatic), "automatic") + + def test_string_conversion_internal(self): + self.assertEqual(str(self.DependencyType.internal), "internal") + + def test_string_conversion_addon(self): + self.assertEqual(str(self.DependencyType.addon), "addon") + + def test_string_conversion_python(self): + self.assertEqual(str(self.DependencyType.python), "python") + + +class TestUrlType(unittest.TestCase): + """Ensure that the UrlType dataclass converts to the correct strings""" + + def setUp(self) -> None: + from addonmanager_metadata import UrlType + + self.UrlType = UrlType + + def test_string_conversion_website(self): + self.assertEqual(str(self.UrlType.website), "website") + + def test_string_conversion_repository(self): + self.assertEqual(str(self.UrlType.repository), "repository") + + def test_string_conversion_bugtracker(self): + self.assertEqual(str(self.UrlType.bugtracker), "bugtracker") + + def test_string_conversion_readme(self): + self.assertEqual(str(self.UrlType.readme), "readme") + + def test_string_conversion_documentation(self): + self.assertEqual(str(self.UrlType.documentation), "documentation") + + def test_string_conversion_discussion(self): + self.assertEqual(str(self.UrlType.discussion), "discussion") + + +class TestMetadataAuxiliaryFunctions(unittest.TestCase): + + def test_get_first_supported_freecad_version_simple(self): + from addonmanager_metadata import Metadata, Version, get_first_supported_freecad_version + expected_result = Version(from_string="0.20.2beta") + metadata = self.given_metadata_with_freecadmin_set(expected_result) + first_version = get_first_supported_freecad_version(metadata) + self.assertEqual(expected_result, first_version) + + @staticmethod + def given_metadata_with_freecadmin_set(min_version): + from addonmanager_metadata import Metadata + metadata = Metadata() + metadata.freecadmin = min_version + return metadata + + def test_get_first_supported_freecad_version_with_content(self): + from addonmanager_metadata import Metadata, Version, get_first_supported_freecad_version + expected_result = Version(from_string="0.20.2beta") + metadata = self.given_metadata_with_freecadmin_in_content(expected_result) + first_version = get_first_supported_freecad_version(metadata) + self.assertEqual(expected_result, first_version) + + @staticmethod + def given_metadata_with_freecadmin_in_content(min_version): + from addonmanager_metadata import Metadata, Version + v_list = min_version.version_as_list + metadata = Metadata() + wb1 = Metadata() + wb1.freecadmin = Version(from_list=[v_list[0]+1,v_list[1],v_list[2],v_list[3]]) + wb2 = Metadata() + wb2.freecadmin = Version(from_list=[v_list[0],v_list[1]+1,v_list[2],v_list[3]]) + wb3 = Metadata() + wb3.freecadmin = Version(from_list=[v_list[0],v_list[1],v_list[2]+1,v_list[3]]) + m1 = Metadata() + m1.freecadmin = min_version + metadata.content = {"workbench":[wb1,wb2,wb3],"macro":[m1]} + return metadata + + +class TestMetadataReader(unittest.TestCase): + """Test reading metadata from XML""" + + def setUp(self) -> None: + if "xml.etree.ElementTree" in sys.modules: + sys.modules.pop("xml.etree.ElementTree") + if "MetadataReader" in sys.modules: + sys.modules.pop("MetadataReader") + + def tearDown(self) -> None: + if "xml.etree.ElementTree" in sys.modules: + sys.modules.pop("xml.etree.ElementTree") + if "MetadataReader" in sys.modules: + sys.modules.pop("MetadataReader") + + def test_from_file(self): + from addonmanager_metadata import MetadataReader + + MetadataReader.from_bytes = Mock() + with tempfile.NamedTemporaryFile(delete=False) as temp: + temp.write(b"Some data") + temp.close() + MetadataReader.from_file(temp.name) + self.assertTrue(MetadataReader.from_bytes.called) + MetadataReader.from_bytes.assert_called_once_with(b"Some data") + os.unlink(temp.name) + + @unittest.skip("Breaks other tests, needs to be fixed") + def test_from_bytes(self): + import xml.etree.ElementTree + + with unittest.mock.patch("xml.etree.ElementTree") as element_tree_mock: + from addonmanager_metadata import MetadataReader + + MetadataReader._process_element_tree = Mock() + MetadataReader.from_bytes(b"Some data") + element_tree_mock.parse.assert_called_once_with(b"Some data") + + def test_process_element_tree(self): + from addonmanager_metadata import MetadataReader + + MetadataReader._determine_namespace = Mock(return_value="") + element_tree_mock = Mock() + MetadataReader._create_node = Mock() + MetadataReader._process_element_tree(element_tree_mock) + MetadataReader._create_node.assert_called_once() + + def test_determine_namespace_found_full(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.tag = "{https://wiki.freecad.org/Package_Metadata}package" + found_ns = MetadataReader._determine_namespace(root) + self.assertEqual(found_ns, "{https://wiki.freecad.org/Package_Metadata}") + + def test_determine_namespace_found_empty(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.tag = "package" + found_ns = MetadataReader._determine_namespace(root) + self.assertEqual(found_ns, "") + + def test_determine_namespace_not_found(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.find = Mock(return_value=False) + with self.assertRaises(RuntimeError): + MetadataReader._determine_namespace(root) + + def test_parse_child_element_simple_strings(self): + from addonmanager_metadata import Metadata, MetadataReader + + tags = ["name", "date", "description", "icon", "classname", "subdirectory"] + for tag in tags: + with self.subTest(tag=tag): + text = f"Test Data for {tag}" + child = self.given_mock_tree_node(tag, text) + mock_metadata = Metadata() + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(mock_metadata.__dict__[tag], text) + + def test_parse_child_element_version(self): + from addonmanager_metadata import Metadata, Version, MetadataReader + + mock_metadata = Metadata() + child = self.given_mock_tree_node("version", "1.2.3") + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(Version("1.2.3"), mock_metadata.version) + + def test_parse_child_element_lists_of_strings(self): + from addonmanager_metadata import Metadata, MetadataReader + + tags = ["file", "tag"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + expected_results.append(text) + child = self.given_mock_tree_node(tag, text) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_lists_of_contacts(self): + from addonmanager_metadata import Metadata, Contact, MetadataReader + + tags = ["maintainer", "author"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + email = f"Email {i} for {tag}" if i % 2 == 0 else None + expected_results.append(Contact(name=text, email=email)) + child = self.given_mock_tree_node(tag, text, {"email": email}) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_list_of_licenses(self): + from addonmanager_metadata import Metadata, License, MetadataReader + + mock_metadata = Metadata() + expected_results = [] + tag = "license" + for i in range(10): + text = f"Test {i} for {tag}" + file = f"Filename {i} for {tag}" if i % 2 == 0 else None + expected_results.append(License(name=text, file=file)) + child = self.given_mock_tree_node(tag, text, {"file": file}) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_list_of_urls(self): + from addonmanager_metadata import Metadata, Url, UrlType, MetadataReader + + mock_metadata = Metadata() + expected_results = [] + tag = "url" + for i in range(10): + text = f"Test {i} for {tag}" + url_type = UrlType(i % len(UrlType)) + type = str(url_type) + branch = "" + if type == "repository": + branch = f"Branch {i} for {tag}" + expected_results.append(Url(location=text, type=url_type, branch=branch)) + child = self.given_mock_tree_node( + tag, text, {"type": type, "branch": branch} + ) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_lists_of_dependencies(self): + from addonmanager_metadata import ( + Metadata, + Dependency, + DependencyType, + MetadataReader, + ) + + tags = ["depend", "conflict", "replace"] + attributes = { + "version_lt": "1.0.0", + "version_lte": "1.0.0", + "version_eq": "1.0.0", + "version_gte": "1.0.0", + "version_gt": "1.0.0", + "condition": "$BuildVersionMajor<1", + "optional": True, + } + + for tag in tags: + for attribute, attr_value in attributes.items(): + with self.subTest(tag=tag, attribute=attribute): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + dependency_type = DependencyType(i % len(DependencyType)) + dependency_type_str = str(dependency_type) + expected = Dependency( + package=text, dependency_type=dependency_type + ) + expected.__dict__[attribute] = attr_value + expected_results.append(expected) + child = self.given_mock_tree_node( + tag, + text, + {"type": dependency_type_str, attribute: str(attr_value)}, + ) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_ignore_unknown_tag(self): + from addonmanager_metadata import Metadata, MetadataReader + + tag = "invalid_tag" + text = "Shouldn't matter" + child = self.given_mock_tree_node(tag, text) + mock_metadata = Metadata() + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertNotIn(tag, mock_metadata.__dict__) + + def test_parse_child_element_versions(self): + from addonmanager_metadata import Metadata, Version, MetadataReader + + tags = ["version", "freecadmin", "freecadmax", "pythonmin"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + text = "3.4.5beta" + child = self.given_mock_tree_node(tag, text) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(mock_metadata.__dict__[tag], Version(from_string=text)) + + def given_mock_tree_node(self, tag, text, attributes=None): + class MockTreeNode: + def __init__(self): + self.tag = tag + self.text = text + self.attrib = attributes if attributes is not None else [] + + return MockTreeNode() + + def test_parse_content_valid(self): + from addonmanager_metadata import MetadataReader + + valid_content_items = ["workbench", "macro", "preferencepack"] + MetadataReader._create_node = Mock() + for content_type in valid_content_items: + with self.subTest(content_type=content_type): + tree_mock = [self.given_mock_tree_node(content_type, None)] + metadata_mock = Mock() + MetadataReader._parse_content("", metadata_mock, tree_mock) + MetadataReader._create_node.assert_called_once() + MetadataReader._create_node.reset_mock() + + def test_parse_content_invalid(self): + from addonmanager_metadata import MetadataReader + + MetadataReader._create_node = Mock() + content_item = "no_such_content_type" + tree_mock = [self.given_mock_tree_node(content_item, None)] + metadata_mock = Mock() + MetadataReader._parse_content("", metadata_mock, tree_mock) + MetadataReader._create_node.assert_not_called() + + +class TestMetadataReaderIntegration(unittest.TestCase): + """Full-up tests of the MetadataReader class (no mocking).""" + + def setUp(self) -> None: + self.test_data_dir = os.path.join(os.path.dirname(__file__), "..", "data") + remove_list = [] + for key in sys.modules: + if "addonmanager_metadata" in key: + remove_list.append(key) + for key in remove_list: + print(f"Removing {key}") + sys.modules.pop(key) + + def test_loading_simple_metadata_file(self): + from addonmanager_metadata import ( + Contact, + Dependency, + License, + MetadataReader, + Url, + UrlType, + Version, + ) + + filename = os.path.join(self.test_data_dir, "good_package.xml") + metadata = MetadataReader.from_file(filename) + self.assertEqual("Test Workbench", metadata.name) + self.assertEqual("A package.xml file for unit testing.", metadata.description) + self.assertEqual(Version("1.0.1"), metadata.version) + self.assertEqual("2022-01-07", metadata.date) + self.assertEqual("Resources/icons/PackageIcon.svg", metadata.icon) + self.assertListEqual( + [License(name="LGPLv2.1", file="LICENSE")], metadata.license + ) + self.assertListEqual( + [Contact(name="FreeCAD Developer", email="developer@freecad.org")], + metadata.maintainer, + ) + self.assertListEqual( + [ + Url( + location="https://github.com/chennes/FreeCAD-Package", + type=UrlType.repository, + branch="main", + ), + Url( + location="https://github.com/chennes/FreeCAD-Package/blob/main/README.md", + type=UrlType.readme, + ), + ], + metadata.url, + ) + self.assertListEqual(["Tag0", "Tag1"], metadata.tag) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 1) + wb_metadata = metadata.content["workbench"][0] + self.assertEqual("MyWorkbench", wb_metadata.classname) + self.assertEqual("./", wb_metadata.subdirectory) + self.assertListEqual(["TagA", "TagB", "TagC"], wb_metadata.tag) + + def test_multiple_workbenches(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "workbench_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 3) + expected_wb_classnames = [ + "MyFirstWorkbench", + "MySecondWorkbench", + "MyThirdWorkbench", + ] + for wb in metadata.content["workbench"]: + self.assertIn(wb.classname, expected_wb_classnames) + expected_wb_classnames.remove(wb.classname) + self.assertEqual(len(expected_wb_classnames), 0) + + def test_multiple_macros(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "macro_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("macro", metadata.content) + self.assertEqual(len(metadata.content["macro"]), 2) + expected_wb_files = ["MyMacro.FCStd", "MyOtherMacro.FCStd"] + for wb in metadata.content["macro"]: + self.assertIn(wb.file[0], expected_wb_files) + expected_wb_files.remove(wb.file[0]) + self.assertEqual(len(expected_wb_files), 0) + + def test_multiple_preference_packs(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "prefpack_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("preferencepack", metadata.content) + self.assertEqual(len(metadata.content["preferencepack"]), 3) + expected_packs = ["MyFirstPack", "MySecondPack", "MyThirdPack"] + for wb in metadata.content["preferencepack"]: + self.assertIn(wb.name, expected_packs) + expected_packs.remove(wb.name) + self.assertEqual(len(expected_packs), 0) + + def test_content_combination(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "combination.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("preferencepack", metadata.content) + self.assertEqual(len(metadata.content["preferencepack"]), 1) + self.assertIn("macro", metadata.content) + self.assertEqual(len(metadata.content["macro"]), 1) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index d4c2b856e1..2d9b3e3376 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -25,6 +25,7 @@ SET(AddonManager_SRCS addonmanager_installer_gui.py addonmanager_macro.py addonmanager_macro_parser.py + addonmanager_metadata.py addonmanager_update_all_gui.py addonmanager_uninstaller.py addonmanager_uninstaller_gui.py @@ -89,6 +90,7 @@ SET(AddonManagerTestsApp_SRCS AddonManagerTest/app/test_installer.py AddonManagerTest/app/test_macro.py AddonManagerTest/app/test_macro_parser.py + AddonManagerTest/app/test_metadata.py AddonManagerTest/app/test_utilities.py AddonManagerTest/app/test_uninstaller.py ) diff --git a/src/Mod/AddonManager/TestAddonManagerApp.py b/src/Mod/AddonManager/TestAddonManagerApp.py index c5356fa0fe..2d4ce76cdb 100644 --- a/src/Mod/AddonManager/TestAddonManagerApp.py +++ b/src/Mod/AddonManager/TestAddonManagerApp.py @@ -52,6 +52,14 @@ from AddonManagerTest.app.test_freecad_interface import ( TestParameters as AddonManagerTestParameters, TestDataPaths as AddonManagerTestDataPaths, ) +from AddonManagerTest.app.test_metadata import ( + TestDependencyType as AddonManagerTestDependencyType, + TestMetadataReader as AddonManagerTestMetadataReader, + TestMetadataReaderIntegration as AddonManagerTestMetadataReaderIntegration, + TestUrlType as AddonManagerTestUrlType, + TestVersion as AddonManagerTestVersion, + TestMetadataAuxiliaryFunctions as AddonManagerTestMetadataAuxiliaryFunctions +) class TestListTerminator: @@ -76,6 +84,12 @@ loaded_gui_tests = [ AddonManagerTestConsole, AddonManagerTestParameters, AddonManagerTestDataPaths, + AddonManagerTestDependencyType, + AddonManagerTestMetadataReader, + AddonManagerTestMetadataReaderIntegration, + AddonManagerTestUrlType, + AddonManagerTestVersion, + AddonManagerTestMetadataAuxiliaryFunctions, TestListTerminator # Needed to prevent the last test from running twice ] for test in loaded_gui_tests: diff --git a/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py b/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py index 3f000d7643..25f2bd35af 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py +++ b/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py @@ -28,12 +28,13 @@ from typing import List import FreeCAD from Addon import Addon +from addonmanager_metadata import Metadata import NetworkManager class MetadataValidators: - """A collection of tools for validating various pieces of metadata. Prints validation - information to the console.""" + """A collection of tools for validating various pieces of metadata. Prints + validation information to the console.""" def validate_all(self, repos): """Developer tool: check all repos for validity and print report""" @@ -64,9 +65,9 @@ class MetadataValidators: if addon.metadata is None: return - # The package.xml standard has some required elements that the basic XML reader is not - # actually checking for. In developer mode, actually make sure that all the rules are - # being followed for each element. + # The package.xml standard has some required elements that the basic XML + # reader is not actually checking for. In developer mode, actually make sure + # that all the rules are being followed for each element. errors = [] @@ -83,15 +84,15 @@ class MetadataValidators: def validate_content(self, addon: Addon) -> List[str]: """Validate the Content items for this Addon""" errors = [] - contents = addon.metadata.Content + contents = addon.metadata.content missing_icon = True - if addon.metadata.Icon and len(addon.metadata.Icon) > 0: + if addon.metadata.icon and len(addon.metadata.icon) > 0: missing_icon = False else: if "workbench" in contents: wb = contents["workbench"][0] - if wb.Icon: + if wb.icon: missing_icon = False if missing_icon: errors.append("No element found, or element is invalid") @@ -106,38 +107,37 @@ class MetadataValidators: return errors - def validate_top_level(self, addon) -> List[str]: + def validate_top_level(self, addon:Addon) -> List[str]: """Check for the presence of the required top-level elements""" errors = [] - if not addon.metadata.Name or len(addon.metadata.Name) == 0: + if not addon.metadata.name or len(addon.metadata.name) == 0: errors.append( "No top-level element found, or element is empty" ) - if not addon.metadata.Version or addon.metadata.Version == "0.0.0": + if not addon.metadata.version: errors.append( "No top-level element found, or element is invalid" ) - # if not addon.metadata.Date or len(addon.metadata.Date) == 0: - # errors.append(f"No top-level element found, or element is invalid") - if not addon.metadata.Description or len(addon.metadata.Description) == 0: + if not addon.metadata.description or len(addon.metadata.description) == 0: errors.append( - "No top-level element found, or element is invalid" + "No top-level element found, or element " + "is invalid" ) - maintainers = addon.metadata.Maintainer + maintainers = addon.metadata.maintainer if len(maintainers) == 0: errors.append("No top-level found, at least one is required") for maintainer in maintainers: - if len(maintainer["email"]) == 0: + if len(maintainer.email) == 0: errors.append( - f"No email address specified for maintainer '{maintainer['name']}'" + f"No email address specified for maintainer '{maintainer.name}'" ) - licenses = addon.metadata.License + licenses = addon.metadata.license if len(licenses) == 0: errors.append("No top-level found, at least one is required") - urls = addon.metadata.Urls + urls = addon.metadata.url errors.extend(self.validate_urls(urls)) return errors @@ -185,17 +185,17 @@ class MetadataValidators: return errors @staticmethod - def validate_workbench_metadata(workbench) -> List[str]: + def validate_workbench_metadata(workbench:Metadata) -> List[str]: """Validate the required element(s) for a workbench""" errors = [] - if not workbench.Classname or len(workbench.Classname) == 0: + if not workbench.classname or len(workbench.classname) == 0: errors.append("No specified for workbench") return errors @staticmethod - def validate_preference_pack_metadata(pack) -> List[str]: + def validate_preference_pack_metadata(pack:Metadata) -> List[str]: """Validate the required element(s) for a preference pack""" errors = [] - if not pack.Name or len(pack.Name) == 0: + if not pack.name or len(pack.name) == 0: errors.append("No specified for preference pack") return errors diff --git a/src/Mod/AddonManager/addonmanager_freecad_interface.py b/src/Mod/AddonManager/addonmanager_freecad_interface.py index 5fc4ca041a..29d1d15962 100644 --- a/src/Mod/AddonManager/addonmanager_freecad_interface.py +++ b/src/Mod/AddonManager/addonmanager_freecad_interface.py @@ -47,8 +47,14 @@ try: getUserCachePath = FreeCAD.getUserCachePath translate = FreeCAD.Qt.translate + if FreeCAD.GuiUp: + import FreeCADGui + else: + FreeCADGui = None + except ImportError: FreeCAD = None + FreeCADGui = None getUserAppDataDir = None getUserCachePath = None getUserMacroDir = None @@ -57,7 +63,7 @@ except ImportError: return string def Version(): - return 1, 0, 0 + return 0, 21, 0, "dev" class ConsoleReplacement: """If FreeCAD's Console is not available, create a replacement by redirecting FreeCAD @@ -140,6 +146,7 @@ class DataPaths: mod_dir = None macro_dir = None cache_dir = None + home_dir = None reference_count = 0 @@ -151,6 +158,8 @@ class DataPaths: self.cache_dir = getUserCachePath() if self.macro_dir is None: self.macro_dir = getUserMacroDir(True) + if self.home_dir is None: + self.home_dir = FreeCAD.getHomePath() else: self.reference_count += 1 if self.mod_dir is None: @@ -159,6 +168,8 @@ class DataPaths: self.cache_dir = tempfile.mkdtemp() if self.macro_dir is None: self.macro_dir = tempfile.mkdtemp() + if self.home_dir is None: + self.home_dir = os.path.join(os.path.dirname(__file__), "..", "..") def __del__(self): self.reference_count -= 1 diff --git a/src/Mod/AddonManager/addonmanager_installer.py b/src/Mod/AddonManager/addonmanager_installer.py index 9b98f4eda1..18c493344b 100644 --- a/src/Mod/AddonManager/addonmanager_installer.py +++ b/src/Mod/AddonManager/addonmanager_installer.py @@ -194,7 +194,7 @@ class AddonInstaller(QtCore.QObject): FreeCAD.Console.PrintLog( "Overriding local ALLOWED_PYTHON_PACKAGES.txt with newer remote version\n" ) - p = p.data().decode("utf8") + p = p.decode("utf8") lines = p.split("\n") cls.allowed_packages.clear() # Unset the locally-defined list for line in lines: @@ -407,7 +407,7 @@ class AddonInstaller(QtCore.QObject): if hasattr(self.addon_to_install, "metadata") and os.path.isfile(package_xml): self.addon_to_install.load_metadata_file(package_xml) self.addon_to_install.installed_version = ( - self.addon_to_install.metadata.Version + self.addon_to_install.metadata.version ) self.addon_to_install.updated_timestamp = os.path.getmtime(package_xml) diff --git a/src/Mod/AddonManager/addonmanager_macro.py b/src/Mod/AddonManager/addonmanager_macro.py index 34595cb921..00083d6e05 100644 --- a/src/Mod/AddonManager/addonmanager_macro.py +++ b/src/Mod/AddonManager/addonmanager_macro.py @@ -32,10 +32,8 @@ import shutil from html import unescape from typing import Dict, Tuple, List, Union, Optional -import NetworkManager - - from addonmanager_macro_parser import MacroParser +import addonmanager_utilities as utils import addonmanager_freecad_interface as fci @@ -50,10 +48,11 @@ translate = fci.translate class Macro: - """This class provides a unified way to handle macros coming from different sources""" + """This class provides a unified way to handle macros coming from different + sources""" # Use a stored class variable for this so that we can override it during testing - network_manager = None + blocking_get = None # pylint: disable=too-many-instance-attributes def __init__(self, name): @@ -76,14 +75,16 @@ class Macro: self.other_files = [] self.parsed = False self._console = fci.Console + if Macro.blocking_get is None: + Macro.blocking_get = utils.blocking_get def __eq__(self, other): return self.filename == other.filename @classmethod def from_cache(cls, cache_dict: Dict): - """Use data from the cache dictionary to create a new macro, returning a reference - to it.""" + """Use data from the cache dictionary to create a new macro, returning a + reference to it.""" instance = Macro(cache_dict["name"]) for key, value in cache_dict.items(): instance.__dict__[key] = value @@ -97,14 +98,6 @@ class Macro: cache_dict[key] = value return cache_dict - @classmethod - def _get_network_manager(cls): - if cls.network_manager is None: - # Make sure we're initialized: - NetworkManager.InitializeNetworkManager() - cls.network_manager = NetworkManager.AM_NETWORK_MANAGER - return cls.network_manager - @property def filename(self): """The filename of this macro""" @@ -113,9 +106,10 @@ class Macro: return (self.name + ".FCMacro").replace(" ", "_") def is_installed(self): - """Returns True if this macro is currently installed (that is, if it exists in the - user macro directory), or False if it is not. Both the exact filename, as well as - the filename prefixed with "Macro", are considered an installation of this macro. + """Returns True if this macro is currently installed (that is, if it exists + in the user macro directory), or False if it is not. Both the exact filename, + as well as the filename prefixed with "Macro", are considered an installation + of this macro. """ if self.on_git and not self.src_filename: return False @@ -140,12 +134,12 @@ class Macro: self.parsed = True def fill_details_from_wiki(self, url): - """For a given URL, download its data and attempt to get the macro's metadata out of - it. If the macro's code is hosted elsewhere, as specified by a "rawcodeurl" found on - the wiki page, that code is downloaded and used as the source.""" + """For a given URL, download its data and attempt to get the macro's metadata + out of it. If the macro's code is hosted elsewhere, as specified by a + "rawcodeurl" found on the wiki page, that code is downloaded and used as the + source.""" code = "" - nm = Macro._get_network_manager() - p = nm.blocking_get(url) + p = Macro.blocking_get(url) if not p: self._console.PrintWarning( translate( @@ -155,7 +149,7 @@ class Macro: + "\n" ) return - p = p.data().decode("utf8") + p = p.decode("utf8") # check if the macro page has its code hosted elsewhere, download if # needed if "rawcodeurl" in p: @@ -207,8 +201,7 @@ class Macro: self.raw_code_url = re.findall('rawcodeurl.*?href="(http.*?)">', page_data) if self.raw_code_url: self.raw_code_url = self.raw_code_url[0] - nm = Macro._get_network_manager() - u2 = nm.blocking_get(self.raw_code_url) + u2 = Macro.blocking_get(self.raw_code_url) if not u2: self._console.PrintWarning( translate( @@ -218,7 +211,7 @@ class Macro: + "\n" ) return None - code = u2.data().decode("utf8") + code = u2.decode("utf8") return code @staticmethod @@ -238,8 +231,7 @@ class Macro: copy, potentially updating the internal icon location to that local storage.""" if self.icon.startswith("http://") or self.icon.startswith("https://"): self._console.PrintLog(f"Attempting to fetch macro icon from {self.icon}\n") - nm = Macro._get_network_manager() - p = nm.blocking_get(self.icon) + p = Macro.blocking_get(self.icon) if p: cache_path = fci.DataPaths().cache_dir am_path = os.path.join(cache_path, "AddonManager", "MacroIcons") @@ -247,21 +239,21 @@ class Macro: _, _, filename = self.icon.rpartition("/") base, _, extension = filename.rpartition(".") if base.lower().startswith("file:"): - # pylint: disable=line-too-long self._console.PrintMessage( - f"Cannot use specified icon for {self.name}, {self.icon} is not a direct download link\n" + f"Cannot use specified icon for {self.name}, {self.icon} " + "is not a direct download link\n" ) self.icon = "" else: constructed_name = os.path.join(am_path, base + "." + extension) with open(constructed_name, "wb") as f: - f.write(p.data()) + f.write(p) self.icon_source = self.icon self.icon = constructed_name else: - # pylint: disable=line-too-long self._console.PrintLog( - f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon} for macro {self.name}\n" + f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon}" + f" for macro {self.name}\n" ) self.icon = "" @@ -364,8 +356,7 @@ class Macro: if self.raw_code_url: fetch_url = self.raw_code_url.rsplit("/", 1)[0] + "/" + other_file self._console.PrintLog(f"Attempting to fetch {fetch_url}...\n") - nm = Macro._get_network_manager() - p = nm.blocking_get(fetch_url) + p = Macro.blocking_get(fetch_url) if p: with open(dst_file, "wb") as f: f.write(p) @@ -381,19 +372,21 @@ class Macro: warnings.append( translate( "AddonsInstaller", - "Could not locate macro-specified file {} (should have been at {})", + "Could not locate macro-specified file {} (expected at {})", ).format(other_file, src_file) ) def parse_wiki_page_for_icon(self, page_data: str) -> None: - """Attempt to find a url for the icon in the wiki page. Sets self.icon if found.""" + """Attempt to find a url for the icon in the wiki page. Sets self.icon if + found.""" # Method 1: the text "toolbar icon" appears on the page, and provides a direct # link to an icon # pylint: disable=line-too-long # Try to get an icon from the wiki page itself: - # ToolBar Icon + # ToolBar Icon icon_regex = re.compile(r'.*href="(.*?)">ToolBar Icon', re.IGNORECASE) wiki_icon = "" if "ToolBar Icon" in page_data: @@ -416,10 +409,9 @@ class Macro: self._console.PrintLog( f"Found a File: link for macro {self.name} -- {wiki_icon}\n" ) - nm = Macro._get_network_manager() - p = nm.blocking_get(wiki_icon) + p = Macro.blocking_get(wiki_icon) if p: - p = p.data().decode("utf8") + p = p.decode("utf8") f = io.StringIO(p) lines = f.readlines() trigger = False @@ -435,7 +427,8 @@ class Macro: #