diff --git a/AppCommand.py b/AppCommand.py index 68e5f04..a8091b0 100644 --- a/AppCommand.py +++ b/AppCommand.py @@ -5,7 +5,7 @@ import PySide from PySide import QtGui from PySide import QtCore -import ExternalAppsList +import XternalAppsList import Embed class AppCommand(): @@ -14,7 +14,7 @@ class AppCommand(): def GetResources(self): return { - 'Pixmap': ExternalAppsList.apps[self.appName].Icon, + 'Pixmap': XternalAppsList.apps[self.appName].Icon, 'Accel': "Shit+E", # E for Embed 'MenuText': "Start " + self.appName, 'ToolTip': "Start " + self.appName, @@ -29,4 +29,5 @@ class AppCommand(): return True def createCommands(appName): - Gui.addCommand('ExternalAppsOpen' + appName + 'Command', AppCommand(appName)) + Gui.addCommand('XternalAppsOpen' + appName + 'Command', AppCommand(appName)) + diff --git a/Embed.py b/Embed.py index 5b0c40a..99a6163 100644 --- a/Embed.py +++ b/Embed.py @@ -6,7 +6,7 @@ import re from PySide import QtGui from PySide import QtCore -import ExternalAppsList +import XternalAppsList from MyX11Utils import * #class MyMdiSubWindow(QMdiSubWindow): @@ -107,9 +107,9 @@ def try_pipe_lines(commandAndArguments): return [] # TODO: this is just a quick & dirty way to attach a field to the FreeCad object -class ExternalApps(): +class XternalApps(): def __init__(self): - setattr(FreeCAD, 'ExternalApps', self) + setattr(FreeCAD, 'XternalApps', self) def deleted(widget): """Detect RuntimeError: Internal C++ object (PySide2.QtGui.QWindow) already deleted.""" @@ -122,7 +122,7 @@ def deleted(widget): class ExternalAppInstance(QtCore.QObject): def __init__(self, appName): super(ExternalAppInstance, self).__init__() - self.app = ExternalAppsList.apps[appName] + self.app = XternalAppsList.apps[appName] # Start the application # TODO: popen_process shouldn't be exposed to in-document scripts, it would allow them to redirect output etc. print('Starting ' + ' '.join(self.app.start_command_and_args)) @@ -131,7 +131,7 @@ class ExternalAppInstance(QtCore.QObject): self.initWaitForWindow() self.foundWindows = dict() self.closedWindows = dict() - setattr(FreeCAD.ExternalApps, self.app.name, self) + setattr(FreeCAD.XternalApps, self.app.name, self) def initWaitForWindow(self): self.TimeoutHasOccurred = False # for other scritps to know the status diff --git a/InitGui.py b/InitGui.py index a6af411..9752545 100644 --- a/InitGui.py +++ b/InitGui.py @@ -1,6 +1,6 @@ import sys -import ExternalAppsList +import XternalAppsList import StealSplash StealSplash.steal() @@ -46,27 +46,30 @@ class XternalAppsWorkbench(Workbench): """Subclasses must implement the appName attribute""" global myIcon global XternalAppsWorkbench - global ExternalAppsList + global XternalAppsList def __init__(self): self.MenuText = "XternalApps: " + self.appName self.ToolTip = "Embeds " + self.appName + " in FreeCAD" - self.Icon = ExternalAppsList.apps[self.appName].Icon + self.Icon = XternalAppsList.apps[self.appName].Icon super(XternalAppsWorkbench, self).__init__() def Initialize(self): # Load commands import AppCommand import ToolCommand + import ReloadCommand import Embed - Embed.ExternalApps() + Embed.XternalApps() AppCommand.createCommands(self.appName) ToolCommand.createCommands(self.appName) + ReloadCommand.createCommands(self.appName) # List of commands for this workbench - self.list = ['ExternalAppsOpen' + self.appName + 'Command'] \ - + ['ExternalAppsTool' + self.appName + toolName + 'Command' - for toolName in ExternalAppsList.apps[self.appName].Tools] + self.list = ['XternalAppsOpen' + self.appName + 'Command'] \ + + ['XternalAppsReload' + self.appName + 'Command'] \ + + ['XternalAppsTool' + self.appName + toolName + 'Command' + for toolName in XternalAppsList.apps[self.appName].Tools] # Create menus and toolbars self.appendMenu("ExternalApplications", self.list) @@ -90,5 +93,5 @@ def addAppWorkbench(appName): (XternalAppsWorkbench,), { 'appName': appName }) Gui.addWorkbench(workbenchClass()) -for appName in ExternalAppsList.apps: +for appName in XternalAppsList.apps: addAppWorkbench(appName) diff --git a/MyX11Utils.py b/MyX11Utils.py index b4ffd09..30e2d80 100644 --- a/MyX11Utils.py +++ b/MyX11Utils.py @@ -6,7 +6,7 @@ import re from PySide import QtGui from PySide import QtCore -import ExternalAppsList +import XternalAppsList def x11stillAlive(windowId): try: diff --git a/ToolCommand.py b/ToolCommand.py index f7b3d28..c3b6436 100644 --- a/ToolCommand.py +++ b/ToolCommand.py @@ -5,13 +5,13 @@ import PySide from PySide import QtGui from PySide import QtCore -import ExternalAppsList +import XternalAppsList import Embed import XternalAppsParametricTool class ToolCommand(): def __init__(self, appName, toolName): - self.Tool = ExternalAppsList.apps[appName].Tools[toolName] + self.Tool = XternalAppsList.apps[appName].Tools[toolName] def GetResources(self): return { @@ -29,5 +29,5 @@ class ToolCommand(): return FreeCAD.ActiveDocument is not None def createCommands(appName): - for toolName in ExternalAppsList.apps[appName].Tools: - Gui.addCommand('ExternalAppsTool' + appName + toolName + 'Command', ToolCommand(appName, toolName)) + for toolName in XternalAppsList.apps[appName].Tools: + Gui.addCommand('XternalAppsTool' + appName + toolName + 'Command', ToolCommand(appName, toolName)) diff --git a/ToolXML.py b/ToolXML.py index 7630f96..545b432 100644 --- a/ToolXML.py +++ b/ToolXML.py @@ -7,8 +7,8 @@ def getSingletonFromXML(xml, path): return elem ns={ -# 'my':"http://github.com/jsmaniac/XternalApps/myTool", - 'XternalApps':"http://github.com/jsmaniac/XternalApps", + 'my':"http://github.com/jsmaniac/XternalApps/myTool", + 'XternalApps':"http://github.com/jsmaniac/XternalApps/v1", 'xforms':"http://www.w3.org/2002/xforms", 'xsd':"http://www.w3.org/2001/XMLSchema", } diff --git a/XternalAppsParametricTool.py b/XternalAppsParametricTool.py index a7b5aa1..291cf33 100644 --- a/XternalAppsParametricTool.py +++ b/XternalAppsParametricTool.py @@ -1,14 +1,19 @@ import FreeCAD as App import FreeCADGui from lxml import etree -import ExternalAppsList +import XternalAppsList from ToolXML import * from collections import namedtuple import re from copy import deepcopy from collections import defaultdict +import pprint -XFormsInput = namedtuple('XFormsInput', ['input', 'modelElementPath', 'type', 'maybeEnum', 'InputValueToModelValue', 'ModelValueToInputValue']) +parser = etree.XMLParser(resolve_entities=False) + +FreeCADType = namedtuple('FreeCADType', ['type', 'defaultForType', 'maybeEnumValues']) + +XFormsInput = namedtuple('XFormsInput', ['modelElementPath', 'label', 'simpleName', 'maybeEnum', 'groupName']) #'type', 'input', 'InputValueToModelValue', 'ModelValueToInputValue' XFormsEnum = namedtuple('XFormsEnum', ['labels', 'values']) InterpretedXML = namedtuple('InterpretedXML', ['xml', 'types', 'inputs']) # Parsed XML, dictionary(modelElementPath) -> type, dictionary(formElementPath) -> XFormsInput @@ -27,25 +32,63 @@ def create(appName, toolName): # TODO: read-only/immutable typeToFreeCADTypeDict = { # TODO:do an XML namespace lookup instead of comparing a constant. - 'xsd:decimal': 'App::PropertyFloat', - 'xsd:string': 'App::PropertyString', + 'xsd:decimal': FreeCADType(type='App::PropertyFloat', defaultForType=0.0, maybeEnumValues=None), + 'xsd:string': FreeCADType(type='App::PropertyString', defaultForType='', maybeEnumValues=None), } -def typeToFreeCADType(type, maybeEnum): - if maybeEnum is not None: - return 'App::PropertyEnumeration' - elif type.startswith('mime:'): - return MIMETypeToFreeCADType(MIMEType[5:]) +def getShortPath(root, elem, root_needs_dot = True): + if isinstance(root, etree._ElementTree): + root = root.getroot() + if root == elem: + return '.' + else: + parent = elem.getparent() + parentChildren = list(parent) # convert to list of children + index = parentChildren.index(elem) + return getShortPath(root, parent) + '/*[' + str(index) + ']' + +def typeToFreeCADType(type, namespacesAtTypeElement, maybeSchema): + def escape(str): return + if type.startswith('mime:'): + return MIMETypeToFreeCADType(type[5:]) elif type in typeToFreeCADTypeDict: return typeToFreeCADTypeDict[type] + elif maybeSchema is not None and ':' in type: + # TODO: should the type be looked up using the namespaces on the 'type="xxx"' side or on the 'schema' side? + nameNs, name = type.split(':', 1) + if '"' in name or '&' in name: + raise ValueError("invaid character in type name") + if nameNs not in namespacesAtTypeElement.keys() or namespacesAtTypeElement[nameNs] != maybeSchema.attrib['targetNamespace']: + raise ValueError('namespace of type reference must match the targetNamespace of the schema') + schemaTypes = maybeSchema.findall('.//*[@name="'+name+'"]', namespaces=namespacesAtTypeElement) + if len(schemaTypes) != 1: + raise ValueError('Could not find definition for XForms type.') + else: + schemaType = schemaTypes[0]; + return schemaTypeToFreeCADType(schemaType) else: - raise ArgumentException('Unsupported XForms type') + raise ValueError('Unsupported XForms type') +def schemaTypeToFreeCADType(schemaType): + if schemaType.tag == "{http://www.w3.org/2001/XMLSchema}simpleType": + restriction = schemaType.find('./xsd:restriction', ns) + base = restriction.attrib['base'] + if ':' not in base: + raise ValueError('only restrictions of xsd:string (a.k.a. enums) are supported') + baseNs, baseName = base.split(':', 1) + if baseName != 'string' or baseNs not in restriction.nsmap.keys() or restriction.nsmap[baseNs] != ns['xsd']: + raise ValueError('only restrictions of xsd:string (a.k.a. enums) are supported') + enumCases = restriction.findall('./xsd:enumeration', ns) + enumValues = [enumCase.attrib['value'] for enumCase in enumCases] + return FreeCADType(type = 'App::PropertyEnumeration', maybeEnumValues = enumValues, defaultForType = (enumValues[0] if len(enumValues) > 0 else None)) + elif schemaType.tag == "{http://www.w3.org/2001/XMLSchema}complexType": + return ValueError("Complex XML chema types are not supported") + def MIMETypeToFreeCADType(MIMEType): if MIMEType == 'image/svg+xml': - return 'App::PropertyLink' + return FreeCADType(type='App::PropertyLink', defaultForType=None, maybeEnumValues=None) else: - raise ArgumentException('Unsupported MIME type') + raise ValueError('Unsupported MIME type') def encode_bytes(bytestring): try: @@ -64,29 +107,61 @@ def decode_bytes(encoding_and_string): else: raise ValueError("invalid encoding: expected utf-8 or base64") +def toSimpleName(name): + return re.sub(r'( |[^-a-zA-Z0-9])+', ' ', name).title().replace(' ', '') + +def toUniqueSimpleName(name, mutableNextUnique): + m = re.match(r'^((.*[^0-9])?)([0-9]*)$', name) + base = m.group(1) + counter = m.group(3) + if counter == '' and mutableNextUnique[base] == 0: + mutableNextUnique[name] = 1 + elif counter == '': + counter = str(mutableNextUnique[name]) + mutableNextUnique[name] = mutableNextUnique[name] + 1 + elif int(counter) > mutableNextUnique[name]: + mutableNextUnique[name] = str(int(counter)+1) + else: + counter = str(mutableNextUnique[name]) + mutableNextUnique[name] = mutableNextUnique[name] + 1 + return base + counter + class XternalAppsParametricTool(): def __init__(self, obj, appName, toolName): self.Type = "XternalAppsParametricTool" self.AppName = appName - self.ToolName = toolName + self.ToolName = toolName + self.MonitorChanges = False; obj.Proxy = self - self.ModelInstance = self.getDefaultModelInstance(obj) - self.ModelToProperties = {} - self.ModelOnChanged = defaultdict(set) - self.createPropertiesFromXML(obj) + + self.types = self.xmlTypesToPython(self.Tool.XForms) + self.defaults = self.xmlDefaultsToPython(self.Tool.XForms, self.types) + self.form = self.xmlFormToPython(self.Tool.XForms, self.types) + + self.ModelInstance = self.defaults + self.createProperties(obj, self.types, self.ModelInstance, self.form) + + self.MonitorChanges = True; + return def __getstate__(self): copied = self.__dict__.copy() - copied['ModelInstance'] = encode_bytes(etree.tostring(copied['ModelInstance'])) + copied['ModelInstance'] = list(copied['ModelInstance'].items()) + del copied['types'] + del copied['defaults'] + del copied['form'] return copied def __setstate__(self, state): if state: - state['ModelInstance'] = etree.fromstring(decode_bytes(state['ModelInstance'])) + state['ModelInstance'] = dict(state['ModelInstance']) self.__dict__ = state + self.types = self.xmlTypesToPython(self.Tool.XForms) + self.defaults = self.xmlDefaultsToPython(self.Tool.XForms, self.types) + self.form = self.xmlFormToPython(self.Tool.XForms, self.types) def getDefaultModelInstance(self, obj): - xml = etree.parse(self.Tool.XForms) + xml = etree.parse(self.Tool.XForms, parser=parser) model = xml.find('./xforms:model', ns) instanceDocument = etree.ElementTree(model.find('./xforms:instance/*', ns)) return deepcopy(instanceDocument) @@ -98,6 +173,27 @@ class XternalAppsParametricTool(): """TODO""" def onChanged(self, obj, prop): + if self.MonitorChanges: + try: + self.MonitorChanges = False + inputs = [input for input in self.form.values() if input.simpleName == prop] + if len(inputs) == 1: + input = inputs[0] + newModelValue = getattr(obj, prop) + if input.maybeEnum: + newModelValue = input.maybeEnum[newModelValue] + self.ModelInstance[input.modelElementPath] = newModelValue + for other in self.form.values(): + if other.modelElementPath == input.modelElementPath and other.simpleName != input.simpleName: + newFormValue = newModelValue + if other.maybeEnum: + newFormValue = [f for f, m in other.maybeEnum.items() if m == newModelValue][0] + setattr(obj, other.simpleName, newFormValue) + obj.setExpression(other.simpleName, None) + finally: + self.MonitorChanges = True + return + if hasattr(self, 'SimpleInputNameToInput'): inputPath = self.SimpleInputNameToInput.get(prop, None) if inputPath is not None: @@ -124,114 +220,99 @@ class XternalAppsParametricTool(): if type is None: raise Exception('Could not find type for ' + modelElementPath) path = xml.getelementpath(xmlXFormsElement) + path = getShortPath(xml, xmlXFormsElement) # YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY return (path, xmlXFormsElement, modelElementPath, type) - def interpretXML(self): - """Parse the self.Tool.XForms document, and return - * the parsed xml, - * a dictionary types[path] = "type" - * a dictionary inputs[path] = (xml_input_element, xml_model_element, type).""" - types = {} - #modelInstance = {} + def xmlFormToPython(self, form_xml, types): + """Parse the …-form.xml document, and return + a dictionary form[form_path] = ("model_path", "label", enum_labels?)""" + + xml = etree.parse(form_xml, parser=parser) # self.Tool.XForms + model_root = xml.find('./xforms:model', ns) + instanceDocument = etree.ElementTree(model_root.find('./xforms:instance/*', ns)) inputs = {} - - xml = etree.parse(self.Tool.XForms) - model = xml.find('./xforms:model', ns) - instanceDocument = etree.ElementTree(model.find('./xforms:instance/*', ns)) - - # register all xform:bind to types[pathToTargetElement] - for bind in model.findall('xforms:bind', ns): - for bound in instanceDocument.findall(bind.attrib['ref'], namespaces=bind.nsmap): - path = instanceDocument.getelementpath(bound) - # TODO: if has attrib type then … - type = bind.attrib['type'] - # TODO: I guess XForms implicitly allows intersection types by using several bind statements? - types[path] = type - # TODO: "required" field + nextUniqueSimpleName = defaultdict(lambda: 0) # register all inputs to inputs[pathToElement] for group in xml.findall('./xforms:group', ns): for input in group.findall('./xforms:input', ns): - path, xmlXFormsElement, modelElementPath, type = self.interpretFormElement(input, xml, instanceDocument, types) - inputs[path] = XFormsInput(input=xmlXFormsElement, modelElementPath=modelElementPath, type=type, maybeEnum=None, InputValueToModelValue = None, ModelValueToInputValue = None) + path, xmlXFormsElement, modelElementPath, _type = self.interpretFormElement(input, xml, instanceDocument, types) + + label = input.attrib['label'] + simpleName = toUniqueSimpleName(toSimpleName(label), nextUniqueSimpleName) + groupName = "/".join(input.xpath('ancestor-or-self::xforms:group/xforms:label/text()', namespaces=ns)) or None + + # input=xmlXFormsElement, + inputs[path] = XFormsInput(modelElementPath=modelElementPath, label=label, simpleName=simpleName, maybeEnum=None, groupName=groupName) # type=type, for select1 in group.findall('./xforms:select1', ns): - path, xmlXFormsElement, modelElementPath, type = self.interpretFormElement(select1, xml, instanceDocument, types) + path, xmlXFormsElement, modelElementPath, _type = self.interpretFormElement(select1, xml, instanceDocument, types) + + label = select1.attrib['label'] + simpleName = toUniqueSimpleName(toSimpleName(label), nextUniqueSimpleName) + groupName = "/".join(select1.xpath('ancestor-or-self::xforms:group/xforms:label/text()', namespaces=ns)) or None + # Gather the allowed elements for the enum enum = {} for item in select1.findall('./xforms:item', ns): enum[item.attrib['label']] = item.attrib['value'] - inputs[path] = XFormsInput(input=xmlXFormsElement, modelElementPath=modelElementPath, type=type, maybeEnum=enum, InputValueToModelValue = None, ModelValueToInputValue = None) - return InterpretedXML(xml=xml, types=types, inputs=inputs) # modelInstance, - - def toSimpleName(self, name): - return re.sub(r'( |[^-a-zA-Z0-9])+', ' ', name).title().replace(' ', '') - - def toUniqueSimpleName(self, name, mutableNextUnique): - m = re.match(r'^((.*[^0-9])?)([0-9]*)$', name) - base = m.group(1) - counter = m.group(3) - if counter == '' and mutableNextUnique[base] == 0: - mutableNextUnique[name] = 1 - elif counter == '': - counter = str(mutableNextUnique[name]) - mutableNextUnique[name] = mutableNextUnique[name] + 1 - elif int(counter) > mutableNextUnique[name]: - mutableNextUnique[name] = str(int(counter)+1) - else: - counter = str(mutableNextUnique[name]) - mutableNextUnique[name] = mutableNextUnique[name] + 1 - return base + counter - - def createPropertiesFromXML(self, obj): - xml, types, inputs = self.interpretXML() # modelInstance, - simpleInputNameToInput = {} - inputToSimpleInputName = {} - nextUniqueSimpleName = defaultdict(lambda: 0) - inputs2 = {} - for inputPath, (input, modelElementPath, type, maybeEnum, _1, _2) in inputs.items(): - simpleName = self.toUniqueSimpleName(self.toSimpleName(input.attrib['label']), nextUniqueSimpleName) - simpleInputNameToInput[simpleName] = inputPath - inputToSimpleInputName[inputPath] = simpleName + # input=xmlXFormsElement, + inputs[path] = XFormsInput(modelElementPath=modelElementPath, label=label, simpleName=simpleName, maybeEnum=enum, groupName=groupName) # type=type, - group = "/".join(input.xpath('ancestor-or-self::xforms:group/xforms:label/text()', namespaces=ns)) or None + return inputs - loadedValue = self.ModelInstance.find(modelElementPath).text + def xmlTypesToPython(self, model_xml): + """Parse the …-model.xml document, and return + a dictionary model[model_path] = ("FreeCAD type", FreeCADValue).""" - obj.addProperty(typeToFreeCADType(type, maybeEnum), + xml = etree.parse(model_xml, parser=parser) # self.Tool.XForms + model_root = xml.find('./xforms:model', ns) + instanceDocument = etree.ElementTree(model_root.find('./xforms:instance/*', ns)) + maybeSchema = model_root.findall('./xsd:schema', ns) + maybeSchema = None if len(maybeSchema) != 1 else maybeSchema[0]; + + types = {} + for bind in model_root.findall('xforms:bind', ns): + for bound in instanceDocument.findall(bind.attrib['ref'], namespaces=bind.nsmap): + path = instanceDocument.getelementpath(bound) + # TODO: if has attrib type then … + type = bind.attrib['type'] + type = typeToFreeCADType(type, bind.nsmap, maybeSchema) + # TODO: I guess XForms implicitly allows intersection types by using several bind statements? + types[path] = type + # TODO: "required" field + return types + + def xmlDefaultsToPython(self, model_xml, types): + xml = etree.parse(model_xml, parser=parser) # self.Tool.XForms + model_root = xml.find('./xforms:model', ns) + instanceDocument = etree.ElementTree(model_root.find('./xforms:instance/*', ns)) + + defaults = {} + for modelElement in instanceDocument.findall('//*', ns): + path = instanceDocument.getelementpath(modelElement) + default = modelElement.text + if default is None: + default = types[path].defaultForType + defaults[path] = default + return defaults + + def createProperties(self, obj, types, defaults, form): + for key, (modelElementPath, label, simpleName, maybeEnum, groupName) in form.items(): + obj.addProperty(types[modelElementPath].type, simpleName, - group, - input.attrib['label'] + '\nA value of type ' + type) - - inputValueToModelValue = str - modelValueToInputValue = lambda x: x + groupName, + label + '\nA value of type ' + types[modelElementPath].type) + default = defaults[modelElementPath] if maybeEnum is not None: - enumWithSimpleNames = { self.toSimpleName(k): v for k, v in maybeEnum.items() } - setattr(obj, simpleName, list(enumWithSimpleNames.keys())) - inputValueToModelValue = enumWithSimpleNames.get - modelValueToInputValue = {v: l for l, v in enumWithSimpleNames.items()}.get - - if loadedValue is not None: - print("setting " + simpleName + "(full label = " + input.attrib['label'] + ") to " + str(loadedValue)) - setattr(obj, simpleName, modelValueToInputValue(loadedValue)) - - # TODO: refactor this! - inputs2[inputPath] = XFormsInput( - input=input, - modelElementPath=modelElementPath, - type=type, - maybeEnum=maybeEnum, - InputValueToModelValue = inputValueToModelValue, - ModelValueToInputValue = modelValueToInputValue) - - self.ModelOnChanged[modelElementPath].add(inputPath) - self.Types = types - self.Inputs = inputs2 - self.SimpleInputNameToInput = simpleInputNameToInput - self.InputToSimpleInputName = inputToSimpleInputName + setattr(obj, simpleName, list(maybeEnum.keys())) + print(default, maybeEnum) + # TODO: use a bidirectional dict + default = [k for k, v in maybeEnum.items() if v == default][0] + setattr(obj, simpleName, default) @property def Tool(self): - return ExternalAppsList.apps[self.AppName].Tools[self.ToolName] + return XternalAppsList.apps[self.AppName].Tools[self.ToolName] def execute(self, obj): """This is called when the object is recomputed""" diff --git a/myTool.xforms b/myTool.xforms index 5ba14bb..d4d6422 100644 --- a/myTool.xforms +++ b/myTool.xforms @@ -1,5 +1,5 @@ - + MyTool This tool is my tool, it is very useful in a toolset. MyTool.svg @@ -24,12 +24,20 @@ - + + + + + + + + + @@ -50,7 +58,7 @@ - +